Merge remote-tracking branch 'origin/main' into litellm_fix_stream_chunk_finish_reason_null
Some checks failed
Cost map pins recheck / rerun-cost-map-guard (push) Has been cancelled
LiteLLM Rust DB / db-queries (push) Has been cancelled
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled
Terraform Modules / fmt, validate, test (aws) (push) Has been cancelled
Terraform Modules / fmt, validate, test (gcp) (push) Has been cancelled
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

This commit is contained in:
mateo-berri 2026-11-10 15:40:48 -08:00
commit cb879f6978
36 changed files with 18148 additions and 56 deletions

View file

@ -397,6 +397,7 @@ jobs:
tests/unit/llms/voyage
tests/unit/llms/wandb
tests/unit/llms/watsonx
tests/unit/llms/webiq
tests/unit/llms/xai
tests/unit/llms/xinference
tests/unit/llms/you_com

View file

@ -17,6 +17,7 @@ from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes impor
safe_set_attribute,
)
from litellm.litellm_core_utils.core_helpers import require_attributes
from litellm.litellm_core_utils.safe_json_dumps import jsonable_or_str
from litellm.types.llms.openai import HttpxBinaryResponseContent, ResponsesAPIResponse
from litellm.types.utils import (
EmbeddingResponse,
@ -103,7 +104,7 @@ class LangfuseLLMObsOTELAttributes(BaseLLMObsOTELAttributes):
prompt["tools"] = tools
input: Final = prompt
safe_set_attribute(span, "langfuse.observation.input", json.dumps(input))
safe_set_attribute(span, "langfuse.observation.input", json.dumps(input, default=jsonable_or_str))
@staticmethod
@override

View file

@ -1,6 +1,7 @@
import json
from typing import TYPE_CHECKING, Any, Final
from litellm.litellm_core_utils.safe_json_dumps import jsonable_or_str
from litellm.types.integrations.otel_span_attributes import SpanAttributes
if TYPE_CHECKING:
@ -60,7 +61,7 @@ class LangtraceAttributes:
if prompts:
span.add_event(
name="gen_ai.content.prompt",
attributes={SpanAttributes.LLM_PROMPTS.value: json.dumps(prompts)},
attributes={SpanAttributes.LLM_PROMPTS.value: json.dumps(prompts, default=jsonable_or_str)},
)
self.set_span_attributes(span, span_attributes)

View file

@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, ClassVar, Final, Literal, Protocol, cast
from urllib.parse import urlsplit
from openai.types.responses.response_function_web_search import ResponseFunctionWebSearch
from pydantic import TypeAdapter, ValidationError
from pydantic import BaseModel, TypeAdapter, ValidationError
from typing_extensions import ReadOnly, TypedDict
from litellm.integrations.otel.model.metadata import (
@ -519,7 +519,7 @@ class LLMCallSpanData:
promoted_metadata=promoted_metadata,
is_streaming=as_bool(payload.get("stream")),
tools=_extract_tools(params),
messages_in=_dicts(payload.get("messages")) if capture_content else (),
messages_in=_message_dicts(payload.get("messages")) if capture_content else (),
choices_out=choices_out if capture_content else (),
system_fingerprint=as_str(response.get("system_fingerprint")),
time_to_first_chunk_seconds=time_to_first_chunk_seconds,
@ -783,6 +783,27 @@ def _dicts(value: object) -> tuple[Mapping[str, object], ...]:
return tuple(item for item in value if isinstance(item, dict))
_OBJECTS: Final = TypeAdapter(tuple[object, ...])
_ANY_KEY_MAPPING: Final = TypeAdapter(Mapping[object, object])
def _message_dict(message: object) -> Mapping[str, object] | None:
if isinstance(message, dict):
return {str(key): value for key, value in _ANY_KEY_MAPPING.validate_python(message).items()}
if not isinstance(message, BaseModel):
return None
try:
return message.model_dump(mode="json", fallback=str)
except ValueError:
return None
def _message_dicts(value: object) -> tuple[Mapping[str, object], ...]:
if not isinstance(value, list):
return ()
return tuple(message for item in _OBJECTS.validate_python(value) if (message := _message_dict(item)) is not None)
def _finish_reasons(choices: tuple[Mapping[str, object], ...]) -> tuple[str, ...]:
"""Non-empty ``finish_reason`` of each response choice."""
return tuple(r for c in choices if (r := as_str(c.get("finish_reason"))))

View file

@ -21,7 +21,7 @@ from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes impor
safe_set_attribute,
)
from litellm.litellm_core_utils.core_helpers import has_attributes, require_attributes
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.litellm_core_utils.safe_json_dumps import jsonable_or_str, safe_dumps
from litellm.types.integrations.weave_otel import WeaveOtelConfig, WeaveSpanAttributes
from litellm.types.utils import StandardCallbackDynamicParams
@ -74,7 +74,11 @@ class WeaveLLMObsOTELAttributes(BaseLLMObsOTELAttributes):
prompt["functions"] = functions
if tools is not None:
prompt["tools"] = tools
safe_set_attribute(span, OpenInferenceSpanAttributes.INPUT_VALUE, json.dumps(prompt))
safe_set_attribute(
span,
OpenInferenceSpanAttributes.INPUT_VALUE,
json.dumps(prompt, default=jsonable_or_str),
)
def _set_weave_specific_attributes(span: Span, kwargs: Mapping[str, object], response_obj: object):

View file

@ -3,7 +3,8 @@ import math
from collections.abc import Callable
from typing import Any, Final
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
from pydantic_core import to_jsonable_python
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
@ -113,3 +114,13 @@ def safe_dumps(
return json.dumps(
safe_json_structure(data, max_depth, value_transform, non_finite_as_null=non_finite_as_null), default=str
)
_JSONABLE: Final = TypeAdapter(object)
def jsonable_or_str(value: object) -> object:
try:
return _JSONABLE.validate_python(to_jsonable_python(value, fallback=str))
except ValueError:
return str(value)

View file

@ -170,7 +170,7 @@ class GoogleImageGenConfig(BaseImageGenerationConfig):
return request_body_obj.model_dump(exclude_none=True)
def _transform_image_usage(self, usage_metadata: dict):
return transform_gemini_image_usage(usage_metadata)
return transform_gemini_image_usage(_JSON_DICT.validate_python(usage_metadata))
def transform_image_generation_response(
self,

View file

@ -1,5 +1,7 @@
from typing import Final
from pydantic import TypeAdapter
from litellm.types.utils import ImageUsage, ImageUsageInputTokensDetails
@ -34,12 +36,12 @@ def _sum_modality_token_details(usage_metadata: dict, *details_keys: str) -> Ima
return tokens_details
def transform_gemini_image_usage(usage_metadata: dict) -> ImageUsage:
def transform_gemini_image_usage(usage_metadata: dict[str, object]) -> ImageUsage:
"""
Transform Gemini usageMetadata to ImageUsage format.
"""
input_tokens_details = _sum_modality_token_details(usage_metadata, "promptTokensDetails", "prompt_tokens_details")
output_tokens: Final = usage_metadata.get("candidatesTokenCount", 0)
output_tokens: Final = TypeAdapter(int).validate_python(usage_metadata.get("candidatesTokenCount", 0))
output_tokens_details: Final = _sum_modality_token_details(
usage_metadata, "candidatesTokensDetails", "candidates_tokens_details"
)

View file

@ -6,10 +6,12 @@ from typing import TYPE_CHECKING, Any, Final, Protocol, cast
import httpx
from httpx._types import RequestFiles
from pydantic import ConfigDict, TypeAdapter
import litellm
from litellm.images.utils import ImageEditRequestUtils
from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig
from litellm.llms.gemini.image_usage_transformation import transform_gemini_image_usage
from litellm.llms.vertex_ai.common_utils import get_vertex_base_url, map_vertex_image_size
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexLLM
from litellm.secret_managers.main import get_secret_str
@ -20,7 +22,9 @@ from litellm.types.llms.vertex_ai import (
HttpxPartType,
)
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import FileTypes, ImageObject, ImageResponse, OpenAIImage
from litellm.types.utils import FileTypes, ImageObject, ImageResponse
_JSON_DICT: Final = TypeAdapter(dict[str, object], config=ConfigDict(hide_input_in_errors=True))
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
@ -202,7 +206,6 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM):
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
) -> ImageResponse:
model_response: Final = ImageResponse()
try:
response_json: Final = _generate_content_payload(raw_response)
except Exception as exc:
@ -223,8 +226,15 @@ class VertexAIGeminiImageEditConfig(BaseImageEditConfig, VertexLLM):
if (inline_data := part.get("inlineData")) and (b64_json := inline_data.get("data"))
]
model_response.data = cast(list[OpenAIImage], data_list)
return model_response
usage_metadata: Final = _JSON_DICT.validate_python(response_json.get("usageMetadata") or {})
traffic_type: Final = usage_metadata.get("trafficType") if usage_metadata else None
return ImageResponse(
data=data_list,
usage=transform_gemini_image_usage(usage_metadata) if usage_metadata else None,
hidden_params={"provider_specific_fields": {"traffic_type": traffic_type}}
if isinstance(traffic_type, str) and traffic_type
else {},
)
def _prepare_inline_image_parts(self, image: FileTypes | list[FileTypes]) -> list[HttpxPartType]:
images: Final[list[FileTypes]] = image if isinstance(image, list) else [image]

View file

View file

View file

@ -0,0 +1,156 @@
from __future__ import annotations
from collections.abc import Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from urllib.parse import urlsplit
import httpx
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.base_llm.search.transformation import BaseSearchConfig, SearchResponse, SearchResult
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
class _WebIQResult(BaseModel):
model_config = ConfigDict(extra="allow", frozen=True)
title: str
url: str
content: str
lastUpdatedAt: str | None = None
class _WebIQResponse(BaseModel):
model_config = ConfigDict(extra="allow", frozen=True)
webResults: tuple[_WebIQResult, ...]
_DOMAINS: Final = TypeAdapter(tuple[str, ...])
def _extra_fields(model: BaseModel, reserved: Mapping[str, object]) -> MappingProxyType[str, object]:
extra: Final[Mapping[str, object]] = model.model_extra or {}
return MappingProxyType({key: value for key, value in extra.items() if key not in reserved})
def _query_with_domains(query: str, domains: object) -> str:
if domains is None:
return query
parsed: Final = _DOMAINS.validate_python(domains)
included: Final = tuple(f"site:{domain}" for domain in parsed if domain and not domain.startswith("-"))
excluded: Final = tuple(f"-site:{domain[1:]}" for domain in parsed if domain.startswith("-") and len(domain) > 1)
include_clause: Final = f"({' OR '.join(included)})" if included else ""
if not included and not excluded:
return query
return " ".join(part for part in (f"({query})", include_clause, *excluded) if part)
class WebIQSearchConfig(BaseSearchConfig):
DEFAULT_API_BASE: Final = "https://api.microsoft.ai/v3"
@staticmethod
def ui_friendly_name() -> str:
return "Microsoft Web IQ"
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: BaseSearchConfig interface
api_key: str | None = None,
api_base: str | None = None,
**kwargs: object, # kwargs-ok: BaseSearchConfig interface
) -> dict[str, str]: # mutable-ok: HTTP handler requires a dict
if urlsplit(api_base or self.DEFAULT_API_BASE).scheme != "https":
raise ValueError("Microsoft Web IQ search requires an HTTPS api_base")
resolved_key: Final = self.resolve_server_api_key(
caller_api_key=api_key,
caller_api_base=api_base,
key_env_vars=("WEBIQ_API_KEY",),
base_env_var=None,
default_api_base=self.DEFAULT_API_BASE,
)
if not resolved_key:
raise ValueError("Set WEBIQ_API_KEY or pass api_key for Microsoft Web IQ search")
return {
**headers,
"x-apikey": resolved_key,
"Content-Type": "application/json",
}
def get_complete_url(
self,
api_base: str | None,
optional_params: dict[str, object], # mutable-ok: BaseSearchConfig interface
data: dict[str, object] | list[dict[str, object]] | None = None, # mutable-ok: BaseSearchConfig interface
**kwargs: object, # kwargs-ok: BaseSearchConfig interface
) -> str:
base: Final = (api_base or self.DEFAULT_API_BASE).rstrip("/")
return base if base.endswith("/search/web") else f"{base}/search/web"
def transform_search_request(
self,
query: str | list[str], # mutable-ok: BaseSearchConfig interface
optional_params: dict[str, object], # mutable-ok: BaseSearchConfig interface
**kwargs: object, # kwargs-ok: BaseSearchConfig interface
) -> dict[str, object]: # mutable-ok: HTTP handler requires a JSON dict
country: Final = optional_params.get("country")
mapped: Final = MappingProxyType(
{
"maxResults": optional_params.get("max_results"),
"region": country.upper() if isinstance(country, str) else None,
}
)
native: Final = MappingProxyType(
{
key: value
for key, value in optional_params.items()
if key not in self.get_supported_perplexity_optional_params()
}
)
return {
"contentFormat": "passage",
"maxLength": 5000,
**MappingProxyType({key: value for key, value in mapped.items() if value is not None}),
**native,
"query": _query_with_domains(
" ".join(query) if isinstance(query, list) else query,
optional_params.get("search_domain_filter"),
),
}
def transform_search_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
**kwargs: object, # kwargs-ok: BaseSearchConfig interface
) -> SearchResponse:
try:
parsed: Final = _WebIQResponse.model_validate_json(raw_response.content)
except ValidationError as exc:
raise BaseLLMException(
status_code=502,
message=f"Microsoft Web IQ returned an invalid search response: {exc}",
headers=dict(raw_response.headers),
) from exc
return SearchResponse.model_validate(
{
**_extra_fields(parsed, SearchResponse.model_fields),
"results": [
SearchResult.model_validate(
{
**_extra_fields(result, SearchResult.model_fields),
"title": result.title,
"url": result.url,
"snippet": result.content,
"date": result.lastUpdatedAt or None,
"last_updated": result.lastUpdatedAt or None,
}
)
for result in parsed.webResults
],
}
)

File diff suppressed because it is too large Load diff

View file

@ -4098,6 +4098,32 @@
"generateContent": false
}
},
"webiq": {
"display_name": "Microsoft Web IQ (`webiq`)",
"url": "https://learn.microsoft.com/en-us/web-iq/reference/web-search",
"endpoints": {
"chat_completions": false,
"responses": false,
"text_completion": false,
"embeddings": false,
"image_generations": false,
"image_edits": false,
"audio_transcriptions": false,
"audio_speech": false,
"rerank": false,
"ocr": false,
"search": true,
"moderations": false,
"realtime": false,
"video_generations": false,
"batches": false,
"messages": false,
"interactions": false,
"systemone": false,
"a2a": false,
"generateContent": false
}
},
"tavily": {
"display_name": "Tavily (`tavily`)",
"url": "https://docs.litellm.ai/docs/search/tavily",

View file

@ -18,7 +18,7 @@ import traceback
from collections.abc import Awaitable, Callable, Iterable, Iterator, Mapping, Sequence
from collections.abc import Set as AbstractSet
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from itertools import chain
from types import MappingProxyType
from typing import (
@ -228,6 +228,7 @@ from litellm.proxy.management_helpers.utils import (
management_endpoint_wrapper,
)
from litellm.proxy.spend_tracking.committed_spend import drop_committed_spend
from litellm.proxy.spend_tracking.spend_log_api_key_scope import spend_log_api_keys_for_teams
from litellm.proxy.utils import (
PrismaClient,
ProxyLogging,
@ -7463,9 +7464,11 @@ def aggregated_date_range_error(start_date: str | None, end_date: str | None) ->
return None
def _team_user_spend_sql(*, team_count: int, restrict_to_user: bool) -> str:
def _team_user_spend_sql(*, team_count: int, restrict_to_user: bool, narrow_to_api_keys: bool) -> str:
team_placeholders: Final = ", ".join(f"${i}" for i in range(3, 3 + team_count))
user_clause: Final = f' AND sl."user" = ${3 + team_count}' if restrict_to_user else ""
api_key_placeholder: Final = 3 + team_count + (1 if restrict_to_user else 0)
api_key_clause: Final = f" AND sl.api_key = ANY(${api_key_placeholder}::text[])" if narrow_to_api_keys else ""
return f"""
SELECT
sl.team_id,
@ -7483,7 +7486,7 @@ def _team_user_spend_sql(*, team_count: int, restrict_to_user: bool) -> str:
LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = sl."user"
WHERE sl."startTime" >= $1::timestamp
AND sl."startTime" < $2::timestamp + INTERVAL '1 day'
AND sl.team_id IN ({team_placeholders}){user_clause}
AND sl.team_id IN ({team_placeholders}){user_clause}{api_key_clause}
GROUP BY sl.team_id, sl."user", u.user_email, u.user_alias
ORDER BY spend DESC, sl.team_id, sl."user"
"""
@ -7555,15 +7558,24 @@ async def get_team_spend_by_user(
own_user_only: Final = scope.api_key_filter is not None
user_param: Final = (user_api_key_dict.user_id or "",) if own_user_only else ()
scoped_api_keys: Final = await spend_log_api_keys_for_teams(
prisma_client, scoped_team_ids, date.fromisoformat(start_date), date.fromisoformat(end_date)
)
api_key_param: Final = (list(scoped_api_keys),) if scoped_api_keys is not None else ()
async with db_span("team_user_spend", "LiteLLM_SpendLogs"):
rows: Final = _TEAM_USER_SPEND_ROWS.validate_python(
await query_raw_within_statement_timeout(
prisma_client,
_team_user_spend_sql(team_count=len(scoped_team_ids), restrict_to_user=own_user_only),
_team_user_spend_sql(
team_count=len(scoped_team_ids),
restrict_to_user=own_user_only,
narrow_to_api_keys=scoped_api_keys is not None,
),
start_date,
end_date,
*scoped_team_ids,
*user_param,
*api_key_param,
what="Spend per user",
)
)

View file

@ -0,0 +1,83 @@
"""``LiteLLM_SpendLogs`` has no index on ``"user"`` or ``team_id``, so a read filtered by owner scans the
whole date window. The owner's ``LiteLLM_Daily*Spend`` rows (kept after key deletion) plus their live keys
name every api_key that produced those logs, and ``(api_key, "startTime")`` is indexed."""
from collections.abc import Mapping, Sequence
from datetime import date, timedelta
from typing import Final
from pydantic import ConfigDict, TypeAdapter
from litellm.proxy.utils import PrismaClient, query_raw_within_statement_timeout
from litellm.types.llms.base import LiteLLMBaseModel
SPEND_LOG_API_KEY_SCOPE_MAX_KEYS: Final = 5000
_WHAT: Final = "Spend log key lookup"
_USER_API_KEYS_SQL: Final = """
SELECT DISTINCT api_key FROM (
SELECT api_key FROM "LiteLLM_DailyUserSpend"
WHERE user_id = $1 AND date >= $2 AND date <= $3
UNION ALL
SELECT token AS api_key FROM "LiteLLM_VerificationToken" WHERE user_id = $1
) owned
LIMIT $4
"""
_TEAM_API_KEYS_SQL: Final = """
SELECT DISTINCT api_key FROM (
SELECT api_key FROM "LiteLLM_DailyTeamSpend"
WHERE team_id = ANY($1::text[]) AND date >= $2 AND date <= $3
UNION ALL
SELECT token AS api_key FROM "LiteLLM_VerificationToken" WHERE team_id = ANY($1::text[])
) owned
LIMIT $4
"""
class _ApiKeyRow(LiteLLMBaseModel):
model_config = ConfigDict(frozen=True)
api_key: str
_API_KEY_ROWS: Final = TypeAdapter(tuple[_ApiKeyRow, ...])
def _narrowing_keys(rows: Sequence[Mapping[str, object]]) -> tuple[str, ...] | None:
"""None when the list cannot narrow the read: nothing known about the owner, or too many keys to help."""
keys: Final = tuple(row.api_key for row in _API_KEY_ROWS.validate_python(rows))
return None if not keys or len(keys) > SPEND_LOG_API_KEY_SCOPE_MAX_KEYS else keys
def _daily_dates_around(start: date, end: date) -> tuple[str, str]:
return (start - timedelta(days=1)).isoformat(), (end + timedelta(days=1)).isoformat()
async def spend_log_api_keys_for_user(
prisma_client: PrismaClient, user_id: str, start: date, end: date
) -> tuple[str, ...] | None:
"""The api_keys that can carry this user's spend logs in the window, or None when there are too many to help."""
first, last = _daily_dates_around(start, end)
return _narrowing_keys(
await query_raw_within_statement_timeout(
prisma_client, _USER_API_KEYS_SQL, user_id, first, last, SPEND_LOG_API_KEY_SCOPE_MAX_KEYS + 1, what=_WHAT
)
)
async def spend_log_api_keys_for_teams(
prisma_client: PrismaClient, team_ids: Sequence[str], start: date, end: date
) -> tuple[str, ...] | None:
first, last = _daily_dates_around(start, end)
return _narrowing_keys(
await query_raw_within_statement_timeout(
prisma_client,
_TEAM_API_KEYS_SQL,
list(team_ids),
first,
last,
SPEND_LOG_API_KEY_SCOPE_MAX_KEYS + 1,
what=_WHAT,
)
)

View file

@ -70,6 +70,10 @@ from litellm.proxy.spend_tracking.spend_capture_rate import (
# NOTE: Avoid module-level import from common_utils: proxy_server imports this
# module while common_utils may pull proxy_server during init, which can leave
# those names undefined. Import the helpers locally where they are used.
from litellm.proxy.spend_tracking.spend_log_api_key_scope import (
spend_log_api_keys_for_teams,
spend_log_api_keys_for_user,
)
from litellm.proxy.spend_tracking.spend_tracking_utils import (
get_spend_by_team,
get_spend_by_team_and_customer,
@ -257,6 +261,22 @@ async def _query_raw(prisma_client: PrismaClient, sql_query: str, *args: object)
return await prisma_client.db.query_raw(sql_query, *args)
async def _scope_api_keys(
prisma_client: PrismaClient, *, where_conditions: Mapping[str, object], start: datetime | None, end: datetime | None
) -> tuple[str, ...] | None:
"""The api_keys behind a ``"user"`` / ``team_id`` filter, so the unindexed column is only checked on rows the
``(api_key, "startTime")`` index already picked."""
if start is None or end is None or isinstance(where_conditions.get("api_key"), str):
return None
user_id: Final = where_conditions.get("user")
if isinstance(user_id, str):
return await spend_log_api_keys_for_user(prisma_client, user_id, start.date(), end.date())
team_id: Final = where_conditions.get("team_id")
if isinstance(team_id, str):
return await spend_log_api_keys_for_teams(prisma_client, (team_id,), start.date(), end.date())
return None
async def _query_raw_or_none(prisma_client: PrismaClient, sql_query: str, *args: object) -> Sequence[_RowT] | None:
"""``_query_raw`` for the call sites that guard the result against ``None``."""
return await _query_raw(prisma_client, sql_query, *args)
@ -2950,6 +2970,18 @@ async def ui_view_spend_logs(
sql_params.append(f"%{like_escaped_session_id}%")
p += 1
scope_api_keys: Final = (
await _scope_api_keys(
prisma_client, where_conditions=where_conditions, start=start_date_obj, end=end_date_obj
)
if not search_owns_window
else None
)
if scope_api_keys is not None:
sql_conditions.append(f"api_key = ANY(${p}::text[])")
sql_params.append(list(scope_api_keys))
p += 1 # rebind-ok: advances the file's shared $N placeholder counter
# Status filter
if status_filter is not None and not (group_by_session is True and not is_search_lookup):
if status_filter == "success":

View file

@ -4869,6 +4869,7 @@ class SearchProviders(str, Enum):
AGENTCORE = "agentcore"
NIMBLE = "nimble"
BING_GROUNDING = "bing_grounding"
WEBIQ = "webiq"
DEEPSEEK = "deepseek"
XAI = "xai"

View file

@ -11357,6 +11357,7 @@ class ProviderConfigManager:
from litellm.llms.serper.search.transformation import SerperSearchConfig
from litellm.llms.tavily.search.transformation import TavilySearchConfig
from litellm.llms.tinyfish.search.transformation import TinyfishSearchConfig
from litellm.llms.webiq.search.transformation import WebIQSearchConfig
from litellm.llms.xai.search.transformation import XAISearchConfig
from litellm.llms.you_com.search.transformation import YouComSearchConfig
@ -11381,6 +11382,7 @@ class ProviderConfigManager:
SearchProviders.AGENTCORE: AgentCoreSearchConfig,
SearchProviders.NIMBLE: NimbleSearchConfig,
SearchProviders.BING_GROUNDING: BingGroundingSearchConfig,
SearchProviders.WEBIQ: WebIQSearchConfig,
SearchProviders.DEEPSEEK: DeepSeekSearchConfig,
SearchProviders.XAI: XAISearchConfig,
}

File diff suppressed because it is too large Load diff

View file

@ -4098,6 +4098,32 @@
"generateContent": false
}
},
"webiq": {
"display_name": "Microsoft Web IQ (`webiq`)",
"url": "https://learn.microsoft.com/en-us/web-iq/reference/web-search",
"endpoints": {
"chat_completions": false,
"responses": false,
"text_completion": false,
"embeddings": false,
"image_generations": false,
"image_edits": false,
"audio_transcriptions": false,
"audio_speech": false,
"rerank": false,
"ocr": false,
"search": true,
"moderations": false,
"realtime": false,
"video_generations": false,
"batches": false,
"messages": false,
"interactions": false,
"systemone": false,
"a2a": false,
"generateContent": false
}
},
"tavily": {
"display_name": "Tavily (`tavily`)",
"url": "https://docs.litellm.ai/docs/search/tavily",

View file

@ -15,6 +15,7 @@ import pytest
from litellm.integrations.otel import GenAIOperation
from litellm.integrations.otel.mappers import (
GenAIMapper,
LangfuseMapper,
LangtraceMapper,
OpenInferenceMapper,
@ -33,6 +34,7 @@ from litellm.integrations.otel.model.payloads import (
ToolDefinition,
)
from litellm.integrations.otel.model.trace_controls import TraceControls
from litellm.types.utils import Message
from tests.unit.integrations.otel.test_otel_v2_sources_of_truth import _responses_payload
@ -708,6 +710,78 @@ def test_langfuse_mapper_keeps_chat_output_when_no_embedding_summary():
assert json.loads(attrs["langfuse.observation.output"]) == [{"role": "assistant", "content": "Sunny."}]
LITELLM_USER_MESSAGE: Final = Message(role="user", content="weather in sf?")
NOT_JSON_ENCODABLE: Final = object()
@pytest.mark.parametrize(
("message", "expected_prompt"),
[
({"role": "user", "content": "weather in sf?"}, {"role": "user", "content": "weather in sf?"}),
(
{"role": "user", "content": "weather in sf?", 1: "one"},
{"role": "user", "content": "weather in sf?", "1": "one"},
),
(LITELLM_USER_MESSAGE, LITELLM_USER_MESSAGE.model_dump(mode="json")),
(
Message(role="user", content="weather in sf?", trace_handle=NOT_JSON_ENCODABLE),
{**LITELLM_USER_MESSAGE.model_dump(mode="json"), "trace_handle": str(NOT_JSON_ENCODABLE)},
),
],
ids=[
"dict_message",
"dict_message_with_int_key",
"litellm_message_object",
"litellm_message_with_value_json_cannot_encode",
],
)
def test_vendor_mappers_keep_the_prompt_of_litellm_message_objects(
message: Mapping[str, object] | Message, expected_prompt: Mapping[str, object]
) -> None:
payload: Final = {
"call_type": "acompletion",
"custom_llm_provider": "openai",
"model": "gpt-5.4-nano",
"messages": [message],
"response": {"choices": [{"finish_reason": "stop", "message": {"role": "assistant", "content": "Sunny."}}]},
}
data: Final = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True)
assert {
"genai": json.loads(str(GenAIMapper().map(data)["gen_ai.input.messages"])),
"langfuse": json.loads(str(LangfuseMapper().map(data)["langfuse.observation.input"])),
"langtrace": json.loads(str(LangtraceMapper().map(data)["llm.prompts"])),
"openinference": json.loads(str(OpenInferenceMapper().map(data)["input.value"])),
} == {
"genai": [expected_prompt],
"langfuse": [expected_prompt],
"langtrace": [expected_prompt],
"openinference": [{"role": "user", "content": "weather in sf?"}],
}
def test_vendor_mappers_keep_the_span_when_a_litellm_message_cannot_be_dumped() -> None:
payload: Final = {
"call_type": "acompletion",
"custom_llm_provider": "openai",
"model": "gpt-5.4-nano",
"messages": [
{"role": "system", "content": "be brief"},
Message(role="user", content="weather in sf?", audio_bytes=b"\xff\xfe"),
],
"response": {"choices": [{"finish_reason": "stop", "message": {"role": "assistant", "content": "Sunny."}}]},
}
attrs: Final = LangfuseMapper().map(LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True))
assert {
"input": json.loads(str(attrs["langfuse.observation.input"])),
"output": json.loads(str(attrs["langfuse.observation.output"])),
} == {
"input": [{"role": "system", "content": "be brief"}],
"output": [{"role": "assistant", "content": "Sunny."}],
}
def test_langfuse_mapper_renders_a_responses_api_call_from_the_standard_logging_payload():
payload = {
"call_type": "aresponses",

View file

@ -20,6 +20,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE
from opentelemetry.trace import Span
import litellm
from litellm.integrations._types.open_inference import SpanAttributes
from litellm.constants import LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL, REDACTED_BY_LITELLM
from litellm.integrations.langfuse.langfuse_otel import LangfuseOtelLogger
from litellm.integrations.langfuse.langfuse_otel_attributes import LangfuseLLMObsOTELAttributes
@ -28,7 +29,20 @@ from litellm.integrations.otel.model.destination import OtelDestination
from litellm.integrations.otel.plumbing.context import set_request_destinations
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.llms.openai import ResponsesAPIResponse
from litellm.types.utils import Choices, ModelResponse, StandardCallbackDynamicParams
from litellm.types.utils import Choices, Message, ModelResponse, StandardCallbackDynamicParams
CHAT_MESSAGE_DICTS: Final = ({"role": "user", "content": "hello"}, {"role": "assistant", "content": "hi back"})
CHAT_MESSAGE_OBJECTS: Final = tuple(
Message(role=message["role"], content=message["content"]) for message in CHAT_MESSAGE_DICTS
)
NOT_JSON_ENCODABLE: Final = object()
NON_UTF8_BYTES: Final = b"\x80"
GENERATION_USAGE_AND_PARAM_KEYS: Final = (
SpanAttributes.LLM_TOKEN_COUNT_PROMPT,
SpanAttributes.LLM_TOKEN_COUNT_COMPLETION,
SpanAttributes.LLM_TOKEN_COUNT_TOTAL,
SpanAttributes.LLM_INVOCATION_PARAMETERS,
)
class TestLangfuseOtelIntegration:
@ -140,6 +154,52 @@ class TestLangfuseOtelIntegration:
"langfuse.observation.type", "generation"
)
@pytest.mark.parametrize(
("messages", "expected_input"),
[
(CHAT_MESSAGE_DICTS, list(CHAT_MESSAGE_DICTS)),
(CHAT_MESSAGE_OBJECTS, [message.model_dump() for message in CHAT_MESSAGE_OBJECTS]),
(
(Message(role="user", content="hello", trace_handle=NOT_JSON_ENCODABLE),),
[{**CHAT_MESSAGE_OBJECTS[0].model_dump(), "trace_handle": str(NOT_JSON_ENCODABLE)}],
),
(
(Message(role="user", content="hello", audio_bytes=NON_UTF8_BYTES),),
[{**CHAT_MESSAGE_OBJECTS[0].model_dump(), "audio_bytes": str(NON_UTF8_BYTES)}],
),
],
ids=[
"dict_messages",
"litellm_message_objects",
"litellm_message_with_value_json_cannot_encode",
"litellm_message_with_non_utf8_bytes",
],
)
def test_generation_keeps_input_model_params_and_token_usage(
self, messages: Sequence[object], expected_input: Sequence[object]
) -> None:
span: Final = TracerProvider().get_tracer("langfuse-otel-tests").start_span("litellm_request")
response: Final = ModelResponse(
model="gpt-4o",
choices=[{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "done"}}],
usage={"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10},
)
LangfuseOtelLogger.set_langfuse_otel_attributes(
span,
{"messages": messages, "standard_logging_object": {"model_parameters": {"temperature": 0.2}}},
response,
)
attributes: Final = span.attributes or {}
assert json.loads(str(attributes["langfuse.observation.input"])) == expected_input
assert {key: attributes.get(key) for key in GENERATION_USAGE_AND_PARAM_KEYS} == {
SpanAttributes.LLM_TOKEN_COUNT_PROMPT: 7,
SpanAttributes.LLM_TOKEN_COUNT_COMPLETION: 3,
SpanAttributes.LLM_TOKEN_COUNT_TOTAL: 10,
SpanAttributes.LLM_INVOCATION_PARAMETERS: json.dumps({"temperature": 0.2}),
}
def test_set_langfuse_environment_attribute(self):
"""Test that Langfuse environment is set correctly when environment variable is present."""
mock_span = MagicMock()

View file

@ -0,0 +1,76 @@
import json
from collections.abc import Sequence
from typing import Final
import pytest
from opentelemetry.sdk.trace import TracerProvider
from litellm.integrations.langtrace import LangtraceAttributes
from litellm.types.integrations.otel_span_attributes import SpanAttributes
from litellm.types.utils import Message, ModelResponse
CHAT_MESSAGE_DICTS: Final = ({"role": "user", "content": "weather in SF?"}, {"role": "assistant", "content": "Sunny."})
CHAT_MESSAGE_OBJECTS: Final = tuple(
Message(role=message["role"], content=message["content"]) for message in CHAT_MESSAGE_DICTS
)
NOT_JSON_ENCODABLE: Final = object()
NON_UTF8_BYTES: Final = b"\x80"
@pytest.mark.parametrize(
("messages", "expected_prompts"),
[
(CHAT_MESSAGE_DICTS, list(CHAT_MESSAGE_DICTS)),
(CHAT_MESSAGE_OBJECTS, [message.model_dump() for message in CHAT_MESSAGE_OBJECTS]),
(
({"role": "user", "content": "before\x00after", "metadata": {1: "one"}},),
[{"role": "user", "content": "before\x00after", "metadata": {"1": "one"}}],
),
(
(Message(role="user", content="weather in SF?", trace_handle=NOT_JSON_ENCODABLE),),
[{**CHAT_MESSAGE_OBJECTS[0].model_dump(), "trace_handle": str(NOT_JSON_ENCODABLE)}],
),
(
(Message(role="user", content="weather in SF?", audio_bytes=NON_UTF8_BYTES),),
[str(Message(role="user", content="weather in SF?", audio_bytes=NON_UTF8_BYTES))],
),
],
ids=[
"dict_messages",
"litellm_message_objects",
"dict_message_with_nul_and_int_key",
"litellm_message_with_value_json_cannot_encode",
"litellm_message_with_non_utf8_bytes",
],
)
def test_langtrace_span_keeps_prompt_and_token_usage(
messages: Sequence[object], expected_prompts: Sequence[object]
) -> None:
span: Final = TracerProvider().get_tracer("langtrace-tests").start_span("litellm_request")
response: Final = ModelResponse(
model="gpt-4o",
choices=[{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "Still sunny."}}],
usage={"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10},
)
LangtraceAttributes().set_langtrace_attributes(
span, {"messages": messages, "litellm_params": {"custom_llm_provider": "openai"}}, response
)
assert [
json.loads(str((event.attributes or {})[SpanAttributes.LLM_PROMPTS.value]))
for event in span.events
if event.name == "gen_ai.content.prompt"
] == [expected_prompts]
assert {
key: (span.attributes or {}).get(key)
for key in (
SpanAttributes.LLM_USAGE_PROMPT_TOKENS.value,
SpanAttributes.LLM_USAGE_COMPLETION_TOKENS.value,
SpanAttributes.LLM_USAGE_TOTAL_TOKENS.value,
)
} == {
SpanAttributes.LLM_USAGE_PROMPT_TOKENS.value: 7,
SpanAttributes.LLM_USAGE_COMPLETION_TOKENS.value: 3,
SpanAttributes.LLM_USAGE_TOTAL_TOKENS.value: 10,
}

View file

@ -1,6 +1,6 @@
import json
import os
from collections.abc import Mapping
from collections.abc import Mapping, Sequence
from typing import Final
from unittest.mock import MagicMock, patch
@ -12,9 +12,10 @@ from litellm.integrations.weave.weave_otel import (
WeaveLLMObsOTELAttributes,
_set_weave_specific_attributes,
get_weave_otel_config,
set_weave_otel_attributes,
)
from litellm.types.integrations.weave_otel import WeaveOtelConfig, WeaveSpanAttributes
from litellm.types.utils import ModelResponse
from litellm.types.utils import Message, ModelResponse
def test_get_weave_otel_config():
@ -205,3 +206,67 @@ def test_weave_output_value_is_the_dumped_model_response() -> None:
output: Final = json.loads(str(span.attributes[OpenInferenceSpanAttributes.OUTPUT_VALUE]))
assert output["id"] == "chatcmpl-1"
assert output["choices"][0]["message"]["content"] == "Sunny."
NOT_JSON_ENCODABLE: Final = object()
NON_UTF8_BYTES: Final = b"\x80"
@pytest.mark.parametrize(
("messages", "expected_messages"),
[
(
(Message(role="user", content="weather in SF?"), Message(role="assistant", content="Sunny.")),
[
Message(role="user", content="weather in SF?").model_dump(),
Message(role="assistant", content="Sunny.").model_dump(),
],
),
(
(Message(role="user", content="weather in SF?", trace_handle=NOT_JSON_ENCODABLE),),
[{**Message(role="user", content="weather in SF?").model_dump(), "trace_handle": str(NOT_JSON_ENCODABLE)}],
),
(
(Message(role="user", content="weather in SF?", audio_bytes=NON_UTF8_BYTES),),
[str(Message(role="user", content="weather in SF?", audio_bytes=NON_UTF8_BYTES))],
),
],
ids=["litellm_messages", "litellm_message_with_value_json_cannot_encode", "litellm_message_with_non_utf8_bytes"],
)
def test_weave_span_keeps_input_and_token_usage_when_messages_are_litellm_messages(
messages: tuple[Message, ...], expected_messages: Sequence[object]
) -> None:
span: Final = _TRACER.start_span("litellm_request")
response: Final = ModelResponse(
model="gpt-4o",
choices=[{"index": 0, "finish_reason": "stop", "message": {"role": "assistant", "content": "Still sunny."}}],
usage={"prompt_tokens": 7, "completion_tokens": 3, "total_tokens": 10},
)
set_weave_otel_attributes(span, {"messages": messages, "standard_logging_object": {}}, response)
attributes: Final = span.attributes or {}
assert json.loads(str(attributes[OpenInferenceSpanAttributes.INPUT_VALUE])) == {"messages": expected_messages}
assert {
key: attributes.get(key)
for key in (
OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_PROMPT,
OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_COMPLETION,
OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_TOTAL,
)
} == {
OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_PROMPT: 7,
OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_COMPLETION: 3,
OpenInferenceSpanAttributes.LLM_TOKEN_COUNT_TOTAL: 10,
}
def test_weave_span_input_keeps_dict_message_content_exactly_as_sent() -> None:
span: Final = _TRACER.start_span("litellm_request")
messages: Final = ({"role": "user", "content": "before\x00after", "metadata": {1: "one"}},)
set_weave_otel_attributes(span, {"messages": messages, "standard_logging_object": {}}, ModelResponse(model="gpt-4o"))
assert json.loads(str((span.attributes or {})[OpenInferenceSpanAttributes.INPUT_VALUE])) == {
"messages": [{"role": "user", "content": "before\x00after", "metadata": {"1": "one"}}]
}

View file

@ -24,6 +24,46 @@ from litellm.llms.vertex_ai.image_edit.vertex_imagen_transformation import (
from litellm.types.router import GenericLiteLLMParams
@pytest.mark.parametrize("traffic_type", ("ON_DEMAND_PRIORITY", "", None))
def test_vertex_gemini_edit_preserves_usage_and_traffic_type(traffic_type: str | None) -> None:
response: Final = VertexAIGeminiImageEditConfig().transform_image_edit_response(
model="gemini-3.1-flash-image",
raw_response=httpx.Response(
200,
json={
"candidates": [],
"usageMetadata": {
"promptTokenCount": 1131,
"candidatesTokenCount": 0,
"totalTokenCount": 1131,
"promptTokensDetails": [
{"modality": "TEXT", "tokenCount": 7},
{"modality": "IMAGE", "tokenCount": 1124},
],
**({"trafficType": traffic_type} if traffic_type is not None else {}),
},
},
),
logging_obj=Logging(
model="gemini-3.1-flash-image",
messages=[],
stream=False,
call_type="image_edit",
start_time=datetime(2025, 1, 1),
litellm_call_id="edit",
function_id="edit",
),
)
assert response.usage is not None
assert response.usage.input_tokens == 1131
assert response.usage.output_tokens == 0
assert response.usage.input_tokens_details.text_tokens == 7
assert response.usage.input_tokens_details.image_tokens == 1124
assert response.hidden_params.get("provider_specific_fields", {}) == (
{"traffic_type": traffic_type} if traffic_type else {}
)
@pytest.mark.parametrize("size", ("1536x1024", "1024x1536", "3072x2048", "256x256", "512x512", "4096x4096"))
@pytest.mark.parametrize("model", ("gemini-3.1-flash-image", "gemini-3-pro-image", "gemini-3.1-flash-lite-image"))
def test_vertex_gemini_edit_size_reaches_request(size: str, model: str) -> None:

View file

@ -1,7 +1,16 @@
import os
from datetime import datetime
from typing import Final
import httpx
import pytest
import litellm
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.llms.vertex_ai.gemini.cost_calculator import cost_per_web_search_request
from litellm.llms.vertex_ai.image_edit.vertex_gemini_transformation import (
VertexAIGeminiImageEditConfig,
)
from litellm.llms.vertex_ai.image_generation.cost_calculator import (
cost_calculator as vertex_image_generation_cost_calculator,
)
@ -15,6 +24,75 @@ from litellm.types.utils import (
)
@pytest.mark.parametrize("call_type", ("image_edit", "aimage_edit"))
@pytest.mark.parametrize("has_image, has_usage", ((True, True), (False, True), (True, False)))
def test_vertex_image_edit_cost_uses_input_tokens_and_retains_image_fallback(
call_type: str,
has_image: bool,
has_usage: bool,
) -> None:
model: Final = "gemini-3.1-flash-image"
model_info: Final = litellm.get_model_info(model=model, custom_llm_provider="vertex_ai")
response: Final = VertexAIGeminiImageEditConfig().transform_image_edit_response(
model=model,
raw_response=httpx.Response(
200,
json={
"candidates": [
{
"finishReason": "STOP" if has_image else "IMAGE_RECITATION",
**(
{"content": {"parts": [{"inlineData": {"mimeType": "image/png", "data": "aW1n"}}]}}
if has_image
else {}
),
}
],
**(
{
"usageMetadata": {
"promptTokenCount": 1131,
"candidatesTokenCount": 100 if has_image else 0,
"totalTokenCount": 1231 if has_image else 1131,
"promptTokensDetails": [
{"modality": "TEXT", "tokenCount": 7},
{"modality": "IMAGE", "tokenCount": 1124},
],
}
}
if has_usage
else {}
),
},
),
logging_obj=Logging(
model=model,
messages=[],
stream=False,
call_type=call_type,
start_time=datetime(2025, 1, 1),
litellm_call_id="edit",
function_id="edit",
),
)
cost: Final = litellm.completion_cost(
model=model,
custom_llm_provider="vertex_ai",
completion_response=response,
call_type=call_type,
)
expected: Final = (
7 * (model_info["input_cost_per_token"] or 0)
+ 1124 * (model_info.get("input_cost_per_image_token") or model_info["input_cost_per_token"] or 0)
+ (100 if has_image else 0) * (model_info.get("output_cost_per_image_token") or 0)
if has_usage
else model_info["output_cost_per_image"]
)
assert expected is not None and expected > 0
assert cost == pytest.approx(expected)
assert vertex_image_generation_cost_calculator(model=model, image_response=response) == pytest.approx(expected)
def _image_response_with_web_search(web_search_requests):
usage = ImageUsage(
input_tokens=20,

View file

View file

View file

@ -0,0 +1,351 @@
import json
from collections.abc import Iterator
from typing import Final
import openai
import pytest
import respx
import litellm
from litellm.llms.base_llm.search.transformation import SearchResponse
from litellm.llms.webiq.search.transformation import WebIQSearchConfig
@pytest.fixture(autouse=True)
def webiq_environment(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.delenv("WEBIQ_API_KEY", raising=False)
monkeypatch.setattr( # test-quality-ok: select HTTPX so respx can intercept the external HTTP boundary
litellm, "disable_aiohttp_transport", True
)
litellm.in_memory_llm_clients_cache.flush_cache()
yield
litellm.in_memory_llm_clients_cache.flush_cache()
def test_search_maps_request_and_preserves_sources(respx_mock: respx.MockRouter) -> None:
endpoint: Final = f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web"
route: Final = respx_mock.post(endpoint).respond(
json={
"webResults": [
{
"title": "A source",
"url": "https://example.com/article",
"content": "Relevant passage",
"lastUpdatedAt": "2026-01-02T00:00:00Z",
"crawledAt": "2026-01-03T00:00:00Z",
"language": "en",
"contentTier": "standard",
"instrumentationSuffix": "source-1",
}
],
"traceId": "trace-1",
"querySignals": {"freshness": True},
}
)
response: Final = litellm.search(
query="RAG research",
search_provider="webiq",
api_key="test-key",
max_results=3,
country="gb",
search_domain_filter=["example.com", "example.org", "-excluded.example"],
language="en",
)
assert json.loads(route.calls.last.request.content) == {
"query": "(RAG research) (site:example.com OR site:example.org) -site:excluded.example",
"maxResults": 3,
"region": "GB",
"contentFormat": "passage",
"maxLength": 5000,
"language": "en",
}
assert route.calls.last.request.headers["x-apikey"] == "test-key"
assert response.model_dump() == {
"object": "search",
"results": [
{
"title": "A source",
"url": "https://example.com/article",
"snippet": "Relevant passage",
"date": "2026-01-02T00:00:00Z",
"last_updated": "2026-01-02T00:00:00Z",
"crawledAt": "2026-01-03T00:00:00Z",
"language": "en",
"contentTier": "standard",
"instrumentationSuffix": "source-1",
}
],
"traceId": "trace-1",
"querySignals": {"freshness": True},
}
@pytest.mark.parametrize("api_base", [None, "https://api.microsoft.ai/v3", "https://api.microsoft.ai/v3/search/web"])
async def test_asearch_uses_environment_credentials_at_default_endpoint(
respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch, api_base: str | None
) -> None:
monkeypatch.setenv("WEBIQ_API_KEY", "environment-key")
route: Final = respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond(json={"webResults": []})
response: Final = await litellm.asearch(query="no matches", search_provider="webiq", api_base=api_base)
assert response.model_dump() == {"object": "search", "results": []}
assert route.calls.last.request.headers["x-apikey"] == "environment-key"
assert json.loads(route.calls.last.request.content) == {
"query": "no matches",
"contentFormat": "passage",
"maxLength": 5000,
}
async def test_asearch_uses_explicit_credentials_at_custom_endpoint(respx_mock: respx.MockRouter) -> None:
route: Final = respx_mock.post("https://webiq.example/v3/search/web").respond(json={"webResults": []})
response: Final = await litellm.asearch(
query="no matches", search_provider="webiq", api_key="custom-key", api_base="https://webiq.example/v3/"
)
assert response.model_dump() == {"object": "search", "results": []}
assert route.calls.last.request.headers["x-apikey"] == "custom-key"
assert json.loads(route.calls.last.request.content) == {
"query": "no matches",
"contentFormat": "passage",
"maxLength": 5000,
}
@pytest.mark.parametrize("updated", [None, "", "2026-02-01T00:00:00Z"])
def test_optional_updated_date_never_uses_crawl_time(respx_mock: respx.MockRouter, updated: str | None) -> None:
respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond(
json={
"webResults": [
{
"title": "Title",
"url": "https://example.com",
"content": "Passage",
"lastUpdatedAt": updated,
"crawledAt": "2026-03-01T00:00:00Z",
}
]
}
)
response: Final = litellm.search(query="query", search_provider="webiq", api_key="key")
assert (response.results[0].date, response.results[0].last_updated) == (updated or None, updated or None)
def test_missing_optional_dates_and_result_order(respx_mock: respx.MockRouter) -> None:
respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond(
json={
"webResults": [
{"title": title, "url": f"https://example.com/{title}", "content": title}
for title in ("first", "second")
]
}
)
response: Final = litellm.search(query="query", search_provider="webiq", api_key="key")
assert [(r.title, r.snippet, r.date, r.last_updated) for r in response.results] == [
("first", "first", None, None),
("second", "second", None, None),
]
@pytest.mark.parametrize("body", ["not json", "{}", '{"webResults": null}', '{"webResults": [{}]}'])
def test_malformed_response_is_not_reported_as_empty_success(respx_mock: respx.MockRouter, body: str) -> None:
respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond(text=body)
with pytest.raises(openai.APIError, match="invalid search response") as error:
litellm.search(query="query", search_provider="webiq", api_key="key")
assert error.value.status_code == 502
@pytest.mark.parametrize("status", [401, 403, 429, 503])
def test_upstream_http_errors_keep_status(respx_mock: respx.MockRouter, status: int) -> None:
respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond(
status_code=status, json={"error": {"message": "upstream failure"}}, headers={"Retry-After": "2"}
)
with pytest.raises(openai.APIError, match="upstream failure") as error:
litellm.search(query="query", search_provider="webiq", api_key="key")
assert error.value.status_code == status
def test_native_options_override_mapped_defaults_without_mutating_input() -> None:
params: Final = {
"max_results": 3,
"maxResults": 7,
"country": "us",
"region": "JP",
"maxLength": 2000,
"max_tokens_per_page": 100,
"contentFormat": "markdown",
"safeSearch": "strict",
}
before: Final = params.copy()
assert WebIQSearchConfig().transform_search_request(["first", "second"], params) == {
"query": "first second",
"maxResults": 7,
"region": "JP",
"maxLength": 2000,
"contentFormat": "markdown",
"safeSearch": "strict",
}
assert params == before
@pytest.mark.parametrize(
"domains, expected",
[
([], "query"),
([""], "query"),
(["-"], "query"),
(["-example.com"], "(query) -site:example.com"),
(["example.com"], "(query) (site:example.com)"),
],
)
def test_domain_filter_queries(domains: list[str], expected: str) -> None:
assert WebIQSearchConfig().transform_search_request("query", {"search_domain_filter": domains}) == {
"query": expected,
"contentFormat": "passage",
"maxLength": 5000,
}
@pytest.mark.parametrize("suffix", ["", "/", "/search/web", "/search/web/"])
def test_endpoint_is_appended_once(suffix: str) -> None:
assert WebIQSearchConfig().get_complete_url(f"https://webiq.example/v3{suffix}", {}) == (
"https://webiq.example/v3/search/web"
)
def test_environment_key_cannot_be_sent_to_caller_selected_host(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("WEBIQ_API_KEY", "server-key")
with pytest.raises(ValueError, match="Refusing to send"):
WebIQSearchConfig().validate_environment({}, api_base="https://untrusted.example/v3")
@pytest.mark.parametrize("use_async", [False, True])
@pytest.mark.parametrize("api_key", [None, "caller-key"])
async def test_http_endpoint_rejected_before_sending_credentials(
monkeypatch: pytest.MonkeyPatch, use_async: bool, api_key: str | None
) -> None:
monkeypatch.setenv("WEBIQ_API_KEY", "server-key")
with respx.mock(assert_all_called=False) as transport:
transport.post("http://api.microsoft.ai/v3/search/web").respond(json={"webResults": []})
if use_async:
with pytest.raises(openai.APIError, match="HTTPS"):
await litellm.asearch(
query="query", search_provider="webiq", api_base="http://api.microsoft.ai/v3", api_key=api_key
)
else:
with pytest.raises(openai.APIError, match="HTTPS"):
litellm.search(
query="query", search_provider="webiq", api_base="http://api.microsoft.ai/v3", api_key=api_key
)
assert len(transport.calls) == 0
def test_explicit_key_wins_and_headers_are_not_mutated(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("WEBIQ_API_KEY", "server-key")
config: Final = WebIQSearchConfig()
original: Final = {"X-Request-ID": "request-1"}
result: Final = config.validate_environment(original, api_key="caller-key", api_base="https://custom.example/v3")
assert result == {"X-Request-ID": "request-1", "x-apikey": "caller-key", "Content-Type": "application/json"}
assert original == {"X-Request-ID": "request-1"}
assert config.validate_environment(result, api_key="caller-key") == result
def test_missing_key_fails_before_network() -> None:
with pytest.raises(ValueError, match="WEBIQ_API_KEY"):
WebIQSearchConfig().validate_environment({})
@pytest.mark.parametrize(
"result_metadata, response_metadata",
[
({"snippet": "upstream", "date": "upstream", "last_updated": "upstream"}, {}),
({}, {"results": "upstream", "object": "upstream"}),
],
)
def test_metadata_cannot_replace_standard_search_fields(
respx_mock: respx.MockRouter, result_metadata: dict[str, str], response_metadata: dict[str, str]
) -> None:
respx_mock.post(f"{WebIQSearchConfig.DEFAULT_API_BASE}/search/web").respond(
json={
"webResults": [
{
"title": "Title",
"url": "https://example.com",
"content": "Passage",
"lastUpdatedAt": "2026-01-02T00:00:00Z",
"language": "en",
**result_metadata,
}
],
"traceId": "trace-1",
**response_metadata,
}
)
response: Final = litellm.search(query="query", search_provider="webiq", api_key="key")
assert response.model_dump() == {
"object": "search",
"results": [
{
"title": "Title",
"url": "https://example.com",
"snippet": "Passage",
"date": "2026-01-02T00:00:00Z",
"last_updated": "2026-01-02T00:00:00Z",
"language": "en",
}
],
"traceId": "trace-1",
}
@pytest.mark.parametrize("tool_parameter", ["model", "search_tool_name"])
async def test_router_resolves_named_webiq_tool_and_forwards_its_configuration(
respx_mock: respx.MockRouter, tool_parameter: str
) -> None:
route: Final = respx_mock.post("https://webiq.example/v3/search/web").respond(
json={"webResults": [{"title": "Source", "url": "https://example.com", "content": "Grounding"}]}
)
router: Final = litellm.Router(
model_list=[],
search_tools=[
{
"search_tool_name": "webiq-search",
"litellm_params": {
"search_provider": "webiq",
"api_key": "tool-key",
"api_base": "https://webiq.example/v3",
"maxLength": 1500,
},
}
],
num_retries=0,
)
response: Final = await router.asearch(**{tool_parameter: "webiq-search"}, query="ground this", max_results=2)
assert isinstance(response, SearchResponse)
assert response.model_dump() == {
"object": "search",
"results": [
{
"title": "Source",
"url": "https://example.com",
"snippet": "Grounding",
"date": None,
"last_updated": None,
}
],
}
assert route.calls.last.request.headers["x-apikey"] == "tool-key"
assert json.loads(route.calls.last.request.content) == {
"query": "ground this",
"contentFormat": "passage",
"maxLength": 1500,
"maxResults": 2,
}

View file

@ -3,6 +3,7 @@ import json
from collections.abc import Callable
from datetime import datetime
from types import SimpleNamespace
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@ -27,6 +28,16 @@ from tests.unit.proxy.router_entry_point_stubs import route_entry_points_to_stub
client = TestClient(app)
def test_dashboard_discovers_webiq_with_its_display_name() -> None:
with _override_auth(UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN)):
response: Final = client.get("/search_tools/ui/available_providers")
assert response.status_code == 200, response.text
assert [provider for provider in response.json()["providers"] if provider["provider_name"] == "webiq"] == [
{"provider_name": "webiq", "ui_friendly_name": "Microsoft Web IQ"}
]
@pytest.mark.asyncio
async def test_list_search_tools_db_only(monkeypatch):
"""Test listing search tools when only DB tools exist"""

View file

@ -17341,6 +17341,19 @@ def _team_spend_by_user_caller(user_id: str, teams: list[str]) -> LiteLLM_UserTa
)
def _team_spend_reads(api_keys: list[str], spend_rows: list[dict] | Exception) -> AsyncMock:
"""Raw reads inside the statement-timeout transaction: the key lookup, then the spend log aggregate."""
async def read(sql: str, *params: object) -> list[dict]:
if 'FROM "LiteLLM_DailyTeamSpend"' in sql:
return [{"api_key": api_key} for api_key in api_keys]
if isinstance(spend_rows, Exception):
raise spend_rows
return spend_rows
return AsyncMock(side_effect=read)
def _team_spend_by_user_db_row(team_id: str, user_id: str, spend: float, requests: int) -> dict:
return {
"team_id": team_id,
@ -17367,12 +17380,13 @@ async def test_get_team_spend_by_user_admin_groups_spend_logs_by_team_and_user(m
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha, beta])
transaction = statement_timeout_reads(
mock_db_client,
AsyncMock(
return_value=[
_team_spend_reads(
["sk-alpha", "sk-beta"],
[
_team_spend_by_user_db_row("team-alpha", "alice", 0.5, 3),
_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2),
_team_spend_by_user_db_row("team-beta", "alice", 0.1, 1),
]
],
),
)
@ -17386,11 +17400,15 @@ async def test_get_team_spend_by_user_admin_groups_spend_logs_by_team_and_user(m
assert any("statement_timeout = 30000" in statement for statement in transaction.statements)
mock_db_client.db.tx.assert_not_called()
sql, *params = transaction.query_raw.call_args.args
assert params == ["2026-09-01", "2026-09-04", "team-alpha", "team-beta"]
assert params == ["2026-09-01", "2026-09-04", "team-alpha", "team-beta", ["sk-alpha", "sk-beta"]]
assert 'FROM "LiteLLM_SpendLogs" sl' in sql
assert 'sl."startTime" >= $1::timestamp' in sql
assert "sl.\"startTime\" < $2::timestamp + INTERVAL '1 day'" in sql
assert "sl.team_id IN ($3, $4)" in sql
assert "sl.api_key = ANY($5::text[])" in sql
lookup_sql, *lookup_params = transaction.query_raw.call_args_list[0].args
assert 'FROM "LiteLLM_DailyTeamSpend"' in lookup_sql and 'FROM "LiteLLM_VerificationToken"' in lookup_sql
assert lookup_params == [["team-alpha", "team-beta"], "2026-08-31", "2026-09-05", 5001]
assert 'GROUP BY sl.team_id, sl."user"' in sql
assert 'sl."user" = $' not in sql
@ -17412,7 +17430,7 @@ async def test_get_team_spend_by_user_team_admin_sees_every_member(mock_db_clien
caller = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER)
alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), [])
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha])
transaction = statement_timeout_reads(mock_db_client, AsyncMock(return_value=[]))
transaction = statement_timeout_reads(mock_db_client, _team_spend_reads(["sk-alpha"], []))
mock_db_client.writer_db.litellm_usertable.find_unique = AsyncMock(
return_value=_team_spend_by_user_caller("alice", ["team-alpha"])
)
@ -17422,8 +17440,9 @@ async def test_get_team_spend_by_user_team_admin_sees_every_member(mock_db_clien
)
sql, *params = transaction.query_raw.call_args.args
assert params == ["2026-09-01", "2026-09-04", "team-alpha"]
assert params == ["2026-09-01", "2026-09-04", "team-alpha", ["sk-alpha"]]
assert 'sl."user" = $' not in sql
assert "sl.api_key = ANY($4::text[])" in sql
@pytest.mark.asyncio
@ -17435,7 +17454,7 @@ async def test_get_team_spend_by_user_plain_member_only_sees_own_row(mock_db_cli
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha])
mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
transaction = statement_timeout_reads(
mock_db_client, AsyncMock(return_value=[_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2)])
mock_db_client, _team_spend_reads(["sk-bob"], [_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2)])
)
mock_db_client.writer_db.litellm_usertable.find_unique = AsyncMock(
return_value=_team_spend_by_user_caller("bob", ["team-alpha"])
@ -17446,9 +17465,10 @@ async def test_get_team_spend_by_user_plain_member_only_sees_own_row(mock_db_cli
)
sql, *params = transaction.query_raw.call_args.args
assert params == ["2026-09-01", "2026-09-04", "team-alpha", "bob"]
assert params == ["2026-09-01", "2026-09-04", "team-alpha", "bob", ["sk-bob"]]
assert "sl.team_id IN ($3)" in sql
assert 'AND sl."user" = $4' in sql
assert "sl.api_key = ANY($5::text[])" in sql
assert [(r.user_id, r.spend) for r in response.results] == [("bob", 0.25)]
@ -17506,7 +17526,7 @@ async def test_get_team_spend_by_user_statement_timeout_returns_503(mock_db_clie
admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), [])
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha])
statement_timeout_reads(mock_db_client, AsyncMock(side_effect=statement_timeout_error()))
statement_timeout_reads(mock_db_client, _team_spend_reads(["sk-alpha"], statement_timeout_error()))
with pytest.raises(HTTPException) as exc_info:
await get_team_spend_by_user(
@ -17520,6 +17540,30 @@ async def test_get_team_spend_by_user_statement_timeout_returns_503(mock_db_clie
assert "longer than 30s" in str(exc_info.value.detail)
@pytest.mark.asyncio
@pytest.mark.parametrize("known_key_count", [0, 5001])
async def test_get_team_spend_by_user_without_a_usable_key_list_keeps_the_plain_team_filter(
mock_db_client, known_key_count: int
):
from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user
admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), [])
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha])
transaction = statement_timeout_reads(
mock_db_client, _team_spend_reads([f"sk-{i}" for i in range(known_key_count)], [])
)
await get_team_spend_by_user(
user_api_key_dict=admin, team_ids="team-alpha", start_date="2026-09-01", end_date="2026-09-04"
)
sql, *params = transaction.query_raw.call_args.args
assert params == ["2026-09-01", "2026-09-04", "team-alpha"]
assert "sl.team_id IN ($3)" in sql
assert "sl.api_key = ANY(" not in sql
class _TeamRowWithOrganization(LiteLLM_TeamTable):
litellm_organization_table: LiteLLM_OrganizationTable | None = None

View file

@ -1,5 +1,3 @@
from litellm.proxy.deps.repositories import get_plugin_upload_repository
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
import asyncio
import json
import re
@ -20,7 +18,9 @@ from pydantic import TypeAdapter
import litellm
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.db.health_check_latest import LatestHealthCheckRow
from litellm.proxy.deps.repositories import get_plugin_upload_repository
from litellm.proxy.public_endpoints import router
from litellm.repositories.plugin_upload_repository import PluginUploadRepository
from litellm.router_strategy.complexity_router.fuse_presets import get_fuse_presets
from litellm.types.proxy.common_proxy_errors import CommonProxyErrors
from litellm.types.proxy.management_endpoints.model_management_endpoints import (
@ -1285,6 +1285,18 @@ def test_get_supported_endpoints_lists_streamlake_on_chat_messages_and_responses
assert "embeddings" not in streamlake_endpoints
def test_get_supported_endpoints_lists_webiq_search(reset_endpoints_cache: None) -> None:
app: Final = FastAPI()
app.include_router(router)
with TestClient(app) as client:
response: Final = client.get("/public/endpoints")
assert response.status_code == 200, response.text
search: Final = next(endpoint for endpoint in response.json()["endpoints"] if endpoint["key"] == "search")
assert search["endpoint"] == "/search"
assert {"slug": "webiq", "display_name": "Microsoft Web IQ"} in search["providers"]
def test_get_supported_endpoints_display_names_have_no_slug_suffix(
reset_endpoints_cache,
):

View file

@ -6,7 +6,7 @@ import hashlib
import json
import re
import sqlite3
from collections.abc import AsyncIterator, Awaitable, Mapping, Sequence
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
from datetime import timezone
from functools import partial
from types import SimpleNamespace
@ -141,6 +141,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond)
status = re.fullmatch(r"status = \$(\d+)", cond)
api_key_not_in = re.fullmatch(r"api_key NOT IN \(\$(\d+), \$(\d+)\)", cond)
api_key_any = re.fullmatch(r"api_key = ANY\(\$(\d+)::text\[\]\)", cond)
req_or_call = re.fullmatch(r"\(request_id = \$(\d+) OR litellm_call_id = \$\1\)", cond)
if req_or_call:
where["request_id_or_call_id"] = params[int(req_or_call.group(1)) - 1]
@ -169,6 +170,8 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")}
elif status:
where["status"] = {"equals": params[int(status.group(1)) - 1]}
elif api_key_any:
where["api_key_in"] = list(params[int(api_key_any.group(1)) - 1])
elif api_key_not_in:
where["api_key_not_in"] = [
params[int(api_key_not_in.group(1)) - 1],
@ -218,7 +221,18 @@ def _reconstruct_ui_where_from_sql(sql_query, params):
return where
def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=None, query_observer=None):
def _emulate_owner_api_key_lookup(mock_spend_logs: list[dict], sql_query: str, params: tuple) -> list[dict]:
"""The daily spend tables and key table name every api_key an owner's requests ran under, so the
lookup answers with the distinct api_keys of the owner's logs."""
owner_col = "user" if 'FROM "LiteLLM_DailyUserSpend"' in sql_query else "team_id"
owners = {params[0]} if isinstance(params[0], str) else set(params[0])
api_keys = {log["api_key"] for log in mock_spend_logs if log.get(owner_col) in owners}
return [{"api_key": api_key} for api_key in sorted(api_keys)]
def make_ui_spend_logs_mock_prisma(
mock_spend_logs, filter_fn, team_lookup_fn=None, query_observer=None, owner_api_keys_fn=None
):
"""
Create a MockPrismaClient for /spend/logs/ui endpoint tests.
@ -228,6 +242,8 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No
where_conditions, returns the filtered list of logs.
team_lookup_fn: Optional async callable for team RBAC (find_unique).
If provided, adds litellm_teamtable to db.
owner_api_keys_fn: Optional Callable[[tuple], list[dict]] answering the
user/team -> api_key lookup instead of deriving it from the logs.
"""
class MockDB:
@ -251,6 +267,10 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No
return []
if 'SELECT DISTINCT "user", team_id' in sql_query:
return _emulate_spend_log_owner_lookup(mock_spend_logs, sql_query, params)
if 'FROM "LiteLLM_DailyUserSpend"' in sql_query or 'FROM "LiteLLM_DailyTeamSpend"' in sql_query:
if owner_api_keys_fn is not None:
return owner_api_keys_fn(params)
return _emulate_owner_api_key_lookup(mock_spend_logs, sql_query, params)
filtered = filter_fn(_reconstruct_ui_where_from_sql(sql_query, params))
total = len(filtered)
if "COUNT(*)" in sql_query:
@ -845,6 +865,196 @@ async def test_ui_view_spend_logs_with_user_id(client, monkeypatch):
assert data["data"][0]["user"] == "test_user_1"
def _owned_log(request_id: str, api_key: str, user: str, team_id: str) -> dict:
return {
"id": request_id,
"request_id": request_id,
"api_key": api_key,
"user": user,
"team_id": team_id,
"spend": 0.01,
"startTime": "2026-09-02T10:00:00+00:00",
"model": "gpt-4o",
}
def _owner_filtered_logs(logs: list[dict]) -> Callable[[dict], list[dict]]:
"""What Postgres returns for the page query: the owner filter and, when present, the api_key list both apply."""
def filter_fn(where: dict) -> list[dict]:
return [
log
for log in logs
if log["user"] == where.get("user", log["user"])
and log["team_id"] == where.get("team_id", log["team_id"])
and log["api_key"] in where.get("api_key_in", [log["api_key"]])
]
return filter_fn
def _owner_api_keys(daily_rows: dict[str, set[str]], live_keys: dict[str, set[str]]) -> Callable[[tuple], list[dict]]:
"""The key lookup's two sources, keyed by owner: api_keys on daily spend rows and api_keys of live keys."""
def lookup(params: tuple) -> list[dict]:
owners = {params[0]} if isinstance(params[0], str) else set(params[0])
api_keys = set().union(*(daily_rows.get(o, set()) | live_keys.get(o, set()) for o in owners))
return [{"api_key": api_key} for api_key in sorted(api_keys)]
return lookup
def _spend_logs_page_query(queries: list[tuple[str, tuple]]) -> tuple[str, tuple, list[str] | None]:
sql, params = next((sql, params) for sql, params in queries if '"LiteLLM_SpendLogs"' in sql and "ORDER BY" in sql)
key_param = re.search(r"api_key = ANY\(\$(\d+)::text\[\]\)", sql)
return sql, params, None if key_param is None else params[int(key_param.group(1)) - 1]
@pytest.mark.asyncio
async def test_ui_view_spend_logs_user_filter_reads_spend_logs_through_the_users_api_keys(client, monkeypatch):
from litellm.proxy.spend_tracking.spend_log_api_key_scope import SPEND_LOG_API_KEY_SCOPE_MAX_KEYS
logs = [
_owned_log("req1", "sk-live", "alice", "team1"),
_owned_log("req2", "sk-deleted", "alice", "team1"),
_owned_log("req3", "sk-bob", "bob", "team1"),
]
queries: list[tuple[str, tuple]] = []
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(
logs,
_owner_filtered_logs(logs),
query_observer=lambda sql, params: queries.append((sql, params)),
owner_api_keys_fn=_owner_api_keys(
daily_rows={"alice": {"sk-deleted"}, "bob": {"sk-bob"}}, live_keys={"alice": {"sk-live"}}
),
),
)
response = client.get(
"/spend/logs/ui",
params={"user_id": "alice", "start_date": "2026-09-01 00:00:00", "end_date": "2026-09-03 23:59:59"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
assert {row["request_id"] for row in response.json()["data"]} == {"req1", "req2"}
lookup_sql, lookup_params = next((sql, params) for sql, params in queries if '"LiteLLM_DailyUserSpend"' in sql)
assert '"LiteLLM_VerificationToken"' in lookup_sql
assert lookup_params == ("alice", "2026-08-31", "2026-09-04", SPEND_LOG_API_KEY_SCOPE_MAX_KEYS + 1)
page_sql, _, scoped_api_keys = _spend_logs_page_query(queries)
assert scoped_api_keys == ["sk-deleted", "sk-live"]
assert '"user" = $' in page_sql
@pytest.mark.asyncio
async def test_ui_view_spend_logs_team_filter_reads_spend_logs_through_the_teams_api_keys(client, monkeypatch):
logs = [
_owned_log("req1", "sk-alpha-1", "alice", "team-alpha"),
_owned_log("req2", "sk-alpha-2", "bob", "team-alpha"),
_owned_log("req3", "sk-beta", "carol", "team-beta"),
]
queries: list[tuple[str, tuple]] = []
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(
logs,
_owner_filtered_logs(logs),
query_observer=lambda sql, params: queries.append((sql, params)),
owner_api_keys_fn=_owner_api_keys(
daily_rows={"team-alpha": {"sk-alpha-1"}, "team-beta": {"sk-beta"}},
live_keys={"team-alpha": {"sk-alpha-2"}},
),
),
)
monkeypatch.setattr(
"litellm.proxy.spend_tracking.spend_management_endpoints._is_admin_view_safe",
lambda user_api_key_dict: True,
)
monkeypatch.setitem(
app.dependency_overrides,
ps.user_api_key_auth,
lambda: UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"),
)
response = client.get(
"/spend/logs/ui",
params={"team_id": "team-alpha", "start_date": "2026-09-01 00:00:00", "end_date": "2026-09-03 23:59:59"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
assert {row["request_id"] for row in response.json()["data"]} == {"req1", "req2"}
_, lookup_params = next((sql, params) for sql, params in queries if '"LiteLLM_DailyTeamSpend"' in sql)
assert lookup_params[:3] == (["team-alpha"], "2026-08-31", "2026-09-04")
page_sql, _, scoped_api_keys = _spend_logs_page_query(queries)
assert scoped_api_keys == ["sk-alpha-1", "sk-alpha-2"]
assert "team_id = $" in page_sql
@pytest.mark.asyncio
@pytest.mark.parametrize("known_keys", [set(), {f"sk-{i}" for i in range(5001)}])
async def test_ui_view_spend_logs_without_a_usable_key_list_keeps_the_plain_user_filter(
client, monkeypatch, known_keys: set[str]
):
logs = [_owned_log("req1", "sk-live", "alice", "team1")]
queries: list[tuple[str, tuple]] = []
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(
logs,
_owner_filtered_logs(logs),
query_observer=lambda sql, params: queries.append((sql, params)),
owner_api_keys_fn=_owner_api_keys(daily_rows={"alice": known_keys}, live_keys={}),
),
)
response = client.get(
"/spend/logs/ui",
params={"user_id": "alice", "start_date": "2026-09-01 00:00:00", "end_date": "2026-09-03 23:59:59"},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
assert [row["request_id"] for row in response.json()["data"]] == ["req1"]
page_sql, _, scoped_api_keys = _spend_logs_page_query(queries)
assert scoped_api_keys is None
assert '"user" = $' in page_sql
@pytest.mark.asyncio
async def test_ui_view_spend_logs_search_with_user_filter_is_not_narrowed_to_the_windows_keys(client, monkeypatch):
logs = [_owned_log("req-old", "sk-retired", "alice", "team1")]
queries: list[tuple[str, tuple]] = []
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client",
make_ui_spend_logs_mock_prisma(
logs,
_owner_filtered_logs(logs),
query_observer=lambda sql, params: queries.append((sql, params)),
owner_api_keys_fn=_owner_api_keys(daily_rows={}, live_keys={"alice": {"sk-live"}}),
),
)
response = client.get(
"/spend/logs/ui",
params={
"user_id": "alice",
"search": "req-old",
"start_date": "2026-09-01 00:00:00",
"end_date": "2026-09-03 23:59:59",
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200, response.text
assert [row["request_id"] for row in response.json()["data"]] == ["req-old"]
page_sql, _, scoped_api_keys = _spend_logs_page_query(queries)
assert scoped_api_keys is None
assert "request_id = $" in page_sql and '"user" = $' in page_sql
@pytest.mark.asyncio
@pytest.mark.parametrize(
"session_id_query,expected_request_ids",

View file

@ -56,6 +56,19 @@ def _proxy_style_logging_obj() -> Logging:
)
@pytest.mark.usefixtures("_local_model_cost_map")
def test_webiq_search_reports_the_catalog_query_cost(respx_mock: respx.MockRouter) -> None:
respx_mock.post("https://api.microsoft.ai/v3/search/web").respond(
json={"webResults": [{"title": "A source", "url": "https://example.com", "content": "A passage"}]}
)
response: Final = litellm.search(query="litellm release notes", search_provider="webiq", api_key="test-webiq-key")
assert response._hidden_params["response_cost"] > 0
assert response._hidden_params["response_cost"] == pytest.approx(
litellm.model_cost["webiq/search"]["input_cost_per_query"]
)
@pytest.mark.usefixtures("_local_model_cost_map")
def test_search_bills_search_tool_input_cost_per_query(respx_mock: respx.MockRouter) -> None:
respx_mock.post(EXA_SEARCH_URL).respond(json={"results": [{"title": "t", "url": "https://example.com"}]})

View file

@ -1453,6 +1453,9 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
"fal_ai/bytedance/seedance-2.0/text-to-video",
"fal_ai/bytedance/seedance-2.0/image-to-video",
"fal_ai/bytedance/seedance-2.0/reference-to-video",
"fal_ai/bytedance/seedance-2.5/us/draft/complete", # 1080p is $1.164/second per fal.ai
"fal_ai/bytedance/seedance-2.5/us/image-to-video", # 1080p is $1.164/second per fal.ai
"fal_ai/bytedance/seedance-2.5/us/text-to-video", # 1080p is $1.164/second per fal.ai
]
is_valid, violations = validate_model_cost_values(actual_json, exceptions)