mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-12 08:42:18 +00:00
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
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:
commit
cb879f6978
36 changed files with 18148 additions and 56 deletions
1
.github/workflows/test-unit.yml
vendored
1
.github/workflows/test-unit.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"))))
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
0
litellm/llms/webiq/__init__.py
Normal file
0
litellm/llms/webiq/__init__.py
Normal file
0
litellm/llms/webiq/search/__init__.py
Normal file
0
litellm/llms/webiq/search/__init__.py
Normal file
156
litellm/llms/webiq/search/transformation.py
Normal file
156
litellm/llms/webiq/search/transformation.py
Normal 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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
83
litellm/proxy/spend_tracking/spend_log_api_key_scope.py
Normal file
83
litellm/proxy/spend_tracking/spend_log_api_key_scope.py
Normal 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,
|
||||
)
|
||||
)
|
||||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -4869,6 +4869,7 @@ class SearchProviders(str, Enum):
|
|||
AGENTCORE = "agentcore"
|
||||
NIMBLE = "nimble"
|
||||
BING_GROUNDING = "bing_grounding"
|
||||
WEBIQ = "webiq"
|
||||
DEEPSEEK = "deepseek"
|
||||
XAI = "xai"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
76
tests/unit/integrations/test_langtrace.py
Normal file
76
tests/unit/integrations/test_langtrace.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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"}}]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
0
tests/unit/llms/webiq/__init__.py
Normal file
0
tests/unit/llms/webiq/__init__.py
Normal file
0
tests/unit/llms/webiq/search/__init__.py
Normal file
0
tests/unit/llms/webiq/search/__init__.py
Normal file
351
tests/unit/llms/webiq/search/test_webiq_search_transformation.py
Normal file
351
tests/unit/llms/webiq/search/test_webiq_search_transformation.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"}]})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue