mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
chore: merge litellm_internal_staging into litellm_realtime_router_session_model
Carries a mutable-ok suppression on the router session rewrite for the tightened LIT002 budget, since the realtime callees deep-copy and JSON-dump the session, and captures the realtime session kwargs through an async mock in the router tests instead of an untyped dict.
This commit is contained in:
commit
ec1fba5dc7
70 changed files with 4651 additions and 394 deletions
|
|
@ -46,6 +46,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||
--extra proxy-runtime \
|
||||
--extra extra_proxy \
|
||||
--extra semantic-router \
|
||||
--extra saml \
|
||||
--python python3.13
|
||||
|
||||
# Stage 2 — copy source and install the project + workspace members.
|
||||
|
|
@ -57,6 +58,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||
--extra proxy-runtime \
|
||||
--extra extra_proxy \
|
||||
--extra semantic-router \
|
||||
--extra saml \
|
||||
--python python3.13
|
||||
|
||||
RUN HOME=/opt/prisma XDG_CACHE_HOME=/opt/prisma/.cache PRISMA_BINARY_CACHE_DIR=/opt/prisma/binaries \
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ type: application
|
|||
# This is the chart version. This version number should be incremented each time you make changes
|
||||
# to the chart and its templates, including the app version.
|
||||
# Versions are expected to follow Semantic Versioning (https://semver.org/)
|
||||
version: 1.1.2
|
||||
version: 1.1.3
|
||||
|
||||
# This is the version number of the application being deployed. This version number should be
|
||||
# incremented each time you make changes to the application. Versions are not expected to
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
suite: "hpa with behavior"
|
||||
suite: "hpa"
|
||||
templates:
|
||||
- hpa.yaml
|
||||
tests:
|
||||
|
|
@ -23,14 +23,44 @@ tests:
|
|||
- equal: { path: spec.behavior.scaleUp.stabilizationWindowSeconds, value: 60 }
|
||||
- equal: { path: spec.behavior.scaleDown.stabilizationWindowSeconds, value: 90 }
|
||||
|
||||
---
|
||||
suite: "hpa without behavior"
|
||||
templates:
|
||||
- hpa.yaml
|
||||
tests:
|
||||
- it: "does not render behavior when not set"
|
||||
set:
|
||||
autoscaling.enabled: true
|
||||
asserts:
|
||||
- isKind: { of: HorizontalPodAutoscaler }
|
||||
- isNull: { path: spec.behavior }
|
||||
|
||||
- it: "scales on cpu at the documented 60 percent by default"
|
||||
set:
|
||||
autoscaling.enabled: true
|
||||
asserts:
|
||||
- isKind: { of: HorizontalPodAutoscaler }
|
||||
- equal: { path: "spec.metrics[0].resource.name", value: cpu }
|
||||
- equal: { path: "spec.metrics[0].resource.target.type", value: Utilization }
|
||||
- equal: { path: "spec.metrics[0].resource.target.averageUtilization", value: 60 }
|
||||
|
||||
- it: "does not scale on memory by default"
|
||||
set:
|
||||
autoscaling.enabled: true
|
||||
asserts:
|
||||
- lengthEqual: { path: spec.metrics, count: 1 }
|
||||
|
||||
- it: "honours an explicit cpu target override"
|
||||
set:
|
||||
autoscaling.enabled: true
|
||||
autoscaling.targetCPUUtilizationPercentage: 75
|
||||
asserts:
|
||||
- equal: { path: "spec.metrics[0].resource.target.averageUtilization", value: 75 }
|
||||
|
||||
- it: "renders a memory metric only when a memory target is set"
|
||||
set:
|
||||
autoscaling.enabled: true
|
||||
autoscaling.targetMemoryUtilizationPercentage: 80
|
||||
asserts:
|
||||
- lengthEqual: { path: spec.metrics, count: 2 }
|
||||
- equal: { path: "spec.metrics[1].resource.name", value: memory }
|
||||
- equal: { path: "spec.metrics[1].resource.target.averageUtilization", value: 80 }
|
||||
|
||||
- it: "renders no hpa when autoscaling is disabled"
|
||||
asserts:
|
||||
- hasDocuments: { count: 0 }
|
||||
|
|
|
|||
|
|
@ -200,7 +200,16 @@ autoscaling:
|
|||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 100
|
||||
targetCPUUtilizationPercentage: 80
|
||||
# 60 is the documented recommendation. See "Recommended Machine Specifications"
|
||||
# in https://docs.litellm.ai/docs/proxy/prod. A new replica clears the startupProbe
|
||||
# above only after up to failureThreshold x periodSeconds = 300 seconds, so a target
|
||||
# high enough to trip near saturation adds capacity minutes after it was needed.
|
||||
targetCPUUtilizationPercentage: 60
|
||||
# Deliberately left unset rather than given a value. The prisma query engine's
|
||||
# resident memory is a high-water mark that ratchets to the pod's worst-ever write
|
||||
# and is never returned, so a memory target reads the largest write a pod ever did
|
||||
# rather than what it is doing now, and replicas ratchet up without scaling back in.
|
||||
# Memory is a floor to provision under 'resources', not a signal to scale on.
|
||||
# targetMemoryUtilizationPercentage: 80
|
||||
# behavior: {}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,38 @@ DEFAULT_HEALTH_CHECK_PROMPT: Final = str(os.getenv("DEFAULT_HEALTH_CHECK_PROMPT"
|
|||
AZURE_DEFAULT_RESPONSES_API_VERSION: Final = str(os.getenv("AZURE_DEFAULT_RESPONSES_API_VERSION", "preview"))
|
||||
ROUTER_MAX_FALLBACKS: Final = int(os.getenv("ROUTER_MAX_FALLBACKS", 5))
|
||||
ROUTER_FALLBACK_ERROR_DETAIL_MAX_CHARS: Final = 2000
|
||||
RUNTIME_UPDATABLE_ROUTER_SETTINGS: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"routing_strategy_args",
|
||||
"routing_strategy",
|
||||
"routing_groups",
|
||||
"allowed_fails",
|
||||
"cooldown_time",
|
||||
"num_retries",
|
||||
"timeout",
|
||||
"max_retries",
|
||||
"retry_after",
|
||||
"fallbacks",
|
||||
"context_window_fallbacks",
|
||||
"retry_policy",
|
||||
"model_group_retry_policy",
|
||||
"model_group_alias",
|
||||
"enable_weighted_failover",
|
||||
"enable_tag_filtering",
|
||||
"tag_routing_prefix",
|
||||
"optional_pre_call_checks",
|
||||
}
|
||||
)
|
||||
ROUTER_SETTINGS_MANAGED_OUTSIDE_CONFIG: Final[frozenset[str]] = frozenset(
|
||||
{
|
||||
"model_list",
|
||||
"search_tools",
|
||||
"assistants_config",
|
||||
"router_general_settings",
|
||||
"ignore_invalid_deployments",
|
||||
"fallback_access_check",
|
||||
}
|
||||
)
|
||||
DEFAULT_BATCH_SIZE: Final = int(os.getenv("DEFAULT_BATCH_SIZE", 512))
|
||||
DEFAULT_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_FLUSH_INTERVAL_SECONDS", 5))
|
||||
DEFAULT_S3_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_S3_FLUSH_INTERVAL_SECONDS", 10))
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import contextvars
|
||||
import copy
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
|
|
@ -39,6 +40,7 @@ except ImportError:
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
|
||||
dc: Final = DualCache()
|
||||
|
||||
|
||||
|
|
@ -852,6 +854,69 @@ class CustomGuardrail(CustomLogger):
|
|||
|
||||
return result
|
||||
|
||||
async def async_logging_hook(
|
||||
self,
|
||||
kwargs: dict, # mutable-ok: CustomLogger.async_logging_hook contract
|
||||
result: object,
|
||||
call_type: str,
|
||||
) -> tuple[dict, object]: # mutable-ok: CustomLogger.async_logging_hook contract
|
||||
"""logging_only: run apply_guardrail on copies of the logged request/response and record the verdict."""
|
||||
from litellm.llms import get_guardrail_translation_mapping
|
||||
|
||||
if not self.uses_apply_guardrail_interface() or self.use_native_lifecycle_hooks:
|
||||
return kwargs, result
|
||||
try:
|
||||
translation: Final = get_guardrail_translation_mapping(CallTypes(call_type))()
|
||||
except ValueError:
|
||||
verbose_logger.debug(
|
||||
"Guardrail %s: no guardrail translation for call_type=%s, skipping logging_only scan",
|
||||
self.guardrail_name,
|
||||
call_type,
|
||||
)
|
||||
return kwargs, result
|
||||
litellm_params: Final = kwargs.get("litellm_params") or {}
|
||||
scratch_metadata: Final = {
|
||||
key: value
|
||||
for key, value in (litellm_params.get("metadata") or {}).items()
|
||||
if key != "standard_logging_guardrail_information"
|
||||
}
|
||||
try:
|
||||
await self._scan_logged_call(kwargs, result, translation, scratch_metadata)
|
||||
except Exception as e:
|
||||
verbose_logger.warning("Guardrail %s: logging_only scan raised: %s", self.guardrail_name, e)
|
||||
recorded: Final = scratch_metadata.get("standard_logging_guardrail_information")
|
||||
standard_logging_object: Final = kwargs.get("standard_logging_object")
|
||||
if not recorded or not isinstance(standard_logging_object, dict):
|
||||
return kwargs, result
|
||||
entries: Final = recorded if isinstance(recorded, list) else [recorded]
|
||||
existing: Final = standard_logging_object.get("guardrail_information") or []
|
||||
return {
|
||||
**kwargs,
|
||||
"standard_logging_object": {**standard_logging_object, "guardrail_information": [*existing, *entries]},
|
||||
}, result
|
||||
|
||||
async def _scan_logged_call(
|
||||
self,
|
||||
kwargs: dict, # mutable-ok: CustomLogger.async_logging_hook contract
|
||||
result: object,
|
||||
translation: "BaseTranslation",
|
||||
scratch_metadata: dict, # mutable-ok: apply_guardrail records its verdict into request metadata
|
||||
) -> None:
|
||||
optional_params: Final = kwargs.get("optional_params") or {}
|
||||
scratch_input: Final = copy.deepcopy(kwargs.get("messages") or kwargs.get("input"))
|
||||
scratch_request: Final = {
|
||||
"model": kwargs.get("model"),
|
||||
"messages": scratch_input,
|
||||
"input": scratch_input,
|
||||
"tools": copy.deepcopy(optional_params.get("tools")),
|
||||
"litellm_call_id": kwargs.get("litellm_call_id"),
|
||||
"metadata": scratch_metadata,
|
||||
}
|
||||
await translation.process_input_messages(data=scratch_request, guardrail_to_apply=self)
|
||||
await translation.process_output_response(
|
||||
response=copy.deepcopy(result), guardrail_to_apply=self, request_data=scratch_request
|
||||
)
|
||||
|
||||
def supports_scan_only_tool_results(self) -> bool:
|
||||
"""Whether this guardrail can scan tool-result content.
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ from litellm.types.utils import (
|
|||
from litellm.utils import print_verbose, token_counter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
from litellm.types.litellm_core_utils.streaming_chunk_builder_utils import (
|
||||
UsagePerChunk,
|
||||
|
|
@ -794,7 +796,7 @@ class ChunkProcessor:
|
|||
|
||||
@staticmethod
|
||||
def _extract_usage_chunk(chunk: "_UsageBearingChunk | ModelResponse | ModelResponseStream") -> Usage | None:
|
||||
usage_chunk: Usage | None = None
|
||||
usage_chunk: Usage | CompletionUsage | None = None
|
||||
if hasattr(chunk, "usage") and chunk.usage is not None:
|
||||
usage_chunk = chunk.usage
|
||||
elif "usage" in chunk:
|
||||
|
|
@ -806,7 +808,9 @@ class ChunkProcessor:
|
|||
|
||||
if isinstance(usage_chunk, dict):
|
||||
return Usage(**usage_chunk)
|
||||
return usage_chunk
|
||||
if usage_chunk is None or isinstance(usage_chunk, Usage):
|
||||
return usage_chunk
|
||||
return Usage(**usage_chunk.model_dump())
|
||||
|
||||
def _calculate_usage_per_chunk(
|
||||
self,
|
||||
|
|
|
|||
|
|
@ -1378,31 +1378,38 @@ def process_anthropic_headers(headers: httpx.Headers | dict) -> dict:
|
|||
return additional_headers
|
||||
|
||||
|
||||
def _anthropic_model_entry(model: ModelInfoResponse, created_at: str) -> Mapping[str, object]:
|
||||
def _anthropic_model_entry(
|
||||
model: ModelInfoResponse, created_at: str, display_names: Mapping[str, str]
|
||||
) -> Mapping[str, object]:
|
||||
return { # mutable-ok: JSON response body, serialized by the route and never mutated
|
||||
"type": "model",
|
||||
"id": model["id"],
|
||||
"display_name": model["id"],
|
||||
"display_name": display_names.get(model["id"], model["id"]),
|
||||
"created_at": created_at,
|
||||
"max_input_tokens": model.get("max_input_tokens"),
|
||||
"max_tokens": model.get("max_output_tokens"),
|
||||
}
|
||||
|
||||
|
||||
def create_anthropic_model_list_response(models: Sequence[ModelInfoResponse]) -> Mapping[str, object]:
|
||||
def create_anthropic_model_list_response(
|
||||
models: Sequence[ModelInfoResponse],
|
||||
display_names: Mapping[str, str] = MappingProxyType({}),
|
||||
) -> Mapping[str, object]:
|
||||
"""Build the Anthropic-native /v1/models envelope.
|
||||
|
||||
Clients that send an anthropic-version header parse the Anthropic Models API
|
||||
shape (type/display_name/created_at plus has_more/first_id/last_id) and filter
|
||||
the list themselves, so every model is returned here. The token limits carry
|
||||
over from the OpenAI-shaped listing, named as the Messages API names them, and
|
||||
are always present because the vendor shape declares them nullable, not optional
|
||||
are always present because the vendor shape declares them nullable, not optional.
|
||||
display_names maps a listed model id to a configured human-readable name; ids
|
||||
without an entry fall back to the id itself, matching the vendor behavior
|
||||
"""
|
||||
created_at: Final = (
|
||||
datetime.fromtimestamp(DEFAULT_MODEL_CREATED_AT_TIME, tz=timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
)
|
||||
data: Final = [ # mutable-ok: JSON response body, serialized by the route and never mutated
|
||||
_anthropic_model_entry(model, created_at) for model in models
|
||||
_anthropic_model_entry(model, created_at, display_names) for model in models
|
||||
]
|
||||
return { # mutable-ok: JSON response body, serialized by the route and never mutated
|
||||
"data": data,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.types.vector_stores import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
|
||||
LiteLLMLoggingObj = _LiteLLMLoggingObj
|
||||
else:
|
||||
|
|
@ -115,6 +116,7 @@ class AzureAIVectorStoreConfig(BaseVectorStoreConfig, BaseAzureLLM):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""
|
||||
Transform search request for Azure AI Search API
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from litellm.types.vector_stores import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
|
||||
from ..chat.transformation import BaseLLMException as _BaseLLMException
|
||||
|
||||
|
|
@ -57,6 +58,7 @@ class BaseVectorStoreConfig:
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict]:
|
||||
pass
|
||||
|
||||
|
|
@ -69,6 +71,7 @@ class BaseVectorStoreConfig:
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Optional async version of transform_search_vector_store_request.
|
||||
|
|
@ -84,6 +87,7 @@ class BaseVectorStoreConfig:
|
|||
litellm_logging_obj=litellm_logging_obj,
|
||||
litellm_params=litellm_params,
|
||||
extra_body=extra_body,
|
||||
router=router,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
|
|
@ -197,6 +201,7 @@ class BaseDirectVectorStoreConfig(BaseVectorStoreConfig):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: Mapping[str, object],
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> NoReturn:
|
||||
raise NotImplementedError("Direct vector store providers execute the search themselves; no HTTP request shape")
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ from litellm.types.vector_stores import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
|
@ -196,6 +197,7 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict]:
|
||||
if isinstance(query, list):
|
||||
query = " ".join(query)
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ if TYPE_CHECKING:
|
|||
AnthropicMessagesStreamingResponse,
|
||||
)
|
||||
from litellm.llms.base_llm.passthrough.transformation import BasePassthroughConfig
|
||||
from litellm.router import Router
|
||||
from litellm.types.llms.openai_evals import (
|
||||
CancelEvalResponse,
|
||||
CancelRunResponse,
|
||||
|
|
@ -2923,7 +2924,7 @@ class BaseLLMHTTPHandler:
|
|||
final_response: Final = await self._call_agentic_completion_hooks(
|
||||
response=initial_response,
|
||||
model=model,
|
||||
messages=(input if isinstance(input, list) else [{"role": "user", "content": input}]),
|
||||
messages=(input if isinstance(input, list) else [{"role": "user", "content": input}]), # pyright: ignore[reportArgumentType] # pre-existing mismatch surfaced by the Router import; the hook accepts response input items at runtime
|
||||
anthropic_messages_provider_config=responses_api_provider_config,
|
||||
anthropic_messages_optional_request_params=response_api_optional_request_params,
|
||||
logging_obj=logging_obj,
|
||||
|
|
@ -5415,7 +5416,7 @@ class BaseLLMHTTPHandler:
|
|||
try:
|
||||
response: ResponsesAPIResponse | BaseResponsesAPIStreamingIterator = await litellm.aresponses(
|
||||
model=patch.model or model,
|
||||
input=patch.messages,
|
||||
input=patch.messages, # pyright: ignore[reportArgumentType] # pre-existing mismatch surfaced by the Router import; patch messages are valid response input at runtime
|
||||
**optional_params,
|
||||
**kwargs_for_followup,
|
||||
)
|
||||
|
|
@ -9688,6 +9689,7 @@ class BaseLLMHTTPHandler:
|
|||
timeout: float | httpx.Timeout | None = None,
|
||||
client: HTTPHandler | AsyncHTTPHandler | None = None,
|
||||
_is_async: bool = False,
|
||||
router: "Router | None" = None,
|
||||
) -> VectorStoreSearchResponse:
|
||||
if isinstance(vector_store_provider_config, BaseDirectVectorStoreConfig):
|
||||
self._pre_call_direct_vector_store_search(
|
||||
|
|
@ -9738,6 +9740,7 @@ class BaseLLMHTTPHandler:
|
|||
litellm_logging_obj=logging_obj,
|
||||
litellm_params=dict(litellm_params),
|
||||
extra_body=extra_body,
|
||||
router=router,
|
||||
)
|
||||
else:
|
||||
(
|
||||
|
|
@ -9751,6 +9754,7 @@ class BaseLLMHTTPHandler:
|
|||
litellm_logging_obj=logging_obj,
|
||||
litellm_params=dict(litellm_params),
|
||||
extra_body=extra_body,
|
||||
router=router,
|
||||
)
|
||||
all_optional_params: Final[dict[str, object]] = dict(litellm_params)
|
||||
all_optional_params.update(vector_store_search_optional_params or {})
|
||||
|
|
@ -9802,6 +9806,7 @@ class BaseLLMHTTPHandler:
|
|||
timeout: float | httpx.Timeout | None = None,
|
||||
client: HTTPHandler | AsyncHTTPHandler | None = None,
|
||||
_is_async: bool = False,
|
||||
router: "Router | None" = None,
|
||||
) -> VectorStoreSearchResponse | Coroutine[object, object, VectorStoreSearchResponse]:
|
||||
if _is_async:
|
||||
return self.async_vector_store_search_handler(
|
||||
|
|
@ -9816,6 +9821,7 @@ class BaseLLMHTTPHandler:
|
|||
extra_body=extra_body,
|
||||
timeout=timeout,
|
||||
client=client,
|
||||
router=router,
|
||||
)
|
||||
|
||||
if isinstance(vector_store_provider_config, BaseDirectVectorStoreConfig):
|
||||
|
|
@ -9862,6 +9868,7 @@ class BaseLLMHTTPHandler:
|
|||
litellm_logging_obj=logging_obj,
|
||||
litellm_params=dict(litellm_params),
|
||||
extra_body=extra_body,
|
||||
router=router,
|
||||
)
|
||||
|
||||
all_optional_params: Final[dict[str, object]] = dict(litellm_params)
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ from litellm.types.vector_stores import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
|
@ -168,6 +169,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Transform search request to Gemini's generateContent format.
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.types.vector_stores import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
|
||||
LiteLLMLoggingObj = _LiteLLMLoggingObj
|
||||
else:
|
||||
|
|
@ -123,6 +124,7 @@ class MilvusVectorStoreConfig(BaseVectorStoreConfig):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""
|
||||
Transform search request for Azure AI Search API
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from litellm.utils import add_openai_metadata
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
|
||||
LiteLLMLoggingObj = _LiteLLMLoggingObj
|
||||
else:
|
||||
|
|
@ -99,6 +100,7 @@ class OpenAIVectorStoreConfig(BaseVectorStoreConfig):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict]:
|
||||
encoded_vector_store_id: Final = encode_url_path_segment(vector_store_id, field_name="vector_store_id")
|
||||
url: Final = f"{api_base}/{encoded_vector_store_id}/search"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from litellm.types.vector_stores import VectorStoreSearchOptionalRequestParams
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
|
@ -80,6 +81,7 @@ class PGVectorStoreConfig(OpenAIVectorStoreConfig):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict]:
|
||||
encoded_vector_store_id: Final = encode_url_path_segment(vector_store_id, field_name="vector_store_id")
|
||||
url: Final = f"{api_base}/{encoded_vector_store_id}/search"
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ from litellm.types.vector_stores import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
|
@ -92,6 +93,7 @@ class RAGFlowVectorStoreConfig(BaseVectorStoreConfig):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""RAGFlow vector stores are management-only, search is not supported."""
|
||||
raise NotImplementedError("RAGFlow vector stores support dataset management only, not search/retrieval")
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import re
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
||||
from litellm.caching._embedding_router import resolve_embedding_router
|
||||
from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig
|
||||
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM
|
||||
from litellm.types.router import GenericLiteLLMParams
|
||||
|
|
@ -18,6 +18,7 @@ from litellm.types.vector_stores import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
else:
|
||||
LiteLLMLoggingObj = Any
|
||||
|
||||
|
|
@ -58,13 +59,20 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
|
|||
return headers
|
||||
|
||||
def get_complete_url(self, api_base: str | None, litellm_params: dict) -> str:
|
||||
aws_region_name: Final = litellm_params.get("aws_region_name")
|
||||
if not aws_region_name:
|
||||
raise ValueError("aws_region_name is required for S3 Vectors")
|
||||
if not re.match(r"^[a-z][a-z0-9-]*$", aws_region_name):
|
||||
raise ValueError("Invalid aws_region_name format")
|
||||
# Resolve region the same way the ingestion path does:
|
||||
# dynamic param -> AWS_REGION_NAME -> AWS_REGION -> default (us-west-2)
|
||||
aws_region_name: Final = self.get_aws_region_name_for_non_llm_api_calls(litellm_params.get("aws_region_name"))
|
||||
return f"https://s3vectors.{aws_region_name}.api.aws"
|
||||
|
||||
def _resolve_query_embedding_router(self, embedding_model: str, router: "Router | None") -> "Router | None":
|
||||
"""Return the router iff it serves ``embedding_model`` as a deployment."""
|
||||
if router is None:
|
||||
return None
|
||||
model_list: Final = [
|
||||
dict(m) for m in (router.get_model_list() or ())
|
||||
] # mutable-ok: resolve_embedding_router requires list[dict]
|
||||
return resolve_embedding_router(embedding_model=embedding_model, llm_router=router, llm_model_list=model_list)
|
||||
|
||||
def transform_search_vector_store_request(
|
||||
self,
|
||||
vector_store_id: str,
|
||||
|
|
@ -74,6 +82,7 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""Sync version - generates embedding synchronously."""
|
||||
# For S3 Vectors, vector_store_id should be in format: bucket_name:index_name
|
||||
|
|
@ -99,10 +108,16 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
|
|||
|
||||
# Generate embedding for the query
|
||||
embedding_model: Final = litellm_params.get("embedding_model", "text-embedding-3-small")
|
||||
embedding_router: Final = self._resolve_query_embedding_router(embedding_model=embedding_model, router=router)
|
||||
|
||||
import litellm as litellm_module
|
||||
|
||||
embedding_response: Final = litellm_module.embedding(model=embedding_model, input=[query])
|
||||
embedding_input: Final = [query] # mutable-ok: the embedding API takes list input
|
||||
embedding_response: Final = (
|
||||
embedding_router.embedding(model=embedding_model, input=embedding_input)
|
||||
if embedding_router is not None
|
||||
else litellm_module.embedding(model=embedding_model, input=embedding_input)
|
||||
)
|
||||
query_embedding: Final = embedding_response.data[0]["embedding"]
|
||||
|
||||
url: Final = f"{api_base}/QueryVectors"
|
||||
|
|
@ -128,6 +143,7 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""Async version - generates embedding asynchronously."""
|
||||
# For S3 Vectors, vector_store_id should be in format: bucket_name:index_name
|
||||
|
|
@ -153,10 +169,16 @@ class S3VectorsVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM):
|
|||
|
||||
# Generate embedding for the query asynchronously
|
||||
embedding_model: Final = litellm_params.get("embedding_model", "text-embedding-3-small")
|
||||
embedding_router: Final = self._resolve_query_embedding_router(embedding_model=embedding_model, router=router)
|
||||
|
||||
import litellm as litellm_module
|
||||
|
||||
embedding_response: Final = await litellm_module.aembedding(model=embedding_model, input=[query])
|
||||
embedding_input: Final = [query] # mutable-ok: the embedding API takes list input
|
||||
embedding_response: Final = (
|
||||
await embedding_router.aembedding(model=embedding_model, input=embedding_input)
|
||||
if embedding_router is not None
|
||||
else await litellm_module.aembedding(model=embedding_model, input=embedding_input)
|
||||
)
|
||||
query_embedding: Final = embedding_response.data[0]["embedding"]
|
||||
|
||||
url: Final = f"{api_base}/QueryVectors"
|
||||
|
|
|
|||
|
|
@ -949,7 +949,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig):
|
|||
# For Gemini 3+ models, use thinkingLevel instead of thinkingBudget
|
||||
if model and VertexGeminiConfig._is_gemini_3_or_newer(model):
|
||||
if thinking_enabled:
|
||||
if thinking_budget is None or thinking_budget == 0:
|
||||
if thinking_budget == 0:
|
||||
params["includeThoughts"] = False
|
||||
else:
|
||||
params["includeThoughts"] = True
|
||||
|
|
|
|||
|
|
@ -177,8 +177,9 @@ class VertexAIDeepSeekOCRConfig(BaseOCRConfig):
|
|||
content_item = {"type": "image_url", "image_url": document_url}
|
||||
|
||||
# Build DeepSeek OCR request
|
||||
provider_model: Final = model if model.startswith("deepseek-ai/") else f"deepseek-ai/{model}"
|
||||
data: Final = {
|
||||
"model": "deepseek-ai/" + model,
|
||||
"model": provider_model,
|
||||
"messages": [{"role": "user", "content": [content_item]}],
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from litellm.types.vector_stores import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
|
||||
LiteLLMLoggingObj = _LiteLLMLoggingObj
|
||||
else:
|
||||
|
|
@ -161,6 +162,7 @@ class VertexVectorStoreConfig(BaseVectorStoreConfig, VertexBase):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
"""
|
||||
Transform search request for Vertex AI RAG API
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ from litellm.types.vector_stores import (
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj
|
||||
from litellm.router import Router
|
||||
|
||||
LiteLLMLoggingObj = _LiteLLMLoggingObj
|
||||
else:
|
||||
|
|
@ -245,6 +246,7 @@ class VertexSearchAPIVectorStoreConfig(BaseVectorStoreConfig, VertexBase):
|
|||
litellm_logging_obj: LiteLLMLoggingObj,
|
||||
litellm_params: dict,
|
||||
extra_body: Mapping[str, object] | None = None,
|
||||
router: "Router | None" = None,
|
||||
) -> tuple[str, dict[str, object]]:
|
||||
"""
|
||||
Transform a search request for the Vertex AI Search (Discovery Engine) API.
|
||||
|
|
|
|||
|
|
@ -8637,6 +8637,16 @@ def _set_stream_builder_response_cost(response: ModelResponse, logging_obj: Opti
|
|||
hidden_params["response_cost"] = response_cost
|
||||
|
||||
|
||||
def _stamp_streaming_usage_cost(usage: Usage, response: ModelResponse, logging_obj: Optional["Logging"]) -> None:
|
||||
if logging_obj is None:
|
||||
return
|
||||
if isinstance(getattr(usage, "cost", None), (int, float)):
|
||||
return
|
||||
computed_cost: Final = logging_obj._response_cost_calculator(result=response)
|
||||
if isinstance(computed_cost, (int, float)) and computed_cost > 0:
|
||||
setattr(usage, "cost", computed_cost)
|
||||
|
||||
|
||||
def stream_chunk_builder(
|
||||
chunks: list,
|
||||
messages: list | None = None,
|
||||
|
|
@ -8731,12 +8741,7 @@ def stream_chunk_builder(
|
|||
)
|
||||
break
|
||||
|
||||
if litellm.include_cost_in_streaming_usage and logging_obj is not None:
|
||||
setattr(
|
||||
usage,
|
||||
"cost",
|
||||
logging_obj._response_cost_calculator(result=response),
|
||||
)
|
||||
_stamp_streaming_usage_cost(usage, response, logging_obj)
|
||||
_set_stream_builder_response_cost(response, logging_obj)
|
||||
|
||||
processor.apply_provider_assembled_streaming_metadata(response, chunks, logging_obj)
|
||||
|
|
@ -8915,10 +8920,7 @@ def stream_chunk_builder(
|
|||
)
|
||||
break
|
||||
|
||||
# Add cost to usage object if include_cost_in_streaming_usage is True
|
||||
if litellm.include_cost_in_streaming_usage and logging_obj is not None:
|
||||
setattr(usage, "cost", logging_obj._response_cost_calculator(result=response))
|
||||
|
||||
_stamp_streaming_usage_cost(usage, response, logging_obj)
|
||||
_set_stream_builder_response_cost(response, logging_obj)
|
||||
|
||||
processor.apply_provider_assembled_streaming_metadata(response, chunks, logging_obj)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -10,13 +10,36 @@ legacy internal names with `general_settings.use_team_public_model_name: false`.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Final, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
|
||||
|
||||
def configured_display_names(
|
||||
entries: Sequence[tuple[str, str]],
|
||||
llm_router: Router | None,
|
||||
) -> Mapping[str, str]:
|
||||
"""response_id -> configured `model_info.display_name` for the listing entries
|
||||
that have one.
|
||||
|
||||
Metadata is looked up by each entry's internal lookup id (so team-scoped rows
|
||||
resolve), while the returned map is keyed by the public response id the
|
||||
Anthropic-shaped listing is built from. Entries without a configured name are
|
||||
omitted so the listing falls back to the id itself.
|
||||
"""
|
||||
if llm_router is None:
|
||||
return MappingProxyType({})
|
||||
resolved: Final = (
|
||||
(response_id, llm_router.get_configured_display_name(lookup_id)) for response_id, lookup_id in entries
|
||||
)
|
||||
return MappingProxyType(
|
||||
{response_id: display_name for response_id, display_name in resolved if display_name is not None}
|
||||
)
|
||||
|
||||
|
||||
class TeamModelNameTranslator:
|
||||
"""Translates internal team routing keys to their public names for the model
|
||||
listing/retrieve responses. Stateless; the live router and general_settings
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ from litellm.proxy._types import (
|
|||
from litellm.proxy.auth.auth_checks import (
|
||||
_delete_cache_access_object, # pyright: ignore[reportPrivateUsage] # the access-group endpoints reach for this same cache primitive
|
||||
)
|
||||
from litellm.proxy.db.routing_prisma_wrapper import WriterPinnedClient
|
||||
from litellm.repositories.table_repositories import AccessGroupRepository
|
||||
|
||||
|
||||
|
|
@ -72,8 +73,9 @@ _REPOINT_KEY_SQL: Final = (
|
|||
|
||||
|
||||
def _raw_executor(prisma_client: object) -> _RawExecutor:
|
||||
"""Narrow the untyped Prisma client down to the raw-query call this module makes."""
|
||||
return AccessGroupRepository(prisma_client).prisma_client.db # pyright: ignore[reportAny] # untyped Prisma client
|
||||
"""Narrow the untyped Prisma client down to the raw-query call this module makes, pinned to the writer."""
|
||||
db: Final = AccessGroupRepository(prisma_client).prisma_client.db # pyright: ignore[reportAny] # untyped Prisma client
|
||||
return WriterPinnedClient(db).db # pyright: ignore[reportAny, reportReturnType] # untyped Prisma client behind the pin
|
||||
|
||||
|
||||
async def _invalidate_access_group_cache(access_group_id: str) -> None:
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ from typing import (
|
|||
import anyio
|
||||
import websockets
|
||||
import websockets.exceptions
|
||||
from pydantic import BaseModel, Json, JsonValue, ValidationError
|
||||
from pydantic import BaseModel, Json, JsonValue, TypeAdapter, ValidationError
|
||||
from typing_extensions import NotRequired, ReadOnly, assert_never
|
||||
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -60,6 +60,7 @@ from litellm.constants import (
|
|||
LITELLM_SETTINGS_SAFE_DB_OVERRIDES,
|
||||
LITELLM_UI_ALLOW_HEADERS,
|
||||
LITELLM_UI_SESSION_DURATION,
|
||||
RUNTIME_UPDATABLE_ROUTER_SETTINGS,
|
||||
)
|
||||
from litellm.litellm_core_utils.litellm_logging import (
|
||||
_init_custom_logger_compatible_class,
|
||||
|
|
@ -253,6 +254,7 @@ from litellm.constants import (
|
|||
PROXY_BUDGET_RESCHEDULER_MAX_TIME,
|
||||
PROXY_BUDGET_RESCHEDULER_MIN_TIME,
|
||||
PROXY_CONFIG_RELOAD_INTERVAL_SECONDS,
|
||||
ROUTER_SETTINGS_MANAGED_OUTSIDE_CONFIG,
|
||||
USER_SPEND_ALERTS_JOB_ID,
|
||||
WEEKLY_SPEND_REPORT_JOB_ID,
|
||||
)
|
||||
|
|
@ -352,7 +354,10 @@ from litellm.proxy.common_utils.load_config_utils import (
|
|||
get_file_contents_from_s3,
|
||||
)
|
||||
from litellm.proxy.common_utils.model_deprecation import collect_model_deprecations
|
||||
from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator
|
||||
from litellm.proxy.common_utils.model_listing_utils import (
|
||||
TeamModelNameTranslator,
|
||||
configured_display_names,
|
||||
)
|
||||
from litellm.proxy.common_utils.openai_endpoint_utils import (
|
||||
remove_sensitive_info_from_deployment,
|
||||
)
|
||||
|
|
@ -5710,13 +5715,9 @@ class ProxyConfig:
|
|||
router_settings: Final = config.get("router_settings", None)
|
||||
|
||||
if router_settings and isinstance(router_settings, dict):
|
||||
# model list and search_tools already set
|
||||
exclude_args: Final = {
|
||||
"model_list",
|
||||
"search_tools",
|
||||
}
|
||||
|
||||
available_args: Final = [x for x in litellm.Router.get_valid_args() if x not in exclude_args]
|
||||
available_args: Final = [
|
||||
x for x in litellm.Router.get_valid_args() if x not in ROUTER_SETTINGS_MANAGED_OUTSIDE_CONFIG
|
||||
]
|
||||
|
||||
for k, v in router_settings.items():
|
||||
if k in available_args:
|
||||
|
|
@ -10223,7 +10224,8 @@ async def model_list(
|
|||
# The internal routing key drives the metadata/fallback lookup, while the
|
||||
# public name is what the client sees as the model id.
|
||||
model_data = []
|
||||
for response_id, lookup_id in TeamModelNameTranslator.listing_entries(all_models, llm_router, settings):
|
||||
admin_entries: Final = TeamModelNameTranslator.listing_entries(all_models, llm_router, settings)
|
||||
for response_id, lookup_id in admin_entries:
|
||||
model_info = create_model_info_response(
|
||||
model_id=lookup_id,
|
||||
provider="openai",
|
||||
|
|
@ -10236,7 +10238,10 @@ async def model_list(
|
|||
|
||||
if wants_anthropic_format:
|
||||
admin_listing: Final = cast(Sequence[ModelInfoResponse], model_data) # cast-ok: rows built above
|
||||
return create_anthropic_model_list_response(admin_listing)
|
||||
return create_anthropic_model_list_response(
|
||||
admin_listing,
|
||||
display_names=configured_display_names(admin_entries, llm_router),
|
||||
)
|
||||
|
||||
return dict(
|
||||
data=model_data,
|
||||
|
|
@ -10267,7 +10272,8 @@ async def model_list(
|
|||
# The internal routing key drives the metadata/fallback lookup, while the
|
||||
# public name is what the client sees as the model id.
|
||||
model_data = []
|
||||
for response_id, lookup_id in TeamModelNameTranslator.listing_entries(all_models, llm_router, settings):
|
||||
entries: Final = TeamModelNameTranslator.listing_entries(all_models, llm_router, settings)
|
||||
for response_id, lookup_id in entries:
|
||||
model_info = create_model_info_response(
|
||||
model_id=lookup_id,
|
||||
provider="openai",
|
||||
|
|
@ -10280,7 +10286,10 @@ async def model_list(
|
|||
|
||||
if wants_anthropic_format:
|
||||
listing: Final = cast(Sequence[ModelInfoResponse], model_data) # cast-ok: rows built above
|
||||
return create_anthropic_model_list_response(listing)
|
||||
return create_anthropic_model_list_response(
|
||||
listing,
|
||||
display_names=configured_display_names(entries, llm_router),
|
||||
)
|
||||
|
||||
return dict(
|
||||
data=model_data,
|
||||
|
|
@ -16207,6 +16216,7 @@ async def invitation_delete(
|
|||
)
|
||||
async def update_config(
|
||||
config_info: ConfigYAML,
|
||||
request: Request,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
|
|
@ -16222,6 +16232,26 @@ async def update_config(
|
|||
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN:
|
||||
raise HTTPException(status_code=403, detail="Only proxy admins can update config")
|
||||
|
||||
request_body: Final[Mapping[str, JsonValue]] = TypeAdapter(Mapping[str, JsonValue]).validate_python(
|
||||
await request.json()
|
||||
)
|
||||
raw_router_settings: Final = request_body.get("router_settings")
|
||||
if isinstance(raw_router_settings, dict):
|
||||
supported_router_settings: Final = RUNTIME_UPDATABLE_ROUTER_SETTINGS | (
|
||||
frozenset(litellm.Router.get_valid_args()) - ROUTER_SETTINGS_MANAGED_OUTSIDE_CONFIG
|
||||
)
|
||||
unsupported_router_settings: Final = sorted(set(raw_router_settings) - supported_router_settings)
|
||||
if unsupported_router_settings:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": (
|
||||
f"Unsupported router settings: {', '.join(unsupported_router_settings)} "
|
||||
"are not valid router settings"
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise Exception("No DB Connected")
|
||||
|
||||
|
|
@ -16323,11 +16353,19 @@ async def update_config(
|
|||
)
|
||||
|
||||
# router_settings: merge existing + request, request wins.
|
||||
if config_info.router_settings is not None:
|
||||
if isinstance(raw_router_settings, dict):
|
||||
existing = await _read_section("router_settings")
|
||||
before_router_settings: Final = copy.deepcopy(existing)
|
||||
updates = config_info.router_settings.dict(exclude_none=True)
|
||||
new_router_settings: Final = {**existing, **updates}
|
||||
typed_router_settings: Final = (
|
||||
config_info.router_settings.dict(exclude_none=True) if config_info.router_settings is not None else {}
|
||||
)
|
||||
raw_router_settings_without_none: Final = {
|
||||
key: value
|
||||
for key, value in raw_router_settings.items()
|
||||
if key not in typed_router_settings and value is not None
|
||||
}
|
||||
router_settings_updates: Final = {**typed_router_settings, **raw_router_settings_without_none}
|
||||
new_router_settings: Final = {**existing, **router_settings_updates}
|
||||
await _upsert_section("router_settings", new_router_settings)
|
||||
asyncio.create_task(
|
||||
create_config_audit_log(
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ Provides:
|
|||
import base64
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import orjson
|
||||
|
|
@ -19,6 +20,9 @@ from starlette.datastructures import UploadFile
|
|||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
|
||||
from litellm.integrations.vector_store_integrations.vector_store_pre_call_hook import (
|
||||
LiteLLM_ManagedVectorStore,
|
||||
)
|
||||
from litellm.litellm_core_utils.streaming_handler import CustomStreamWrapper
|
||||
from litellm.proxy._types import *
|
||||
from litellm.proxy.auth.auth_utils import is_request_body_safe
|
||||
|
|
@ -36,6 +40,10 @@ from litellm.proxy.rag_endpoints.upload_security import (
|
|||
RejectedUpload,
|
||||
validate_upload,
|
||||
)
|
||||
from litellm.proxy.vector_store_endpoints.endpoints import (
|
||||
build_request_data_from_managed_vector_store,
|
||||
reject_caller_embedding_selection_params,
|
||||
)
|
||||
from litellm.proxy.vector_store_endpoints.utils import (
|
||||
assert_user_can_access_vector_store_id,
|
||||
)
|
||||
|
|
@ -120,12 +128,21 @@ def _collect_vector_store_ids_from_payload(payload: object) -> set[str]:
|
|||
async def _authorize_nested_vector_store_ids(
|
||||
payload: object,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> None:
|
||||
for vector_store_id in sorted(_collect_vector_store_ids_from_payload(payload)):
|
||||
await assert_user_can_access_vector_store_id(
|
||||
vector_store_id=vector_store_id,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
) -> Mapping[str, LiteLLM_ManagedVectorStore]:
|
||||
"""Authorize every nested vector store id and return the managed stores it resolved."""
|
||||
return MappingProxyType(
|
||||
{
|
||||
vector_store_id: store
|
||||
for vector_store_id in sorted(_collect_vector_store_ids_from_payload(payload))
|
||||
if (
|
||||
store := await assert_user_can_access_vector_store_id(
|
||||
vector_store_id=vector_store_id,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
)
|
||||
is not None
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _build_file_metadata_entry(
|
||||
|
|
@ -700,11 +717,27 @@ async def rag_query(
|
|||
status_code=400,
|
||||
detail={"error": "retrieval_config must contain 'vector_store_id'"},
|
||||
)
|
||||
await _authorize_nested_vector_store_ids(
|
||||
reject_caller_embedding_selection_params(payload=retrieval_config, source="retrieval_config")
|
||||
resolved_stores: Final = await _authorize_nested_vector_store_ids(
|
||||
payload=retrieval_config,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
# Merge litellm-managed vector store params (provider, region, embedding
|
||||
# model, credentials, ...) from the registry: the same source the direct
|
||||
# /vector_stores/{id}/search endpoint uses. Store-managed keys win on
|
||||
# conflict so callers cannot override the store's provider or credentials.
|
||||
managed_store: Final = resolved_stores.get(retrieval_config["vector_store_id"])
|
||||
store_data: Final = (
|
||||
await build_request_data_from_managed_vector_store(managed_store)
|
||||
if managed_store is not None
|
||||
else MappingProxyType({})
|
||||
)
|
||||
merged_retrieval_config: Final = {
|
||||
**retrieval_config,
|
||||
**store_data,
|
||||
} # mutable-ok: litellm.aquery requires a plain dict payload
|
||||
|
||||
# Add litellm data
|
||||
request_data: dict[str, object] = {}
|
||||
request_data = await add_litellm_data_to_request(
|
||||
|
|
@ -716,13 +749,18 @@ async def rag_query(
|
|||
proxy_config=proxy_config,
|
||||
)
|
||||
|
||||
verbose_proxy_logger.debug("RAG Query - model: %s, retrieval_config: %s", model, retrieval_config)
|
||||
verbose_proxy_logger.debug(
|
||||
"RAG Query - model: %s, vector_store_id: %s, custom_llm_provider: %s",
|
||||
model,
|
||||
retrieval_config["vector_store_id"],
|
||||
merged_retrieval_config.get("custom_llm_provider"),
|
||||
)
|
||||
|
||||
# Call query
|
||||
response: Final = await litellm.aquery(
|
||||
model=model,
|
||||
messages=messages,
|
||||
retrieval_config=retrieval_config,
|
||||
retrieval_config=merged_retrieval_config,
|
||||
rerank=rerank,
|
||||
stream=stream,
|
||||
router=llm_router,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
from collections.abc import Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import (
|
||||
Annotated,
|
||||
Any, # noqa: TID251 # jsonify_object in proxy/utils.py is annotated with a bare dict
|
||||
|
|
@ -27,11 +29,69 @@ from litellm.types.vector_stores import IndexCreateRequest, IndexListResponse
|
|||
from litellm.vector_stores.vector_store_registry import VectorStoreIndexRegistry
|
||||
|
||||
router: Final = APIRouter()
|
||||
|
||||
BLOCKED_QUERY_EMBEDDING_SELECTION_PARAMS: Final = frozenset(
|
||||
{
|
||||
"embedding_model",
|
||||
"litellm_embedding_model",
|
||||
"litellm_embedding_config",
|
||||
"litellm_credential_name",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def reject_caller_embedding_selection_params(payload: Mapping[str, object], source: str) -> None:
|
||||
blocked: Final = sorted(BLOCKED_QUERY_EMBEDDING_SELECTION_PARAMS & payload.keys())
|
||||
if blocked:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={
|
||||
"error": f"'{blocked[0]}' cannot be set in {source}. "
|
||||
"Embedding configuration comes from the vector store's server-side registration."
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
########################################################
|
||||
# OpenAI Compatible Endpoints
|
||||
########################################################
|
||||
|
||||
|
||||
async def build_request_data_from_managed_vector_store(
|
||||
vector_store: LiteLLM_ManagedVectorStore,
|
||||
) -> Mapping[str, object]:
|
||||
"""
|
||||
Build request params (provider, credential ref, litellm_params) from an
|
||||
already-resolved managed vector store.
|
||||
|
||||
``litellm_embedding_config`` is resolved here, at request-handling time,
|
||||
instead of at row-creation time: the resolved api_key/api_base/api_version
|
||||
lives only in the returned per-request mapping and is never persisted back
|
||||
to the registry cache. Legacy rows that already carry a resolved
|
||||
(cleartext) config skip the lookup and pass through unchanged.
|
||||
"""
|
||||
top_level: Final = MappingProxyType(
|
||||
{
|
||||
key: vector_store.get(key)
|
||||
for key in ("custom_llm_provider", "litellm_credential_name")
|
||||
if key in vector_store
|
||||
}
|
||||
)
|
||||
litellm_params: Final = vector_store.get("litellm_params") or MappingProxyType({})
|
||||
embedding_model: Final = litellm_params.get("litellm_embedding_model")
|
||||
if not embedding_model or litellm_params.get("litellm_embedding_config"):
|
||||
return MappingProxyType({**top_level, **litellm_params})
|
||||
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
resolved_config: Final = await _resolve_embedding_config(
|
||||
embedding_model=embedding_model, prisma_client=prisma_client
|
||||
)
|
||||
if not resolved_config:
|
||||
return MappingProxyType({**top_level, **litellm_params})
|
||||
return MappingProxyType({**top_level, **litellm_params, "litellm_embedding_config": resolved_config})
|
||||
|
||||
|
||||
async def _update_request_data_with_litellm_managed_vector_store_registry(
|
||||
data: dict,
|
||||
vector_store_id: str,
|
||||
|
|
@ -51,47 +111,14 @@ async def _update_request_data_with_litellm_managed_vector_store_registry(
|
|||
vector_store_to_run: Final[LiteLLM_ManagedVectorStore | None] = await get_litellm_managed_vector_store(
|
||||
vector_store_id=vector_store_id
|
||||
)
|
||||
if vector_store_to_run is not None:
|
||||
if user_api_key_dict is not None:
|
||||
await assert_user_can_access_vector_store(
|
||||
vector_store=vector_store_to_run,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
|
||||
if "custom_llm_provider" in vector_store_to_run:
|
||||
data["custom_llm_provider"] = vector_store_to_run.get("custom_llm_provider")
|
||||
|
||||
if "litellm_credential_name" in vector_store_to_run:
|
||||
data["litellm_credential_name"] = vector_store_to_run.get("litellm_credential_name")
|
||||
|
||||
if "litellm_params" in vector_store_to_run:
|
||||
litellm_params = vector_store_to_run.get("litellm_params", {}) or {}
|
||||
# Resolve ``litellm_embedding_config`` here, at request-handling
|
||||
# time, instead of at row-creation time. The resolved
|
||||
# ``api_key`` / ``api_base`` / ``api_version`` lives only in
|
||||
# this per-request ``data`` dict and is never persisted.
|
||||
# Legacy rows that already carry a resolved (cleartext)
|
||||
# ``litellm_embedding_config`` skip the lookup and pass through
|
||||
# unchanged so the embed call keeps working.
|
||||
embedding_model: Final = litellm_params.get("litellm_embedding_model")
|
||||
if embedding_model and not litellm_params.get("litellm_embedding_config"):
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
resolved_config: Final = await _resolve_embedding_config(
|
||||
embedding_model=embedding_model, prisma_client=prisma_client
|
||||
)
|
||||
if resolved_config:
|
||||
# Build a fresh dict via spread instead of mutating
|
||||
# ``litellm_params`` in place — the registry hands back
|
||||
# a reference to its cached object, so an in-place
|
||||
# update would persist the resolved cleartext into the
|
||||
# in-memory cache for the lifetime of the process.
|
||||
litellm_params = {
|
||||
**litellm_params,
|
||||
"litellm_embedding_config": resolved_config,
|
||||
}
|
||||
data.update(litellm_params)
|
||||
return data
|
||||
if vector_store_to_run is None:
|
||||
return data
|
||||
if user_api_key_dict is not None:
|
||||
await assert_user_can_access_vector_store(
|
||||
vector_store=vector_store_to_run,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
return {**data, **(await build_request_data_from_managed_vector_store(vector_store_to_run))}
|
||||
|
||||
|
||||
@router.post(
|
||||
|
|
@ -130,6 +157,7 @@ async def vector_store_search(
|
|||
)
|
||||
|
||||
data = await _read_request_body(request=request)
|
||||
reject_caller_embedding_selection_params(payload=data, source="the search request body")
|
||||
data["vector_store_id"] = vector_store_id
|
||||
|
||||
# Check for legacy vector store registry (non-managed vector stores)
|
||||
|
|
|
|||
|
|
@ -470,7 +470,7 @@ async def create_vector_store_in_db(
|
|||
# exposed every env-stored embedding-model credential on the
|
||||
# ``/vector_store/{new,info,update,list}`` responses. Keep the user's
|
||||
# raw ``litellm_embedding_model`` reference; resolution now happens in
|
||||
# ``_update_request_data_with_litellm_managed_vector_store_registry``
|
||||
# ``build_request_data_from_managed_vector_store``
|
||||
# at request-handling time so the cleartext config exists only in
|
||||
# per-request memory and never reaches the database.
|
||||
if litellm_params:
|
||||
|
|
@ -864,7 +864,7 @@ async def update_vector_store(
|
|||
# embedding-config auto-resolve previously persisted cleartext
|
||||
# credentials into the row; resolution now happens at request-
|
||||
# handling time in
|
||||
# ``_update_request_data_with_litellm_managed_vector_store_registry``
|
||||
# ``build_request_data_from_managed_vector_store``
|
||||
# so this row only ever stores the user-supplied
|
||||
# ``litellm_embedding_model`` reference.
|
||||
if "litellm_params" in update_data:
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import contextvars
|
|||
from collections.abc import Coroutine, Iterator
|
||||
from contextlib import contextmanager
|
||||
from functools import partial
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -50,6 +51,21 @@ INGESTION_REGISTRY: Final[dict[str, type[BaseRAGIngestion]]] = {
|
|||
"vertex_ai": VertexAIRAGIngestion,
|
||||
}
|
||||
|
||||
# Only these retrieval_config keys are forwarded to vector_stores.asearch as
|
||||
# provider-specific params. The explicit allowlist keeps caller-controlled
|
||||
# connection overrides (api_base, api_key, ...) away from the search call,
|
||||
# where they could redirect store credentials to an attacker-chosen host.
|
||||
_FORWARDABLE_RETRIEVAL_CONFIG_KEYS: Final = frozenset(
|
||||
{
|
||||
"aws_region_name",
|
||||
"vector_bucket_name",
|
||||
"embedding_model",
|
||||
"litellm_embedding_model",
|
||||
"litellm_embedding_config",
|
||||
"litellm_credential_name",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_ingestion_class(provider: str) -> type[BaseRAGIngestion]:
|
||||
"""
|
||||
|
|
@ -224,13 +240,20 @@ async def _execute_query_pipeline(
|
|||
raise ValueError("No query found in messages for RAG query")
|
||||
|
||||
# 2. Search vector store
|
||||
# Forward allowlisted provider retrieval_config extras (region, embedding
|
||||
# model, bucket, credential refs) to the search call; kwargs win on conflict.
|
||||
provider_search_params: Final = MappingProxyType(
|
||||
{k: v for k, v in retrieval_config.items() if k in _FORWARDABLE_RETRIEVAL_CONFIG_KEYS}
|
||||
)
|
||||
forwarded_search_params: Final = MappingProxyType({**provider_search_params, **kwargs})
|
||||
with _suppressed_sub_call_billing():
|
||||
search_response: Final = await litellm.vector_stores.asearch(
|
||||
vector_store_id=retrieval_config["vector_store_id"],
|
||||
query=query_text,
|
||||
max_num_results=retrieval_config.get("top_k", 10),
|
||||
custom_llm_provider=retrieval_config.get("custom_llm_provider", "openai"),
|
||||
**kwargs,
|
||||
router=router,
|
||||
**forwarded_search_params,
|
||||
)
|
||||
|
||||
search_provider: Final = retrieval_config.get("custom_llm_provider", "openai")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from typing import Any, Final, Literal
|
|||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.get_llm_provider_logic import declared_authenticating_provider
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.rerank.transformation import BaseRerankConfig
|
||||
from litellm.llms.bedrock.rerank.handler import BedrockRerankHandler
|
||||
|
|
@ -43,10 +44,23 @@ async def arerank(
|
|||
"""
|
||||
Async: Reranks a list of documents based on their relevance to the query
|
||||
"""
|
||||
_custom_llm_provider: str | None = (
|
||||
None # rebind-ok: set by the declared-provider guard or the get_llm_provider unpack; read in the except
|
||||
)
|
||||
try:
|
||||
loop: Final = asyncio.get_event_loop()
|
||||
kwargs["arerank"] = True
|
||||
|
||||
declared_provider: Final = declared_authenticating_provider(model, custom_llm_provider)
|
||||
if declared_provider is not None:
|
||||
_custom_llm_provider = declared_provider # rebind-ok: see pre-declaration above
|
||||
else:
|
||||
_, _custom_llm_provider, _, _ = litellm.get_llm_provider( # rebind-ok: see pre-declaration above
|
||||
model=model,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
api_base=kwargs.get("api_base", None),
|
||||
)
|
||||
|
||||
func: Final = partial(
|
||||
rerank,
|
||||
model,
|
||||
|
|
@ -70,7 +84,11 @@ async def arerank(
|
|||
response = init_response
|
||||
return response
|
||||
except Exception as e:
|
||||
raise e
|
||||
raise exception_type(
|
||||
model=model,
|
||||
custom_llm_provider=_custom_llm_provider or custom_llm_provider,
|
||||
original_exception=e,
|
||||
)
|
||||
|
||||
|
||||
@client
|
||||
|
|
@ -115,6 +133,7 @@ def rerank(
|
|||
model_info: Final = kwargs.get("model_info", None)
|
||||
user: Final = kwargs.get("user", None)
|
||||
client: Final = kwargs.get("client", None)
|
||||
_custom_llm_provider: str | None = None # rebind-ok: set by the get_llm_provider unpack; read in the except
|
||||
try:
|
||||
_is_async: Final = kwargs.pop("arerank", False) is True
|
||||
optional_params: Final = GenericLiteLLMParams(**kwargs)
|
||||
|
|
@ -127,7 +146,7 @@ def rerank(
|
|||
|
||||
(
|
||||
model,
|
||||
_custom_llm_provider,
|
||||
_custom_llm_provider, # rebind-ok: see pre-declaration above
|
||||
dynamic_api_key,
|
||||
dynamic_api_base,
|
||||
) = litellm.get_llm_provider(
|
||||
|
|
@ -538,4 +557,8 @@ def rerank(
|
|||
return response
|
||||
except Exception as e:
|
||||
verbose_logger.error("Error in rerank: %s", e)
|
||||
raise exception_type(model=model, custom_llm_provider=custom_llm_provider, original_exception=e)
|
||||
raise exception_type(
|
||||
model=model,
|
||||
custom_llm_provider=_custom_llm_provider or custom_llm_provider,
|
||||
original_exception=e,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1169,16 +1169,6 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
|
|||
|
||||
def _emit_response_completed_event(self, litellm_model_response: ModelResponse) -> ResponseCompletedEvent | None:
|
||||
if litellm_model_response:
|
||||
# Add cost to usage object if include_cost_in_streaming_usage is True
|
||||
if litellm.include_cost_in_streaming_usage and self.litellm_logging_obj is not None:
|
||||
usage: Final[object] = getattr(litellm_model_response, "usage", None)
|
||||
if usage is not None:
|
||||
setattr(
|
||||
usage,
|
||||
"cost",
|
||||
self.litellm_logging_obj._response_cost_calculator(result=litellm_model_response),
|
||||
)
|
||||
|
||||
# Transform the response
|
||||
responses_api_response: Final = (
|
||||
LiteLLMCompletionResponsesConfig.transform_chat_completion_response_to_responses_api_response(
|
||||
|
|
|
|||
|
|
@ -407,23 +407,7 @@ class BaseResponsesAPIStreamingIterator:
|
|||
openai_types.ResponsesAPIStreamEvents.RESPONSE_FAILED,
|
||||
):
|
||||
self.completed_response = openai_responses_api_chunk
|
||||
# Add cost to usage object if include_cost_in_streaming_usage is True
|
||||
if litellm.include_cost_in_streaming_usage and self.logging_obj is not None:
|
||||
response_obj: Final[ResponsesAPIResponse | None] = getattr(
|
||||
openai_responses_api_chunk, "response", None
|
||||
)
|
||||
if response_obj:
|
||||
usage_obj: Final[ResponseAPIUsage | None] = getattr(response_obj, "usage", None)
|
||||
if usage_obj is not None:
|
||||
try:
|
||||
cost: Final[float | None] = self.logging_obj._response_cost_calculator(
|
||||
result=response_obj
|
||||
)
|
||||
if cost is not None:
|
||||
setattr(usage_obj, "cost", cost)
|
||||
except Exception:
|
||||
# Best-effort usage cost annotation should not break stream replay.
|
||||
pass
|
||||
_stamp_responses_usage_cost(getattr(openai_responses_api_chunk, "response", None), self.logging_obj)
|
||||
|
||||
if _chunk_type == openai_types.ResponsesAPIStreamEvents.RESPONSE_FAILED:
|
||||
self._handle_logging_failed_response()
|
||||
|
|
@ -1274,6 +1258,24 @@ def _add_text_like_part_events(
|
|||
)
|
||||
|
||||
|
||||
def _stamp_responses_usage_cost(
|
||||
response_obj: ResponsesAPIResponse | None, logging_obj: LiteLLMLoggingObj | None
|
||||
) -> None:
|
||||
if response_obj is None or logging_obj is None:
|
||||
return
|
||||
usage_obj: Final[ResponseAPIUsage | None] = getattr(response_obj, "usage", None)
|
||||
if usage_obj is None:
|
||||
return
|
||||
if isinstance(getattr(usage_obj, "cost", None), (int, float)):
|
||||
return
|
||||
try:
|
||||
cost: Final[float | None] = logging_obj._response_cost_calculator(result=response_obj)
|
||||
except Exception:
|
||||
return
|
||||
if isinstance(cost, (int, float)) and cost > 0:
|
||||
setattr(usage_obj, "cost", cost)
|
||||
|
||||
|
||||
def build_synthetic_response_events(
|
||||
*,
|
||||
transformed: ResponsesAPIResponse,
|
||||
|
|
@ -1281,15 +1283,7 @@ def build_synthetic_response_events(
|
|||
chunk_size: int,
|
||||
) -> list[ResponsesAPIStreamingResponse]:
|
||||
openai_types: Final = _get_openai_response_types()
|
||||
if litellm.include_cost_in_streaming_usage and logging_obj is not None:
|
||||
usage_obj: Final = transformed.usage if hasattr(transformed, "usage") else None
|
||||
if usage_obj is not None:
|
||||
try:
|
||||
cost: Final[float | None] = logging_obj._response_cost_calculator(result=transformed)
|
||||
if cost is not None:
|
||||
setattr(usage_obj, "cost", cost)
|
||||
except Exception:
|
||||
pass
|
||||
_stamp_responses_usage_cost(transformed, logging_obj)
|
||||
|
||||
events: Final[list[ResponsesAPIStreamingResponse]] = [
|
||||
_build_response_status_event(openai_types.ResponsesAPIStreamEvents.RESPONSE_CREATED, transformed),
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ from litellm.constants import (
|
|||
DEFAULT_HEALTH_CHECK_INTERVAL,
|
||||
DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER,
|
||||
DEFAULT_MAX_LRU_CACHE_SIZE,
|
||||
RUNTIME_UPDATABLE_ROUTER_SETTINGS,
|
||||
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
|
||||
)
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
|
@ -354,6 +355,13 @@ _PreRoutingStrategyT = TypeVar("_PreRoutingStrategyT")
|
|||
_ALIAS_PARAMS_NEVER_FORWARDED: Final = frozenset({"model", "api_base", "api_key", "api_version"})
|
||||
_ALIAS_MARKER_FORWARDED_PARAMS_KWARG: Final = "_alias_marker_forwarded_params"
|
||||
|
||||
_RUNTIME_TOGGLEABLE_PRE_CALL_CHECKS: Final[Mapping[str, type[CustomLogger]]] = MappingProxyType(
|
||||
{
|
||||
"prompt_caching": PromptCachingDeploymentCheck,
|
||||
"enforce_model_rate_limits": ModelRateLimitingCheck,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _stream_chunks_have_generated_content(chunks: Sequence[ModelResponseStream]) -> bool:
|
||||
for chunk in chunks:
|
||||
|
|
@ -392,7 +400,9 @@ def _with_router_resolved_session_model(session: object, model_name: str) -> Map
|
|||
return _NO_SESSION_KWARGS
|
||||
if "model" not in typed_session:
|
||||
return _NO_SESSION_KWARGS
|
||||
return MappingProxyType({"session": {**typed_session, "model": model_name}})
|
||||
return MappingProxyType(
|
||||
{"session": {**typed_session, "model": model_name}} # mutable-ok: callees deepcopy and JSON-dump session
|
||||
)
|
||||
|
||||
|
||||
# Router._aanthropic_messages_streaming_iterator buffers lifecycle chunks
|
||||
|
|
@ -2092,11 +2102,39 @@ class Router:
|
|||
if _callback is None:
|
||||
continue
|
||||
|
||||
if self.optional_callbacks is not None and any(
|
||||
isinstance(callback, type(_callback)) for callback in self.optional_callbacks
|
||||
):
|
||||
continue
|
||||
if self.optional_callbacks is None:
|
||||
self.optional_callbacks = []
|
||||
self.optional_callbacks.append(_callback)
|
||||
litellm.logging_callback_manager.add_litellm_callback(_callback)
|
||||
|
||||
def set_optional_pre_call_checks(self, optional_pre_call_checks: OptionalPreCallChecks | None) -> None:
|
||||
if optional_pre_call_checks is None:
|
||||
return
|
||||
requested: Final = frozenset(optional_pre_call_checks)
|
||||
for name, callback_cls in _RUNTIME_TOGGLEABLE_PRE_CALL_CHECKS.items():
|
||||
if name not in requested:
|
||||
self._remove_optional_callbacks_of_type(callback_cls)
|
||||
self.add_optional_pre_call_checks(optional_pre_call_checks)
|
||||
|
||||
def _remove_optional_callbacks_of_type(self, callback_cls: type[CustomLogger]) -> None:
|
||||
if self.optional_callbacks is None or not any(type(cb) is callback_cls for cb in self.optional_callbacks):
|
||||
return
|
||||
self.optional_callbacks = [cb for cb in self.optional_callbacks if type(cb) is not callback_cls]
|
||||
if any(
|
||||
router is not self and any(type(cb) is callback_cls for cb in (router.optional_callbacks or []))
|
||||
for router in tuple(_live_routers)
|
||||
):
|
||||
return
|
||||
for cb in tuple(litellm.callbacks):
|
||||
if type(cb) is callback_cls:
|
||||
litellm.logging_callback_manager.remove_callback_from_list_by_object(
|
||||
litellm.callbacks, cb, require_self=False
|
||||
)
|
||||
|
||||
def print_deployment(self, deployment: dict):
|
||||
"""
|
||||
returns a copy of the deployment with the api key masked
|
||||
|
|
@ -2344,7 +2382,7 @@ class Router:
|
|||
@overload
|
||||
async def acompletion(
|
||||
self, model: str, messages: list[AllMessageValues], stream: Literal[True, False] = False, **kwargs
|
||||
) -> CustomStreamWrapper | ModelResponse:
|
||||
) -> CustomStreamWrapper | ModelResponse:
|
||||
...
|
||||
|
||||
# fmt: on
|
||||
|
|
@ -6395,8 +6433,6 @@ class Router:
|
|||
"responses",
|
||||
"generate_content",
|
||||
"generate_content_stream",
|
||||
"vector_store_search",
|
||||
"vector_store_create",
|
||||
"ocr",
|
||||
"search",
|
||||
"video_generation",
|
||||
|
|
@ -6420,6 +6456,8 @@ class Router:
|
|||
return sync_wrapper
|
||||
|
||||
if call_type in (
|
||||
"vector_store_search",
|
||||
"vector_store_create",
|
||||
"vector_store_retrieve",
|
||||
"vector_store_list",
|
||||
"vector_store_update",
|
||||
|
|
@ -6431,11 +6469,16 @@ class Router:
|
|||
client: object | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
if custom_llm_provider and "custom_llm_provider" not in kwargs:
|
||||
kwargs["custom_llm_provider"] = custom_llm_provider
|
||||
if kwargs.get("model"):
|
||||
return self._generic_api_call_with_fallbacks(original_function=original_function, **kwargs)
|
||||
return original_function(**kwargs)
|
||||
provider_kwargs: Final = (
|
||||
MappingProxyType({**kwargs, "custom_llm_provider": custom_llm_provider})
|
||||
if custom_llm_provider and "custom_llm_provider" not in kwargs
|
||||
else MappingProxyType(kwargs)
|
||||
)
|
||||
if provider_kwargs.get("model"):
|
||||
return self._generic_api_call_with_fallbacks(original_function=original_function, **provider_kwargs)
|
||||
if call_type == "vector_store_search":
|
||||
return original_function(**MappingProxyType({**provider_kwargs, "router": self}))
|
||||
return original_function(**provider_kwargs)
|
||||
|
||||
return vector_store_sync_wrapper
|
||||
|
||||
|
|
@ -6611,6 +6654,7 @@ class Router:
|
|||
return await self._init_vector_store_api_endpoints(
|
||||
original_function=original_function,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
call_type=call_type,
|
||||
**kwargs,
|
||||
)
|
||||
elif call_type in ("afile_delete", "afile_content"):
|
||||
|
|
@ -6651,6 +6695,7 @@ class Router:
|
|||
self,
|
||||
original_function: Callable,
|
||||
custom_llm_provider: str | None = None,
|
||||
call_type: str | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
|
|
@ -6669,6 +6714,13 @@ class Router:
|
|||
**kwargs,
|
||||
)
|
||||
|
||||
# For search, pass the router so provider transforms can resolve
|
||||
# router-managed embedding models (e.g. S3 Vectors query embeddings).
|
||||
# The merge also overrides any client-supplied `router` key.
|
||||
if call_type == "avector_store_search":
|
||||
search_kwargs: Final = MappingProxyType({**kwargs, "router": self})
|
||||
return await original_function(**search_kwargs)
|
||||
|
||||
# Otherwise, call the original function directly
|
||||
return await original_function(**kwargs)
|
||||
|
||||
|
|
@ -9767,6 +9819,26 @@ class Router:
|
|||
coerce_token_limit(model_info.get("max_output_tokens")),
|
||||
)
|
||||
|
||||
def get_configured_display_name(self, model_name: str) -> "str | None":
|
||||
"""
|
||||
Return the display_name explicitly configured in a concrete deployment's
|
||||
model_info for model_name, via O(1) index lookup.
|
||||
|
||||
Returns None for wildcard-expanded or unknown names, and treats a
|
||||
non-string or empty configured value as absent rather than failing the
|
||||
listing. Like get_configured_token_limits, this never triggers pattern
|
||||
matching or deep copies, so it is safe to call per listed model on the
|
||||
/v1/models hot path.
|
||||
"""
|
||||
deployment: Final = self.get_deployment_by_model_group_name(model_group_name=model_name)
|
||||
if deployment is None:
|
||||
return None
|
||||
|
||||
display_name: Final = deployment.model_info.get("display_name")
|
||||
if isinstance(display_name, str) and display_name.strip():
|
||||
return display_name
|
||||
return None
|
||||
|
||||
def get_deployment_credentials_with_provider(
|
||||
self, model_id: str, team_id: str | None = None
|
||||
) -> dict[str, Any] | None:
|
||||
|
|
@ -11352,27 +11424,6 @@ class Router:
|
|||
"""
|
||||
Update the router settings.
|
||||
"""
|
||||
# only the following settings are allowed to be configured
|
||||
_allowed_settings: Final = [
|
||||
"routing_strategy_args",
|
||||
"routing_strategy",
|
||||
"routing_groups",
|
||||
"allowed_fails",
|
||||
"cooldown_time",
|
||||
"num_retries",
|
||||
"timeout",
|
||||
"max_retries",
|
||||
"retry_after",
|
||||
"fallbacks",
|
||||
"context_window_fallbacks",
|
||||
"retry_policy",
|
||||
"model_group_retry_policy",
|
||||
"model_group_alias",
|
||||
"enable_weighted_failover",
|
||||
"enable_tag_filtering",
|
||||
"tag_routing_prefix",
|
||||
]
|
||||
|
||||
_int_settings: Final = [
|
||||
"timeout",
|
||||
"num_retries",
|
||||
|
|
@ -11385,13 +11436,15 @@ class Router:
|
|||
rebuild_routing_groups = False
|
||||
relink_lar1_from_args = False
|
||||
for var in kwargs:
|
||||
if var in _allowed_settings:
|
||||
if var in RUNTIME_UPDATABLE_ROUTER_SETTINGS:
|
||||
if var in _int_settings:
|
||||
_casted_value = int(kwargs[var])
|
||||
setattr(self, var, _casted_value)
|
||||
elif var == "routing_groups":
|
||||
self._routing_groups_input = kwargs[var]
|
||||
rebuild_routing_groups = True
|
||||
elif var == "optional_pre_call_checks":
|
||||
self.set_optional_pre_call_checks(kwargs[var])
|
||||
elif var == "retry_policy":
|
||||
value = kwargs[var]
|
||||
if isinstance(value, dict):
|
||||
|
|
|
|||
|
|
@ -106,6 +106,20 @@ class RetryPolicy(BaseModel):
|
|||
InternalServerErrorRetries: int | None = None
|
||||
|
||||
|
||||
OptionalPreCallChecks = list[
|
||||
Literal[
|
||||
"prompt_caching",
|
||||
"router_budget_limiting",
|
||||
"responses_api_deployment_check",
|
||||
"deployment_affinity",
|
||||
"session_affinity",
|
||||
"forward_client_headers_by_model_group",
|
||||
"enforce_model_rate_limits",
|
||||
"encrypted_content_affinity",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
class UpdateRouterConfig(BaseModel):
|
||||
"""
|
||||
Set of params that you can modify via `router.update_settings()`.
|
||||
|
|
@ -128,6 +142,7 @@ class UpdateRouterConfig(BaseModel):
|
|||
model_group_alias: dict[str, str | dict] | None = {}
|
||||
enable_tag_filtering: bool | None = None
|
||||
tag_routing_prefix: str | None = None
|
||||
optional_pre_call_checks: OptionalPreCallChecks | None = None
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
|
|
@ -869,20 +884,6 @@ class FallbackAccessCheck(Protocol):
|
|||
async def __call__(self, *, model: str, request_kwargs: Mapping[str, object], llm_router: "Router") -> bool: ...
|
||||
|
||||
|
||||
OptionalPreCallChecks = list[
|
||||
Literal[
|
||||
"prompt_caching",
|
||||
"router_budget_limiting",
|
||||
"responses_api_deployment_check",
|
||||
"deployment_affinity",
|
||||
"session_affinity",
|
||||
"forward_client_headers_by_model_group",
|
||||
"enforce_model_rate_limits",
|
||||
"encrypted_content_affinity",
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
class LiteLLM_RouterFileObject(TypedDict, total=False):
|
||||
"""
|
||||
Tracking the litellm params hash, used for mapping the file id to the right model
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import builtins
|
|||
import contextvars
|
||||
from collections.abc import Coroutine, Mapping
|
||||
from functools import partial
|
||||
from typing import Final
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import httpx
|
||||
|
||||
|
|
@ -29,6 +29,9 @@ from litellm.types.vector_stores import (
|
|||
from litellm.utils import ProviderConfigManager, client
|
||||
from litellm.vector_stores.utils import VectorStoreRequestUtils
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.router import Router
|
||||
|
||||
####### ENVIRONMENT VARIABLES ###################
|
||||
# Initialize any necessary instances or variables here
|
||||
base_llm_http_handler = BaseLLMHTTPHandler()
|
||||
|
|
@ -280,6 +283,7 @@ async def asearch(
|
|||
timeout: float | httpx.Timeout | None = None,
|
||||
# LiteLLM specific params,
|
||||
custom_llm_provider: str | None = None,
|
||||
router: "Router | None" = None,
|
||||
**kwargs,
|
||||
) -> VectorStoreSearchResponse:
|
||||
"""
|
||||
|
|
@ -308,6 +312,7 @@ async def asearch(
|
|||
extra_body=extra_body,
|
||||
timeout=timeout,
|
||||
custom_llm_provider=custom_llm_provider,
|
||||
router=router,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
|
@ -347,6 +352,7 @@ def search(
|
|||
timeout: float | httpx.Timeout | None = None,
|
||||
# LiteLLM specific params,
|
||||
custom_llm_provider: str | None = None,
|
||||
router: "Router | None" = None,
|
||||
**kwargs,
|
||||
) -> VectorStoreSearchResponse | Coroutine[object, object, VectorStoreSearchResponse]:
|
||||
"""
|
||||
|
|
@ -450,6 +456,7 @@ def search(
|
|||
timeout=timeout or request_timeout,
|
||||
_is_async=_is_async,
|
||||
client=kwargs.get("client"),
|
||||
router=router,
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -24,25 +24,10 @@ from junit_properties import (
|
|||
)
|
||||
|
||||
|
||||
class FakeMarker:
|
||||
def __init__(self, name: str, *args: object) -> None:
|
||||
self.name = name
|
||||
self.args = args
|
||||
|
||||
|
||||
class FakeItem:
|
||||
"""The three attributes junit_properties reads off a pytest Item."""
|
||||
|
||||
def __init__(
|
||||
self, nodeid: str, location: tuple[str, int | None, str], markers: tuple[FakeMarker, ...] = ()
|
||||
) -> None:
|
||||
self.nodeid = nodeid
|
||||
self.location = location
|
||||
self.user_properties: list[tuple[str, str]] = []
|
||||
self._markers = markers
|
||||
|
||||
def iter_markers(self, name: str):
|
||||
return (marker for marker in self._markers if marker.name == name)
|
||||
def collected_item(request: pytest.FixtureRequest, name: str) -> pytest.Item:
|
||||
"""The Item pytest collected for test ``name`` in this file: the real nodeid,
|
||||
location and marker machinery the collection hook reads, as pytest built it."""
|
||||
return next(item for item in request.session.items if item.path == request.path and item.name == name)
|
||||
|
||||
|
||||
def repo_root() -> Path | None:
|
||||
|
|
@ -109,22 +94,22 @@ class TestSourceFromLocation:
|
|||
|
||||
|
||||
class TestResultProperties:
|
||||
def test_every_test_carries_package_covers_and_source(self) -> None:
|
||||
item = FakeItem(
|
||||
"logging/test_x.py::TestFoo::test_bar",
|
||||
("logging/test_x.py", 40, "TestFoo.test_bar"),
|
||||
(FakeMarker("covers", "LOG-1", "LOG-2"),),
|
||||
)
|
||||
assert result_properties(item) == (
|
||||
("package", "logging"),
|
||||
def test_every_test_carries_package_covers_and_source(self, request: pytest.FixtureRequest) -> None:
|
||||
"""Read off this test's own collected Item, so the nodeid and location are
|
||||
whatever pytest reports for the launch shape in use, and the marker is added
|
||||
at run time so the coverage registry's collect-only pass never sees it."""
|
||||
test = type(self).test_every_test_carries_package_covers_and_source
|
||||
request.applymarker(pytest.mark.covers("LOG-1", "LOG-2"))
|
||||
assert result_properties(collected_item(request, test.__name__)) == (
|
||||
("package", "root"),
|
||||
("covers", "LOG-1,LOG-2"),
|
||||
("source", "tests/e2e/logging/test_x.py:41"),
|
||||
("source", f"tests/e2e/test_junit_properties.py:{test.__code__.co_firstlineno}"),
|
||||
)
|
||||
|
||||
def test_attach_is_idempotent(self) -> None:
|
||||
def test_attach_is_idempotent(self, request: pytest.FixtureRequest) -> None:
|
||||
"""Collection can run the hook more than once; a second pass must not
|
||||
double the <property> entries in the report."""
|
||||
item = FakeItem("logging/test_x.py::test_bar", ("logging/test_x.py", 40, "test_bar"))
|
||||
item = collected_item(request, type(self).test_attach_is_idempotent.__name__)
|
||||
attach_result_properties(item)
|
||||
attach_result_properties(item)
|
||||
assert [name for name, _ in item.user_properties] == ["package", "covers", "source"]
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ Note: Vertex AI OCR automatically converts URLs to base64 data URIs since
|
|||
the Vertex AI endpoint doesn't have internet access.
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
from base_ocr_unit_tests import BaseOCRTest
|
||||
|
||||
|
|
@ -139,3 +141,19 @@ def test_vertex_ai_ocr_routing():
|
|||
assert isinstance(
|
||||
deepseek_variant, VertexAIDeepSeekOCRConfig
|
||||
), "DeepSeek variant should route to VertexAIDeepSeekOCRConfig"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", ("deepseek-ocr-maas", "deepseek-ai/deepseek-ocr-maas"))
|
||||
def test_deepseek_request_uses_single_provider_namespace(model: str) -> None:
|
||||
from litellm.llms.vertex_ai.ocr.deepseek_transformation import (
|
||||
VertexAIDeepSeekOCRConfig,
|
||||
)
|
||||
|
||||
request: Final = VertexAIDeepSeekOCRConfig().transform_ocr_request(
|
||||
model=model,
|
||||
document={"type": "image_url", "image_url": "data:image/png;base64,AA=="},
|
||||
optional_params={},
|
||||
headers={},
|
||||
)
|
||||
|
||||
assert request.data["model"] == "deepseek-ai/deepseek-ocr-maas"
|
||||
|
|
|
|||
|
|
@ -3076,7 +3076,9 @@ async def test_update_config_success_callback_normalization():
|
|||
admin_user = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-test"
|
||||
)
|
||||
await proxy_server.update_config(config_update, user_api_key_dict=admin_user)
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(return_value={"litellm_settings": {"success_callback": ["SQS", "sQs"]}})
|
||||
await proxy_server.update_config(config_update, request=request, user_api_key_dict=admin_user)
|
||||
|
||||
assert (
|
||||
"litellm_settings" in upserted
|
||||
|
|
|
|||
|
|
@ -2237,3 +2237,202 @@ class TestRecordsOwnGuardrailInformation:
|
|||
)
|
||||
|
||||
assert _guardrail_entries(request_data) == []
|
||||
|
||||
|
||||
class _ApplyOnlyObserver(CustomGuardrail):
|
||||
"""Overrides only apply_guardrail, like panw_prisma_airs; inherits async_logging_hook."""
|
||||
|
||||
def __init__(self, block: bool = False):
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
|
||||
super().__init__(guardrail_name="apply-only-observer", event_hook=GuardrailEventHooks.logging_only)
|
||||
self.block = block
|
||||
self.calls: list = []
|
||||
|
||||
@log_guardrail_information
|
||||
async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None):
|
||||
from fastapi import HTTPException
|
||||
|
||||
self.calls.append((input_type, list(inputs.get("texts") or [])))
|
||||
if self.block:
|
||||
raise HTTPException(status_code=400, detail={"error": "flagged"})
|
||||
return GenericGuardrailAPIInputs(texts=["[MASKED]" for _ in inputs.get("texts") or []])
|
||||
|
||||
|
||||
def _logged_call(messages: list | str) -> tuple[dict, object]:
|
||||
from litellm.types.utils import Choices, Message, ModelResponse
|
||||
|
||||
response = ModelResponse(choices=[Choices(message=Message(role="assistant", content="general kenobi"))])
|
||||
kwargs = {
|
||||
"model": "gpt-5.4-mini",
|
||||
"messages": messages,
|
||||
"litellm_call_id": "call-1",
|
||||
"litellm_params": {"metadata": {"user_api_key_user_id": "u1"}},
|
||||
"optional_params": {},
|
||||
"standard_logging_object": {"guardrail_information": None},
|
||||
}
|
||||
return kwargs, response
|
||||
|
||||
|
||||
class TestLoggingOnlyApplyGuardrail:
|
||||
"""LIT-4876 regression: a guardrail in mode logging_only that implements only
|
||||
apply_guardrail must still run against the logged request and response and
|
||||
record guardrail_information, instead of inheriting the CustomLogger no-op."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runs_apply_guardrail_observe_only_and_records_verdict(self):
|
||||
guardrail = _ApplyOnlyObserver()
|
||||
messages = [{"role": "user", "content": "hello there"}]
|
||||
kwargs, response = _logged_call(messages)
|
||||
|
||||
out_kwargs, out_response = await guardrail.async_logging_hook(kwargs, response, CallTypes.acompletion.value)
|
||||
|
||||
assert guardrail.calls == [("request", ["hello there"]), ("response", ["general kenobi"])]
|
||||
assert out_kwargs["messages"] == [{"role": "user", "content": "hello there"}]
|
||||
assert out_response.choices[0].message.content == "general kenobi"
|
||||
entries = out_kwargs["standard_logging_object"]["guardrail_information"]
|
||||
assert [e["guardrail_name"] for e in entries] == ["apply-only-observer", "apply-only-observer"]
|
||||
assert {e["guardrail_mode"] for e in entries} == {"logging_only"}
|
||||
assert {e["guardrail_status"] for e in entries} == {"success"}
|
||||
assert "standard_logging_guardrail_information" not in kwargs["litellm_params"]["metadata"]
|
||||
assert kwargs["standard_logging_object"] == {"guardrail_information": None}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_appends_to_pre_call_verdicts_without_duplicating_them(self):
|
||||
guardrail = _ApplyOnlyObserver()
|
||||
kwargs, response = _logged_call([{"role": "user", "content": "hello there"}])
|
||||
pre_call_entry = {"guardrail_name": "pii-blocker", "guardrail_mode": "pre_call", "guardrail_status": "success"}
|
||||
kwargs["litellm_params"]["metadata"]["standard_logging_guardrail_information"] = [pre_call_entry]
|
||||
kwargs["standard_logging_object"]["guardrail_information"] = [pre_call_entry]
|
||||
|
||||
out_kwargs, _ = await guardrail.async_logging_hook(kwargs, response, CallTypes.acompletion.value)
|
||||
|
||||
entries = out_kwargs["standard_logging_object"]["guardrail_information"]
|
||||
assert [e["guardrail_name"] for e in entries] == ["pii-blocker", "apply-only-observer", "apply-only-observer"]
|
||||
assert kwargs["litellm_params"]["metadata"]["standard_logging_guardrail_information"] == [pre_call_entry]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_copy_failure_is_swallowed(self):
|
||||
import threading
|
||||
|
||||
guardrail = _ApplyOnlyObserver()
|
||||
kwargs, response = _logged_call([{"role": "user", "content": "hello there", "lock": threading.Lock()}])
|
||||
|
||||
out_kwargs, out_response = await guardrail.async_logging_hook(kwargs, response, CallTypes.acompletion.value)
|
||||
|
||||
assert guardrail.calls == []
|
||||
assert out_kwargs is kwargs
|
||||
assert out_response is response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_block_verdict_is_recorded_without_raising(self):
|
||||
guardrail = _ApplyOnlyObserver(block=True)
|
||||
kwargs, response = _logged_call([{"role": "user", "content": "flagged content"}])
|
||||
|
||||
out_kwargs, _ = await guardrail.async_logging_hook(kwargs, response, CallTypes.acompletion.value)
|
||||
|
||||
assert guardrail.calls == [("request", ["flagged content"])]
|
||||
entries = out_kwargs["standard_logging_object"]["guardrail_information"]
|
||||
assert [e["guardrail_status"] for e in entries] == ["guardrail_intervened"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_type_without_translation_is_skipped(self):
|
||||
guardrail = _ApplyOnlyObserver()
|
||||
kwargs, response = _logged_call([{"role": "user", "content": "hello there"}])
|
||||
|
||||
out_kwargs, _ = await guardrail.async_logging_hook(kwargs, response, CallTypes.amoderation.value)
|
||||
|
||||
assert guardrail.calls == []
|
||||
assert out_kwargs["standard_logging_object"]["guardrail_information"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aembedding_scans_logged_input(self):
|
||||
from litellm.types.utils import EmbeddingResponse
|
||||
|
||||
guardrail = _ApplyOnlyObserver()
|
||||
kwargs, _ = _logged_call("hello there")
|
||||
response = EmbeddingResponse(data=[{"embedding": [0.1], "index": 0, "object": "embedding"}])
|
||||
|
||||
out_kwargs, out_response = await guardrail.async_logging_hook(kwargs, response, CallTypes.aembedding.value)
|
||||
|
||||
assert guardrail.calls == [("request", ["hello there"])]
|
||||
assert out_kwargs["messages"] == "hello there"
|
||||
assert out_response is response
|
||||
entries = out_kwargs["standard_logging_object"]["guardrail_information"]
|
||||
assert [e["guardrail_status"] for e in entries] == ["success"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_lifecycle_hook_guardrail_is_left_alone(self):
|
||||
class _NativeHooks(_ApplyOnlyObserver):
|
||||
use_native_lifecycle_hooks = True
|
||||
|
||||
guardrail = _NativeHooks()
|
||||
kwargs, response = _logged_call([{"role": "user", "content": "hello there"}])
|
||||
|
||||
out_kwargs, out_response = await guardrail.async_logging_hook(kwargs, response, CallTypes.acompletion.value)
|
||||
|
||||
assert guardrail.calls == []
|
||||
assert out_kwargs is kwargs
|
||||
assert out_response is response
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aresponses_scans_logged_messages_when_input_is_cleared(self):
|
||||
from litellm.types.llms.openai import ResponsesAPIResponse
|
||||
|
||||
guardrail = _ApplyOnlyObserver()
|
||||
kwargs, _ = _logged_call([{"role": "user", "content": "hello there"}])
|
||||
kwargs["input"] = None
|
||||
response = ResponsesAPIResponse(
|
||||
id="resp_1",
|
||||
created_at=1,
|
||||
model="gpt-5.4-mini",
|
||||
object="response",
|
||||
status="completed",
|
||||
output=[
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_1",
|
||||
"status": "completed",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "general kenobi"}],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
out_kwargs, _ = await guardrail.async_logging_hook(kwargs, response, CallTypes.aresponses.value)
|
||||
|
||||
assert guardrail.calls == [("request", ["hello there"]), ("response", ["general kenobi"])]
|
||||
entries = out_kwargs["standard_logging_object"]["guardrail_information"]
|
||||
assert [e["guardrail_status"] for e in entries] == ["success", "success"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_success_handler_records_verdict_in_standard_logging_object(self):
|
||||
import datetime as dt
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging
|
||||
|
||||
guardrail = _ApplyOnlyObserver()
|
||||
guardrail.default_on = True
|
||||
messages = [{"role": "user", "content": "hello there"}]
|
||||
_, response = _logged_call(messages)
|
||||
logging_obj = Logging(
|
||||
model="gpt-5.4-mini",
|
||||
messages=messages,
|
||||
stream=False,
|
||||
call_type=CallTypes.acompletion.value,
|
||||
start_time=dt.datetime.now(),
|
||||
litellm_call_id="call-1",
|
||||
function_id="fn-1",
|
||||
dynamic_async_success_callbacks=[guardrail],
|
||||
)
|
||||
logging_obj.update_environment_variables(
|
||||
litellm_params={"metadata": {}}, optional_params={}, model="gpt-5.4-mini", custom_llm_provider="openai"
|
||||
)
|
||||
|
||||
await logging_obj.async_success_handler(
|
||||
result=response, start_time=dt.datetime.now(), end_time=dt.datetime.now()
|
||||
)
|
||||
|
||||
assert guardrail.calls == [("request", ["hello there"]), ("response", ["general kenobi"])]
|
||||
entries = logging_obj.model_call_details["standard_logging_object"]["guardrail_information"]
|
||||
assert [e["guardrail_status"] for e in entries] == ["success", "success"]
|
||||
|
|
|
|||
|
|
@ -1522,7 +1522,7 @@ def test_gpt_5_6_alias_prices_match_sol(local_model_cost_map):
|
|||
sol = litellm.model_cost["gpt-5.6-sol"]
|
||||
|
||||
cost_fields = sorted(field for field in sol if "cost" in field)
|
||||
assert len(cost_fields) == 23
|
||||
assert len(cost_fields) == 27
|
||||
|
||||
for field in cost_fields:
|
||||
assert alias.get(field) == sol.get(field), field
|
||||
|
|
@ -4039,8 +4039,8 @@ def test_fast_service_tier_matches_priority_above_the_context_threshold(_local_m
|
|||
)
|
||||
|
||||
assert fast == priority
|
||||
assert fast[0] == pytest.approx(300_000 * 8e-06, rel=1e-9)
|
||||
assert fast[1] == pytest.approx(1_000 * 3e-05, rel=1e-9)
|
||||
assert fast[0] == pytest.approx(300_000 * 1.6e-05, rel=1e-9)
|
||||
assert fast[1] == pytest.approx(1_000 * 6e-05, rel=1e-9)
|
||||
|
||||
|
||||
def test_priority_reasoning_tokens_bill_at_the_priority_output_rate(_local_model_cost_map):
|
||||
|
|
@ -4200,6 +4200,86 @@ def test_generic_cost_per_token_gemini_37_flash(_local_model_cost_map):
|
|||
assert completion_cost == pytest.approx(0.001875)
|
||||
|
||||
|
||||
GEMINI_38_FLASH_LAUNCH_PRICING = [
|
||||
("gemini-3.8-flash", 7.5e-07, 3.75e-06, 7.5e-08),
|
||||
("gemini/gemini-3.8-flash", 7.5e-07, 3.75e-06, 7.5e-08),
|
||||
("vertex_ai/gemini-3.8-flash", 7.5e-07, 3.75e-06, 7.5e-08),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model,input_cost,output_cost,cache_read_cost", GEMINI_38_FLASH_LAUNCH_PRICING)
|
||||
def test_gemini_38_flash_launch_pricing(model, input_cost, output_cost, cache_read_cost, _local_model_cost_map):
|
||||
model_cost_map = litellm.model_cost[model]
|
||||
assert model_cost_map["input_cost_per_token"] == input_cost
|
||||
assert model_cost_map["output_cost_per_token"] == output_cost
|
||||
assert model_cost_map["output_cost_per_reasoning_token"] == output_cost
|
||||
assert model_cost_map["cache_read_input_token_cost"] == cache_read_cost
|
||||
assert model_cost_map["mode"] == "chat"
|
||||
assert model_cost_map["supports_reasoning"] is True
|
||||
assert model_cost_map["supports_function_calling"] is True
|
||||
assert model_cost_map["max_input_tokens"] == 1048576
|
||||
|
||||
|
||||
GEMINI_38_FLASH_FIELDS_SHARED_WITH_37_FLASH = (
|
||||
"input_cost_per_token",
|
||||
"output_cost_per_token",
|
||||
"output_cost_per_reasoning_token",
|
||||
"cache_read_input_token_cost",
|
||||
"input_cost_per_token_batches",
|
||||
"output_cost_per_token_batches",
|
||||
"input_cost_per_token_flex",
|
||||
"output_cost_per_token_flex",
|
||||
"cache_read_input_token_cost_flex",
|
||||
"input_cost_per_token_priority",
|
||||
"output_cost_per_token_priority",
|
||||
"cache_read_input_token_cost_priority",
|
||||
"search_context_cost_per_query",
|
||||
"google_maps_grounding_cost_per_query",
|
||||
"prompt_cache_min_tokens",
|
||||
"max_input_tokens",
|
||||
"max_output_tokens",
|
||||
"supports_reasoning",
|
||||
"supports_function_calling",
|
||||
"supports_prompt_caching",
|
||||
"supports_vision",
|
||||
"supports_pdf_input",
|
||||
"supports_audio_input",
|
||||
"supports_video_input",
|
||||
"supports_response_schema",
|
||||
"supports_tool_choice",
|
||||
"supports_web_search",
|
||||
"supports_url_context",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("prefix", ["", "gemini/", "vertex_ai/"])
|
||||
def test_gemini_38_flash_matches_37_flash_promotional_pricing(prefix, _local_model_cost_map):
|
||||
new_model = litellm.model_cost[f"{prefix}gemini-3.8-flash"]
|
||||
old_model = litellm.model_cost[f"{prefix}gemini-3.7-flash"]
|
||||
for field in GEMINI_38_FLASH_FIELDS_SHARED_WITH_37_FLASH:
|
||||
assert new_model[field] == old_model[field], field
|
||||
|
||||
|
||||
def test_generic_cost_per_token_gemini_38_flash(_local_model_cost_map):
|
||||
usage = Usage(
|
||||
prompt_tokens=1000,
|
||||
completion_tokens=500,
|
||||
total_tokens=1500,
|
||||
completion_tokens_details=CompletionTokensDetailsWrapper(
|
||||
reasoning_tokens=200,
|
||||
text_tokens=300,
|
||||
),
|
||||
prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=1000),
|
||||
)
|
||||
prompt_cost, completion_cost = generic_cost_per_token(
|
||||
model="gemini-3.8-flash",
|
||||
usage=usage,
|
||||
custom_llm_provider="gemini",
|
||||
)
|
||||
assert prompt_cost == pytest.approx(0.00075)
|
||||
assert completion_cost == pytest.approx(0.001875)
|
||||
|
||||
|
||||
def test_grok_46_launch_pricing(_local_model_cost_map):
|
||||
model_cost_map = litellm.model_cost["xai/grok-4.6"]
|
||||
assert model_cost_map["input_cost_per_token"] == 2e-06
|
||||
|
|
|
|||
|
|
@ -592,6 +592,59 @@ def test_stream_chunk_builder_litellm_usage_chunks():
|
|||
assert usage.total_tokens == 77
|
||||
|
||||
|
||||
def test_calculate_usage_honors_openai_sdk_completion_usage_chunks():
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
content_chunk = ModelResponseStream(
|
||||
id="chatcmpl-sdk-usage-1",
|
||||
created=1745513206,
|
||||
model="mantle-claude",
|
||||
object="chat.completion.chunk",
|
||||
system_fingerprint=None,
|
||||
choices=[
|
||||
StreamingChoices(
|
||||
finish_reason="stop",
|
||||
index=0,
|
||||
delta=Delta(
|
||||
provider_specific_fields=None,
|
||||
content="ok",
|
||||
role=None,
|
||||
function_call=None,
|
||||
tool_calls=None,
|
||||
audio=None,
|
||||
),
|
||||
logprobs=None,
|
||||
)
|
||||
],
|
||||
provider_specific_fields=None,
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
usage_chunk = ModelResponseStream(
|
||||
id="chatcmpl-sdk-usage-1",
|
||||
created=1745513207,
|
||||
model="mantle-claude",
|
||||
object="chat.completion.chunk",
|
||||
system_fingerprint=None,
|
||||
choices=[],
|
||||
provider_specific_fields=None,
|
||||
stream_options={"include_usage": True},
|
||||
)
|
||||
usage_chunk.usage = CompletionUsage(
|
||||
prompt_tokens=20, completion_tokens=60, total_tokens=80, cost=0.000704
|
||||
)
|
||||
assert type(usage_chunk.usage) is CompletionUsage
|
||||
|
||||
chunks = [content_chunk, usage_chunk]
|
||||
usage = ChunkProcessor(chunks=chunks).calculate_usage(
|
||||
chunks=chunks, model="mantle-claude", completion_output=""
|
||||
)
|
||||
|
||||
assert usage.prompt_tokens == 20
|
||||
assert usage.completion_tokens == 60
|
||||
assert usage.total_tokens == 80
|
||||
assert getattr(usage, "cost", None) == pytest.approx(0.000704)
|
||||
|
||||
|
||||
def test_get_model_from_chunks_azure_model_router():
|
||||
"""
|
||||
Test that _get_model_from_chunks finds the actual model from Azure Model Router chunks.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
from unittest.mock import MagicMock, Mock
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
|
@ -9,6 +9,18 @@ from litellm.llms.s3_vectors.vector_stores.transformation import (
|
|||
from litellm.types.vector_stores import VectorStoreSearchResponse
|
||||
|
||||
|
||||
def _mock_router(model_names, sync=False):
|
||||
"""Router mock serving the given embedding model names."""
|
||||
router = MagicMock()
|
||||
router.get_model_list.return_value = [{"model_name": name} for name in model_names]
|
||||
embedding_response = Mock(data=[{"embedding": [0.1, 0.2, 0.3]}])
|
||||
if sync:
|
||||
router.embedding = MagicMock(return_value=embedding_response)
|
||||
else:
|
||||
router.aembedding = AsyncMock(return_value=embedding_response)
|
||||
return router
|
||||
|
||||
|
||||
class TestS3VectorsVectorStoreConfig:
|
||||
def test_init(self):
|
||||
"""Test that S3VectorsVectorStoreConfig initializes correctly"""
|
||||
|
|
@ -28,19 +40,174 @@ class TestS3VectorsVectorStoreConfig:
|
|||
url = config.get_complete_url(None, litellm_params)
|
||||
assert url == "https://s3vectors.us-west-2.api.aws"
|
||||
|
||||
def test_get_complete_url_missing_region(self):
|
||||
"""Test that missing region raises error"""
|
||||
def test_get_complete_url_missing_region(self, monkeypatch):
|
||||
"""Missing region falls back to the default region (parity with ingestion)"""
|
||||
monkeypatch.delenv("AWS_REGION_NAME", raising=False)
|
||||
monkeypatch.delenv("AWS_REGION", raising=False)
|
||||
config = S3VectorsVectorStoreConfig()
|
||||
litellm_params = {}
|
||||
with pytest.raises(ValueError, match="aws_region_name is required"):
|
||||
config.get_complete_url(None, litellm_params)
|
||||
url = config.get_complete_url(None, {})
|
||||
assert url == "https://s3vectors.us-west-2.api.aws"
|
||||
|
||||
def test_get_complete_url_uses_env_region(self, monkeypatch):
|
||||
"""Missing region param resolves from AWS_REGION_NAME env var"""
|
||||
monkeypatch.setenv("AWS_REGION_NAME", "eu-west-1")
|
||||
monkeypatch.delenv("AWS_REGION", raising=False)
|
||||
config = S3VectorsVectorStoreConfig()
|
||||
url = config.get_complete_url(None, {})
|
||||
assert url == "https://s3vectors.eu-west-1.api.aws"
|
||||
|
||||
def test_get_complete_url_invalid_region_format(self):
|
||||
"""Invalid region format raises"""
|
||||
config = S3VectorsVectorStoreConfig()
|
||||
with pytest.raises(ValueError, match="Invalid AWS region format"):
|
||||
config.get_complete_url(None, {"aws_region_name": "Bad_Region!"})
|
||||
|
||||
@pytest.mark.skip(reason="Requires embedding API call, tested in integration tests")
|
||||
def test_transform_search_request(self):
|
||||
"""Test search request transformation"""
|
||||
# This test requires making an actual embedding API call
|
||||
# It's better tested in integration tests
|
||||
pass
|
||||
"""Full request-body transformation with a router-injected embedding"""
|
||||
config = S3VectorsVectorStoreConfig()
|
||||
mock_logging_obj = Mock()
|
||||
mock_logging_obj.model_call_details = {}
|
||||
router = _mock_router(["text-embedding-3-small"], sync=True)
|
||||
|
||||
url, request_body = config.transform_search_vector_store_request(
|
||||
vector_store_id="test-bucket:test-index",
|
||||
query="test query",
|
||||
vector_store_search_optional_params={"max_num_results": 7},
|
||||
api_base="https://s3vectors.us-west-2.api.aws",
|
||||
litellm_logging_obj=mock_logging_obj,
|
||||
litellm_params={},
|
||||
extra_body=None,
|
||||
router=router,
|
||||
)
|
||||
|
||||
assert url == "https://s3vectors.us-west-2.api.aws/QueryVectors"
|
||||
assert request_body == {
|
||||
"vectorBucketName": "test-bucket",
|
||||
"indexName": "test-index",
|
||||
"queryVector": {"float32": [0.1, 0.2, 0.3]},
|
||||
"topK": 7,
|
||||
"returnDistance": True,
|
||||
"returnMetadata": True,
|
||||
}
|
||||
assert mock_logging_obj.model_call_details["query"] == "test query"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_atransform_search_uses_router_for_virtual_model(self):
|
||||
"""Regression: router-served embedding models must resolve via the router,
|
||||
not a bare litellm.aembedding call (which has no deployment credentials)."""
|
||||
config = S3VectorsVectorStoreConfig()
|
||||
mock_logging_obj = Mock()
|
||||
mock_logging_obj.model_call_details = {}
|
||||
router = _mock_router(["my-embedding-model"])
|
||||
|
||||
with patch("litellm.aembedding", new=AsyncMock()) as mock_bare_aembedding: # test-quality-ok: guards that the bare-embedding path is not taken; dispatch seam is the behavior under test
|
||||
url, request_body = await config.atransform_search_vector_store_request(
|
||||
vector_store_id="test-bucket:test-index",
|
||||
query="test query",
|
||||
vector_store_search_optional_params={},
|
||||
api_base="https://s3vectors.us-west-2.api.aws",
|
||||
litellm_logging_obj=mock_logging_obj,
|
||||
litellm_params={"embedding_model": "my-embedding-model"},
|
||||
extra_body=None,
|
||||
router=router,
|
||||
)
|
||||
|
||||
router.aembedding.assert_awaited_once_with(model="my-embedding-model", input=["test query"])
|
||||
mock_bare_aembedding.assert_not_awaited()
|
||||
assert request_body["queryVector"]["float32"] == [0.1, 0.2, 0.3]
|
||||
assert request_body["topK"] == 5 # default
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_atransform_search_falls_back_when_router_does_not_serve_model(self):
|
||||
"""Router present but embedding_model is not a router deployment ->
|
||||
bare litellm.aembedding keeps working (provider-prefixed + env creds stores)."""
|
||||
config = S3VectorsVectorStoreConfig()
|
||||
mock_logging_obj = Mock()
|
||||
mock_logging_obj.model_call_details = {}
|
||||
router = _mock_router(["some-other-model"])
|
||||
|
||||
mock_bare = AsyncMock(return_value=Mock(data=[{"embedding": [0.4, 0.5]}]))
|
||||
with patch("litellm.aembedding", new=mock_bare): # test-quality-ok: stubs the bare-embedding fallback whose request body the test asserts on
|
||||
_, request_body = await config.atransform_search_vector_store_request(
|
||||
vector_store_id="test-bucket:test-index",
|
||||
query="test query",
|
||||
vector_store_search_optional_params={},
|
||||
api_base="https://s3vectors.us-west-2.api.aws",
|
||||
litellm_logging_obj=mock_logging_obj,
|
||||
litellm_params={"embedding_model": "azure/text-embedding-3-small"},
|
||||
extra_body=None,
|
||||
router=router,
|
||||
)
|
||||
|
||||
mock_bare.assert_awaited_once_with(model="azure/text-embedding-3-small", input=["test query"])
|
||||
router.aembedding.assert_not_awaited()
|
||||
assert request_body["queryVector"]["float32"] == [0.4, 0.5]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_atransform_search_without_router_uses_bare_embedding(self):
|
||||
"""Backward compat: no router -> bare litellm.aembedding as before"""
|
||||
config = S3VectorsVectorStoreConfig()
|
||||
mock_logging_obj = Mock()
|
||||
mock_logging_obj.model_call_details = {}
|
||||
|
||||
mock_bare = AsyncMock(return_value=Mock(data=[{"embedding": [0.6, 0.7]}]))
|
||||
with patch("litellm.aembedding", new=mock_bare): # test-quality-ok: stubs the bare-embedding fallback whose request body the test asserts on
|
||||
_, request_body = await config.atransform_search_vector_store_request(
|
||||
vector_store_id="test-bucket:test-index",
|
||||
query="test query",
|
||||
vector_store_search_optional_params={},
|
||||
api_base="https://s3vectors.us-west-2.api.aws",
|
||||
litellm_logging_obj=mock_logging_obj,
|
||||
litellm_params={},
|
||||
extra_body=None,
|
||||
)
|
||||
|
||||
mock_bare.assert_awaited_once_with(model="text-embedding-3-small", input=["test query"])
|
||||
assert request_body["queryVector"]["float32"] == [0.6, 0.7]
|
||||
|
||||
def test_transform_search_uses_router_for_virtual_model_sync(self):
|
||||
"""Sync twin: router-served embedding model resolves via router.embedding"""
|
||||
config = S3VectorsVectorStoreConfig()
|
||||
mock_logging_obj = Mock()
|
||||
mock_logging_obj.model_call_details = {}
|
||||
router = _mock_router(["my-embedding-model"], sync=True)
|
||||
|
||||
with patch("litellm.embedding", new=MagicMock()) as mock_bare_embedding: # test-quality-ok: guards that the bare-embedding path is not taken; dispatch seam is the behavior under test
|
||||
_, request_body = config.transform_search_vector_store_request(
|
||||
vector_store_id="test-bucket:test-index",
|
||||
query="test query",
|
||||
vector_store_search_optional_params={},
|
||||
api_base="https://s3vectors.us-west-2.api.aws",
|
||||
litellm_logging_obj=mock_logging_obj,
|
||||
litellm_params={"embedding_model": "my-embedding-model"},
|
||||
extra_body=None,
|
||||
router=router,
|
||||
)
|
||||
|
||||
router.embedding.assert_called_once_with(model="my-embedding-model", input=["test query"])
|
||||
mock_bare_embedding.assert_not_called()
|
||||
assert request_body["queryVector"]["float32"] == [0.1, 0.2, 0.3]
|
||||
|
||||
def test_transform_search_without_router_uses_bare_embedding_sync(self):
|
||||
"""Sync twin: no router -> bare litellm.embedding as before"""
|
||||
config = S3VectorsVectorStoreConfig()
|
||||
mock_logging_obj = Mock()
|
||||
mock_logging_obj.model_call_details = {}
|
||||
|
||||
mock_bare = MagicMock(return_value=Mock(data=[{"embedding": [0.8, 0.9]}]))
|
||||
with patch("litellm.embedding", new=mock_bare): # test-quality-ok: stubs the bare-embedding fallback whose request body the test asserts on
|
||||
_, request_body = config.transform_search_vector_store_request(
|
||||
vector_store_id="test-bucket:test-index",
|
||||
query="test query",
|
||||
vector_store_search_optional_params={},
|
||||
api_base="https://s3vectors.us-west-2.api.aws",
|
||||
litellm_logging_obj=mock_logging_obj,
|
||||
litellm_params={},
|
||||
extra_body=None,
|
||||
)
|
||||
|
||||
mock_bare.assert_called_once_with(model="text-embedding-3-small", input=["test query"])
|
||||
assert request_body["queryVector"]["float32"] == [0.8, 0.9]
|
||||
|
||||
def test_transform_search_request_invalid_vector_store_id(self):
|
||||
"""Test that invalid vector_store_id format raises error"""
|
||||
|
|
|
|||
|
|
@ -1096,10 +1096,13 @@ def test_natively_signed_parallel_turn_never_carries_a_placeholder(model):
|
|||
"gemini-3.5-flash",
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3.7-flash",
|
||||
"gemini-3.8-flash",
|
||||
"vertex_ai/gemini-3.5-flash",
|
||||
"vertex_ai/gemini-3.7-flash",
|
||||
"vertex_ai/gemini-3.8-flash",
|
||||
"gemini/gemini-3.5-flash",
|
||||
"gemini/gemini-3.7-flash",
|
||||
"gemini/gemini-3.8-flash",
|
||||
],
|
||||
)
|
||||
def test_placeholder_scoped_to_first_call_across_gemini_3_variants(model):
|
||||
|
|
|
|||
|
|
@ -1185,6 +1185,18 @@ def test_vertex_ai_map_thinking_param_with_budget_tokens_0():
|
|||
}
|
||||
|
||||
|
||||
def test_vertex_ai_map_thinking_param_without_budget_tokens_for_gemini_3():
|
||||
v = VertexGeminiConfig()
|
||||
result = v.map_openai_params(
|
||||
non_default_params={"thinking": {"type": "enabled"}},
|
||||
optional_params={},
|
||||
model="gemini-3.5-flash",
|
||||
drop_params=False,
|
||||
)
|
||||
|
||||
assert result["thinkingConfig"] == {"includeThoughts": True}
|
||||
|
||||
|
||||
def test_vertex_ai_map_tools():
|
||||
v = VertexGeminiConfig()
|
||||
optional_params = {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from litellm.proxy.db.prisma_client import PrismaWrapper
|
||||
from litellm.proxy.db.routing_prisma_wrapper import RoutingPrismaWrapper
|
||||
from litellm.proxy.management_helpers.access_group_key_sync import (
|
||||
sync_key_access_group_membership,
|
||||
sync_key_regeneration_access_group_membership,
|
||||
)
|
||||
|
||||
|
||||
def _routed_prisma_client():
|
||||
writer_inner = MagicMock(name="writer_prisma")
|
||||
reader_inner = MagicMock(name="reader_prisma")
|
||||
writer_inner.query_raw = AsyncMock(return_value=[])
|
||||
reader_inner.query_raw = AsyncMock(return_value=[])
|
||||
writer = PrismaWrapper(original_prisma=writer_inner, iam_token_db_auth=False)
|
||||
reader = PrismaWrapper(original_prisma=reader_inner, iam_token_db_auth=False)
|
||||
routing = RoutingPrismaWrapper(writer=writer, reader=reader)
|
||||
return SimpleNamespace(db=routing), writer_inner, reader_inner
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_regeneration_repoint_update_runs_on_the_writer():
|
||||
prisma_client, writer_inner, reader_inner = _routed_prisma_client()
|
||||
|
||||
await sync_key_regeneration_access_group_membership(
|
||||
prisma_client=prisma_client,
|
||||
previous_key_token="old-token",
|
||||
new_key_token="new-token",
|
||||
data=None,
|
||||
existing_key_row=MagicMock(),
|
||||
)
|
||||
|
||||
writer_inner.query_raw.assert_awaited_once()
|
||||
assert writer_inner.query_raw.await_args.args[0].startswith('UPDATE "LiteLLM_AccessGroupTable"')
|
||||
reader_inner.query_raw.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_membership_attach_and_detach_updates_run_on_the_writer():
|
||||
prisma_client, writer_inner, reader_inner = _routed_prisma_client()
|
||||
|
||||
await sync_key_access_group_membership(
|
||||
prisma_client=prisma_client,
|
||||
key_token="token",
|
||||
previous_access_group_ids=["ag-old"],
|
||||
updated_access_group_ids=["ag-new"],
|
||||
)
|
||||
|
||||
assert writer_inner.query_raw.await_count == 2
|
||||
assert all(
|
||||
call.args[0].startswith('UPDATE "LiteLLM_AccessGroupTable"') for call in writer_inner.query_raw.await_args_list
|
||||
)
|
||||
reader_inner.query_raw.assert_not_awaited()
|
||||
|
|
@ -60,6 +60,141 @@ def test_config_update_happy_admin(client, auth_as, mock_prisma, monkeypatch):
|
|||
assert normalize(response.json()) == {"message": "Config updated successfully"}
|
||||
|
||||
|
||||
def test_config_update_persists_optional_pre_call_checks(client, auth_as, mock_prisma, monkeypatch):
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
table = _install_litellm_config(mock_prisma)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
fake_proxy_config = MagicMock()
|
||||
fake_proxy_config.add_deployment = AsyncMock()
|
||||
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
|
||||
|
||||
with auth_as(LitellmUserRoles.PROXY_ADMIN):
|
||||
response = client.post(
|
||||
"/config/update",
|
||||
json={"router_settings": {"optional_pre_call_checks": ["prompt_caching"]}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
persisted = json.loads(table.upsert.call_args.kwargs["data"]["create"]["param_value"])
|
||||
assert persisted["optional_pre_call_checks"] == ["prompt_caching"]
|
||||
|
||||
|
||||
def test_config_update_persists_model_group_affinity_config(client, auth_as, mock_prisma, monkeypatch):
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
table = _install_litellm_config(mock_prisma)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
fake_proxy_config = MagicMock()
|
||||
fake_proxy_config.add_deployment = AsyncMock()
|
||||
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
|
||||
|
||||
model_group_affinity_config = {"gpt-4": ["session_affinity"]}
|
||||
with auth_as(LitellmUserRoles.PROXY_ADMIN):
|
||||
response = client.post(
|
||||
"/config/update",
|
||||
json={"router_settings": {"model_group_affinity_config": model_group_affinity_config}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
persisted = json.loads(table.upsert.call_args.kwargs["data"]["create"]["param_value"])
|
||||
assert persisted["model_group_affinity_config"] == model_group_affinity_config
|
||||
|
||||
|
||||
def test_config_update_persists_disable_cooldowns(client, auth_as, mock_prisma, monkeypatch):
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
table = _install_litellm_config(mock_prisma)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
fake_proxy_config = MagicMock()
|
||||
fake_proxy_config.add_deployment = AsyncMock()
|
||||
monkeypatch.setattr(ps, "proxy_config", fake_proxy_config)
|
||||
|
||||
with auth_as(LitellmUserRoles.PROXY_ADMIN):
|
||||
response = client.post(
|
||||
"/config/update",
|
||||
json={"router_settings": {"disable_cooldowns": True}},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
persisted = json.loads(table.upsert.call_args.kwargs["data"]["create"]["param_value"])
|
||||
assert persisted["disable_cooldowns"] is True
|
||||
|
||||
|
||||
def test_config_update_rejects_assistants_config(client, auth_as, mock_prisma, monkeypatch):
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
table = _install_litellm_config(mock_prisma)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
|
||||
with auth_as(LitellmUserRoles.PROXY_ADMIN):
|
||||
response = client.post(
|
||||
"/config/update",
|
||||
json={"router_settings": {"assistants_config": {"enabled": True}}},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "assistants_config" in response.json()["error"]["message"]
|
||||
table.upsert.assert_not_called()
|
||||
|
||||
|
||||
def test_config_update_rejects_router_general_settings(client, auth_as, mock_prisma, monkeypatch):
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
table = _install_litellm_config(mock_prisma)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
|
||||
with auth_as(LitellmUserRoles.PROXY_ADMIN):
|
||||
response = client.post(
|
||||
"/config/update",
|
||||
json={"router_settings": {"router_general_settings": {"async_only_mode": True}}},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "router_general_settings" in response.json()["error"]["message"]
|
||||
table.upsert.assert_not_called()
|
||||
|
||||
|
||||
def test_config_update_rejects_unknown_router_setting(client, auth_as, mock_prisma, monkeypatch):
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
table = _install_litellm_config(mock_prisma)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
|
||||
with auth_as(LitellmUserRoles.PROXY_ADMIN):
|
||||
response = client.post(
|
||||
"/config/update",
|
||||
json={"router_settings": {"optional_precall_checks": ["prompt_caching"]}},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "optional_precall_checks" in response.json()["error"]["message"]
|
||||
table.upsert.assert_not_called()
|
||||
|
||||
|
||||
def test_config_update_unknown_router_setting_non_admin_forbidden(client, auth_as, mock_prisma, monkeypatch):
|
||||
from litellm.proxy import proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles
|
||||
|
||||
_install_litellm_config(mock_prisma)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
|
||||
with auth_as(LitellmUserRoles.INTERNAL_USER):
|
||||
response = client.post(
|
||||
"/config/update",
|
||||
json={"router_settings": {"optional_precall_checks": ["prompt_caching"]}},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "admin" in response.json()["error"]["message"].lower()
|
||||
|
||||
|
||||
def test_config_update_non_admin_forbidden(client, auth_as, mock_prisma, monkeypatch):
|
||||
"""POST /config/update by a non-admin caller is rejected; the error
|
||||
surfaces as a ProxyException with the admin-only message."""
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ def patched_models(monkeypatch):
|
|||
deployment = MagicMock()
|
||||
deployment.litellm_params.model = "gpt-4"
|
||||
router.get_deployment_by_model_group_name = MagicMock(return_value=deployment)
|
||||
router.get_configured_display_name = MagicMock(return_value=None)
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", MagicMock())
|
||||
|
|
@ -187,6 +188,83 @@ def test_anthropic_format_carries_router_configured_token_limits(client, auth_as
|
|||
assert (claude["max_input_tokens"], claude["max_tokens"]) == (500000, 4096)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
||||
def test_anthropic_format_uses_configured_display_name(client, auth_as, patched_models, path):
|
||||
"""A deployment's ``model_info.display_name`` becomes the Anthropic-native
|
||||
``display_name`` so Claude Code's picker shows a clean name while the id keeps
|
||||
routing; models without one keep the id fallback, and the OpenAI-shaped
|
||||
listing carries no display_name either way."""
|
||||
|
||||
def _configured(model_name):
|
||||
return "Kimi K3" if model_name == "gpt-4" else None
|
||||
|
||||
patched_models.get_configured_display_name = MagicMock(side_effect=_configured)
|
||||
|
||||
with auth_as():
|
||||
anthropic_response = client.get(path, headers={"anthropic-version": "2023-06-01"})
|
||||
openai_response = client.get(path)
|
||||
|
||||
assert anthropic_response.status_code == 200
|
||||
gpt_4, claude = anthropic_response.json()["data"]
|
||||
assert (gpt_4["id"], gpt_4["display_name"]) == ("gpt-4", "Kimi K3")
|
||||
assert (claude["id"], claude["display_name"]) == ("claude-sonnet", "claude-sonnet")
|
||||
|
||||
assert openai_response.status_code == 200
|
||||
openai_models = openai_response.json()["data"]
|
||||
assert [m["id"] for m in openai_models] == ["gpt-4", "claude-sonnet"]
|
||||
assert all("display_name" not in m for m in openai_models)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("params", [{}, {"scope": "expand"}])
|
||||
def test_anthropic_display_name_resolved_via_internal_team_key(
|
||||
client, auth_as, patched_models, monkeypatch, params
|
||||
):
|
||||
"""For a team-scoped row the configured display name must be looked up by the
|
||||
internal routing key while the entry itself is keyed by the public name, so
|
||||
the clean name lands on the id the client actually sees."""
|
||||
from litellm.proxy import utils as proxy_utils
|
||||
from litellm.proxy.auth import model_checks
|
||||
|
||||
internal_name = "model_name_team-1_c0ffee"
|
||||
|
||||
patched_models.get_model_list = MagicMock(
|
||||
return_value=[
|
||||
{
|
||||
"model_name": internal_name,
|
||||
"model_info": {
|
||||
"team_id": "team-1",
|
||||
"team_public_model_name": "gpt-4-team",
|
||||
},
|
||||
}
|
||||
]
|
||||
)
|
||||
patched_models.get_model_names = MagicMock(return_value=[internal_name])
|
||||
patched_models.get_configured_display_name = MagicMock(
|
||||
side_effect=lambda model_name: "Team GPT" if model_name == internal_name else None
|
||||
)
|
||||
|
||||
async def _fake_get_available_models_for_user(**kwargs):
|
||||
return [internal_name]
|
||||
|
||||
monkeypatch.setattr(
|
||||
proxy_utils,
|
||||
"get_available_models_for_user",
|
||||
_fake_get_available_models_for_user,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
model_checks, "get_complete_model_list", lambda **kwargs: [internal_name]
|
||||
)
|
||||
|
||||
with auth_as():
|
||||
response = client.get(
|
||||
"/v1/models", params=params, headers={"anthropic-version": "2023-06-01"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
(entry,) = response.json()["data"]
|
||||
assert (entry["id"], entry["display_name"]) == ("gpt-4-team", "Team GPT")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/v1/models", "/models"])
|
||||
def test_get_models_invalid_scope_returns_400(client, auth_as, patched_models, path):
|
||||
"""Pins: ``GET /v1/models``, ``GET /models`` (error path: invalid scope)."""
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ from litellm.proxy._types import (
|
|||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.common_utils.model_listing_utils import TeamModelNameTranslator
|
||||
from litellm.proxy.common_utils.model_listing_utils import (
|
||||
TeamModelNameTranslator,
|
||||
configured_display_names,
|
||||
)
|
||||
from litellm.proxy.proxy_server import (
|
||||
_get_proxy_model_info,
|
||||
_translate_model_name_for_response,
|
||||
|
|
@ -1391,6 +1394,27 @@ def test_resolve_public_name_respects_legacy_flag():
|
|||
)
|
||||
|
||||
|
||||
def test_configured_display_names_keyed_by_response_id():
|
||||
"""The map is keyed by the public response id while the router lookup uses
|
||||
the internal routing key, and entries without a configured name are omitted."""
|
||||
router = MagicMock()
|
||||
router.get_configured_display_name = MagicMock(
|
||||
side_effect=lambda model_name: "Team Sonnet" if model_name == "model_name_team-abc-123_4a6b8" else None
|
||||
)
|
||||
|
||||
assert configured_display_names(
|
||||
entries=[
|
||||
("team-claude-sonnet", "model_name_team-abc-123_4a6b8"),
|
||||
("gpt-4o", "gpt-4o"),
|
||||
],
|
||||
llm_router=router,
|
||||
) == {"team-claude-sonnet": "Team Sonnet"}
|
||||
|
||||
|
||||
def test_configured_display_names_empty_without_router():
|
||||
assert configured_display_names(entries=[("gpt-4o", "gpt-4o")], llm_router=None) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retrieve_model_by_public_name_returns_200(monkeypatch):
|
||||
"""Regression: `GET /v1/models/{public_name}` must NOT 404. The listing
|
||||
|
|
|
|||
|
|
@ -324,6 +324,127 @@ def test_rag_query_stream_returns_event_stream(client_internal_user):
|
|||
assert "data: [DONE]" in response.text
|
||||
|
||||
|
||||
def test_rag_query_merges_managed_store_params(client_internal_user):
|
||||
"""
|
||||
Regression: /v1/rag/query must consult the managed vector store registry
|
||||
(like the direct /v1/vector_stores/{id}/search endpoint does) so that
|
||||
provider, region, embedding model, etc. don't have to be repeated in
|
||||
retrieval_config. Pre-fix the registry was never read, so managed S3
|
||||
Vectors stores failed with "aws_region_name is required".
|
||||
"""
|
||||
import litellm
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
||||
mock_vector_store = {
|
||||
"vector_store_id": "s3-store",
|
||||
"custom_llm_provider": "s3_vectors",
|
||||
"litellm_params": {
|
||||
"aws_region_name": "eu-west-1",
|
||||
"embedding_model": "my-embed",
|
||||
"vector_bucket_name": "bkt",
|
||||
},
|
||||
}
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_litellm_managed_vector_store_from_registry.return_value = mock_vector_store
|
||||
|
||||
mock_response = ModelResponse(
|
||||
id="chatcmpl-test",
|
||||
choices=[{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
with patch( # test-quality-ok: aquery is the endpoint's downstream boundary; the forwarded config is what the test asserts
|
||||
"litellm.proxy.rag_endpoints.endpoints.litellm.aquery",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_aquery, patch.object(litellm, "vector_store_registry", mock_registry), patch( # test-quality-ok: seeds the managed-store registry the merge under test reads and grants access so real store resolution runs
|
||||
"litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
response = client_internal_user.post(
|
||||
"/v1/rag/query",
|
||||
json={
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"retrieval_config": {"vector_store_id": "s3-store"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
mock_aquery.assert_awaited_once()
|
||||
forwarded_config = mock_aquery.await_args.kwargs["retrieval_config"]
|
||||
assert forwarded_config["vector_store_id"] == "s3-store"
|
||||
assert forwarded_config["custom_llm_provider"] == "s3_vectors"
|
||||
assert forwarded_config["aws_region_name"] == "eu-west-1"
|
||||
assert forwarded_config["embedding_model"] == "my-embed"
|
||||
assert forwarded_config["vector_bucket_name"] == "bkt"
|
||||
|
||||
|
||||
def test_rag_query_store_params_win_over_user_retrieval_config(client_internal_user):
|
||||
"""Registry values must win over user-supplied retrieval_config keys so callers cannot override store credentials."""
|
||||
import litellm
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
||||
mock_vector_store = {
|
||||
"vector_store_id": "s3-store",
|
||||
"custom_llm_provider": "s3_vectors",
|
||||
"litellm_params": {"aws_region_name": "eu-west-1"},
|
||||
}
|
||||
mock_registry = MagicMock()
|
||||
mock_registry.get_litellm_managed_vector_store_from_registry.return_value = mock_vector_store
|
||||
|
||||
mock_response = ModelResponse(
|
||||
id="chatcmpl-test",
|
||||
choices=[{"index": 0, "message": {"role": "assistant", "content": "hi"}, "finish_reason": "stop"}],
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
with patch( # test-quality-ok: aquery is the endpoint's downstream boundary; the forwarded config is what the test asserts
|
||||
"litellm.proxy.rag_endpoints.endpoints.litellm.aquery",
|
||||
new_callable=AsyncMock,
|
||||
return_value=mock_response,
|
||||
) as mock_aquery, patch.object(litellm, "vector_store_registry", mock_registry), patch( # test-quality-ok: seeds the managed-store registry the merge under test reads and grants access so real store resolution runs
|
||||
"litellm.proxy.vector_store_endpoints.utils.can_user_access_vector_store",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
response = client_internal_user.post(
|
||||
"/v1/rag/query",
|
||||
json={
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"retrieval_config": {"vector_store_id": "s3-store", "aws_region_name": "us-east-1"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.json()
|
||||
forwarded_config = mock_aquery.await_args.kwargs["retrieval_config"]
|
||||
assert forwarded_config["aws_region_name"] == "eu-west-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"blocked_key",
|
||||
["embedding_model", "litellm_embedding_model", "litellm_embedding_config", "litellm_credential_name"],
|
||||
)
|
||||
def test_rag_query_rejects_caller_embedding_selection_params(client_internal_user, blocked_key):
|
||||
"""
|
||||
Regression: a caller must not pick the embedding model or credential used at
|
||||
search time. Those resolve through the Router with the proxy's credentials,
|
||||
bypassing the key's model permissions, so they may only come from the
|
||||
managed store's server-side registration.
|
||||
"""
|
||||
response = client_internal_user.post(
|
||||
"/v1/rag/query",
|
||||
json={
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
"retrieval_config": {"vector_store_id": "s3-store", blocked_key: "attacker-choice"},
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.json()
|
||||
assert blocked_key in str(response.json())
|
||||
|
||||
|
||||
EICAR = r"X5O!P%@AP[4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H*"
|
||||
INGEST_REQUEST = '{"ingest_options":{"vector_store":{"custom_llm_provider":"openai"}}}'
|
||||
|
||||
|
|
|
|||
|
|
@ -3158,3 +3158,35 @@ class TestAzureAIAnalyzeNamedIndexClassification:
|
|||
user_api_key_dict=self._team_member("analyze", ["read"]),
|
||||
)
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"blocked_key",
|
||||
["embedding_model", "litellm_embedding_model", "litellm_embedding_config", "litellm_credential_name"],
|
||||
)
|
||||
def test_vector_store_search_rejects_caller_embedding_selection_params(blocked_key):
|
||||
"""
|
||||
Regression: the search request body must not pick the embedding model or
|
||||
credential used to embed the query. Those resolve through the Router with
|
||||
the proxy's credentials, bypassing the key's model permissions, so they may
|
||||
only come from the managed store's server-side registration.
|
||||
"""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
mock_auth = UserAPIKeyAuth(user_id="test_internal_user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
||||
original_overrides = app.dependency_overrides.copy()
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: mock_auth
|
||||
try:
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/v1/vector_stores/s3-store/search",
|
||||
json={"query": "hello", blocked_key: "attacker-choice"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides = original_overrides
|
||||
|
||||
assert response.status_code == 400, response.json()
|
||||
assert blocked_key in str(response.json())
|
||||
|
|
|
|||
|
|
@ -259,6 +259,135 @@ async def test_aquery_streaming_bills_sub_call_costs_into_final_event():
|
|||
assert standard_logging_object["response_cost"] >= 0.003
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aquery_forwards_provider_retrieval_config_and_router_to_search():
|
||||
"""
|
||||
Regression: provider-specific retrieval_config keys (aws_region_name,
|
||||
embedding_model, vector_bucket_name, ...) and the router must be forwarded
|
||||
to the vector store search call. Pre-fix they were silently dropped, so
|
||||
/v1/rag/query failed with provider config errors (e.g. S3 Vectors
|
||||
"aws_region_name is required") even when the caller supplied them.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.types.vector_stores import VectorStoreSearchResponse
|
||||
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-4o-mini",
|
||||
"litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "test-key"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
fake_search = AsyncMock(
|
||||
return_value=VectorStoreSearchResponse(
|
||||
object="vector_store.search_results.page", search_query="q", data=[]
|
||||
)
|
||||
)
|
||||
with patch("litellm.vector_stores.asearch", new=fake_search): # test-quality-ok: asearch is the boundary the forwarding contract under test targets
|
||||
response = await litellm.aquery(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
retrieval_config={
|
||||
"vector_store_id": "bkt:idx",
|
||||
"custom_llm_provider": "s3_vectors",
|
||||
"top_k": 5,
|
||||
"aws_region_name": "eu-west-1",
|
||||
"embedding_model": "my-embed",
|
||||
"vector_bucket_name": "bkt",
|
||||
},
|
||||
router=router,
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
assert isinstance(response, ModelResponse)
|
||||
fake_search.assert_awaited_once()
|
||||
search_kwargs = fake_search.await_args.kwargs
|
||||
assert search_kwargs["vector_store_id"] == "bkt:idx"
|
||||
assert search_kwargs["custom_llm_provider"] == "s3_vectors"
|
||||
assert search_kwargs["max_num_results"] == 5
|
||||
assert search_kwargs["router"] is router
|
||||
# provider-specific extras forwarded
|
||||
assert search_kwargs["aws_region_name"] == "eu-west-1"
|
||||
assert search_kwargs["embedding_model"] == "my-embed"
|
||||
assert search_kwargs["vector_bucket_name"] == "bkt"
|
||||
# consumed keys are not duplicated into the spread
|
||||
assert "top_k" not in search_kwargs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aquery_minimal_retrieval_config_forwards_no_extras():
|
||||
"""
|
||||
A minimal retrieval_config must not leak consumed keys (or invent extras)
|
||||
into the vector store search call.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.types.vector_stores import VectorStoreSearchResponse
|
||||
|
||||
fake_search = AsyncMock(
|
||||
return_value=VectorStoreSearchResponse(
|
||||
object="vector_store.search_results.page", search_query="q", data=[]
|
||||
)
|
||||
)
|
||||
with patch("litellm.vector_stores.asearch", new=fake_search): # test-quality-ok: asearch is the boundary the forwarding contract under test targets
|
||||
await litellm.aquery(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
retrieval_config={"vector_store_id": "vs_test_123", "custom_llm_provider": "openai"},
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
fake_search.assert_awaited_once()
|
||||
search_kwargs = fake_search.await_args.kwargs
|
||||
assert search_kwargs["vector_store_id"] == "vs_test_123"
|
||||
assert search_kwargs["custom_llm_provider"] == "openai"
|
||||
assert search_kwargs["router"] is None
|
||||
leaked = {"top_k", "filters", "retrieval_filter", "aws_region_name", "embedding_model", "vector_bucket_name"}
|
||||
assert not (leaked & set(search_kwargs.keys()))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_aquery_does_not_forward_connection_override_keys_to_search():
|
||||
"""
|
||||
Only allowlisted retrieval_config keys may reach the vector store search
|
||||
call. Caller-controlled connection overrides (api_base, api_key, arbitrary
|
||||
extras) must be dropped, otherwise a caller could redirect store
|
||||
credentials to an attacker-chosen host.
|
||||
"""
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from litellm.types.vector_stores import VectorStoreSearchResponse
|
||||
|
||||
fake_search = AsyncMock(
|
||||
return_value=VectorStoreSearchResponse(
|
||||
object="vector_store.search_results.page", search_query="q", data=[]
|
||||
)
|
||||
)
|
||||
with patch("litellm.vector_stores.asearch", new=fake_search): # test-quality-ok: asearch is the boundary the forwarding contract under test targets
|
||||
await litellm.aquery(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
retrieval_config={
|
||||
"vector_store_id": "bkt:idx",
|
||||
"custom_llm_provider": "s3_vectors",
|
||||
"aws_region_name": "eu-west-1",
|
||||
"api_base": "https://attacker.example.com",
|
||||
"api_key": "attacker-key",
|
||||
"arbitrary_extra": "nope",
|
||||
},
|
||||
mock_response="hi",
|
||||
)
|
||||
|
||||
fake_search.assert_awaited_once()
|
||||
search_kwargs = fake_search.await_args.kwargs
|
||||
assert search_kwargs["aws_region_name"] == "eu-west-1"
|
||||
blocked = {"api_base", "api_key", "arbitrary_extra"}
|
||||
assert not (blocked & set(search_kwargs.keys()))
|
||||
|
||||
|
||||
def test_rag_call_types_are_registered():
|
||||
"""
|
||||
query/aquery/ingest/aingest are @client-decorated entry points, so their
|
||||
|
|
|
|||
|
|
@ -111,6 +111,99 @@ def test_together_rerank_honors_api_base(respx_mock: respx.MockRouter):
|
|||
assert mock_route.calls[0].request.headers["authorization"] == "Bearer fake-together-key"
|
||||
|
||||
|
||||
DASHSCOPE_404_BODY = {
|
||||
"error": {
|
||||
"message": "The model `does-not-exist` does not exist or you do not have access to it.",
|
||||
"type": "invalid_request_error",
|
||||
"param": None,
|
||||
"code": "model_not_found",
|
||||
},
|
||||
"request_id": "mock-request-id",
|
||||
}
|
||||
|
||||
|
||||
def test_rerank_error_names_provider_and_keeps_body(respx_mock: respx.MockRouter, monkeypatch):
|
||||
"""Regression for the rerank error path mapping with the unresolved provider param:
|
||||
a provider 404 surfaced as 'None - ' instead of naming the provider and its error body."""
|
||||
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
|
||||
monkeypatch.delenv("DASHSCOPE_API_BASE_RERANK", raising=False)
|
||||
|
||||
mock_route = respx_mock.post("https://dashscope.example/v1/reranks")
|
||||
mock_route.return_value = httpx.Response(404, json=DASHSCOPE_404_BODY)
|
||||
|
||||
with pytest.raises(litellm.NotFoundError) as exc_info:
|
||||
litellm.rerank(
|
||||
model="dashscope/does-not-exist",
|
||||
query=MARKER_QUERY,
|
||||
documents=[MARKER_DOC],
|
||||
api_key="fake-dashscope-key",
|
||||
api_base="https://dashscope.example/v1",
|
||||
)
|
||||
|
||||
assert mock_route.called
|
||||
assert "DashscopeException" in str(exc_info.value)
|
||||
assert "does not exist or you do not have access to it" in str(exc_info.value)
|
||||
assert "None - " not in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arerank_error_is_mapped_to_litellm_exception(respx_mock: respx.MockRouter, monkeypatch):
|
||||
"""Regression for arerank's bare re-raise: provider errors escaped as raw
|
||||
provider exception classes instead of the mapped litellm exception contract."""
|
||||
monkeypatch.delenv("DASHSCOPE_API_BASE", raising=False)
|
||||
monkeypatch.delenv("DASHSCOPE_API_BASE_RERANK", raising=False)
|
||||
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
|
||||
|
||||
mock_route = respx_mock.post("https://dashscope.example/v1/reranks")
|
||||
mock_route.return_value = httpx.Response(404, json=DASHSCOPE_404_BODY)
|
||||
|
||||
with pytest.raises(litellm.NotFoundError) as exc_info:
|
||||
await litellm.arerank(
|
||||
model="dashscope/does-not-exist",
|
||||
query=MARKER_QUERY,
|
||||
documents=[MARKER_DOC],
|
||||
api_key="fake-dashscope-key",
|
||||
api_base="https://dashscope.example/v1",
|
||||
)
|
||||
|
||||
assert mock_route.called
|
||||
assert "DashscopeException" in str(exc_info.value)
|
||||
assert "does not exist or you do not have access to it" in str(exc_info.value)
|
||||
assert "None - " not in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_arerank_declared_authenticating_provider_skips_resolution(monkeypatch):
|
||||
"""Regression for the event-loop hazard in arerank's provider pre-resolution:
|
||||
get_llm_provider runs the blocking OAuth device flow for github_copilot/chatgpt,
|
||||
so arerank must adopt the declared provider instead of resolving it, while the
|
||||
except path still maps with that declared provider."""
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
resolution_calls = []
|
||||
|
||||
def record_resolution(*args, **kwargs):
|
||||
resolution_calls.append((args, kwargs))
|
||||
return "gpt-4o", "github_copilot", None, None
|
||||
|
||||
def rerank_raises_provider_error(*args, **kwargs):
|
||||
raise BaseLLMException(status_code=401, message='{"error":"bad key"}')
|
||||
|
||||
monkeypatch.setattr(litellm, "get_llm_provider", record_resolution)
|
||||
monkeypatch.setattr("litellm.rerank_api.main.rerank", rerank_raises_provider_error)
|
||||
|
||||
with pytest.raises(litellm.AuthenticationError) as exc_info:
|
||||
await litellm.arerank(
|
||||
model="github_copilot/gpt-4o",
|
||||
query=MARKER_QUERY,
|
||||
documents=[MARKER_DOC],
|
||||
)
|
||||
|
||||
assert resolution_calls == []
|
||||
assert "Github_copilotException" in str(exc_info.value)
|
||||
assert "None - " not in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_together_rerank_async_honors_env_api_base(respx_mock: respx.MockRouter, monkeypatch):
|
||||
"""Regression: TOGETHER_AI_API_BASE was honored by chat but ignored by rerank."""
|
||||
|
|
|
|||
|
|
@ -326,3 +326,55 @@ def test_run_post_success_hooks_does_not_report_generation_time_as_overhead():
|
|||
|
||||
assert iterator.completed_response._hidden_params["_response_ms"] == 10000.0
|
||||
assert "litellm_overhead_time_ms" not in iterator.completed_response._hidden_params
|
||||
|
||||
|
||||
def _responses_api_response_with_usage() -> ResponsesAPIResponse:
|
||||
from litellm.types.llms.openai import ResponseAPIUsage
|
||||
|
||||
return ResponsesAPIResponse(
|
||||
id="resp_lit6427",
|
||||
created_at=int(datetime(2025, 1, 1).timestamp()),
|
||||
status="completed",
|
||||
model="mantle-claude",
|
||||
object="response",
|
||||
output=[],
|
||||
usage=ResponseAPIUsage(input_tokens=20, output_tokens=60, total_tokens=80),
|
||||
)
|
||||
|
||||
|
||||
def test_stamp_responses_usage_cost_stamps_computed_cost():
|
||||
from litellm.responses.streaming_iterator import _stamp_responses_usage_cost
|
||||
|
||||
response = _responses_api_response_with_usage()
|
||||
logging_obj = Mock(spec=LiteLLMLoggingObj)
|
||||
logging_obj._response_cost_calculator.return_value = 0.000704
|
||||
|
||||
_stamp_responses_usage_cost(response, logging_obj)
|
||||
|
||||
assert getattr(response.usage, "cost", None) == pytest.approx(0.000704)
|
||||
logging_obj._response_cost_calculator.assert_called_once_with(result=response)
|
||||
|
||||
|
||||
def test_stamp_responses_usage_cost_keeps_provider_reported_cost():
|
||||
from litellm.responses.streaming_iterator import _stamp_responses_usage_cost
|
||||
|
||||
response = _responses_api_response_with_usage()
|
||||
setattr(response.usage, "cost", 0.5)
|
||||
logging_obj = Mock(spec=LiteLLMLoggingObj)
|
||||
|
||||
_stamp_responses_usage_cost(response, logging_obj)
|
||||
|
||||
assert getattr(response.usage, "cost", None) == pytest.approx(0.5)
|
||||
logging_obj._response_cost_calculator.assert_not_called()
|
||||
|
||||
|
||||
def test_stamp_responses_usage_cost_survives_calculator_failure():
|
||||
from litellm.responses.streaming_iterator import _stamp_responses_usage_cost
|
||||
|
||||
response = _responses_api_response_with_usage()
|
||||
logging_obj = Mock(spec=LiteLLMLoggingObj)
|
||||
logging_obj._response_cost_calculator.side_effect = RuntimeError("cost map unavailable")
|
||||
|
||||
_stamp_responses_usage_cost(response, logging_obj)
|
||||
|
||||
assert getattr(response.usage, "cost", None) is None
|
||||
|
|
|
|||
|
|
@ -26,9 +26,7 @@ import pytest
|
|||
|
||||
@pytest.fixture(scope="module")
|
||||
def model_data():
|
||||
json_path = os.path.join(
|
||||
os.path.dirname(__file__), "../../model_prices_and_context_window.json"
|
||||
)
|
||||
json_path = os.path.join(os.path.dirname(__file__), "../../model_prices_and_context_window.json")
|
||||
with open(json_path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
|
@ -51,21 +49,14 @@ def test_usgov_sonnet_4_5_pricing(model_data, model_key):
|
|||
info = model_data[model_key]
|
||||
|
||||
assert info["input_cost_per_token"] == 3.6e-06, (
|
||||
f"{model_key}: input_cost_per_token should be $3.60/MTok "
|
||||
f"(got {info['input_cost_per_token']})"
|
||||
f"{model_key}: input_cost_per_token should be $3.60/MTok (got {info['input_cost_per_token']})"
|
||||
)
|
||||
assert (
|
||||
info["output_cost_per_token"] == 1.8e-05
|
||||
), f"{model_key}: output_cost_per_token should be $18.00/MTok"
|
||||
assert (
|
||||
info["cache_creation_input_token_cost"] == 4.5e-06
|
||||
), f"{model_key}: 5m cache write should be $4.50/MTok"
|
||||
assert (
|
||||
info["cache_creation_input_token_cost_above_1hr"] == 7.2e-06
|
||||
), f"{model_key}: 1h cache write should be $7.20/MTok"
|
||||
assert (
|
||||
info["cache_read_input_token_cost"] == 3.6e-07
|
||||
), f"{model_key}: cache read should be $0.36/MTok"
|
||||
assert info["output_cost_per_token"] == 1.8e-05, f"{model_key}: output_cost_per_token should be $18.00/MTok"
|
||||
assert info["cache_creation_input_token_cost"] == 4.5e-06, f"{model_key}: 5m cache write should be $4.50/MTok"
|
||||
assert info["cache_creation_input_token_cost_above_1hr"] == 7.2e-06, (
|
||||
f"{model_key}: 1h cache write should be $7.20/MTok"
|
||||
)
|
||||
assert info["cache_read_input_token_cost"] == 3.6e-07, f"{model_key}: cache read should be $0.36/MTok"
|
||||
|
||||
|
||||
def test_usgov_carries_20_percent_premium_over_global(model_data):
|
||||
|
|
@ -84,9 +75,7 @@ def test_usgov_carries_20_percent_premium_over_global(model_data):
|
|||
"cache_read_input_token_cost",
|
||||
):
|
||||
ratio = usgov_info[field] / global_info[field]
|
||||
assert (
|
||||
abs(ratio - 1.2) < 1e-9
|
||||
), f"{field}: us-gov / global ratio is {ratio}, expected 1.2"
|
||||
assert abs(ratio - 1.2) < 1e-9, f"{field}: us-gov / global ratio is {ratio}, expected 1.2"
|
||||
|
||||
|
||||
# The us-gov.anthropic.* cross-region inference profile is the only us-gov
|
||||
|
|
@ -112,9 +101,7 @@ def test_usgov_cross_region_above_200k_carries_gov_premium(model_data, field, ex
|
|||
"""
|
||||
info = model_data[USGOV_CROSS_REGION_KEY]
|
||||
assert field in info, f"{USGOV_CROSS_REGION_KEY}: missing field {field}"
|
||||
assert (
|
||||
info[field] == expected
|
||||
), f"{USGOV_CROSS_REGION_KEY}: {field} should be {expected} (got {info[field]})"
|
||||
assert info[field] == expected, f"{USGOV_CROSS_REGION_KEY}: {field} should be {expected} (got {info[field]})"
|
||||
|
||||
|
||||
def test_usgov_cross_region_above_200k_ratio_to_global(model_data):
|
||||
|
|
@ -127,6 +114,176 @@ def test_usgov_cross_region_above_200k_ratio_to_global(model_data):
|
|||
usgov_info = model_data[USGOV_CROSS_REGION_KEY]
|
||||
for field in EXPECTED_USGOV_ABOVE_200K:
|
||||
ratio = usgov_info[field] / global_info[field]
|
||||
assert (
|
||||
abs(ratio - 1.2) < 1e-9
|
||||
), f"{field}: us-gov / global ratio is {ratio}, expected 1.2"
|
||||
assert abs(ratio - 1.2) < 1e-9, f"{field}: us-gov / global ratio is {ratio}, expected 1.2"
|
||||
|
||||
|
||||
CLAUDE_GOV_EXPECTED = {
|
||||
"anthropic.claude-sonnet-5": {
|
||||
"input_cost_per_token": 2.4e-06,
|
||||
"output_cost_per_token": 1.2e-05,
|
||||
"cache_creation_input_token_cost": 3e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 4.8e-06,
|
||||
"cache_read_input_token_cost": 2.4e-07,
|
||||
},
|
||||
"anthropic.claude-opus-4-8": {
|
||||
"input_cost_per_token": 6e-06,
|
||||
"output_cost_per_token": 3e-05,
|
||||
"cache_creation_input_token_cost": 7.5e-06,
|
||||
"cache_creation_input_token_cost_above_1hr": 1.2e-05,
|
||||
"cache_read_input_token_cost": 6e-07,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
USGOV_CLAUDE_KEY_TEMPLATES = {
|
||||
"bedrock/us-gov-east-1/{base_key}": "bedrock",
|
||||
"bedrock/us-gov-west-1/{base_key}": "bedrock",
|
||||
"us-gov.{base_key}": "bedrock_converse",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_key", CLAUDE_GOV_EXPECTED)
|
||||
@pytest.mark.parametrize("key_template,expected_provider", USGOV_CLAUDE_KEY_TEMPLATES.items())
|
||||
def test_usgov_claude_sonnet5_opus48_pricing(model_data, key_template, expected_provider, base_key):
|
||||
"""Sonnet 5 and Opus 4.8 gov entries, both in-region keys and the us-gov.
|
||||
geo inference profile the model cards list for GovCloud, must match the
|
||||
rates AWS publishes on the Bedrock pricing page (1.2x global).
|
||||
"""
|
||||
gov_key = key_template.format(base_key=base_key)
|
||||
assert gov_key in model_data, f"Missing model entry: {gov_key}"
|
||||
info = model_data[gov_key]
|
||||
assert info["litellm_provider"] == expected_provider
|
||||
for field, expected in CLAUDE_GOV_EXPECTED[base_key].items():
|
||||
assert info[field] == expected, f"{gov_key}: {field} should be {expected} (got {info[field]})"
|
||||
ratio = info[field] / model_data[base_key][field]
|
||||
assert abs(ratio - 1.2) < 1e-9, f"{gov_key}: {field} gov/global ratio is {ratio}, expected 1.2"
|
||||
|
||||
|
||||
CONVERSE_GOV_EXPECTED = {
|
||||
"nvidia.nemotron-nano-3-30b": (7.2e-08, 2.88e-07),
|
||||
"nvidia.nemotron-nano-12b-v2": (2.4e-07, 7.2e-07),
|
||||
"nvidia.nemotron-super-3-120b": (1.8e-07, 7.8e-07),
|
||||
"openai.gpt-oss-20b-1:0": (8.4e-08, 3.6e-07),
|
||||
"openai.gpt-oss-120b-1:0": (1.8e-07, 7.2e-07),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("base_key", CONVERSE_GOV_EXPECTED)
|
||||
@pytest.mark.parametrize("region", ["us-gov-east-1", "us-gov-west-1"])
|
||||
def test_usgov_converse_model_pricing(model_data, region, base_key):
|
||||
"""Nemotron and gpt-oss gov entries must match the AWS Bedrock offer file,
|
||||
which prices both GovCloud regions identically at 1.2x commercial.
|
||||
"""
|
||||
gov_key = f"bedrock/{region}/{base_key}"
|
||||
assert gov_key in model_data, f"Missing model entry: {gov_key}"
|
||||
info = model_data[gov_key]
|
||||
expected_input, expected_output = CONVERSE_GOV_EXPECTED[base_key]
|
||||
assert info["input_cost_per_token"] == expected_input
|
||||
assert info["output_cost_per_token"] == expected_output
|
||||
assert info["litellm_provider"] == "bedrock"
|
||||
base = model_data[base_key]
|
||||
assert abs(info["input_cost_per_token"] / base["input_cost_per_token"] - 1.2) < 1e-9
|
||||
assert abs(info["output_cost_per_token"] / base["output_cost_per_token"] - 1.2) < 1e-9
|
||||
|
||||
|
||||
def test_usgov_west_llama3_8b_output_price_fixed(model_data):
|
||||
"""The us-gov-west-1 llama3-8b entry carried the 70B output rate ($2.65/MTok);
|
||||
the AWS Bedrock offer file prices output at $0.60/MTok. AWS lists the model
|
||||
in us-gov-west-1 only, so there is no east entry to check.
|
||||
"""
|
||||
info = model_data["bedrock/us-gov-west-1/meta.llama3-8b-instruct-v1:0"]
|
||||
assert info["input_cost_per_token"] == 3e-07
|
||||
assert info["output_cost_per_token"] == 6e-07
|
||||
|
||||
|
||||
MANTLE_GOV_TIERED_EXPECTED = {
|
||||
"openai.gpt-5.6-luna": {
|
||||
"input_cost_per_token": 2.64e-07,
|
||||
"input_cost_per_token_above_272k_tokens": 5.28e-07,
|
||||
"cache_creation_input_token_cost": 3.3e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 6.6e-07,
|
||||
"cache_read_input_token_cost": 2.64e-08,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 5.28e-08,
|
||||
"output_cost_per_token": 1.584e-06,
|
||||
"output_cost_per_token_above_272k_tokens": 2.376e-06,
|
||||
},
|
||||
"openai.gpt-5.6-terra": {
|
||||
"input_cost_per_token": 2.64e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 5.28e-06,
|
||||
"cache_creation_input_token_cost": 3.3e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens": 6.6e-06,
|
||||
"cache_read_input_token_cost": 2.64e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 5.28e-07,
|
||||
"output_cost_per_token": 1.584e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 2.376e-05,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", MANTLE_GOV_TIERED_EXPECTED)
|
||||
def test_usgov_west_mantle_terra_luna_pricing(model_data, model):
|
||||
"""Terra and Luna carry 1.2x commercial across every tier in the
|
||||
us-gov-west-1 offer file; the us-gov-east-1 offer file has no SKUs for them.
|
||||
"""
|
||||
gov_key = f"bedrock_mantle/us-gov-west-1/{model}"
|
||||
assert gov_key in model_data, f"Missing model entry: {gov_key}"
|
||||
info = model_data[gov_key]
|
||||
for field, expected in MANTLE_GOV_TIERED_EXPECTED[model].items():
|
||||
assert info[field] == expected, f"{gov_key}: {field} should be {expected} (got {info[field]})"
|
||||
assert info["litellm_provider"] == "bedrock_mantle"
|
||||
assert f"bedrock_mantle/us-gov-east-1/{model}" not in model_data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("region", ["us-gov-east-1", "us-gov-west-1"])
|
||||
def test_usgov_mantle_gpt_5_4_pricing_has_no_long_context_tier(model_data, region):
|
||||
"""gpt-5.4 gov rates come from the offer file, which publishes only the
|
||||
standard tier in GovCloud: no long-context SKUs exist there, unlike commercial.
|
||||
"""
|
||||
gov_key = f"bedrock_mantle/{region}/openai.gpt-5.4"
|
||||
assert gov_key in model_data, f"Missing model entry: {gov_key}"
|
||||
info = model_data[gov_key]
|
||||
assert info["input_cost_per_token"] == 3.3e-06
|
||||
assert info["cache_read_input_token_cost"] == 3.3e-07
|
||||
assert info["output_cost_per_token"] == 1.98e-05
|
||||
assert not any(field.endswith("_above_272k_tokens") for field in info)
|
||||
|
||||
|
||||
def test_usgov_mantle_grok_4_3_west_only(model_data):
|
||||
"""grok-4.3 is priced in the us-gov-west-1 offer file only; the east offer
|
||||
file carries grok-4.6 instead.
|
||||
"""
|
||||
info = model_data["bedrock_mantle/us-gov-west-1/xai.grok-4.3"]
|
||||
assert info["input_cost_per_token"] == 1.5e-06
|
||||
assert info["output_cost_per_token"] == 3e-06
|
||||
assert info["cache_read_input_token_cost"] == 2.4e-07
|
||||
assert "bedrock_mantle/us-gov-east-1/xai.grok-4.3" not in model_data
|
||||
|
||||
|
||||
AZURE_GOV_EXPECTED = {
|
||||
"azure/us-gov/gpt-5.1": {
|
||||
"input_cost_per_token": 1.71875e-06,
|
||||
"cache_read_input_token_cost": 1.71875e-07,
|
||||
"output_cost_per_token": 1.375e-05,
|
||||
},
|
||||
"azure/us-gov/o3-mini": {
|
||||
"input_cost_per_token": 1.513e-06,
|
||||
"cache_read_input_token_cost": 7.57e-07,
|
||||
"output_cost_per_token": 6.05e-06,
|
||||
},
|
||||
"azure/us-gov/text-embedding-3-large": {"input_cost_per_token": 1.63e-07},
|
||||
"azure/us-gov/text-embedding-3-small": {"input_cost_per_token": 2.5e-08},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("gov_key", AZURE_GOV_EXPECTED)
|
||||
def test_azure_usgov_pricing(model_data, gov_key):
|
||||
"""Azure Government meters from the Azure retail prices API
|
||||
(usgovvirginia/usgovarizona, serviceName 'Foundry Models'). No Government
|
||||
retirement schedule is published, so these entries carry no deprecation_date.
|
||||
"""
|
||||
assert gov_key in model_data, f"Missing model entry: {gov_key}"
|
||||
info = model_data[gov_key]
|
||||
for field, expected in AZURE_GOV_EXPECTED[gov_key].items():
|
||||
assert info[field] == expected, f"{gov_key}: {field} should be {expected} (got {info[field]})"
|
||||
assert info["litellm_provider"] == "azure"
|
||||
assert "deprecation_date" not in info
|
||||
|
|
|
|||
|
|
@ -75,6 +75,22 @@ def test_additional_current_models_are_present():
|
|||
assert entry["output_cost_per_token"] > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"key, published_price_per_audio_minute",
|
||||
[
|
||||
("cloudflare/@cf/openai/whisper", 0.00045),
|
||||
("cloudflare/@cf/openai/whisper-large-v3-turbo", 0.00051),
|
||||
],
|
||||
)
|
||||
def test_whisper_transcription_pricing_is_stored_per_second(key, published_price_per_audio_minute):
|
||||
entry = litellm.model_cost[key]
|
||||
assert entry["litellm_provider"] == "cloudflare"
|
||||
assert entry["mode"] == "audio_transcription"
|
||||
assert entry["supported_endpoints"] == ["/v1/audio/transcriptions"]
|
||||
assert entry["output_cost_per_second"] == 0.0
|
||||
assert entry["input_cost_per_second"] == pytest.approx(published_price_per_audio_minute / 60)
|
||||
|
||||
|
||||
def test_root_and_backup_have_identical_cloudflare_keys():
|
||||
if not os.path.exists(ROOT_MAP):
|
||||
pytest.skip("root cost map only ships in source checkouts")
|
||||
|
|
|
|||
|
|
@ -3150,8 +3150,8 @@ def _stream_builder_logging_obj() -> LiteLLMLogging:
|
|||
return logging_obj
|
||||
|
||||
|
||||
def test_stream_chunk_builder_reports_streaming_usage_cost_when_enabled(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "include_cost_in_streaming_usage", True)
|
||||
def test_stream_chunk_builder_stamps_streaming_usage_cost_by_default(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "include_cost_in_streaming_usage", False)
|
||||
chunks: Final = [
|
||||
_stream_builder_text_chunk("gpt-4o", "Hello "),
|
||||
_stream_builder_text_chunk("gpt-4o", "world.", finish_reason="stop"),
|
||||
|
|
@ -3168,11 +3168,45 @@ def test_stream_chunk_builder_reports_streaming_usage_cost_when_enabled(monkeypa
|
|||
assert response._hidden_params["response_cost"] == pytest.approx(usage_cost)
|
||||
|
||||
|
||||
def test_stream_chunk_builder_defers_cost_to_logging_obj_when_usage_cost_absent(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(litellm, "include_cost_in_streaming_usage", False)
|
||||
def test_stream_chunk_builder_skips_stamp_when_cost_is_unpriceable():
|
||||
import time as time_module
|
||||
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging
|
||||
|
||||
logging_obj: Final = LiteLLMLogging(
|
||||
model="us.anthropic.claude-opus-5",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
stream=True,
|
||||
call_type="completion",
|
||||
start_time=time_module.time(),
|
||||
litellm_call_id="stream-builder-alias-unpriceable",
|
||||
function_id="1",
|
||||
)
|
||||
logging_obj.model_call_details["custom_llm_provider"] = "bedrock"
|
||||
logging_obj.optional_params = {}
|
||||
usage_chunk: Final = _stream_builder_text_chunk("bedrock-claude-opus-5", "")
|
||||
usage_chunk.usage = Usage(prompt_tokens=40, completion_tokens=5, total_tokens=45)
|
||||
chunks: Final = [
|
||||
_stream_builder_text_chunk("bedrock-claude-opus-5", "Hello ", finish_reason="stop"),
|
||||
usage_chunk,
|
||||
]
|
||||
|
||||
response: Final = litellm.stream_chunk_builder(
|
||||
chunks=chunks, messages=[{"role": "user", "content": "hi"}], logging_obj=logging_obj
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert getattr(response.usage, "cost", None) is None
|
||||
assert response._hidden_params.get("response_cost") is None
|
||||
|
||||
|
||||
def test_stream_chunk_builder_keeps_provider_reported_usage_cost():
|
||||
usage_chunk: Final = _stream_builder_text_chunk("gpt-4o", "")
|
||||
usage_chunk.usage = Usage(prompt_tokens=10, completion_tokens=5, total_tokens=15, cost=0.5)
|
||||
chunks: Final = [
|
||||
_stream_builder_text_chunk("gpt-4o", "Hello "),
|
||||
_stream_builder_text_chunk("gpt-4o", "world.", finish_reason="stop"),
|
||||
usage_chunk,
|
||||
]
|
||||
|
||||
response: Final = litellm.stream_chunk_builder(
|
||||
|
|
@ -3180,4 +3214,26 @@ def test_stream_chunk_builder_defers_cost_to_logging_obj_when_usage_cost_absent(
|
|||
)
|
||||
|
||||
assert response is not None
|
||||
assert response._hidden_params.get("response_cost") is None
|
||||
assert getattr(response.usage, "cost", None) == pytest.approx(0.5)
|
||||
assert response._hidden_params["response_cost"] == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_stream_chunk_builder_prices_alias_from_openai_sdk_usage_chunk():
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
usage_chunk: Final = _stream_builder_text_chunk("mantle-claude", "")
|
||||
usage_chunk.usage = CompletionUsage(prompt_tokens=20, completion_tokens=60, total_tokens=80, cost=0.000704)
|
||||
assert type(usage_chunk.usage) is CompletionUsage
|
||||
chunks: Final = [
|
||||
_stream_builder_text_chunk("mantle-claude", "Hello "),
|
||||
_stream_builder_text_chunk("mantle-claude", "world.", finish_reason="stop"),
|
||||
usage_chunk,
|
||||
]
|
||||
|
||||
response: Final = litellm.stream_chunk_builder(chunks=chunks, messages=[{"role": "user", "content": "hi"}])
|
||||
|
||||
assert response is not None
|
||||
assert response.usage.prompt_tokens == 20
|
||||
assert response.usage.completion_tokens == 60
|
||||
assert getattr(response.usage, "cost", None) == pytest.approx(0.000704)
|
||||
assert response._hidden_params["response_cost"] == pytest.approx(0.000704)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
import json
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
|
||||
REPO_ROOT = Path(__file__).parents[2]
|
||||
MAIN_PATH = REPO_ROOT / "model_prices_and_context_window.json"
|
||||
BACKUP_PATH = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json"
|
||||
|
||||
FLEX_LONG_CONTEXT = {
|
||||
"gpt-5.4": {
|
||||
"input_cost_per_token_above_272k_tokens_flex": 2.5e-06,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 1.125e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": 2.5e-07,
|
||||
},
|
||||
"gpt-5.4-pro": {
|
||||
"input_cost_per_token_above_272k_tokens_flex": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 0.000135,
|
||||
},
|
||||
"gpt-5.5": {
|
||||
"input_cost_per_token_above_272k_tokens_flex": 5e-06,
|
||||
"output_cost_per_token_above_272k_tokens_flex": 2.25e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_flex": 5e-07,
|
||||
},
|
||||
}
|
||||
|
||||
PRIORITY_LONG_CONTEXT = {
|
||||
"gpt-5.6": {
|
||||
"input_cost_per_token_above_272k_tokens_priority": 1.6e-05,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 6e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 1.6e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 2e-05,
|
||||
},
|
||||
"gpt-5.6-sol": {
|
||||
"input_cost_per_token_above_272k_tokens_priority": 1.6e-05,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 6e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 1.6e-06,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 2e-05,
|
||||
},
|
||||
"gpt-5.6-terra": {
|
||||
"input_cost_per_token_above_272k_tokens_priority": 8e-06,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 3.6e-05,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 8e-07,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 1e-05,
|
||||
},
|
||||
"gpt-5.6-luna": {
|
||||
"input_cost_per_token_above_272k_tokens_priority": 8e-07,
|
||||
"output_cost_per_token_above_272k_tokens_priority": 3.6e-06,
|
||||
"cache_read_input_token_cost_above_272k_tokens_priority": 8e-08,
|
||||
"cache_creation_input_token_cost_above_272k_tokens_priority": 1e-06,
|
||||
},
|
||||
}
|
||||
|
||||
EXPECTED = {**FLEX_LONG_CONTEXT, **PRIORITY_LONG_CONTEXT}
|
||||
|
||||
NO_PUBLISHED_PRIORITY_LONG_CONTEXT = ("gpt-5.4", "gpt-5.5")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
|
||||
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def _load(path: Path) -> dict[str, dict[str, object]]:
|
||||
with open(path) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", [MAIN_PATH, BACKUP_PATH], ids=["main", "backup"])
|
||||
@pytest.mark.parametrize("model", sorted(EXPECTED))
|
||||
def test_service_tier_long_context_rates_are_published(model: str, path: Path) -> None:
|
||||
"""Each tier must carry its own above-272K rates, in both price files."""
|
||||
info = _load(path).get(model)
|
||||
assert info is not None, f"{model} not found in {path.name}"
|
||||
for key, expected in EXPECTED[model].items():
|
||||
assert info.get(key) == pytest.approx(expected), f"{model}.{key} is {info.get(key)!r}, expected {expected!r}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", sorted(EXPECTED))
|
||||
def test_tier_long_context_rate_is_half_or_double_the_standard(model: str) -> None:
|
||||
"""Flex is half the standard long-context rate; priority is double it."""
|
||||
info = _load(MAIN_PATH)[model]
|
||||
tier = "flex" if model in FLEX_LONG_CONTEXT else "priority"
|
||||
ratio = 0.5 if tier == "flex" else 2.0
|
||||
for base in ("input_cost_per_token", "output_cost_per_token"):
|
||||
standard = info[f"{base}_above_272k_tokens"]
|
||||
tiered = info[f"{base}_above_272k_tokens_{tier}"]
|
||||
assert tiered == pytest.approx(standard * ratio), (
|
||||
f"{model}.{base}_above_272k_tokens_{tier} is {tiered!r}, "
|
||||
f"expected {ratio}x the standard long-context rate {standard!r}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model", NO_PUBLISHED_PRIORITY_LONG_CONTEXT)
|
||||
def test_no_priority_long_context_rates_where_openai_publishes_none(model: str) -> None:
|
||||
"""Guard against back-filling a rate OpenAI does not publish."""
|
||||
info = _load(MAIN_PATH)[model]
|
||||
assert "input_cost_per_token_above_272k_tokens_priority" not in info
|
||||
|
||||
|
||||
LONG_CONTEXT_PROMPT_TOKENS = 300_000
|
||||
COMPLETION_TOKENS = 1_000
|
||||
|
||||
TIERED_COST_CASES = [
|
||||
("gpt-5.4", "flex", 2.5e-06, 1.125e-05),
|
||||
("gpt-5.4-pro", "flex", 3e-05, 0.000135),
|
||||
("gpt-5.5", "flex", 5e-06, 2.25e-05),
|
||||
("gpt-5.6", "priority", 1.6e-05, 6e-05),
|
||||
("gpt-5.6-sol", "priority", 1.6e-05, 6e-05),
|
||||
("gpt-5.6-terra", "priority", 8e-06, 3.6e-05),
|
||||
("gpt-5.6-luna", "priority", 8e-07, 3.6e-06),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model,tier,input_rate,output_rate", TIERED_COST_CASES)
|
||||
def test_cost_per_token_bills_long_context_at_the_tier_rate(
|
||||
model: str, tier: str, input_rate: float, output_rate: float
|
||||
) -> None:
|
||||
"""A prompt over 272K on flex or priority must bill at that tier's long-context rate."""
|
||||
input_cost, output_cost = litellm.cost_per_token(
|
||||
model=model,
|
||||
prompt_tokens=LONG_CONTEXT_PROMPT_TOKENS,
|
||||
completion_tokens=COMPLETION_TOKENS,
|
||||
service_tier=tier,
|
||||
)
|
||||
assert input_cost == pytest.approx(LONG_CONTEXT_PROMPT_TOKENS * input_rate)
|
||||
assert output_cost == pytest.approx(COMPLETION_TOKENS * output_rate)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model,tier,input_rate,output_rate", TIERED_COST_CASES)
|
||||
def test_cost_per_token_tier_differs_from_the_standard_long_context_cost(
|
||||
model: str, tier: str, input_rate: float, output_rate: float
|
||||
) -> None:
|
||||
"""Flex halves the standard long-context bill and priority doubles it."""
|
||||
ratio = 0.5 if tier == "flex" else 2.0
|
||||
standard = sum(
|
||||
litellm.cost_per_token(
|
||||
model=model,
|
||||
prompt_tokens=LONG_CONTEXT_PROMPT_TOKENS,
|
||||
completion_tokens=COMPLETION_TOKENS,
|
||||
)
|
||||
)
|
||||
tiered = sum(
|
||||
litellm.cost_per_token(
|
||||
model=model,
|
||||
prompt_tokens=LONG_CONTEXT_PROMPT_TOKENS,
|
||||
completion_tokens=COMPLETION_TOKENS,
|
||||
service_tier=tier,
|
||||
)
|
||||
)
|
||||
assert tiered == pytest.approx(standard * ratio)
|
||||
|
|
@ -6,6 +6,7 @@ import logging
|
|||
import os
|
||||
import threading
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
|
|
@ -1544,11 +1545,7 @@ async def test_ageneric_api_call_resolves_realtime_session_model():
|
|||
fills it with the pre-routing model group name. The underlying litellm function reads session.model first,
|
||||
so it must see the resolved deployment, while a caller's nested transcription model stays untouched.
|
||||
"""
|
||||
captured: dict = {}
|
||||
|
||||
async def capture_kwargs(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"result": "ok"}
|
||||
routed: Final = AsyncMock(return_value={"result": "ok"})
|
||||
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
|
|
@ -1565,7 +1562,7 @@ async def test_ageneric_api_call_resolves_realtime_session_model():
|
|||
|
||||
await router._ageneric_api_call_with_fallbacks(
|
||||
model="my-realtime-group",
|
||||
original_function=capture_kwargs,
|
||||
original_function=routed,
|
||||
session={
|
||||
"type": "realtime",
|
||||
"model": "my-realtime-group",
|
||||
|
|
@ -1573,9 +1570,10 @@ async def test_ageneric_api_call_resolves_realtime_session_model():
|
|||
},
|
||||
)
|
||||
|
||||
assert captured["model"] == "openai/gpt-realtime-2.1-mini"
|
||||
assert captured["session"]["model"] == "openai/gpt-realtime-2.1-mini"
|
||||
assert captured["session"]["audio"]["input"]["transcription"]["model"] == "gpt-4o-transcribe"
|
||||
sent: Final = routed.call_args.kwargs
|
||||
assert sent["model"] == "openai/gpt-realtime-2.1-mini"
|
||||
assert sent["session"]["model"] == "openai/gpt-realtime-2.1-mini"
|
||||
assert sent["session"]["audio"]["input"]["transcription"]["model"] == "gpt-4o-transcribe"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1584,11 +1582,7 @@ async def test_ageneric_api_call_does_not_add_session_model():
|
|||
A session that never carried a model must not gain one from routing: the underlying function then falls back
|
||||
to the resolved `model` kwarg itself, and the outgoing session body keeps the caller's shape.
|
||||
"""
|
||||
captured: dict = {}
|
||||
|
||||
async def capture_kwargs(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {"result": "ok"}
|
||||
routed: Final = AsyncMock(return_value={"result": "ok"})
|
||||
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
|
|
@ -1605,12 +1599,13 @@ async def test_ageneric_api_call_does_not_add_session_model():
|
|||
|
||||
await router._ageneric_api_call_with_fallbacks(
|
||||
model="my-realtime-group",
|
||||
original_function=capture_kwargs,
|
||||
original_function=routed,
|
||||
session={"type": "realtime"},
|
||||
)
|
||||
|
||||
assert captured["model"] == "openai/gpt-realtime-2.1-mini"
|
||||
assert captured["session"] == {"type": "realtime"}
|
||||
sent: Final = routed.call_args.kwargs
|
||||
assert sent["model"] == "openai/gpt-realtime-2.1-mini"
|
||||
assert sent["session"] == {"type": "realtime"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -7362,6 +7357,71 @@ def test_get_configured_token_limits_coerces_numeric_strings():
|
|||
assert router.get_configured_token_limits("quoted-limits-model") == (32000, 8000)
|
||||
|
||||
|
||||
def test_get_configured_display_name_reads_deployment_model_info():
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "Kimi K3-claude-compatible",
|
||||
"litellm_params": {"model": "openai/some-unmapped-model"},
|
||||
"model_info": {"display_name": "Kimi K3"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert router.get_configured_display_name("Kimi K3-claude-compatible") == "Kimi K3"
|
||||
|
||||
|
||||
def test_get_configured_display_name_returns_none_for_unset_or_unknown():
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "no-display-model",
|
||||
"litellm_params": {"model": "openai/some-unmapped-model"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
assert router.get_configured_display_name("no-display-model") is None
|
||||
assert router.get_configured_display_name("not-a-real-model") is None
|
||||
|
||||
|
||||
def test_get_configured_display_name_skips_wildcard_pattern_matching():
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "bedrock/*",
|
||||
"litellm_params": {"model": "bedrock/*"},
|
||||
"model_info": {"display_name": "Bedrock"},
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
with patch.object(
|
||||
router.pattern_router, "route", side_effect=AssertionError("pattern route called")
|
||||
):
|
||||
assert (
|
||||
router.get_configured_display_name("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0")
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_get_configured_display_name_treats_malformed_values_as_absent():
|
||||
malformed = ["", " ", 12345, ["Kimi K3"], {"name": "Kimi K3"}, True]
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": f"bad-display-{i}",
|
||||
"litellm_params": {"model": "openai/some-unmapped-model"},
|
||||
"model_info": {"display_name": bad},
|
||||
}
|
||||
for i, bad in enumerate(malformed)
|
||||
]
|
||||
)
|
||||
|
||||
for i in range(len(malformed)):
|
||||
assert router.get_configured_display_name(f"bad-display-{i}") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_acreate_batch_disable_fallbacks_surfaces_owning_provider_error():
|
||||
router = litellm.Router(
|
||||
|
|
@ -7601,6 +7661,118 @@ async def test_acreate_batch_request_bedrock_tags_override_deployment_tags():
|
|||
assert mock_sign.call_args.kwargs["data"]["tags"] == request_tags
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avector_store_search_injects_router():
|
||||
"""
|
||||
Regression: router.avector_store_search must pass the router down to the
|
||||
SDK search call so provider transforms can resolve router-managed
|
||||
embedding models (e.g. S3 Vectors query embeddings).
|
||||
"""
|
||||
from litellm.types.vector_stores import VectorStoreSearchResponse
|
||||
|
||||
expected_response = VectorStoreSearchResponse(
|
||||
object="vector_store.search_results.page", search_query="q", data=[]
|
||||
)
|
||||
mock_asearch = AsyncMock(return_value=expected_response)
|
||||
# Router.__init__ binds asearch via a local import, so patch the module
|
||||
# attribute before constructing the Router.
|
||||
with patch("litellm.vector_stores.main.asearch", new=mock_asearch): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "test-key"},
|
||||
}
|
||||
]
|
||||
)
|
||||
search_response = await router.avector_store_search(
|
||||
vector_store_id="v", query="q", custom_llm_provider="s3_vectors"
|
||||
)
|
||||
|
||||
assert search_response is expected_response
|
||||
mock_asearch.assert_awaited_once()
|
||||
assert mock_asearch.await_args.kwargs["router"] is router
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_avector_store_create_does_not_inject_router():
|
||||
"""The router injection is gated on the search call type: the create path
|
||||
must keep calling the SDK without a router kwarg."""
|
||||
expected_response = {"id": "vs_1", "object": "vector_store"}
|
||||
mock_acreate = AsyncMock(return_value=expected_response)
|
||||
# avector_store_create(model=None) resolves acreate via a local import at
|
||||
# call time, so patching after Router construction works here.
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "test-key"},
|
||||
}
|
||||
]
|
||||
)
|
||||
with patch("litellm.vector_stores.main.acreate", new=mock_acreate): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface
|
||||
create_response = await router.avector_store_create(model=None, custom_llm_provider="openai")
|
||||
|
||||
assert create_response is expected_response
|
||||
mock_acreate.assert_awaited_once()
|
||||
assert "router" not in mock_acreate.await_args.kwargs
|
||||
|
||||
|
||||
def test_vector_store_search_injects_router():
|
||||
"""
|
||||
Sync parity for the router injection: router.vector_store_search must pass
|
||||
the router down to the SDK search call so provider transforms can resolve
|
||||
router-managed embedding models, same as avector_store_search.
|
||||
"""
|
||||
from litellm.types.vector_stores import VectorStoreSearchResponse
|
||||
|
||||
expected_response = VectorStoreSearchResponse(
|
||||
object="vector_store.search_results.page", search_query="q", data=[]
|
||||
)
|
||||
mock_search = MagicMock(return_value=expected_response)
|
||||
# Router.__init__ binds search via a local import, so patch the module
|
||||
# attribute before constructing the Router.
|
||||
with patch("litellm.vector_stores.main.search", new=mock_search): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "test-key"},
|
||||
}
|
||||
]
|
||||
)
|
||||
search_response = router.vector_store_search(
|
||||
vector_store_id="v", query="q", custom_llm_provider="s3_vectors"
|
||||
)
|
||||
|
||||
assert search_response is expected_response
|
||||
mock_search.assert_called_once()
|
||||
assert mock_search.call_args.kwargs["router"] is router
|
||||
assert mock_search.call_args.kwargs["custom_llm_provider"] == "s3_vectors"
|
||||
|
||||
|
||||
def test_vector_store_create_does_not_inject_router():
|
||||
"""The sync create path must keep calling the SDK without a router kwarg."""
|
||||
expected_response = {"id": "vs_1", "object": "vector_store"}
|
||||
mock_create = MagicMock(return_value=expected_response)
|
||||
# Router.__init__ binds create via a local import, so patch the module
|
||||
# attribute before constructing the Router.
|
||||
with patch("litellm.vector_stores.main.create", new=mock_create): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface
|
||||
router = litellm.Router(
|
||||
model_list=[
|
||||
{
|
||||
"model_name": "gpt-3.5-turbo",
|
||||
"litellm_params": {"model": "openai/gpt-3.5-turbo", "api_key": "test-key"},
|
||||
}
|
||||
]
|
||||
)
|
||||
create_response = router.vector_store_create(custom_llm_provider="openai")
|
||||
|
||||
assert create_response is expected_response
|
||||
mock_create.assert_called_once()
|
||||
assert "router" not in mock_create.call_args.kwargs
|
||||
|
||||
|
||||
class TestPreRoutingStrategyRegistryLifecycle:
|
||||
"""
|
||||
Regression tests: a deployment leaving the model_list must release the
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ This file pins both halves of the fix.
|
|||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
|
@ -28,8 +29,19 @@ from pydantic import ValidationError
|
|||
|
||||
|
||||
import litellm
|
||||
from litellm.router_strategy.budget_limiter import RouterBudgetLimiting
|
||||
from litellm.router_utils.pre_call_checks.model_rate_limit_check import ModelRateLimitingCheck
|
||||
from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import PromptCachingDeploymentCheck
|
||||
from litellm.types.router import RetryPolicy, UpdateRouterConfig
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_litellm_callbacks():
|
||||
callbacks_before: Final = litellm.callbacks.copy()
|
||||
yield
|
||||
litellm.callbacks = callbacks_before # test-quality-ok: required callback-state restoration fixture
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UpdateRouterConfig schema membership (LIT-3152 part 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -100,6 +112,114 @@ def _build_router() -> litellm.Router:
|
|||
)
|
||||
|
||||
|
||||
def test_update_settings_adds_optional_pre_call_check_once():
|
||||
router = _build_router()
|
||||
|
||||
router.update_settings(num_retries=7, optional_pre_call_checks=["prompt_caching"])
|
||||
router.update_settings(optional_pre_call_checks=["prompt_caching"])
|
||||
|
||||
prompt_caching_callbacks = [
|
||||
callback for callback in router.optional_callbacks if isinstance(callback, PromptCachingDeploymentCheck)
|
||||
]
|
||||
assert len(prompt_caching_callbacks) == 1
|
||||
assert router.num_retries == 7
|
||||
|
||||
|
||||
def test_update_settings_clears_omitted_toggleable_pre_call_checks():
|
||||
router = _build_router()
|
||||
|
||||
router.update_settings(optional_pre_call_checks=["prompt_caching"])
|
||||
router.update_settings(optional_pre_call_checks=[])
|
||||
|
||||
assert not any(isinstance(callback, PromptCachingDeploymentCheck) for callback in (router.optional_callbacks or []))
|
||||
assert not any(isinstance(callback, PromptCachingDeploymentCheck) for callback in litellm.callbacks)
|
||||
|
||||
|
||||
def test_set_optional_pre_call_checks_reconciles_callback_types():
|
||||
router = _build_router()
|
||||
|
||||
router.set_optional_pre_call_checks(["prompt_caching"])
|
||||
router.set_optional_pre_call_checks([])
|
||||
|
||||
assert not any(isinstance(callback, PromptCachingDeploymentCheck) for callback in (router.optional_callbacks or []))
|
||||
assert not any(isinstance(callback, PromptCachingDeploymentCheck) for callback in litellm.callbacks)
|
||||
|
||||
|
||||
def test_remove_optional_pre_call_check_removes_local_and_global_callbacks():
|
||||
router = _build_router()
|
||||
|
||||
router.set_optional_pre_call_checks(["prompt_caching"])
|
||||
router._remove_optional_callbacks_of_type(PromptCachingDeploymentCheck)
|
||||
|
||||
assert not any(type(callback) is PromptCachingDeploymentCheck for callback in (router.optional_callbacks or []))
|
||||
assert not any(type(callback) is PromptCachingDeploymentCheck for callback in litellm.callbacks)
|
||||
|
||||
|
||||
def test_remove_optional_pre_call_check_keeps_global_callback_for_another_router():
|
||||
router_a = _build_router()
|
||||
router_b = _build_router()
|
||||
|
||||
router_a.update_settings(optional_pre_call_checks=["prompt_caching"])
|
||||
router_b.update_settings(optional_pre_call_checks=["prompt_caching"])
|
||||
|
||||
router_a.update_settings(optional_pre_call_checks=[])
|
||||
|
||||
assert not any(type(callback) is PromptCachingDeploymentCheck for callback in (router_a.optional_callbacks or []))
|
||||
assert any(type(callback) is PromptCachingDeploymentCheck for callback in (router_b.optional_callbacks or []))
|
||||
assert any(type(callback) is PromptCachingDeploymentCheck for callback in litellm.callbacks)
|
||||
|
||||
router_b.update_settings(optional_pre_call_checks=[])
|
||||
|
||||
assert not any(type(callback) is PromptCachingDeploymentCheck for callback in (router_b.optional_callbacks or []))
|
||||
assert not any(type(callback) is PromptCachingDeploymentCheck for callback in litellm.callbacks)
|
||||
|
||||
|
||||
def test_remove_optional_pre_call_check_keeps_global_callback_when_second_router_clears_first():
|
||||
router_a = _build_router()
|
||||
router_b = _build_router()
|
||||
|
||||
router_a.update_settings(optional_pre_call_checks=["prompt_caching"])
|
||||
router_b.update_settings(optional_pre_call_checks=["prompt_caching"])
|
||||
|
||||
router_b.update_settings(optional_pre_call_checks=[])
|
||||
|
||||
assert any(type(callback) is PromptCachingDeploymentCheck for callback in (router_a.optional_callbacks or []))
|
||||
assert not any(type(callback) is PromptCachingDeploymentCheck for callback in (router_b.optional_callbacks or []))
|
||||
assert any(type(callback) is PromptCachingDeploymentCheck for callback in litellm.callbacks)
|
||||
|
||||
router_a.update_settings(optional_pre_call_checks=[])
|
||||
|
||||
assert not any(type(callback) is PromptCachingDeploymentCheck for callback in litellm.callbacks)
|
||||
|
||||
|
||||
def test_update_settings_replaces_toggleable_pre_call_checks():
|
||||
router = _build_router()
|
||||
|
||||
router.update_settings(optional_pre_call_checks=["prompt_caching"])
|
||||
router.update_settings(optional_pre_call_checks=["enforce_model_rate_limits"])
|
||||
|
||||
assert not any(isinstance(callback, PromptCachingDeploymentCheck) for callback in (router.optional_callbacks or []))
|
||||
assert not any(isinstance(callback, PromptCachingDeploymentCheck) for callback in litellm.callbacks)
|
||||
assert any(isinstance(callback, ModelRateLimitingCheck) for callback in (router.optional_callbacks or []))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_settings_preserves_router_budget_limiting_when_omitted(monkeypatch):
|
||||
async def _disable_periodic_sync(*args, **kwargs):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"litellm.router_strategy.budget_limiter.RouterBudgetLimiting.periodic_sync_in_memory_spend_with_redis",
|
||||
_disable_periodic_sync,
|
||||
)
|
||||
router = _build_router()
|
||||
|
||||
router.add_optional_pre_call_checks(["router_budget_limiting"])
|
||||
router.update_settings(optional_pre_call_checks=[])
|
||||
|
||||
assert any(isinstance(callback, RouterBudgetLimiting) for callback in (router.optional_callbacks or []))
|
||||
|
||||
|
||||
def test_update_settings_persists_retry_policy_dict():
|
||||
"""When the proxy's ``_add_router_settings_from_db_config`` calls
|
||||
``llm_router.update_settings(retry_policy={...})`` after reading the
|
||||
|
|
@ -255,8 +375,12 @@ async def test_config_update_persists_and_reads_back_retry_policy(monkeypatch):
|
|||
RateLimitErrorRetries=7,
|
||||
)
|
||||
)
|
||||
request = MagicMock()
|
||||
request.json = AsyncMock(return_value={"router_settings": {"retry_policy": posted.model_dump()}})
|
||||
|
||||
await proxy_server.update_config(
|
||||
config_info=ConfigYAML(router_settings=posted),
|
||||
request=request,
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, api_key="sk-1234"),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -4655,6 +4655,7 @@ GEMINI_4096_CACHE_MIN_MODELS: Final = tuple(
|
|||
"gemini-3.5-flash",
|
||||
"gemini-3.6-flash",
|
||||
"gemini-3.7-flash",
|
||||
"gemini-3.8-flash",
|
||||
"gemini-3.1-pro-preview",
|
||||
"gemini-3.1-pro-preview-customtools",
|
||||
)
|
||||
|
|
|
|||
0
tests/test_litellm/vector_stores/__init__.py
Normal file
0
tests/test_litellm/vector_stores/__init__.py
Normal file
78
tests/test_litellm/vector_stores/test_main.py
Normal file
78
tests/test_litellm/vector_stores/test_main.py
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
"""
|
||||
Tests for litellm/vector_stores/main.py.
|
||||
|
||||
Pins the router threading contract for vector store search: the router is an
|
||||
explicit named parameter that reaches the HTTP handler, and it must never leak
|
||||
into litellm_params/kwargs where logging would model_dump() it (the #19550
|
||||
serialization trap).
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import litellm.vector_stores.main as vector_stores_main
|
||||
from litellm.vector_stores.main import search
|
||||
|
||||
MOCK_SEARCH_RESPONSE = {
|
||||
"object": "vector_store.search_results.page",
|
||||
"search_query": "q",
|
||||
"data": [],
|
||||
}
|
||||
|
||||
|
||||
def test_search_threads_router_to_handler():
|
||||
"""search() must pass its router param through to the HTTP handler"""
|
||||
mock_router = MagicMock()
|
||||
logger = MagicMock()
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: stubs provider config resolution; the seam under test is the router kwarg threading
|
||||
"litellm.vector_stores.main.ProviderConfigManager.get_provider_vector_stores_config",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch.object( # test-quality-ok: the handler call is the observable boundary for the router kwarg contract
|
||||
vector_stores_main.base_llm_http_handler,
|
||||
"vector_store_search_handler",
|
||||
return_value=MOCK_SEARCH_RESPONSE,
|
||||
) as mock_handler,
|
||||
):
|
||||
response = search(
|
||||
vector_store_id="bkt:idx",
|
||||
query="q",
|
||||
custom_llm_provider="s3_vectors",
|
||||
router=mock_router,
|
||||
litellm_logging_obj=logger,
|
||||
)
|
||||
|
||||
assert response == MOCK_SEARCH_RESPONSE
|
||||
mock_handler.assert_called_once()
|
||||
assert mock_handler.call_args.kwargs["router"] is mock_router
|
||||
|
||||
|
||||
def test_search_router_not_in_litellm_params():
|
||||
"""Regression (#19550 class): the router must stay out of GenericLiteLLMParams,
|
||||
otherwise pre-call logging model_dump()s it and breaks serialization."""
|
||||
mock_router = MagicMock()
|
||||
logger = MagicMock()
|
||||
|
||||
with (
|
||||
patch( # test-quality-ok: stubs provider config resolution; the seam under test is litellm_params contents
|
||||
"litellm.vector_stores.main.ProviderConfigManager.get_provider_vector_stores_config",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch.object( # test-quality-ok: the handler call is where a leaked router in litellm_params would surface
|
||||
vector_stores_main.base_llm_http_handler,
|
||||
"vector_store_search_handler",
|
||||
return_value=MOCK_SEARCH_RESPONSE,
|
||||
) as mock_handler,
|
||||
):
|
||||
search(
|
||||
vector_store_id="bkt:idx",
|
||||
query="q",
|
||||
custom_llm_provider="s3_vectors",
|
||||
router=mock_router,
|
||||
litellm_logging_obj=logger,
|
||||
)
|
||||
|
||||
litellm_params = mock_handler.call_args.kwargs["litellm_params"]
|
||||
assert "router" not in litellm_params.model_dump(exclude_none=True)
|
||||
assert getattr(litellm_params, "router", None) is None
|
||||
|
|
@ -116,16 +116,33 @@ describe("VectorStoreTester", () => {
|
|||
await waitFor(() => expect(mockSearch).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("reports a failed search and keeps the history empty", async () => {
|
||||
it("shows the backend error in the history when a search fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSearch.mockRejectedValue(new Error("boom"));
|
||||
const errorBody = '{"error":{"message":"OpenAIException - api_key is required"}}';
|
||||
mockSearch.mockRejectedValue(new Error(errorBody));
|
||||
renderTester();
|
||||
|
||||
await user.type(queryInput(), "hello");
|
||||
await user.click(searchButton());
|
||||
|
||||
await waitFor(() => expect(mockFromBackend).toHaveBeenCalledWith("Failed to search vector store"));
|
||||
expect(screen.getByText(EMPTY_STATE)).toBeInTheDocument();
|
||||
await waitFor(() => expect(mockFromBackend).toHaveBeenCalledWith(errorBody));
|
||||
expect(screen.getByText(`Search failed: ${errorBody}`)).toBeInTheDocument();
|
||||
expect(screen.queryByText("No results found")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(EMPTY_STATE)).not.toBeInTheDocument();
|
||||
// the failed query stays in the input for retry
|
||||
expect(queryInput()).toHaveValue("hello");
|
||||
});
|
||||
|
||||
it('renders "No results found" for an empty result set, not an error', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockSearch.mockResolvedValue({ object: "vector_store.search_results.page", search_query: "hello", data: [] });
|
||||
renderTester();
|
||||
|
||||
await user.type(queryInput(), "hello");
|
||||
await user.click(searchButton());
|
||||
|
||||
expect(await screen.findByText("No results found")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/search failed/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the search history", async () => {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
{
|
||||
query: string;
|
||||
response: VectorStoreSearchResponse | null;
|
||||
error: string | null;
|
||||
timestamp: number;
|
||||
}[]
|
||||
>([]);
|
||||
|
|
@ -59,6 +60,7 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
const historyEntry = {
|
||||
query,
|
||||
response,
|
||||
error: null,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
|
|
@ -66,7 +68,9 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
setQuery("");
|
||||
} catch (error) {
|
||||
console.error("Error searching vector store:", error);
|
||||
toast.fromError("Failed to search vector store");
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
toast.fromError(errorMessage);
|
||||
setSearchHistory((prev) => [{ query, response: null, error: errorMessage, timestamp: Date.now() }, ...prev]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
|
|
@ -228,7 +232,13 @@ export const VectorStoreTester: React.FC<VectorStoreTesterProps> = ({ vectorStor
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">No results found</div>
|
||||
<div
|
||||
className={
|
||||
entry.error ? "text-sm break-words text-destructive" : "text-sm text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{entry.error ? `Search failed: ${entry.error}` : "No results found"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -6970,7 +6970,7 @@ export const vectorStoreSearchCall = async (
|
|||
if (!response.ok) {
|
||||
const errorData = await response.text();
|
||||
await handleError(errorData);
|
||||
return null;
|
||||
throw new Error(errorData);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -37473,6 +37473,8 @@ export interface components {
|
|||
} | null;
|
||||
/** Num Retries */
|
||||
num_retries?: number | null;
|
||||
/** Optional Pre Call Checks */
|
||||
optional_pre_call_checks?: ("prompt_caching" | "router_budget_limiting" | "responses_api_deployment_check" | "deployment_affinity" | "session_affinity" | "forward_client_headers_by_model_group" | "enforce_model_rate_limits" | "encrypted_content_affinity")[] | null;
|
||||
/** Retry After */
|
||||
retry_after?: number | null;
|
||||
retry_policy?: components["schemas"]["RetryPolicy"] | null;
|
||||
|
|
|
|||
|
|
@ -217,3 +217,17 @@ bedrock/us-east-1/zai.glm-5
|
|||
bedrock/us-west-2/zai.glm-5
|
||||
bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0
|
||||
bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0
|
||||
bedrock/us-gov-west-1/nvidia.nemotron-nano-3-30b
|
||||
bedrock/us-gov-west-1/nvidia.nemotron-nano-12b-v2
|
||||
bedrock/us-gov-west-1/nvidia.nemotron-super-3-120b
|
||||
bedrock/us-gov-west-1/openai.gpt-oss-20b-1:0
|
||||
bedrock/us-gov-west-1/openai.gpt-oss-120b-1:0
|
||||
bedrock/us-gov-west-1/anthropic.claude-sonnet-5
|
||||
bedrock/us-gov-west-1/anthropic.claude-opus-4-8
|
||||
bedrock/us-gov-east-1/nvidia.nemotron-nano-3-30b
|
||||
bedrock/us-gov-east-1/nvidia.nemotron-nano-12b-v2
|
||||
bedrock/us-gov-east-1/nvidia.nemotron-super-3-120b
|
||||
bedrock/us-gov-east-1/openai.gpt-oss-20b-1:0
|
||||
bedrock/us-gov-east-1/openai.gpt-oss-120b-1:0
|
||||
bedrock/us-gov-east-1/anthropic.claude-sonnet-5
|
||||
bedrock/us-gov-east-1/anthropic.claude-opus-4-8
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue