diff --git a/CLAUDE.md b/CLAUDE.md index f42235fc972..930825aeb89 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,7 +66,7 @@ Commit and push your work when you're done without asking When referencing or running models (coding, QA'ing, writing docs, writing tests, etc.), use the latest model in that model family unless otherwise specified; treat your training knowledge, memories, configs, and tests as stale, and determine the family's latest with model_prices_and_context_window.json or the web -Always pull before starting any work. The checkout or worktree may be sitting on a stale branch, and work built on a stale base lands on top of code that has already moved. Run `git fetch origin` first, then update the branch you're on with `git pull --no-rebase`, which fast-forwards when the branch hasn't diverged and merges the remote tip in when it has. When working a feature branch, bring it up to date with a freshly fetched `origin/litellm_internal_staging` before touching it: rebase onto it while the branch is still unpushed, merge it in once it has been pushed, and never rewrite pushed history +Always pull before starting any work. The checkout or worktree may be sitting on a stale branch If you're an internal contributor, when creating a new PR, the typical flow is to branch off litellm_internal_staging and create a branch prefixed with litellm_. Do not create a branch prefixed with claude/ and generally do not have / in your branch names diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 225a2c04339..3357212a6c8 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -99,19 +99,19 @@ "limit": 0 }, "reportUnknownArgumentType": { - "limit": 44530 + "limit": 44528 }, "reportUnknownLambdaType": { "limit": 109 }, "reportUnknownMemberType": { - "limit": 38808 + "limit": 38804 }, "reportUnknownParameterType": { "limit": 19829 }, "reportUnknownVariableType": { - "limit": 30356 + "limit": 30355 }, "reportUnnecessaryCast": { "limit": 117 @@ -138,9 +138,9 @@ "limit": 139 }, "reportUnusedImport": { - "limit": 545 + "limit": 544 }, "reportUnusedVariable": { - "limit": 146 + "limit": 145 } } diff --git a/ci_cd/generate_model_prices_schema.py b/ci_cd/generate_model_prices_schema.py index b2bc3ebadb4..276e5da5a23 100644 --- a/ci_cd/generate_model_prices_schema.py +++ b/ci_cd/generate_model_prices_schema.py @@ -157,6 +157,9 @@ COST_DESCRIPTIONS: dict[str, str] = { "input_cost_per_token": "USD per prompt token.", "output_cost_per_token": "USD per generated token.", "output_cost_per_reasoning_token": "USD per reasoning/thinking token, when billed separately.", + "google_maps_grounding_cost_per_query": ( + "USD per Grounding with Google Maps request; billed per query or per prompt per web_search_billing_unit." + ), "cache_creation_input_token_cost": "USD per token written to the provider's prompt cache.", "cache_read_input_token_cost": "USD per prompt token served from the provider's prompt cache.", "input_cost_per_token_batches": "USD per prompt token via the provider's batch API.", diff --git a/litellm/caching/caching_handler.py b/litellm/caching/caching_handler.py index 7526dfd4e4c..8fe60876b4e 100644 --- a/litellm/caching/caching_handler.py +++ b/litellm/caching/caching_handler.py @@ -18,7 +18,7 @@ import asyncio import datetime import inspect import time -from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Mapping +from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Generator, Mapping from typing import TYPE_CHECKING, Any, Final, Optional, TypeVar from pydantic import BaseModel @@ -27,6 +27,7 @@ import litellm from litellm._logging import print_verbose, verbose_logger from litellm.caching import InMemoryCache from litellm.caching.caching import S3Cache +from litellm.constants import CACHE_WRITE_SHUTDOWN_FLUSH_TIMEOUT_SECONDS from litellm.litellm_core_utils.llm_response_utils.response_metadata import ( update_response_metadata, ) @@ -124,6 +125,29 @@ def _prompt_tokens_details_as_mapping(details: "PromptTokensDetailsWrapper") -> return details.model_dump(exclude_none=True) if hasattr(details, "model_dump") else {} +_PENDING_CACHE_WRITES: Final[set["asyncio.Task[None]"]] = set() # mutable-ok: strong refs to pending write tasks + + +async def _complete_cache_write_despite_cancellation(write_factory: Callable[[], Awaitable[None]]) -> None: + try: + await write_factory() + except asyncio.CancelledError: + try: + await asyncio.wait_for(write_factory(), timeout=CACHE_WRITE_SHUTDOWN_FLUSH_TIMEOUT_SECONDS) + except Exception as flush_error: # noqa: BLE001 # shutdown flush failures are logged, never raised + verbose_logger.warning( + "LiteLLM Cache: pending cache write failed during event loop shutdown: %s", flush_error + ) + raise + + +def create_cache_write_task(write_factory: Callable[[], Awaitable[None]]) -> "asyncio.Task[None]": + task: Final = asyncio.create_task(_complete_cache_write_despite_cancellation(write_factory)) + _PENDING_CACHE_WRITES.add(task) + task.add_done_callback(_PENDING_CACHE_WRITES.discard) + return task + + def _request_cache_key(request_kwargs: Mapping[str, Any]) -> str | None: """Read the caller-supplied ``cache_key`` off the request kwargs.""" return request_kwargs.get("cache_key", None) @@ -983,6 +1007,7 @@ class LLMCachingHandler: if litellm.cache is None: return + cache: Final = litellm.cache new_kwargs: Final = kwargs.copy() new_kwargs.update( @@ -1004,24 +1029,24 @@ class LLMCachingHandler: ): if ( isinstance(result, EmbeddingResponse) - and litellm.cache is not None - and not isinstance(litellm.cache.cache, S3Cache) # s3 doesn't support bulk writing. Exclude. + and not isinstance(cache.cache, S3Cache) # s3 doesn't support bulk writing. Exclude. ): - asyncio.create_task( - litellm.cache.async_add_cache_pipeline( + create_cache_write_task( + lambda: cache.async_add_cache_pipeline( result, dynamic_cache_object=self.dual_cache, **new_kwargs ) ) else: - asyncio.create_task( - litellm.cache.async_add_cache( - result.model_dump_json(), + result_json: Final = result.model_dump_json() + create_cache_write_task( + lambda: cache.async_add_cache( + result_json, dynamic_cache_object=self.dual_cache, **new_kwargs, ) ) else: - asyncio.create_task(litellm.cache.async_add_cache(result, **new_kwargs)) + create_cache_write_task(lambda: cache.async_add_cache(result, **new_kwargs)) def sync_set_cache( self, diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index 68cad24ee96..f1c80eaacbe 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -435,7 +435,7 @@ class RedisCache(BaseCache): """ if key is None: return key - if self.namespace is not None and not key.startswith(self.namespace): + if self.namespace and not key.startswith(self.namespace + ":"): key = self.namespace + ":" + key return key diff --git a/litellm/constants.py b/litellm/constants.py index 23e92d26a59..8713bd49f57 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -381,6 +381,7 @@ AZURE_OPERATION_POLLING_TIMEOUT: Final = int(os.getenv("AZURE_OPERATION_POLLING_ AZURE_DOCUMENT_INTELLIGENCE_API_VERSION: Final = str(os.getenv("AZURE_DOCUMENT_INTELLIGENCE_API_VERSION", "2024-11-30")) AZURE_DOCUMENT_INTELLIGENCE_DEFAULT_DPI: Final = int(os.getenv("AZURE_DOCUMENT_INTELLIGENCE_DEFAULT_DPI", 96)) REDIS_SOCKET_TIMEOUT: Final = float(os.getenv("REDIS_SOCKET_TIMEOUT", 0.1)) +CACHE_WRITE_SHUTDOWN_FLUSH_TIMEOUT_SECONDS: Final[float] = 5.0 REDIS_CONNECTION_POOL_TIMEOUT: Final = int(os.getenv("REDIS_CONNECTION_POOL_TIMEOUT", 5)) REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD: Final = int(os.getenv("REDIS_CIRCUIT_BREAKER_FAILURE_THRESHOLD", 5)) REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT: Final = int(os.getenv("REDIS_CIRCUIT_BREAKER_RECOVERY_TIMEOUT", 60)) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 6536941a094..572d76ad555 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -591,6 +591,7 @@ def cost_per_token( prompt_characters=prompt_characters, completion_characters=completion_characters, usage=usage_block, + service_tier=service_tier, vertex_location=vertex_location, ) elif cost_router == "cost_per_token": @@ -794,14 +795,27 @@ def _select_model_name_for_cost_calc( and custom_llm_provider is not None and not _model_contains_known_llm_provider(return_model) ): # add provider prefix if not already present, to match model_cost - if region_name is not None: - return_model = f"{custom_llm_provider}/{region_name}/{return_model}" - else: - return_model = f"{custom_llm_provider}/{return_model}" + provider_prefix: Final = custom_llm_provider if region_name is None else f"{custom_llm_provider}/{region_name}" + return_model = _strip_unregistered_leading_segments(f"{provider_prefix}/{return_model}", region_name) return return_model +def _strip_unregistered_leading_segments(model: str, region_name: str | None) -> str: + """Resolve a provider-prefixed slash alias like "vertex_ai/vertex/claude-opus-5" to the + registered cost key ("vertex_ai/claude-opus-5"), keeping the model unchanged when it already + resolves downstream (custom-priced router ids) or no stripped candidate is registered (#38069).""" + segments: Final = model.split("/") + if "/".join(segments[1:]) in litellm.model_cost: + return model + head_len: Final = 2 if region_name is not None and len(segments) > 2 and segments[1] == region_name else 1 + head: Final = "/".join(segments[:head_len]) + tail: Final = segments[head_len:] + strippable: Final = next((index for index, segment in enumerate(tail) if segment in LlmProvidersSet), len(tail)) + candidates: Final = (f"{head}/{'/'.join(tail[start:])}" for start in range(min(strippable, len(tail) - 1) + 1)) + return next((candidate for candidate in candidates if candidate in litellm.model_cost), model) + + @lru_cache(maxsize=DEFAULT_MAX_LRU_CACHE_SIZE) def _model_contains_known_llm_provider(model: str) -> bool: """ @@ -832,9 +846,11 @@ def _get_response_model(completion_response: object) -> str | None: _GEMINI_TRAFFIC_TYPE_TO_SERVICE_TIER: Final[dict] = { # ON_DEMAND_PRIORITY maps to "priority" — selects input_cost_per_token_priority, etc. "ON_DEMAND_PRIORITY": "priority", - # FLEX / BATCH maps to "flex" — selects input_cost_per_token_flex, etc. + # FLEX / BATCH / ON_DEMAND_FLEX maps to "flex" — selects input_cost_per_token_flex, etc. + # Vertex AI reports flex/shared-capacity traffic as ON_DEMAND_FLEX, not FLEX. "FLEX": "flex", "BATCH": "flex", + "ON_DEMAND_FLEX": "flex", # ON_DEMAND is standard pricing — no service_tier suffix applied "ON_DEMAND": None, } @@ -849,9 +865,9 @@ def _map_traffic_type_to_service_tier(traffic_type: str | None) -> str | None: trafficType values seen in practice ------------------------------------ - ON_DEMAND -> standard pricing (service_tier = None) - ON_DEMAND_PRIORITY -> priority pricing (service_tier = "priority") - FLEX / BATCH -> batch/flex pricing (service_tier = "flex") + ON_DEMAND -> standard pricing (service_tier = None) + ON_DEMAND_PRIORITY -> priority pricing (service_tier = "priority") + FLEX / BATCH / ON_DEMAND_FLEX -> batch/flex pricing (service_tier = "flex") """ if traffic_type is None: return None diff --git a/litellm/endpoints/speech/speech_to_completion_bridge/transformation.py b/litellm/endpoints/speech/speech_to_completion_bridge/transformation.py index a9429b673e4..fb66edbf272 100644 --- a/litellm/endpoints/speech/speech_to_completion_bridge/transformation.py +++ b/litellm/endpoints/speech/speech_to_completion_bridge/transformation.py @@ -8,6 +8,14 @@ if TYPE_CHECKING: from litellm.types.utils import ModelResponse +def _completion_response_cost(model_response: "ModelResponse") -> float | None: + hidden_params: Final = getattr(model_response, "_hidden_params", None) + if not isinstance(hidden_params, dict): + return None + response_cost: Final = hidden_params.get("response_cost") + return response_cost if isinstance(response_cost, float) else None + + class SpeechToCompletionBridgeTransformationHandler: def transform_request( self, @@ -123,4 +131,6 @@ class SpeechToCompletionBridgeTransformationHandler: # Create an httpx.Response object response: Final = httpx.Response(status_code=200, content=binary_data, headers=headers) - return HttpxBinaryResponseContent(response) + binary_response: Final = HttpxBinaryResponseContent(response) + binary_response.set_response_cost(_completion_response_cost(model_response)) + return binary_response diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py index f4f3b00dda0..ef2edbf1007 100644 --- a/litellm/integrations/anthropic_cache_control_hook.py +++ b/litellm/integrations/anthropic_cache_control_hook.py @@ -104,6 +104,13 @@ def _accepts_prompt_cache_breakpoint(block: object) -> bool: return isinstance(block, dict) and block.get("type") in OPENAI_PROMPT_CACHE_BREAKPOINT_BLOCK_TYPES +# Set by a caller whose message list is not the one that goes upstream -- today the +# Responses API layer, whose `instructions` only becomes a system message further down. +# Tells this hook to hand role-targeted points to the pass holding the final messages +# rather than spending them on a list that is still missing some of their targets. +CARRY_UNMATCHED_MESSAGE_POINTS: Final = "_litellm_carry_unmatched_cache_control_points" + + class AnthropicCacheControlHook(CustomPromptManagement): def get_chat_completion_prompt( self, @@ -128,6 +135,7 @@ class AnthropicCacheControlHook(CustomPromptManagement): - non_default_params: dict - params with any global cache controls """ # Extract cache control injection points + carry_unmatched: Final = bool(non_default_params.pop(CARRY_UNMATCHED_MESSAGE_POINTS, False)) injection_points: Final[list[CacheControlInjectionPoint]] = non_default_params.pop( "cache_control_injection_points", [] ) @@ -161,12 +169,25 @@ class AnthropicCacheControlHook(CustomPromptManagement): non_default_params.get("prompt_cache_options"), ) ) + # A provisional message list defers every role-targeted point to the pass holding + # the final one: a role with no message here may have one there, and settling all + # of them in one pass is what lets config order decide the shared breakpoint + # budget. An ordinal names a different message once a later layer builds its own + # list, so it is placed here or not at all. + carried_message_points: Final[Sequence[CacheControlMessageInjectionPoint]] = ( + tuple(point for point in message_points if point.get("index") is None) if carry_unmatched else () + ) + applied_message_points: Final[Sequence[CacheControlMessageInjectionPoint]] = ( + tuple(point for point in message_points if point.get("index") is not None) + if carry_unmatched + else tuple(message_points) + ) reserved_blocks: Final = ( 1 if not openai_dialect and any(p.get("location") == "tool_config" for p in remaining_points) else 0 ) breakpoints_before: Final = AnthropicCacheControlHook._count_request_cache_breakpoints(processed_messages) processed_messages = self._apply_message_injections( - points=message_points, + points=applied_message_points, messages=processed_messages, max_blocks=MAX_CACHE_CONTROL_BLOCKS - reserved_blocks, openai_dialect=openai_dialect, @@ -177,10 +198,15 @@ class AnthropicCacheControlHook(CustomPromptManagement): ): non_default_params.setdefault("prompt_cache_options", PromptCacheOptions(mode="explicit")) - # Pass through non-message injection points for provider-specific handling - if remaining_points: + # Points this pass did not place: non-message ones for the provider transform, and + # the deferred role-targeted ones. Deferring is what reaches the Responses API's + # `instructions`, which is only a system message once the bridge builds one. The + # judged stamp is what makes it safe: the next pass must not re-judge points + # against messages this pass already marked (see `_should_stand_down`). + carried_points: Final[Sequence[CacheControlInjectionPoint]] = (*remaining_points, *carried_message_points) + if carried_points: non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_as_judged( - remaining_points + carried_points ) return model, processed_messages, non_default_params @@ -218,7 +244,7 @@ class AnthropicCacheControlHook(CustomPromptManagement): @staticmethod def _apply_message_injections( - points: list[CacheControlMessageInjectionPoint], + points: Sequence[CacheControlMessageInjectionPoint], messages: list[AllMessageValues], max_blocks: int, openai_dialect: bool = False, diff --git a/litellm/integrations/callback_configs.json b/litellm/integrations/callback_configs.json index 6d2bcea8bae..7a2295a35ae 100644 --- a/litellm/integrations/callback_configs.json +++ b/litellm/integrations/callback_configs.json @@ -220,6 +220,12 @@ "ui_name": "Host URL", "description": "Langfuse host URL (default: https://cloud.langfuse.com)", "required": false + }, + "langfuse_environment": { + "type": "text", + "ui_name": "Tracing Environment", + "description": "Langfuse tracing environment (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT)", + "required": false } }, "description": "Langfuse v2 Logging Integration" @@ -247,6 +253,12 @@ "ui_name": "Host URL", "description": "Langfuse host URL (default: https://cloud.langfuse.com)", "required": false + }, + "langfuse_environment": { + "type": "text", + "ui_name": "Tracing Environment", + "description": "Langfuse tracing environment (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT)", + "required": false } }, "description": "Langfuse v3 OTEL Logging Integration" diff --git a/litellm/integrations/dotprompt/__init__.py b/litellm/integrations/dotprompt/__init__.py index 07d83bc34d5..1188bce27da 100644 --- a/litellm/integrations/dotprompt/__init__.py +++ b/litellm/integrations/dotprompt/__init__.py @@ -62,12 +62,16 @@ def prompt_initializer(litellm_params: "PromptLiteLLMParams", prompt_spec: "Prom if dotprompt_content and not prompt_data and not prompt_file: prompt_data = _get_prompt_data_from_dotprompt_content(dotprompt_content) + from .prompt_manager import strip_version_suffix + + registration_prompt_id: Final = prompt_id or strip_version_suffix(prompt_spec.prompt_id) or prompt_spec.prompt_id + try: dot_prompt_manager: Final = DotpromptManager( prompt_directory=prompt_directory, prompt_data=prompt_data, prompt_file=prompt_file, - prompt_id=prompt_id, + prompt_id=registration_prompt_id, ) return dot_prompt_manager diff --git a/litellm/integrations/dotprompt/dotprompt_manager.py b/litellm/integrations/dotprompt/dotprompt_manager.py index e5e868f0523..0f45e926c30 100644 --- a/litellm/integrations/dotprompt/dotprompt_manager.py +++ b/litellm/integrations/dotprompt/dotprompt_manager.py @@ -96,7 +96,7 @@ class DotpromptManager(CustomPromptManagement): if prompt_id is None: return False try: - return prompt_id in self.prompt_manager.list_prompts() + return self.prompt_manager.get_prompt(prompt_id) is not None except Exception: # If there's any error accessing prompts, don't run prompt management return False diff --git a/litellm/integrations/dotprompt/prompt_manager.py b/litellm/integrations/dotprompt/prompt_manager.py index 46750ed9799..a0d5be71392 100644 --- a/litellm/integrations/dotprompt/prompt_manager.py +++ b/litellm/integrations/dotprompt/prompt_manager.py @@ -11,6 +11,13 @@ from jinja2 import DictLoader, select_autoescape from jinja2.sandbox import ImmutableSandboxedEnvironment +def strip_version_suffix(prompt_id: str) -> str | None: + base, separator, version = prompt_id.rpartition(".v") + if separator and base and version.isdigit(): + return base + return None + + class PromptTemplate: """Represents a single prompt template with metadata and content.""" @@ -124,11 +131,13 @@ class PromptManager: "content": "template content", "metadata": {"model": "gpt-4", "temperature": 0.7, ...} } + prompt_id - """ - if prompt_id: - prompt_data = {prompt_id: prompt_data} - for prompt_id, prompt_info in prompt_data.items(): + A dict carrying a "content" key is a single flat template registered under + prompt_id; anything else is treated as already keyed by template ID. + """ + keyed_prompts: Final = {prompt_id: prompt_data} if prompt_id and "content" in prompt_data else prompt_data + + for template_id, prompt_info in keyed_prompts.items(): try: content = prompt_info.get("content", "") metadata = prompt_info.get("metadata", {}) @@ -136,11 +145,11 @@ class PromptManager: template = PromptTemplate( content=content, metadata=metadata, - template_id=prompt_id, + template_id=template_id, ) - self.prompts[prompt_id] = template + self.prompts[template_id] = template except Exception: - # Optional: print(f"Error loading prompt from JSON: {prompt_id}") + # Optional: print(f"Error loading prompt from JSON: {template_id}") pass def _load_prompt_file(self, file_path: str | Path, prompt_id: str) -> PromptTemplate: @@ -272,8 +281,12 @@ class PromptManager: if versioned_id in self.prompts: return self.prompts[versioned_id] - # Fall back to base prompt_id - return self.prompts.get(prompt_id) + direct_match: Final = self.prompts.get(prompt_id) + if direct_match is not None: + return direct_match + + base_prompt_id: Final = strip_version_suffix(prompt_id) + return self.prompts.get(base_prompt_id) if base_prompt_id else None def list_prompts(self) -> list[str]: """Get a list of all available prompt IDs.""" diff --git a/litellm/integrations/langfuse/langfuse.py b/litellm/integrations/langfuse/langfuse.py index da924a81e0c..d1a9125ac71 100644 --- a/litellm/integrations/langfuse/langfuse.py +++ b/litellm/integrations/langfuse/langfuse.py @@ -1,5 +1,6 @@ #### What this does #### # On success, logs events to Langfuse +import inspect import os import traceback from collections.abc import Callable, Iterable, Mapping @@ -21,6 +22,9 @@ from litellm.litellm_core_utils.core_helpers import ( reconstruct_model_name, safe_deep_copy, ) +from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( + validate_langfuse_environment_value, +) from litellm.litellm_core_utils.redact_messages import redact_user_api_key_info from litellm.llms.custom_httpx.http_handler import _get_httpx_client from litellm.secret_managers.main import str_to_bool @@ -140,6 +144,7 @@ class LangFuseLogger: langfuse_public_key=None, langfuse_secret=None, langfuse_host=None, + langfuse_environment: str | None = None, flush_interval=1, allow_env_credentials: bool = True, ): @@ -159,6 +164,10 @@ class LangFuseLogger: if not (self.langfuse_host.startswith("http://") or self.langfuse_host.startswith("https://")): # add http:// if unset, assume communicating over private network - e.g. render self.langfuse_host = "http://" + self.langfuse_host + _env_override: Final = str(langfuse_environment).strip() if langfuse_environment is not None else None + self.langfuse_environment = _env_override or os.getenv("LANGFUSE_TRACING_ENVIRONMENT") + if self.langfuse_environment: + validate_langfuse_environment_value(self.langfuse_environment) self.langfuse_release = os.getenv("LANGFUSE_RELEASE") self.langfuse_debug = os.getenv("LANGFUSE_DEBUG") self.langfuse_flush_interval = LangFuseLogger._get_langfuse_flush_interval(flush_interval) @@ -182,6 +191,8 @@ class LangFuseLogger: } self.langfuse_sdk_version: str = langfuse.version.__version__ + if "environment" in inspect.signature(Langfuse.__init__).parameters: + parameters["environment"] = self.langfuse_environment if Version(self.langfuse_sdk_version) >= Version("2.6.0"): parameters["sdk_integration"] = "litellm" self.Langfuse: Langfuse = self.safe_init_langfuse_client(parameters) diff --git a/litellm/integrations/langfuse/langfuse_handler.py b/litellm/integrations/langfuse/langfuse_handler.py index f4dd80f91f5..8a407f71b3b 100644 --- a/litellm/integrations/langfuse/langfuse_handler.py +++ b/litellm/integrations/langfuse/langfuse_handler.py @@ -1,3 +1,5 @@ +import os + """ This file contains the LangFuseHandler class @@ -108,6 +110,7 @@ class LangFuseHandler: langfuse_public_key=credentials.get("langfuse_public_key"), langfuse_secret=credentials.get("langfuse_secret") or credentials.get("langfuse_secret_key"), langfuse_host=credentials.get("langfuse_host"), + langfuse_environment=credentials.get("langfuse_environment"), allow_env_credentials=credentials.get("langfuse_host") is None, ) in_memory_dynamic_logger_cache.set_cache( @@ -135,8 +138,29 @@ class LangFuseHandler: or standard_callback_dynamic_params.get("langfuse_secret_key"), langfuse_public_key=standard_callback_dynamic_params.get("langfuse_public_key"), langfuse_host=standard_callback_dynamic_params.get("langfuse_host"), + langfuse_environment=LangFuseHandler._meaningful_dynamic_environment(standard_callback_dynamic_params), ) + @staticmethod + def _meaningful_dynamic_environment( + standard_callback_dynamic_params: StandardCallbackDynamicParams, + ) -> str | None: + """Return the per-request environment only when it changes behavior. + + Empty/whitespace values and values equal to the deployment-wide + LANGFUSE_TRACING_ENVIRONMENT fallback are treated as absent so an + environment-only override that matches the default does not mint a + duplicate SDK client (each client costs threads and counts against + MAX_LANGFUSE_INITIALIZED_CLIENTS). + """ + raw = standard_callback_dynamic_params.get("langfuse_environment") + if raw is None: + return None + value = str(raw).strip() + if not value or value == os.getenv("LANGFUSE_TRACING_ENVIRONMENT"): + return None + return value + @staticmethod def _dynamic_langfuse_credentials_are_passed( standard_callback_dynamic_params: StandardCallbackDynamicParams, @@ -153,6 +177,7 @@ class LangFuseHandler: or standard_callback_dynamic_params.get("langfuse_public_key") is not None or standard_callback_dynamic_params.get("langfuse_secret") is not None or standard_callback_dynamic_params.get("langfuse_secret_key") is not None + or LangFuseHandler._meaningful_dynamic_environment(standard_callback_dynamic_params) is not None ): return True return False diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py index 3d044c3ea15..a96fac32c2a 100644 --- a/litellm/integrations/langfuse/langfuse_otel.py +++ b/litellm/integrations/langfuse/langfuse_otel.py @@ -231,7 +231,10 @@ class LangfuseOtelLogger(OpenTelemetry): from litellm.integrations.arize._utils import safe_set_attribute from litellm.litellm_core_utils.safe_json_dumps import safe_dumps - langfuse_environment: Final = os.environ.get("LANGFUSE_TRACING_ENVIRONMENT") + dynamic_params: Final = kwargs.get("standard_callback_dynamic_params") + langfuse_environment: Final = ( + dynamic_params.get("langfuse_environment") if dynamic_params else None + ) or os.environ.get("LANGFUSE_TRACING_ENVIRONMENT") if langfuse_environment: safe_set_attribute( span, diff --git a/litellm/litellm_core_utils/health_check_helpers.py b/litellm/litellm_core_utils/health_check_helpers.py index 43b2eca2fd5..c745bbea5c4 100644 --- a/litellm/litellm_core_utils/health_check_helpers.py +++ b/litellm/litellm_core_utils/health_check_helpers.py @@ -3,19 +3,25 @@ Helper functions for health check calls. """ import base64 -from collections.abc import Callable +from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Final, Literal from litellm.types.utils import LIST_BATCHES_SUPPORTED_PROVIDERS if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging + from litellm.types.utils import ImageResponse # Minimal PDF for health checks - base64 encoded 1-page PDF with just "test" TEST_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMSAwIFIKL01lZGlhQm94IFswIDAgNjEyIDc5Ml0KL0NvbnRlbnRzIDQgMCBSCi9SZXNvdXJjZXMgPDwvRm9udCA8PC9GMSAyIDAgUj4+Pj4+PgplbmRvYmoKNCAwIG9iago8PC9MZW5ndGggNDQ+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjEwMCA3MDAgVGQKKHRlc3QpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKMiAwIG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYT4+CmVuZG9iagoxIDAgb2JqCjw8L1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDE+PgplbmRvYmoKNSAwIG9iago8PC9UeXBlIC9DYXRhbG9nCi9QYWdlcyAxIDAgUj4+CmVuZG9iagp0cmFpbGVyCjw8L1NpemUgNgovUm9vdCA1IDAgUj4+CnN0YXJ0eHJlZgozMjQKJSVFT0Y=" -# Minimal image for health checks - base64 encoded 512x512 solid-gray PNG -TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAFlklEQVR42u3VMQEAAAzCMKQjHQ97l0jo0xSAlyIBgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAUgAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAADcDrctaAb6XeXAAAAAASUVORK5CYII=" +# Minimal image for health checks - base64 encoded 512x512 blue circle on a white background PNG +TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAJk0lEQVR42u3VQREAIRADwVWCOmTjBVzwSLorCri6nbkAVBpPACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAAAgAAAIAZdY+HgEBgIRr/meeGgGA8EMvDAgAuPh6gACAi68HCAA4+mKAAICjLwYIALj7SoAAgLuvBAgA7r4pAQKAu29KgADg7psSIAA4/SYDCADuvikBAoDTbzKAAOD0mwwgADj9JgMIAE6/yQACgNNvMoAA4PSbDCAAOP0mAwgATr/JAAKA668BIAA4/TKAAOD0mwwgALj+pgEIAE6/yQACgOtvGoAA4PSbDCAAuP6mAQgATr/JAAKA628agADg9JsMIAC4/qYBCACuv2kAAoDrbxqAAOD0mwwgALj+pgEIAK6/aQACgOtvGoAA4PqbBiAArr+ZBiAATr+ZDCAArr+ZBiAArr+ZBiAArr+ZBiAArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggAAmAmAAKA62+mAQKA62+mAQKA62/m1xYAXH/TAAQA1980AAHA9TcNQAAEwEwAEADX30wDEADX30wDEADX30wDEADX30wDEAABMBMABMD1N9MABMD1N9MABEAAzAQAAXD9zTQAAXD9zTRAABAAMwEQAFx/Mw0QAFx/Mw0QAATATAAEANffTAMEANffTAMEAAEwEwABwPU30wABQADMBEAAXH8z0wABcP3NTAMEQADMTAAEwPU3Mw0QAAEwMwEQANffTAMQAAEwEwAEwPU30wAEQADMBAABcP3NNAABEAAzAUAAXH8zDUAABMBMABAA199MAwQAATATAAHA9TfTAAFAAMwEQABw/c00QAAQADMBEAAEwEwABMD1NzMNEAABMDMBEADX38w0QAAEwMwEQAAEwMwEQABcfzPTAAEQADMTAAEQADMTAAFw/c1MAwRAAMxMAARAAMxMAATA9TczDRAAATATAARAAMwEAAFw/c00AAEQADMBQAAEwEwAEADX30wDBAABMBMAAUAAzARAABAAMwEQAFx/Mw0QAAEwMwEQAAEwMwEQAAEwMwEQANffzDRAAATAzARAAATAzARAAATAzARAAATAzARAAFx/M9MAARAAMxMAARAAMxMAARAAMxMAARAAMxMAAXD9zUwDBEAAzEwABEAAzAQAARAAMwFAAATATAAQAAEwEwABQADMBEAAEAAzARAAXH8zDRAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAA19/MNEAANMDM9UcABMBMABAAATATAAHwBAJgJgACgACYCYAAIABmAiAA+IvMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAANMDMXH8BEAAzEwABEAAzEwABEAAzEwABEAAzEwABEAAzEwABEAAzAUAABMBMABAADTBz/REAATATAAFAAMwEQAAQADMBEAAEwEwABAANMHP9BUAAzEwABEAAzEwABEAAzEwABEAAzEwABEADzMz1FwABMDMBEAABMDMBEAABMDMBEAANMDPXXwAEwMwEQAAEwMwEQAAEwMwEQAA0wMxcfwEQADMTAAEQADMBQAA0wMz1RwAEwEwAEAABMBMABEADzFx/AUAAzARAABAAMwEQADTAzPUXAATATAAEAAEwEwABQAPMXH8BEAAzEwABEAAzEwAB0AAzc/0FQADMTAAEQAPMzPUXAAEwMwEQAAEwMwEQAA0wM9dfAATAzARAADTAzFx/ARAAMwFAADTAzPVHAATATAAQAA0wc/0RAAEwEwAEQAPMXH8EQADMBAAB0AAz118AEAAzARAANMDM9RcABMBMAAQADTBz/QUAATATAAFAA8xcfwFAA8xcfwFAAMwEQADQADPXXwAEwMwEQAA0wMxcfwHQADNz/QVAAMxMAARAA8xcfwRAA8xcfwRAAMwEAAHQADPXHwHQADPXHwEQADMBQAA0wMz1RwA0wMz1RwAEwEwAEAANMHP9BQANMHP9BQANMHP9BQANMHP9BQABMBMAAUADzFx/AUADzFx/AUADzFx/AUADzFx/AUADzFx/AUADzPVHABAAEwAEAA0w1x8BQAPM9UcA0ABz/REANMBcfwQADTDXHwHQADPXHwHQADPXHwHQADPXHwHQADPXHwHQADPXHwGQATOnHwHQADPXHwHQADPXXwDQADPXXwDQADPXXwDQADPXXwCQAXP6EQA0wFx/BAANMNcfAUADzPVHAJABc/oRADTAXH8EABkwpx8BQAPM9UcAkAFz+hEANMBcfwQAGTCnHwFAA8z1RwCQAXP6EQBkwJx+BAANMNcfAUAGzOlHAJABc/oRAGTA6QcBQAacfhAAZMDpRwBABpx+BABkwOlHAEAGnH4EAJTA3UcAQAacfgQAlMDdRwBACdx9BACUwN1HAEAJ3H0EAJTA3UcAQAwcfQQAmmLgsyIA0NIDHw4BgJYe+DQIAISHwVMjAJDQDI+AAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACACAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAIAABNHpialFcmLajuAAAAAElFTkSuQmCC" + + +IMAGE_EDIT_HEALTH_CHECK_PROMPT: Final = ( + "Add a small yellow star in the top right corner of this simple drawing of a blue circle on a white background" +) def get_image_file_for_health_check() -> bytes: @@ -121,6 +127,17 @@ class HealthCheckHelpers: else: return await litellm.acompletion(**model_params) + @staticmethod + async def _image_edit_health_check(edit_request: Callable[[], Awaitable["ImageResponse"]]) -> "ImageResponse": + import litellm + + try: + return await edit_request() + except litellm.BadRequestError as e: + if isinstance(e, litellm.ContentPolicyViolationError) or "moderation_blocked" in str(e): + return litellm.ImageResponse() + raise + @staticmethod def get_mode_handlers( model: str, @@ -195,10 +212,12 @@ class HealthCheckHelpers: **_filter_model_params(model_params=model_params), prompt=prompt, ), - "image_edit": lambda: litellm.aimage_edit( - **_filter_model_params(model_params=model_params), - image=get_image_file_for_health_check(), - prompt=prompt or "test", + "image_edit": lambda: HealthCheckHelpers._image_edit_health_check( + edit_request=lambda: litellm.aimage_edit( + **_filter_model_params(model_params=model_params), + image=get_image_file_for_health_check(), + prompt=IMAGE_EDIT_HEALTH_CHECK_PROMPT, + ), ), "video_generation": lambda: litellm.avideo_generation( **_filter_model_params(model_params=model_params), diff --git a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py index 3b42ca4eaaf..65c5b0d9799 100644 --- a/litellm/litellm_core_utils/initialize_dynamic_callback_params.py +++ b/litellm/litellm_core_utils/initialize_dynamic_callback_params.py @@ -1,3 +1,4 @@ +import re from collections.abc import Iterator, Mapping from typing import Any, Final @@ -45,12 +46,29 @@ def validate_no_callback_env_reference(param: str, value: object, *, source: str _raise_env_reference_error(param, source=source) +# Langfuse rejects events whose environment does not match this pattern +# (lowercase alphanumerics, hyphens, underscores; no "langfuse" prefix). +# Validating here fails fast at config/init time instead of silently +# dropping every trace server-side. +LANGFUSE_ENVIRONMENT_PATTERN: Final = r"^(?!langfuse)[a-z0-9-_]+$" + + +def validate_langfuse_environment_value(value: str) -> None: + if not re.match(LANGFUSE_ENVIRONMENT_PATTERN, value): + raise ValueError( + f"Invalid langfuse_environment {value!r}: must be lowercase " + "alphanumerics/hyphens/underscores and must not start with " + f"'langfuse' (pattern {LANGFUSE_ENVIRONMENT_PATTERN})" + ) + + # Hardcoded list of supported callback params to avoid runtime inspection issues with TypedDict _supported_callback_params: Final[tuple[str, ...]] = ( "langfuse_public_key", "langfuse_secret", "langfuse_secret_key", "langfuse_host", + "langfuse_environment", "langfuse_prompt_version", "langsmith_api_key", "langsmith_project", diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 626af7530a4..fd2200c59cc 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1590,7 +1590,7 @@ class Logging(LiteLLMLoggingBaseClass): if transformed_result is not None: result = transformed_result - if isinstance(result, BaseModel) and hasattr(result, "_hidden_params"): + if isinstance(result, (BaseModel, HttpxBinaryResponseContent)) and hasattr(result, "_hidden_params"): hidden_params: Final = getattr(result, "_hidden_params", {}) if ( "response_cost" in hidden_params and hidden_params["response_cost"] is not None diff --git a/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py b/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py index 887f167c262..875e4e156c7 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py +++ b/litellm/litellm_core_utils/llm_cost_calc/tool_call_cost_tracking.py @@ -64,11 +64,17 @@ class StandardBuiltInToolCostTracking: """ standard_built_in_tools_params = standard_built_in_tools_params or {} + google_maps_grounding_cost: Final = StandardBuiltInToolCostTracking._handle_google_maps_grounding_cost( + model=model, + custom_llm_provider=custom_llm_provider, + usage=usage, + ) + # Handle web search if StandardBuiltInToolCostTracking.response_object_includes_web_search_call( response_object=response_object, usage=usage ): - return StandardBuiltInToolCostTracking._handle_web_search_cost( + return google_maps_grounding_cost + StandardBuiltInToolCostTracking._handle_web_search_cost( model=model, custom_llm_provider=custom_llm_provider, usage=usage, @@ -78,19 +84,56 @@ class StandardBuiltInToolCostTracking: # Handle file search if StandardBuiltInToolCostTracking.response_object_includes_file_search_call(response_object=response_object): - return StandardBuiltInToolCostTracking._handle_file_search_cost( + return google_maps_grounding_cost + StandardBuiltInToolCostTracking._handle_file_search_cost( model=model, custom_llm_provider=custom_llm_provider, standard_built_in_tools_params=standard_built_in_tools_params, ) # Handle Azure assistant features - return StandardBuiltInToolCostTracking._handle_azure_assistant_costs( + return google_maps_grounding_cost + StandardBuiltInToolCostTracking._handle_azure_assistant_costs( model=model, custom_llm_provider=custom_llm_provider, standard_built_in_tools_params=standard_built_in_tools_params, ) + @staticmethod + def _resolve_model_info(model: str, custom_llm_provider: str | None) -> tuple[ModelInfo | None, str | None]: + direct: Final = StandardBuiltInToolCostTracking._safe_get_model_info( + model=model, custom_llm_provider=custom_llm_provider + ) + if direct is not None: + return direct, custom_llm_provider or direct["litellm_provider"] + if "/" not in model: + return None, custom_llm_provider + by_prefix: Final = StandardBuiltInToolCostTracking._safe_get_model_info(model=model) + if by_prefix is None: + return None, custom_llm_provider + return by_prefix, by_prefix["litellm_provider"] + + @staticmethod + def _handle_google_maps_grounding_cost( + model: str, + custom_llm_provider: str | None, + usage: Usage | None, + ) -> float: + from litellm.llms import get_cost_for_google_maps_grounding_request + from litellm.llms.gemini.cost_calculator import google_maps_grounding_requests + + if usage is None or google_maps_grounding_requests(usage) is None: + return 0.0 + model_info, resolved_provider = StandardBuiltInToolCostTracking._resolve_model_info( + model=model, custom_llm_provider=custom_llm_provider + ) + if model_info is None or resolved_provider is None: + return 0.0 + return ( + get_cost_for_google_maps_grounding_request( + custom_llm_provider=resolved_provider, usage=usage, model_info=model_info + ) + or 0.0 + ) + @staticmethod def _handle_web_search_cost( model: str, @@ -102,29 +145,21 @@ class StandardBuiltInToolCostTracking: """Handle web search cost calculation.""" from litellm.llms import get_cost_for_web_search_request - model_info = StandardBuiltInToolCostTracking._safe_get_model_info( + # A provider-prefixed model (e.g. gemini/gemini-3.1-flash-lite) may not map under the + # request's custom_llm_provider. _resolve_model_info re-resolves from the prefix and adopts + # that provider so the cost is routed and priced with the model_info that was actually + # resolved, instead of feeding a re-resolved model into the original provider's calculator. + model_info, resolved_provider = StandardBuiltInToolCostTracking._resolve_model_info( model=model, custom_llm_provider=custom_llm_provider ) - # A provider-prefixed model (e.g. gemini/gemini-3.1-flash-lite) may not map under the - # request's custom_llm_provider. Re-resolve from the prefix and adopt that provider so the - # cost is routed and priced with the model_info that was actually resolved, instead of - # feeding a re-resolved model into the original provider's calculator. - if model_info is None and "/" in model: - model_info = StandardBuiltInToolCostTracking._safe_get_model_info(model=model) - if model_info is not None: - custom_llm_provider = model_info["litellm_provider"] - - if custom_llm_provider is None and model_info is not None: - custom_llm_provider = model_info["litellm_provider"] - resolved_usage: Final = StandardBuiltInToolCostTracking._usage_with_anthropic_web_search( usage=usage, response_object=response_object ) - if model_info is not None and resolved_usage is not None and custom_llm_provider is not None: + if model_info is not None and resolved_usage is not None and resolved_provider is not None: result: Final = get_cost_for_web_search_request( - custom_llm_provider=custom_llm_provider, + custom_llm_provider=resolved_provider, usage=resolved_usage, model_info=model_info, ) diff --git a/litellm/litellm_core_utils/logging_worker.py b/litellm/litellm_core_utils/logging_worker.py index 77792671f6f..1d74595781a 100644 --- a/litellm/litellm_core_utils/logging_worker.py +++ b/litellm/litellm_core_utils/logging_worker.py @@ -4,6 +4,7 @@ import asyncio import atexit import contextvars +import inspect import logging from collections.abc import Coroutine, Iterator from typing import Final @@ -53,6 +54,7 @@ class LoggingWorker: self._queue: asyncio.Queue[LoggingTask] | None = None self._worker_task: asyncio.Task | None = None self._running_tasks: set[asyncio.Task] = set() + self._dequeued_tasks: dict[int, LoggingTask] = {} # mutable-ok: refs so flush can rescue never-started tasks self._sem: asyncio.Semaphore | None = None self._bound_loop: asyncio.AbstractEventLoop | None = None self._last_aggressive_clear_time: float = 0.0 @@ -61,6 +63,38 @@ class LoggingWorker: # Register cleanup handler to flush remaining events on exit atexit.register(self._flush_on_exit) + def _track_dequeued(self, task: LoggingTask) -> None: + self._dequeued_tasks[id(task)] = task + + def _untrack_dequeued(self, task: LoggingTask) -> None: + self._dequeued_tasks.pop(id(task), None) + + def _unstarted_dequeued_tasks(self) -> tuple[LoggingTask, ...]: + return tuple( + task + for task in self._dequeued_tasks.values() + if inspect.getcoroutinestate(task["coroutine"]) == inspect.CORO_CREATED + ) + + def _requeue_unstarted_dequeued(self, new_queue: "asyncio.Queue[LoggingTask]") -> int: + revived: Final = self._unstarted_dequeued_tasks() + self._dequeued_tasks.clear() + for index, revived_task in enumerate(revived): + try: + new_queue.put_nowait(revived_task) + except asyncio.QueueFull: + for leftover in revived[index:]: + self._track_dequeued(leftover) + return index + return len(revived) + + def _run_coroutine_silently(self, loop: asyncio.AbstractEventLoop, coroutine: Coroutine) -> bool: + try: + loop.run_until_complete(asyncio.wait_for(coroutine, timeout=self.timeout)) + except (Exception, asyncio.CancelledError): # noqa: BLE001 # atexit flush must never break the user's program + return False + return True + @staticmethod def _drain_pending(queue: "asyncio.Queue[LoggingTask]") -> tuple[LoggingTask, ...]: """Pop every task still queued, without awaiting them, so they can be moved to another queue.""" @@ -90,10 +124,12 @@ class LoggingWorker: new_queue: Final[asyncio.Queue[LoggingTask]] = asyncio.Queue(maxsize=self.max_queue_size) for carried_task in carried_over: new_queue.put_nowait(carried_task) - if carried_over: + revived_count: Final = self._requeue_unstarted_dequeued(new_queue) + if carried_over or revived_count: verbose_logger.warning( - "LoggingWorker: event loop changed; carried %d pending logging task(s) onto the new loop", + "LoggingWorker: event loop changed; carried %d pending and revived %d dequeued logging task(s) onto the new loop", len(carried_over), + revived_count, ) else: verbose_logger.debug("LoggingWorker: Event loop changed, reinitializing queue and worker") @@ -129,6 +165,7 @@ class LoggingWorker: except Exception as e: verbose_logger.exception("LoggingWorker error: %s", e) finally: + self._untrack_dequeued(task) self._queue.task_done() finally: # Always release semaphore, even if queue is None @@ -146,6 +183,7 @@ class LoggingWorker: await self._sem.acquire() try: task = await self._queue.get() + self._track_dequeued(task) # Track each spawned coroutine so we can cancel on shutdown. processing_task = asyncio.create_task(self._process_log_task(task, self._sem)) self._running_tasks.add(processing_task) @@ -298,9 +336,10 @@ class LoggingWorker: extracted_tasks: Final = [] for _ in range(items_to_extract): try: - extracted_tasks.append(self._queue.get_nowait()) + extracted_tasks.append(extracted := self._queue.get_nowait()) except asyncio.QueueEmpty: break + self._track_dequeued(extracted) return extracted_tasks @@ -318,6 +357,7 @@ class LoggingWorker: # Add new task to extracted tasks to process directly if new_task is not None: + self._track_dequeued(new_task) extracted_tasks.append(new_task) # Process extracted tasks directly @@ -343,6 +383,7 @@ class LoggingWorker: # Suppress errors during processing to ensure we keep going pass finally: + self._untrack_dequeued(task) self._queue.task_done() async def _process_extracted_tasks(self, tasks: list[LoggingTask]) -> None: @@ -486,11 +527,12 @@ class LoggingWorker: self._safe_log("debug", "[LoggingWorker] atexit: No queue initialized") return - if self._queue.empty(): + unstarted_dequeued: Final = self._unstarted_dequeued_tasks() + if self._queue.empty() and not unstarted_dequeued: self._safe_log("debug", "[LoggingWorker] atexit: Queue is empty") return - queue_size: Final = self._queue.qsize() + queue_size: Final = self._queue.qsize() + len(unstarted_dequeued) self._safe_log("info", f"[LoggingWorker] atexit: Flushing {queue_size} remaining events...") # Create a new event loop since the original is closed @@ -509,6 +551,16 @@ class LoggingWorker: previous_raise_exceptions: Final = logging.raiseExceptions logging.raiseExceptions = False try: + for pending in unstarted_dequeued: + if ( + processed >= MAX_ITERATIONS_TO_CLEAR_QUEUE + or loop.time() - start_time >= MAX_TIME_TO_CLEAR_QUEUE + ): + break + if self._run_coroutine_silently(loop, pending["coroutine"]): + processed += 1 + self._untrack_dequeued(pending) + while not self._queue.empty() and processed < MAX_ITERATIONS_TO_CLEAR_QUEUE: if loop.time() - start_time >= MAX_TIME_TO_CLEAR_QUEUE: self._safe_log( @@ -526,11 +578,8 @@ class LoggingWorker: # Note: We run the coroutine directly, not via create_task, # since we're in a new event loop context try: - loop.run_until_complete(task["coroutine"]) - processed += 1 - except Exception: - # Silent failure to not break user's program - pass + if self._run_coroutine_silently(loop, task["coroutine"]): + processed += 1 finally: # Clear reference to prevent memory leaks task = None diff --git a/litellm/litellm_core_utils/ptu_pricing.py b/litellm/litellm_core_utils/ptu_pricing.py index 021210d9175..f545ba4aa3b 100644 --- a/litellm/litellm_core_utils/ptu_pricing.py +++ b/litellm/litellm_core_utils/ptu_pricing.py @@ -28,6 +28,7 @@ PTU_ZEROED_PRICING_FIELDS: Final = tuple(f for f in MirroredPricingParams.model_ "cache_creation_input_token_cost_above_1hr", "cache_creation_input_token_cost_above_200k_tokens", "cache_read_input_token_cost_above_200k_tokens", + "google_maps_grounding_cost_per_query", ) # tiered_pricing is emptied rather than zeroed: its tiers outrank the zeros written beside # them, so a zero here would leave the cost map's tiers billing the traffic the reserved diff --git a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py index ee0518c4aec..33f939b4b95 100644 --- a/litellm/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/litellm_core_utils/streaming_chunk_builder_utils.py @@ -173,6 +173,27 @@ def attach_cache_creation_token_details( return prompt_tokens_details.model_copy(update={"cache_creation_token_details": cache_creation_token_details}) +def apply_grounding_request_counts( + prompt_tokens_details: PromptTokensDetailsWrapper | None, + web_search_requests: int | None, + google_maps_grounding_requests: int | None, +) -> PromptTokensDetailsWrapper | None: + updates: Final = MappingProxyType( + { + field: value + for field, value in ( + ("web_search_requests", web_search_requests), + ("google_maps_grounding_requests", google_maps_grounding_requests), + ) + if value is not None + } + ) + if not updates: + return prompt_tokens_details + counted: Final = prompt_tokens_details if prompt_tokens_details is not None else PromptTokensDetailsWrapper() + return counted.model_copy(update=updates) + + class ChunkProcessor: def __init__(self, chunks: list, messages: list | None = None): self.chunks = self._sort_chunks(chunks) @@ -778,6 +799,7 @@ class ChunkProcessor: server_tool_use: ServerToolUse | None = None web_search_requests: int | None = None + google_maps_grounding_requests: int | None = None completion_tokens_details: CompletionTokensDetails | None = None prompt_tokens_details: PromptTokensDetailsWrapper | None = None # Anthropic emits the cache-creation TTL breakdown (5m/1h split) only on @@ -827,6 +849,13 @@ class ChunkProcessor: ) if chunk_web_search_requests is not None: web_search_requests = chunk_web_search_requests + chunk_google_maps_grounding_requests: int | None = getattr( + usage_chunk_dict["prompt_tokens_details"], + "google_maps_grounding_requests", + None, + ) + if chunk_google_maps_grounding_requests is not None: + google_maps_grounding_requests = chunk_google_maps_grounding_requests prompt_tokens_details = usage_chunk_dict["prompt_tokens_details"] or prompt_tokens_details @@ -852,6 +881,7 @@ class ChunkProcessor: cache_read_input_tokens=cache_read_input_tokens, server_tool_use=server_tool_use, web_search_requests=web_search_requests, + google_maps_grounding_requests=google_maps_grounding_requests, completion_tokens_details=completion_tokens_details, prompt_tokens_details=prompt_tokens_details, cost=cost, @@ -939,6 +969,7 @@ class ChunkProcessor: server_tool_use: Final[ServerToolUse | None] = calculated_usage_per_chunk["server_tool_use"] web_search_requests: Final[int | None] = calculated_usage_per_chunk["web_search_requests"] + google_maps_grounding_requests: Final[int | None] = calculated_usage_per_chunk["google_maps_grounding_requests"] completion_tokens_details: Final[CompletionTokensDetails | None] = calculated_usage_per_chunk[ "completion_tokens_details" ] @@ -998,13 +1029,11 @@ class ChunkProcessor: if server_tool_use is not None: returned_usage.server_tool_use = server_tool_use - if web_search_requests is not None: - if returned_usage.prompt_tokens_details is None: - returned_usage.prompt_tokens_details = PromptTokensDetailsWrapper( - web_search_requests=web_search_requests - ) - else: - returned_usage.prompt_tokens_details.web_search_requests = web_search_requests + returned_usage.prompt_tokens_details = apply_grounding_request_counts( + returned_usage.prompt_tokens_details, + web_search_requests, + google_maps_grounding_requests, + ) if cost is not None: setattr(returned_usage, "cost", cost) diff --git a/litellm/llms/__init__.py b/litellm/llms/__init__.py index c178ad12a0f..88a44f38c57 100644 --- a/litellm/llms/__init__.py +++ b/litellm/llms/__init__.py @@ -14,6 +14,21 @@ if TYPE_CHECKING: from litellm.types.utils import ModelInfo, Usage +def get_cost_for_google_maps_grounding_request( + custom_llm_provider: str, usage: "Usage", model_info: "ModelInfo" +) -> float | None: + """ + Get the cost of Grounding with Google Maps for a given model. Only Gemini models on the + Gemini API and Vertex AI can populate the Maps grounding counter, so every other provider + returns None. + """ + if custom_llm_provider != "gemini" and not custom_llm_provider.startswith("vertex_ai"): + return None + from .gemini.cost_calculator import cost_per_google_maps_grounding_request + + return cost_per_google_maps_grounding_request(usage=usage, model_info=model_info) + + def get_cost_for_web_search_request(custom_llm_provider: str, usage: "Usage", model_info: "ModelInfo") -> float | None: """ Get the cost for a web search request for a given model. diff --git a/litellm/llms/anthropic/chat/handler.py b/litellm/llms/anthropic/chat/handler.py index 5842cc2679d..cd47cdd57d6 100644 --- a/litellm/llms/anthropic/chat/handler.py +++ b/litellm/llms/anthropic/chat/handler.py @@ -712,11 +712,14 @@ class ModelResponseIterator: def _handle_usage(self, anthropic_usage_chunk: dict | UsageDelta) -> Usage: reasoning_content: Final = "".join(self.reasoning_content_chunks) if self.reasoning_content_chunks else None - return AnthropicConfig().calculate_usage( + usage: Final = AnthropicConfig().calculate_usage( usage_object=cast(dict, anthropic_usage_chunk), reasoning_content=reasoning_content, speed=self.speed, ) + if usage.speed is not None: + self.speed = usage.speed + return usage def _content_block_delta_helper( self, chunk: dict diff --git a/litellm/llms/anthropic/chat/transformation.py b/litellm/llms/anthropic/chat/transformation.py index 26380ad0af8..15fc482b34e 100644 --- a/litellm/llms/anthropic/chat/transformation.py +++ b/litellm/llms/anthropic/chat/transformation.py @@ -2279,6 +2279,8 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): str | None, _usage.get("service_tier"), ) + raw_speed: Final = _usage.get("speed") + resolved_speed: Final = raw_speed if isinstance(raw_speed, str) else speed iterations: Final[list[Any] | None] = _usage.get("iterations") if iterations: @@ -2353,7 +2355,7 @@ class AnthropicConfig(AnthropicModelInfo, BaseConfig): else None ), inference_geo=inference_geo, - speed=speed, + speed=resolved_speed, service_tier=service_tier, ) return usage diff --git a/litellm/llms/anthropic/cost_calculation.py b/litellm/llms/anthropic/cost_calculation.py index 7bb3e0294f0..f2f9d1c730d 100644 --- a/litellm/llms/anthropic/cost_calculation.py +++ b/litellm/llms/anthropic/cost_calculation.py @@ -8,12 +8,9 @@ from typing import TYPE_CHECKING, Final, Optional from pydantic import BaseModel, ValidationError from litellm.litellm_core_utils.llm_cost_calc.utils import ( - _get_token_base_cost, _get_web_search_requests, - calculate_cache_writing_cost, generic_cost_per_token, get_provider_specific_geo_multiplier, - parse_prompt_tokens_details, ) if TYPE_CHECKING: @@ -21,43 +18,6 @@ if TYPE_CHECKING: import litellm -def _compute_cache_only_cost(model_info: "ModelInfo", usage: "Usage", service_tier: str | None = None) -> float: - """ - Return only the cache-related portion of the prompt cost (cache read + cache write). - - These costs must NOT be scaled by the ``fast`` speed multiplier because the old - explicit ``fast/`` model entries carried unchanged cache rates while - multiplying only the regular input/output token costs. Regional pricing, by - contrast, uplifts every token type, so the geo multiplier does scale them. - """ - if usage.prompt_tokens_details is None: - return 0.0 - - prompt_tokens_details: Final = parse_prompt_tokens_details(usage) - ( - _, - _, - cache_creation_cost, - cache_creation_cost_above_1hr, - cache_read_cost, - ) = _get_token_base_cost(model_info=model_info, usage=usage, service_tier=service_tier) - - cache_cost = float(prompt_tokens_details["cache_hit_tokens"]) * cache_read_cost - - if ( - prompt_tokens_details["cache_creation_tokens"] - or prompt_tokens_details["cache_creation_token_details"] is not None - ): - cache_cost += calculate_cache_writing_cost( - cache_creation_tokens=prompt_tokens_details["cache_creation_tokens"], - cache_creation_token_details=prompt_tokens_details["cache_creation_token_details"], - cache_creation_cost_above_1hr=cache_creation_cost_above_1hr, - cache_creation_cost=cache_creation_cost, - ) - - return cache_cost - - def cost_per_token(model: str, usage: "Usage", service_tier: str | None = None) -> tuple[float, float]: """ Calculates the cost per token for a given model, prompt tokens, and completion tokens. @@ -89,8 +49,7 @@ def cost_per_token(model: str, usage: "Usage", service_tier: str | None = None) ) if speed_multiplier != 1.0: - cache_cost: Final = _compute_cache_only_cost(model_info=model_info, usage=usage, service_tier=service_tier) - prompt_cost = (prompt_cost - cache_cost) * speed_multiplier + cache_cost + prompt_cost *= speed_multiplier completion_cost *= speed_multiplier if geo_multiplier != 1.0: diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index a4d37810f82..435506831dc 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -65,6 +65,7 @@ from litellm.types.llms.openai import ( OpenAIMessageContentListBlock, ) from litellm.types.utils import ( + CacheCreationTokenDetails, ChatCompletionMessageToolCall, CompletionTokensDetailsWrapper, Function, @@ -1807,6 +1808,26 @@ class AmazonConverseConfig(BaseConfig): thinking_blocks_list.append(_redacted_block) return thinking_blocks_list + @staticmethod + def _parse_cache_details(usage: ConverseTokenUsageBlock) -> "CacheCreationTokenDetails | None": + """Split ``cacheDetails`` into 5m/1h buckets, or ``None`` unless the split fully + accounts for ``cacheWriteInputTokens``, since a partial or unrecognized-ttl + breakdown would understate the cache-write cost. + + https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CacheDetail.html + """ + cache_details: Final = usage.get("cacheDetails") + if not cache_details: + return None + tokens_5m: Final = sum(d["inputTokens"] for d in cache_details if d.get("ttl") == "5m") + tokens_1h: Final = sum(d["inputTokens"] for d in cache_details if d.get("ttl") == "1h") + if tokens_5m + tokens_1h != usage.get("cacheWriteInputTokens", 0): + return None + return CacheCreationTokenDetails( + ephemeral_5m_input_tokens=tokens_5m, + ephemeral_1h_input_tokens=tokens_1h, + ) + @staticmethod def thinking_tokens_from_additional_fields(additional_fields: object) -> int | None: """Converse omits thinking tokens from its usage block; they only arrive under @@ -1878,6 +1899,7 @@ class AmazonConverseConfig(BaseConfig): prompt_tokens_details: Final = PromptTokensDetailsWrapper( cached_tokens=cache_read_input_tokens, cache_creation_tokens=cache_creation_input_tokens, + cache_creation_token_details=self._parse_cache_details(usage), text_tokens=raw_input_tokens, ) estimated_reasoning_tokens: Final = ( diff --git a/litellm/llms/custom_httpx/aiohttp_handler.py b/litellm/llms/custom_httpx/aiohttp_handler.py index 9f579fd6f55..e9140e63cb3 100644 --- a/litellm/llms/custom_httpx/aiohttp_handler.py +++ b/litellm/llms/custom_httpx/aiohttp_handler.py @@ -1,3 +1,4 @@ +import ssl from collections.abc import Callable from typing import TYPE_CHECKING, Any, Final, cast @@ -18,6 +19,7 @@ from litellm.llms.custom_httpx.http_handler import ( AsyncHTTPHandler, HTTPHandler, _get_httpx_client, + get_ssl_configuration, ) from litellm.types.llms.openai import FileTypes from litellm.types.utils import HttpHandlerRequestFields, ImageResponse, LlmProviders @@ -56,7 +58,11 @@ class BaseLLMAIOHTTPHandler: # Create a transport using AsyncHTTPHandler's logic try: - self.transport = AsyncHTTPHandler._create_aiohttp_transport() + ssl_config: Final = get_ssl_configuration() + self.transport = AsyncHTTPHandler._create_aiohttp_transport( + ssl_verify=ssl_config if isinstance(ssl_config, bool) else None, + ssl_context=ssl_config if isinstance(ssl_config, ssl.SSLContext) else None, + ) self._owns_transport = True return self.transport except Exception: @@ -79,20 +85,19 @@ class BaseLLMAIOHTTPHandler: def _create_client_session_with_transport(self) -> ClientSession: """Create a new client session using transport or connector configuration.""" - connector: Final = self._get_connector() + if self.transport is None: + connector: Final = self._get_connector() + if connector: + return aiohttp.ClientSession(connector=connector) - if self.transport and hasattr(self.transport, "_get_valid_client_session"): - # Use transport's session creation if available - session = self.transport._get_valid_client_session() - return session - elif connector: - # Use provided connector - session = aiohttp.ClientSession(connector=connector) - return session - else: - # Default session creation - session = aiohttp.ClientSession() - return session + transport: Final = self.transport or self._get_or_create_transport() + if transport is not None and hasattr(transport, "_get_valid_client_session"): + try: + return transport._get_valid_client_session() + except RuntimeError: + pass + + return aiohttp.ClientSession() def _get_async_client_session(self, dynamic_client_session: ClientSession | None = None) -> ClientSession: if dynamic_client_session: diff --git a/litellm/llms/deepseek/chat/transformation.py b/litellm/llms/deepseek/chat/transformation.py index 566c960333a..ea19a7c7ddf 100644 --- a/litellm/llms/deepseek/chat/transformation.py +++ b/litellm/llms/deepseek/chat/transformation.py @@ -2,16 +2,17 @@ Translates from OpenAI's `/v1/chat/completions` to DeepSeek's `/v1/chat/completions` """ -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping, Sequence from typing import Any, Final, Literal, cast, overload import litellm from litellm.litellm_core_utils.prompt_templates.common_utils import ( - handle_messages_with_content_list_to_str_conversion, + convert_content_list_to_str, + extract_search_results_text, ) from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import AllMessageValues -from litellm.utils import supports_reasoning +from litellm.utils import supports_reasoning, supports_vision from ...openai.chat.gpt_transformation import OpenAIGPTConfig @@ -117,13 +118,98 @@ class DeepSeekChatConfig(OpenAIGPTConfig): self, messages: list[AllMessageValues], model: str, is_async: bool = False ) -> list[AllMessageValues] | Coroutine[Any, Any, list[AllMessageValues]]: """ - DeepSeek does not support content in list format. + DeepSeek vision models accept image_url content blocks in user + messages (https://api-docs.deepseek.com/guides/vision), so those + content lists are forwarded as-is, with any search_results text + appended as a trailing text block. Every other message keeps the + historical string collapse (which also folds search_results text + into string content); a list with no extractable text stays + unchanged, matching what DeepSeek historically received. """ - messages = handle_messages_with_content_list_to_str_conversion(messages) + forward_images: Final = any( + isinstance(message.get("content"), list) for message in messages + ) and supports_vision(model=model, custom_llm_provider="deepseek") + transformed: Final = [ # mutable-ok: provider messages must stay JSON-array lists the base transform mutates + self._forward_or_collapse_content(message=message, forward_images=forward_images) for message in messages + ] + if is_async: - return super()._transform_messages(messages=messages, model=model, is_async=True) + return super()._transform_messages(messages=transformed, model=model, is_async=True) else: - return super()._transform_messages(messages=messages, model=model, is_async=False) + return super()._transform_messages(messages=transformed, model=model, is_async=False) + + def _forward_or_collapse_content(self, message: AllMessageValues, forward_images: bool) -> AllMessageValues: + """ + Returns the vision-forwardable message with any search_results text + appended as a text block; every other message keeps the historical + string collapse, which extracts the text from a content list and + folds search_results text into string content. + """ + content: Final = message.get("content") + if ( + forward_images + and isinstance(content, list) + and self._is_vision_forwardable_content(message=message, content=content) + ): + return self._with_search_results_text_block(message=message, content=content) + collapsed: Final = convert_content_list_to_str(message=message) + if not collapsed or collapsed == content: + return message + collapsed_message: Final = {**message, "content": collapsed} # mutable-ok: wire messages are plain JSON dicts + return cast(AllMessageValues, collapsed_message) # cast-ok: TypedDict spread narrows to dict + + def _is_vision_forwardable_content(self, message: AllMessageValues, content: Sequence[object]) -> bool: + """ + True only for a user message whose content list holds well-formed + text and image_url blocks with at least one image; a block missing + its payload falls back to the string collapse instead of crashing + or reaching the wire malformed. The model capability gate lives in + the caller. + """ + if message.get("role") != "user": + return False + if not all(self._is_forwardable_block(block) for block in content): + return False + return any(isinstance(block, dict) and block.get("type") == "image_url" for block in content) + + @staticmethod + def _is_forwardable_block(block: object) -> bool: + """A dict block typed text or image_url that carries its payload.""" + if not isinstance(block, dict): + return False + block_type: Final = block.get("type") + if block_type == "image_url": + return DeepSeekChatConfig._is_image_url_payload(block.get("image_url")) + if block_type == "text": + return isinstance(block.get("text"), str) + return False + + @staticmethod + def _is_image_url_payload(payload: object) -> bool: + """A url string or an object carrying one, per the OpenAI image_url shape.""" + if isinstance(payload, str): + return bool(payload) + if not isinstance(payload, Mapping): + return False + url: Final = payload.get("url") + return isinstance(url, str) and bool(url) + + def _with_search_results_text_block(self, message: AllMessageValues, content: Sequence[object]) -> AllMessageValues: + """ + Appends the message's search_results text as a trailing text block, + keeping the context that the string collapse used to fold in, and + drops the non-OpenAI search_results key from the wire message. + """ + message_fields: Final = cast(Mapping[str, object], message) # cast-ok: search_results is not on the TypedDicts + search_text: Final = extract_search_results_text(message_fields.get("search_results")) + if not search_text: + return message + forwarded_content: Final = [*content, {"type": "text", "text": search_text}] # mutable-ok: JSON-array content + forwarded: Final = { # mutable-ok: wire messages are plain JSON dicts + **{key: value for key, value in message_fields.items() if key != "search_results"}, + "content": forwarded_content, + } + return cast(AllMessageValues, forwarded) # cast-ok: TypedDict spread narrows to dict def _thinking_mode_active(self, model: str, optional_params: dict) -> bool: """ diff --git a/litellm/llms/fireworks_ai/common_utils.py b/litellm/llms/fireworks_ai/common_utils.py index 8e35cfebc5b..8c306faa036 100644 --- a/litellm/llms/fireworks_ai/common_utils.py +++ b/litellm/llms/fireworks_ai/common_utils.py @@ -13,6 +13,13 @@ class FireworksAIException(BaseLLMException): def get_fireworks_session_id(litellm_params: dict) -> str | None: + """ + Session id to send as `x-session-affinity`, or None when the caller gave none. + + Deliberately does not fall back to `litellm_trace_id`: that is generated per + request (`str(uuid.uuid4())` when absent), so using it pins every request to a + different Fireworks node and prompt caching never hits. + """ params: Final = litellm_params for key in ("litellm_session_id", "session_id"): value = params.get(key) @@ -23,9 +30,6 @@ def get_fireworks_session_id(litellm_params: dict) -> str | None: value = metadata.get("session_id") if value: return str(value) - value = params.get("litellm_trace_id") - if value: - return str(value) return None diff --git a/litellm/llms/gemini/cost_calculator.py b/litellm/llms/gemini/cost_calculator.py index a041ef40622..94326f0e657 100644 --- a/litellm/llms/gemini/cost_calculator.py +++ b/litellm/llms/gemini/cost_calculator.py @@ -61,3 +61,42 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa number_of_web_search_requests = 1 return _cost * number_of_web_search_requests + + +GOOGLE_MAPS_GROUNDING_DEFAULT_COST_PER_QUERY: Final = 14e-3 +GOOGLE_MAPS_GROUNDING_DEFAULT_COST_PER_PROMPT: Final = 25e-3 + + +def google_maps_grounding_requests(usage: "Usage | None") -> int | None: + from litellm.types.utils import PromptTokensDetailsWrapper + + details: Final = usage.prompt_tokens_details if usage is not None else None + if not isinstance(details, PromptTokensDetailsWrapper) or not hasattr(details, "google_maps_grounding_requests"): + return None + return details.google_maps_grounding_requests + + +def cost_per_google_maps_grounding_request(usage: "Usage", model_info: "ModelInfo") -> float: + """ + Calculates the cost of Grounding with Google Maps. + + Billing follows ``web_search_billing_unit`` in model_info the same way Google Search grounding + does: ``"per_query"`` (Gemini 3.x) multiplies the executed Maps queries, ``"per_prompt"`` + (default, Gemini 2.x) charges one flat fee per grounded prompt. + + The rate comes from ``google_maps_grounding_cost_per_query`` in ``model_info``, falling back + to Google's list price for that billing unit when the pricing JSON has no entry yet. + """ + requests: Final = google_maps_grounding_requests(usage) + if not requests or requests <= 0: + return 0.0 + billing_mode: Final = model_info.get("web_search_billing_unit") or "per_prompt" + default_cost: Final = ( + GOOGLE_MAPS_GROUNDING_DEFAULT_COST_PER_QUERY + if billing_mode == "per_query" + else GOOGLE_MAPS_GROUNDING_DEFAULT_COST_PER_PROMPT + ) + configured_cost: Final = model_info.get("google_maps_grounding_cost_per_query") + cost: Final = default_cost if configured_cost is None else configured_cost + billed_requests: Final = requests if billing_mode == "per_query" else 1 + return cost * billed_requests diff --git a/litellm/llms/gemini/realtime/transformation.py b/litellm/llms/gemini/realtime/transformation.py index ea576750cf3..51801e91356 100644 --- a/litellm/llms/gemini/realtime/transformation.py +++ b/litellm/llms/gemini/realtime/transformation.py @@ -4,6 +4,7 @@ This file contains the transformation logic for the Gemini realtime API. import json from collections import OrderedDict +from collections.abc import Mapping from typing import Any, Final, cast import litellm @@ -72,6 +73,28 @@ MAP_GEMINI_FIELD_TO_OPENAI_EVENT: Final[dict[str, OpenAIRealtimeEventTypes | Res _KNOWN_GEMINI_TOP_LEVEL_KEYS: Final[set] = {map_key.split(".", 1)[0] for map_key in MAP_GEMINI_FIELD_TO_OPENAI_EVENT} +OPENAI_STOCK_REALTIME_VOICES: Final[frozenset[str]] = frozenset( + {"alloy", "ash", "ballad", "cedar", "coral", "echo", "marin", "sage", "shimmer", "verse"} +) + + +def _gemini_live_speech_config(voice: object) -> Mapping[str, object] | None: + """Build the Gemini Live speechConfig for a client-requested voice. + + OpenAI stock voice names have no Gemini equivalent and Gemini Live closes + the session on an unknown voice, so they are dropped with a warning and + the model keeps its default voice. Every other name is forwarded verbatim. + """ + if isinstance(voice, str) and voice.lower() in OPENAI_STOCK_REALTIME_VOICES: + verbose_logger.warning( + "Gemini Realtime: voice %s is an OpenAI voice with no Gemini equivalent; " + "dropping it so the session keeps the model's default voice.", + voice, + ) + return None + return VertexGeminiConfig()._map_audio_params({"voice": voice}) + + class GeminiRealtimeConfig(BaseRealtimeConfig): _TOOL_CALL_ID_TO_NAME_MAX = 256 # LRU cap for call_id→name mapping @@ -282,12 +305,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): automaticActivityDetection=transformed_audio_activity_config ) elif key == "voice": - from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( - VertexGeminiConfig, - ) - - vertex_gemini_config = VertexGeminiConfig() - speech_config = vertex_gemini_config._map_audio_params({"voice": value}) + speech_config = _gemini_live_speech_config(value) if speech_config: optional_params["generationConfig"]["speechConfig"] = speech_config if len(optional_params["generationConfig"]) == 0: @@ -365,10 +383,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): entry: Final = GeminiRealtimeConfig._model_cost_entry(model) return bool(entry.get("gemini_native_audio") or entry.get("gemini_audio_only_live")) - @staticmethod - def _is_native_audio_model(model: str) -> bool: - return bool(GeminiRealtimeConfig._model_cost_entry(model).get("gemini_native_audio")) - @staticmethod def _coerce_response_modalities(model: str, modalities: list[Any]) -> list[str]: """Map unsupported TEXT responseModalities to AUDIO for audio-only Live models.""" @@ -384,7 +398,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): @staticmethod def _finalize_gemini_live_setup(model: str, setup: dict[str, Any]) -> dict[str, Any]: - """Drop fields Gemini Live native-audio rejects on ``setup``.""" generation_config: Final = setup.get("generationConfig") if isinstance(generation_config, dict): modalities: Final = generation_config.get("responseModalities") @@ -392,8 +405,6 @@ class GeminiRealtimeConfig(BaseRealtimeConfig): generation_config["responseModalities"] = GeminiRealtimeConfig._coerce_response_modalities( model, modalities ) - if GeminiRealtimeConfig._is_native_audio_model(model): - generation_config.pop("speechConfig", None) return setup def _handle_session_update( diff --git a/litellm/llms/minimax/messages/transformation.py b/litellm/llms/minimax/messages/transformation.py index 095b6c0c4b6..d4c24c65cfa 100644 --- a/litellm/llms/minimax/messages/transformation.py +++ b/litellm/llms/minimax/messages/transformation.py @@ -2,7 +2,7 @@ MiniMax Anthropic transformation config - extends AnthropicConfig for MiniMax's Anthropic-compatible API """ -from typing import Final +from typing import Any, Final # noqa: TID251 # override below must mirror the legacy base signature import litellm from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( @@ -49,6 +49,26 @@ class MinimaxMessagesConfig(AnthropicMessagesConfig): """ return api_base or get_secret_str("MINIMAX_API_BASE") or "https://api.minimax.io/anthropic/v1/messages" + def validate_anthropic_messages_environment( + self, + headers: dict, # mutable-ok: mirrors the legacy base override signature + model: str, + messages: list[Any], # mutable-ok: mirrors the legacy base override signature + optional_params: dict, # mutable-ok: mirrors the legacy base override signature + litellm_params: dict, # mutable-ok: mirrors the legacy base override signature + api_key: str | None = None, + api_base: str | None = None, + ) -> tuple[dict, str | None]: # mutable-ok: mirrors the legacy base override signature + return super().validate_anthropic_messages_environment( + headers=headers, + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + api_key=self.get_api_key(api_key=api_key), + api_base=api_base, + ) + def get_complete_url( self, api_base: str | None, diff --git a/litellm/llms/vertex_ai/cost_calculator.py b/litellm/llms/vertex_ai/cost_calculator.py index 23cb1e5b580..8b00fc2e925 100644 --- a/litellm/llms/vertex_ai/cost_calculator.py +++ b/litellm/llms/vertex_ai/cost_calculator.py @@ -64,6 +64,7 @@ def cost_per_character( usage: Usage, prompt_characters: float | None = None, completion_characters: float | None = None, + service_tier: str | None = None, vertex_location: str | None = None, ) -> tuple[float, float]: """ @@ -74,6 +75,8 @@ def cost_per_character( - custom_llm_provider: str, "vertex_ai-*" - prompt_characters: float, the number of input characters - completion_characters: float, the number of output characters + - service_tier: optional tier derived from Gemini trafficType + ("priority" for ON_DEMAND_PRIORITY, "flex" for FLEX/batch). - vertex_location: the Vertex AI location serving the request; non-global locations apply the model's regional-endpoint uplift multiplier @@ -92,6 +95,7 @@ def cost_per_character( model=model, custom_llm_provider=custom_llm_provider, usage=usage, + service_tier=service_tier, ) else: try: @@ -123,6 +127,7 @@ def cost_per_character( model=model, custom_llm_provider=custom_llm_provider, usage=usage, + service_tier=service_tier, ) ## CALCULATE OUTPUT COST @@ -131,6 +136,7 @@ def cost_per_character( model=model, custom_llm_provider=custom_llm_provider, usage=usage, + service_tier=service_tier, ) else: completion_tokens: Final = usage.completion_tokens @@ -162,6 +168,7 @@ def cost_per_character( model=model, custom_llm_provider=custom_llm_provider, usage=usage, + service_tier=service_tier, ) vertex_uplift: Final = get_vertex_regional_endpoint_uplift(model_info, vertex_location) diff --git a/litellm/llms/vertex_ai/gemini/grounding_requests.py b/litellm/llms/vertex_ai/gemini/grounding_requests.py new file mode 100644 index 00000000000..40acd9378df --- /dev/null +++ b/litellm/llms/vertex_ai/gemini/grounding_requests.py @@ -0,0 +1,56 @@ +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import Final + + +@dataclass(frozen=True, slots=True) +class GroundingRequests: + web_search_requests: int | None + google_maps_grounding_requests: int | None + + def has_billable_grounding(self) -> bool: + return bool(self.web_search_requests or self.google_maps_grounding_requests) + + +def _chunk_kinds(item: Mapping[str, object]) -> frozenset[str]: + chunks: Final = item.get("groundingChunks") + if not isinstance(chunks, list): + return frozenset() + return frozenset(kind for chunk in chunks if isinstance(chunk, Mapping) for kind in chunk) + + +def _queries(item: Mapping[str, object]) -> frozenset[str]: + queries: Final = item.get("webSearchQueries") + if not isinstance(queries, list): + return frozenset() + return frozenset(query for query in queries if isinstance(query, str) and query) + + +def _is_maps_item(item: Mapping[str, object]) -> bool: + return "maps" in _chunk_kinds(item) or bool(item.get("googleMapsWidgetContextToken")) + + +def _attributes_queries_to_maps(item: Mapping[str, object]) -> bool: + return _is_maps_item(item) and "web" not in _chunk_kinds(item) + + +def calculate_grounding_requests(grounding_metadata: Sequence[Mapping[str, object]]) -> GroundingRequests: + """Billable grounding requests across candidates, counting each distinct query once. + + Duplicate queries within and across grounding metadata items collapse to the + distinct-query count (#36377), and empty strings are ignored. Maps grounding is + floored at one request whenever a candidate carries maps chunks or a widget token, + since per-prompt billing charges the prompt even when no query is reported. + """ + items: Final = tuple(item for item in grounding_metadata if isinstance(item, Mapping)) + web_queries: Final = frozenset( + query for item in items if not _attributes_queries_to_maps(item) for query in _queries(item) + ) + maps_queries: Final = frozenset( + query for item in items if _attributes_queries_to_maps(item) for query in _queries(item) + ) + has_maps: Final = any(_is_maps_item(item) for item in items) + return GroundingRequests( + web_search_requests=len(web_queries) or None, + google_maps_grounding_requests=max(len(maps_queries), 1) if has_maps else None, + ) diff --git a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py index d12ba24eda4..d8b1e7ba17c 100644 --- a/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py +++ b/litellm/llms/vertex_ai/gemini/vertex_and_google_ai_studio_gemini.py @@ -89,6 +89,7 @@ from ..common_utils import ( supports_response_json_schema, ) from ..vertex_llm_base import VertexBase +from .grounding_requests import calculate_grounding_requests from .transformation import ( _gemini_convert_messages_with_history, async_transform_request_body, @@ -1717,14 +1718,15 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): completion_response: GenerateContentResponseBody | BidiGenerateContentServerMessage, ) -> bool: """ - Whether the response used Grounding with Google Search, detected via - groundingMetadata.webSearchQueries (an actual web search was performed). + Whether the response used Grounding with Google Search or Grounding with Google Maps, + detected via groundingMetadata.webSearchQueries (an actual web search was performed) or + groundingMetadata.groundingChunks[].maps (a Maps lookup was performed). - Google bills grounding-with-Google-Search retrieved tokens separately (a per-request / - per-query search fee) and excludes them from input token billing, unlike URL context / - File Search / code execution whose tool-use tokens are charged at the input token rate. - URL context also emits groundingMetadata (with groundingChunks but no webSearchQueries), - so presence of groundingMetadata alone is not a sufficient signal. + Google bills both groundings separately (a per-request / per-query fee) and excludes their + retrieved tokens from input token billing, unlike URL context / File Search / code execution + whose tool-use tokens are charged at the input token rate. URL context also emits + groundingMetadata (with web groundingChunks but no webSearchQueries), so presence of + groundingMetadata alone is not a sufficient signal. See https://ai.google.dev/gemini-api/docs/pricing and https://github.com/BerriAI/litellm/discussions/33198 """ @@ -1732,7 +1734,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): return False for candidate in completion_response["candidates"] or []: grounding_metadata, _, _, _ = VertexGeminiConfig._extract_candidate_metadata(candidate) - if VertexGeminiConfig._calculate_web_search_requests(grounding_metadata): + if calculate_grounding_requests(grounding_metadata).has_billable_grounding(): return True return False @@ -1979,16 +1981,16 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): @staticmethod def _calculate_web_search_requests(grounding_metadata: list[dict]) -> int | None: - web_search_requests: int | None = None + return calculate_grounding_requests(grounding_metadata).web_search_requests - if grounding_metadata and isinstance(grounding_metadata, list) and len(grounding_metadata) > 0: - for grounding_metadata_item in grounding_metadata: - web_search_queries = grounding_metadata_item.get("webSearchQueries") - if web_search_queries and web_search_requests: - web_search_requests += len([q for q in web_search_queries if q]) - elif web_search_queries: - web_search_requests = len([q for q in web_search_queries if q]) - return web_search_requests + @staticmethod + def _set_grounding_usage_counters(usage: Usage, grounding_metadata: Sequence[Mapping[str, object]]) -> None: + grounding_requests: Final = calculate_grounding_requests(grounding_metadata) + details: Final = cast(PromptTokensDetailsWrapper, usage.prompt_tokens_details) + if grounding_requests.web_search_requests is not None: + details.web_search_requests = grounding_requests.web_search_requests + if grounding_requests.google_maps_grounding_requests is not None: + details.google_maps_grounding_requests = grounding_requests.google_maps_grounding_requests @staticmethod def _create_streaming_choice( @@ -2454,9 +2456,7 @@ class VertexGeminiConfig(VertexAIBaseConfig, BaseConfig): usage: Final = VertexGeminiConfig._calculate_usage(completion_response=completion_response) - web_search_requests: Final = VertexGeminiConfig._calculate_web_search_requests(grounding_metadata) - if web_search_requests is not None: - cast(PromptTokensDetailsWrapper, usage.prompt_tokens_details).web_search_requests = web_search_requests + VertexGeminiConfig._set_grounding_usage_counters(usage, grounding_metadata) setattr(model_response, "usage", usage) @@ -3221,9 +3221,7 @@ class ModelResponseIterator: completion_response=processed_chunk, ) - web_search_requests: Final = VertexGeminiConfig._calculate_web_search_requests(grounding_metadata) - if web_search_requests is not None: - cast(PromptTokensDetailsWrapper, usage.prompt_tokens_details).web_search_requests = web_search_requests + VertexGeminiConfig._set_grounding_usage_counters(usage, grounding_metadata) traffic_type: Final = processed_chunk.get("usageMetadata", {}).get("trafficType") if traffic_type: diff --git a/litellm/main.py b/litellm/main.py index 98f92e50599..8ee102f5d07 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8013,7 +8013,7 @@ def speech( if max_retries is None: max_retries = litellm.num_retries or openai.DEFAULT_MAX_RETRIES - litellm_params_dict: Final = get_litellm_params(**kwargs) + litellm_params_dict: Final = get_litellm_params(metadata=metadata, api_key=api_key or dynamic_api_key, **kwargs) # Get provider-specific text-to-speech config and map parameters text_to_speech_provider_config = ProviderConfigManager.get_provider_text_to_speech_config( diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index c05aefee15e..ad19818a5ce 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -1428,7 +1428,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 512 }, "global.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.25e-05, @@ -1465,7 +1465,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 512 }, "us.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -1502,7 +1502,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 512 }, "eu.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -1539,7 +1539,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 512 }, "anthropic.claude-opus-5": { "bedrock_converse_supports_strict_tools": false, @@ -2933,7 +2933,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 4096 }, "azure_ai/claude-opus-4-5": { "deprecation_date": "2026-10-19", @@ -2956,7 +2957,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 4096 }, "azure_ai/claude-opus-4-6": { "deprecation_date": "2027-02-02", @@ -2987,7 +2989,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 4096 }, "azure_ai/claude-opus-4-7": { "deprecation_date": "2027-04-06", @@ -3018,7 +3021,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 2048 }, "azure_ai/claude-fable-5": { "supports_mid_conversation_system": true, @@ -3050,7 +3054,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 }, "azure_ai/claude-opus-5": { "supports_mid_conversation_system": true, @@ -3113,7 +3118,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 1024 }, "azure_ai/claude-opus-4-1": { "deprecation_date": "2026-08-05", @@ -3135,7 +3141,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "azure_ai/claude-sonnet-4-5": { "deprecation_date": "2026-10-19", @@ -3157,7 +3164,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "azure_ai/claude-sonnet-5": { "supports_mid_conversation_system": true, @@ -3188,7 +3196,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 1024 }, "azure_ai/claude-sonnet-4-6": { "deprecation_date": "2027-02-10", @@ -3214,7 +3223,8 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 1024 }, "azure/computer-use-preview": { "input_cost_per_token": 3e-06, @@ -6592,6 +6602,10 @@ "supports_web_search": true }, "azure/gpt-5.6": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_creation_input_token_cost_priority": 1.25e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.5e-05, "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_priority": 1e-06, @@ -6642,6 +6656,10 @@ "supports_minimal_reasoning_effort": false }, "azure/gpt-5.6-sol": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_creation_input_token_cost_priority": 1.25e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.5e-05, "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_priority": 1e-06, @@ -6693,6 +6711,10 @@ "supports_minimal_reasoning_effort": false }, "azure/gpt-5.6-terra": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_creation_input_token_cost_priority": 5e-06, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-05, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_272k_tokens": 4e-07, "cache_read_input_token_cost_priority": 4e-07, @@ -6744,6 +6766,10 @@ "supports_minimal_reasoning_effort": false }, "azure/gpt-5.6-luna": { + "cache_creation_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5e-07, + "cache_creation_input_token_cost_priority": 5e-07, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-06, "cache_read_input_token_cost": 2e-08, "cache_read_input_token_cost_above_272k_tokens": 4e-08, "cache_read_input_token_cost_priority": 4e-08, @@ -6795,12 +6821,18 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6": { + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, + "cache_creation_input_token_cost_priority": 1.375e-05, "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.375e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, + "cache_read_input_token_cost_priority": 1.1e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_priority": 1.375e-05, + "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, + "input_cost_per_token_priority": 1.1e-05, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -6808,7 +6840,8 @@ "mode": "chat", "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_priority": 8.25e-05, + "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, + "output_cost_per_token_priority": 6.6e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -6842,13 +6875,19 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6-sol": { + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, + "cache_creation_input_token_cost_priority": 1.375e-05, "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.375e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, + "cache_read_input_token_cost_priority": 1.1e-06, "deprecation_date": "2028-01-11", "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_priority": 1.375e-05, + "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, + "input_cost_per_token_priority": 1.1e-05, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -6856,7 +6895,8 @@ "mode": "chat", "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_priority": 8.25e-05, + "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, + "output_cost_per_token_priority": 6.6e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -6890,13 +6930,19 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6-terra": { + "cache_creation_input_token_cost": 2.75e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5.5e-06, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1.1e-05, + "cache_creation_input_token_cost_priority": 5.5e-06, "cache_read_input_token_cost": 2.2e-07, "cache_read_input_token_cost_above_272k_tokens": 4.4e-07, - "cache_read_input_token_cost_priority": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 8.8e-07, + "cache_read_input_token_cost_priority": 4.4e-07, "deprecation_date": "2028-01-11", "input_cost_per_token": 2.2e-06, "input_cost_per_token_above_272k_tokens": 4.4e-06, - "input_cost_per_token_priority": 5.5e-06, + "input_cost_per_token_above_272k_tokens_priority": 8.8e-06, + "input_cost_per_token_priority": 4.4e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -6904,7 +6950,8 @@ "mode": "chat", "output_cost_per_token": 1.32e-05, "output_cost_per_token_above_272k_tokens": 1.98e-05, - "output_cost_per_token_priority": 3.3e-05, + "output_cost_per_token_above_272k_tokens_priority": 3.96e-05, + "output_cost_per_token_priority": 2.64e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -6938,13 +6985,19 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6-luna": { + "cache_creation_input_token_cost": 2.75e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5.5e-07, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1.1e-06, + "cache_creation_input_token_cost_priority": 5.5e-07, "cache_read_input_token_cost": 2.2e-08, "cache_read_input_token_cost_above_272k_tokens": 4.4e-08, - "cache_read_input_token_cost_priority": 5.5e-08, + "cache_read_input_token_cost_above_272k_tokens_priority": 8.8e-08, + "cache_read_input_token_cost_priority": 4.4e-08, "deprecation_date": "2028-01-11", "input_cost_per_token": 2.2e-07, "input_cost_per_token_above_272k_tokens": 4.4e-07, - "input_cost_per_token_priority": 5.5e-07, + "input_cost_per_token_above_272k_tokens_priority": 8.8e-07, + "input_cost_per_token_priority": 4.4e-07, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -6952,7 +7005,8 @@ "mode": "chat", "output_cost_per_token": 1.32e-06, "output_cost_per_token_above_272k_tokens": 1.98e-06, - "output_cost_per_token_priority": 3.3e-06, + "output_cost_per_token_above_272k_tokens_priority": 3.96e-06, + "output_cost_per_token_priority": 2.64e-06, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -6986,12 +7040,18 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6": { + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, + "cache_creation_input_token_cost_priority": 1.375e-05, "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.375e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, + "cache_read_input_token_cost_priority": 1.1e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_priority": 1.375e-05, + "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, + "input_cost_per_token_priority": 1.1e-05, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -6999,7 +7059,8 @@ "mode": "chat", "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_priority": 8.25e-05, + "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, + "output_cost_per_token_priority": 6.6e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -7033,13 +7094,19 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6-sol": { + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, + "cache_creation_input_token_cost_priority": 1.375e-05, "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.375e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, + "cache_read_input_token_cost_priority": 1.1e-06, "deprecation_date": "2028-01-11", "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_priority": 1.375e-05, + "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, + "input_cost_per_token_priority": 1.1e-05, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -7047,7 +7114,8 @@ "mode": "chat", "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_priority": 8.25e-05, + "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, + "output_cost_per_token_priority": 6.6e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -7081,13 +7149,19 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6-terra": { + "cache_creation_input_token_cost": 2.75e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5.5e-06, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1.1e-05, + "cache_creation_input_token_cost_priority": 5.5e-06, "cache_read_input_token_cost": 2.2e-07, "cache_read_input_token_cost_above_272k_tokens": 4.4e-07, - "cache_read_input_token_cost_priority": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 8.8e-07, + "cache_read_input_token_cost_priority": 4.4e-07, "deprecation_date": "2028-01-11", "input_cost_per_token": 2.2e-06, "input_cost_per_token_above_272k_tokens": 4.4e-06, - "input_cost_per_token_priority": 5.5e-06, + "input_cost_per_token_above_272k_tokens_priority": 8.8e-06, + "input_cost_per_token_priority": 4.4e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -7095,7 +7169,8 @@ "mode": "chat", "output_cost_per_token": 1.32e-05, "output_cost_per_token_above_272k_tokens": 1.98e-05, - "output_cost_per_token_priority": 3.3e-05, + "output_cost_per_token_above_272k_tokens_priority": 3.96e-05, + "output_cost_per_token_priority": 2.64e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -7129,13 +7204,19 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6-luna": { + "cache_creation_input_token_cost": 2.75e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5.5e-07, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1.1e-06, + "cache_creation_input_token_cost_priority": 5.5e-07, "cache_read_input_token_cost": 2.2e-08, "cache_read_input_token_cost_above_272k_tokens": 4.4e-08, - "cache_read_input_token_cost_priority": 5.5e-08, + "cache_read_input_token_cost_above_272k_tokens_priority": 8.8e-08, + "cache_read_input_token_cost_priority": 4.4e-08, "deprecation_date": "2028-01-11", "input_cost_per_token": 2.2e-07, "input_cost_per_token_above_272k_tokens": 4.4e-07, - "input_cost_per_token_priority": 5.5e-07, + "input_cost_per_token_above_272k_tokens_priority": 8.8e-07, + "input_cost_per_token_priority": 4.4e-07, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -7143,7 +7224,8 @@ "mode": "chat", "output_cost_per_token": 1.32e-06, "output_cost_per_token_above_272k_tokens": 1.98e-06, - "output_cost_per_token_priority": 3.3e-06, + "output_cost_per_token_above_272k_tokens_priority": 3.96e-06, + "output_cost_per_token_priority": 2.64e-06, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -12727,8 +12809,7 @@ "supports_tool_choice": true, "supports_vision": true, "provider_specific_entry": { - "us": 1.1, - "fast": 6.0 + "us": 1.1 }, "supports_output_config": true, "supports_max_reasoning_effort": true, @@ -12765,8 +12846,7 @@ "supports_tool_choice": true, "supports_vision": true, "provider_specific_entry": { - "us": 1.1, - "fast": 6.0 + "us": 1.1 }, "supports_max_reasoning_effort": true, "supports_output_config": true, @@ -12805,8 +12885,7 @@ "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true, "provider_specific_entry": { - "us": 1.1, - "fast": 6.0 + "us": 1.1 }, "supports_output_config": true, "supports_speed": true, @@ -12844,8 +12923,7 @@ "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true, "provider_specific_entry": { - "us": 1.1, - "fast": 6.0 + "us": 1.1 }, "supports_output_config": true, "supports_speed": true, @@ -14656,7 +14734,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 4096 }, "databricks/databricks-claude-opus-4": { "cache_creation_input_token_cost": 1.874999e-05, @@ -14678,7 +14757,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 1024 }, "databricks/databricks-claude-opus-4-1": { "cache_creation_input_token_cost": 1.874999e-05, @@ -14700,7 +14780,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 1024 }, "databricks/databricks-claude-opus-4-5": { "cache_creation_input_token_cost": 6.25002e-06, @@ -14723,7 +14804,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 4096 }, "databricks/databricks-claude-opus-4-6": { "cache_creation_input_token_cost": 6.25002e-06, @@ -14746,7 +14828,8 @@ "supports_legacy_thinking": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 4096 }, "databricks/databricks-claude-opus-4-7": { "cache_creation_input_token_cost": 6.25002e-06, @@ -14848,7 +14931,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 1024 }, "databricks/databricks-claude-sonnet-4-1": { "cache_creation_input_token_cost": 3.74997e-06, @@ -14892,7 +14976,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 1024 }, "databricks/databricks-claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.74997e-06, @@ -14915,7 +15000,8 @@ "supports_legacy_thinking": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 1024 }, "databricks/databricks-claude-sonnet-5": { "cache_creation_input_token_cost": 3.74997e-06, @@ -19710,6 +19796,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini-2.5-flash-image": { @@ -19999,7 +20086,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3.1-flash-lite": { "deprecation_date": "2027-05-07", @@ -20056,7 +20144,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", @@ -20112,7 +20201,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -20192,6 +20282,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini-2.5-flash-lite-preview-09-2025": { @@ -20237,6 +20328,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini-2.5-flash-preview-09-2025": { @@ -20282,6 +20374,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini-live-2.5-flash-preview-native-audio-09-2025": { @@ -20418,6 +20511,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini-2.5-pro": { @@ -20463,7 +20557,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini-3-pro-preview": { "deprecation_date": "2026-03-26", @@ -20577,7 +20672,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3.1-pro-preview-customtools": { "prompt_cache_min_tokens": 4096, @@ -20629,7 +20725,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -20732,7 +20829,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, @@ -20787,6 +20885,7 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_token_batches": 7.5e-07, "output_cost_per_token_batches": 4.5e-06, "input_cost_per_token_flex": 7.5e-07, @@ -20847,7 +20946,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, @@ -20903,7 +21003,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.1-pro-preview": { "prompt_cache_min_tokens": 4096, @@ -20961,7 +21062,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.1-pro-preview-customtools": { "prompt_cache_min_tokens": 4096, @@ -21019,7 +21121,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -21558,6 +21661,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini/gemini-2.5-flash-image": { @@ -21905,6 +22009,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { @@ -21953,6 +22058,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini/gemini-2.5-flash-preview-09-2025": { @@ -22001,6 +22107,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini/gemini-flash-latest": { @@ -22047,7 +22154,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini/gemini-flash-lite-latest": { "cache_read_input_token_cost": 2.5e-08, @@ -22093,7 +22201,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini/gemini-2.5-flash-lite-preview-06-17": { "deprecation_date": "2025-11-18", @@ -22141,6 +22250,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini/gemini-2.5-flash-preview-tts": { @@ -22202,7 +22312,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini/gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, @@ -22339,7 +22450,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.1-flash-lite": { "cache_read_input_token_cost": 2.5e-08, @@ -22398,7 +22510,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.5-flash-lite": { "cache_read_input_token_cost": 3e-08, @@ -22455,7 +22568,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -22507,7 +22621,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, @@ -22563,6 +22678,7 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_token_batches": 7.5e-07, "output_cost_per_token_batches": 4.5e-06, "input_cost_per_token_flex": 7.5e-07, @@ -22625,7 +22741,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, @@ -22683,7 +22800,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-omni-flash-preview": { "input_cost_per_audio_token": 1.5e-06, @@ -22774,7 +22892,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.1-pro-preview-customtools": { "prompt_cache_min_tokens": 4096, @@ -22832,7 +22951,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -22882,7 +23002,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-omni-flash-preview": { "input_cost_per_audio_token": 1.5e-06, @@ -22968,6 +23089,7 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_token_batches": 7.5e-07, "output_cost_per_token_batches": 4.5e-06, "input_cost_per_token_flex": 7.5e-07, @@ -23028,7 +23150,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, @@ -23084,7 +23207,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -33793,7 +33917,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "openrouter/anthropic/claude-opus-4.1": { "input_cost_per_image": 0.0048, @@ -33813,7 +33938,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "openrouter/anthropic/claude-sonnet-4": { "input_cost_per_image": 0.0048, @@ -33836,7 +33962,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "openrouter/anthropic/claude-sonnet-4.6": { "supports_adaptive_thinking": true, @@ -33862,7 +33989,8 @@ "supports_reasoning": true, "supports_max_reasoning_effort": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, @@ -33881,7 +34009,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 4096 }, "openrouter/anthropic/claude-opus-4.6": { "supports_adaptive_thinking": true, @@ -33902,7 +34031,8 @@ "supports_reasoning": true, "supports_max_reasoning_effort": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 4096 }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -33925,7 +34055,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "openrouter/anthropic/claude-haiku-4.5": { "cache_creation_input_token_cost": 1.25e-06, @@ -33943,7 +34074,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 4096 }, "openrouter/anthropic/claude-opus-4.7": { "supports_adaptive_thinking": true, @@ -33966,7 +34098,8 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "prompt_cache_min_tokens": 2048 }, "openrouter/anthropic/claude-opus-5": { "prompt_cache_min_tokens": 512, @@ -36433,7 +36566,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_response_schema": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "prompt_cache_min_tokens": 4096 }, "replicate/ibm-granite/granite-3.3-8b-instruct": { "input_cost_per_token": 3e-08, @@ -36515,7 +36649,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_response_schema": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "prompt_cache_min_tokens": 1024 }, "replicate/deepseek-ai/deepseek-v3": { "input_cost_per_token": 1.45e-06, @@ -36590,7 +36725,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_response_schema": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "prompt_cache_min_tokens": 1024 }, "replicate/openai/gpt-4.1": { "input_cost_per_token": 2e-06, @@ -39619,7 +39755,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 4096 }, "vercel_ai_gateway/anthropic/claude-opus-4": { "cache_creation_input_token_cost": 1.875e-05, @@ -39638,7 +39775,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "vercel_ai_gateway/anthropic/claude-opus-4.1": { "cache_creation_input_token_cost": 1.875e-05, @@ -39657,7 +39795,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "vercel_ai_gateway/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, @@ -39677,7 +39816,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 4096 }, "vercel_ai_gateway/anthropic/claude-opus-4.6": { "supports_adaptive_thinking": true, @@ -39699,7 +39839,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 4096 }, "vercel_ai_gateway/anthropic/claude-sonnet-4": { "cache_creation_input_token_cost": 3.75e-06, @@ -39718,7 +39859,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "vercel_ai_gateway/anthropic/claude-sonnet-4.5": { "cache_creation_input_token_cost": 3.75e-06, @@ -39736,7 +39878,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "vercel_ai_gateway/cohere/command-a": { "input_cost_per_token": 2.5e-06, @@ -41101,7 +41244,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 }, "vertex_ai/claude-fable-5@default": { "deprecation_date": "2027-06-08", @@ -41135,7 +41279,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 }, "vertex_ai/claude-opus-5": { "deprecation_date": "2027-01-24", @@ -41817,7 +41962,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.1-flash-lite": { "deprecation_date": "2027-05-07", @@ -41875,7 +42021,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", @@ -41932,7 +42079,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -49028,7 +49176,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini-flash-lite-latest": { "cache_read_input_token_cost": 1e-08, @@ -49074,7 +49223,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini-pro-latest": { "cache_read_input_token_cost": 1.25e-07, @@ -49119,7 +49269,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini/gemini-pro-latest": { "cache_read_input_token_cost": 1.25e-07, @@ -49164,7 +49315,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini-exp-1206": { "cache_read_input_token_cost": 3e-08, @@ -50039,7 +50191,8 @@ "supports_vision": true, "supports_prompt_caching": true, "supports_system_messages": true, - "supports_response_schema": true + "supports_response_schema": true, + "prompt_cache_min_tokens": 1024 }, "snowflake/claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -50056,7 +50209,8 @@ "supports_vision": true, "supports_prompt_caching": true, "supports_system_messages": true, - "supports_response_schema": true + "supports_response_schema": true, + "prompt_cache_min_tokens": 1024 }, "snowflake/claude-4-sonnet": { "max_tokens": 16384, @@ -50071,7 +50225,8 @@ "supports_vision": true, "supports_prompt_caching": true, "supports_system_messages": true, - "supports_response_schema": true + "supports_response_schema": true, + "prompt_cache_min_tokens": 1024 }, "snowflake/claude-4-opus": { "max_tokens": 16384, @@ -50087,7 +50242,8 @@ "supports_prompt_caching": true, "supports_system_messages": true, "supports_reasoning": true, - "supports_response_schema": true + "supports_response_schema": true, + "prompt_cache_min_tokens": 1024 }, "snowflake/claude-haiku-4-5": { "max_tokens": 16384, @@ -50102,7 +50258,8 @@ "supports_vision": true, "supports_prompt_caching": true, "supports_system_messages": true, - "supports_response_schema": true + "supports_response_schema": true, + "prompt_cache_min_tokens": 4096 }, "snowflake/claude-3-7-sonnet": { "max_tokens": 16384, @@ -50413,6 +50570,32 @@ "supports_tool_choice": true, "supports_vision": false }, + "deepseek-v4-flash-vision-exp": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 1.4e-08, + "input_cost_per_token": 4.4e-07, + "input_cost_per_token_cache_hit": 1.4e-08, + "litellm_provider": "deepseek", + "max_input_tokens": 1000000, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 1.32e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, "deepseek-v4-pro": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 4.4e-08, @@ -50465,6 +50648,32 @@ "supports_tool_choice": true, "supports_vision": false }, + "deepseek/deepseek-v4-flash-vision-exp": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 1.4e-08, + "input_cost_per_token": 4.4e-07, + "input_cost_per_token_cache_hit": 1.4e-08, + "litellm_provider": "deepseek", + "max_input_tokens": 1000000, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 1.32e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, "deepseek/deepseek-v4-pro": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 4.4e-08, diff --git a/litellm/proxy/_lazy_features.py b/litellm/proxy/_lazy_features.py index fdd15a89aa5..c435234cbbc 100644 --- a/litellm/proxy/_lazy_features.py +++ b/litellm/proxy/_lazy_features.py @@ -8,8 +8,8 @@ omits each feature's routes until the feature is warmed. import asyncio import importlib -import sys from collections.abc import Callable +from collections.abc import Set as AbstractSet from dataclasses import dataclass, field from typing import TYPE_CHECKING, Final @@ -397,11 +397,27 @@ def _make_warmup_router(app: "FastAPI") -> "APIRouter": return router -def inject_lazy_stubs(schema: dict) -> dict: - """Inject openapi entries for unloaded features. Uses the snapshot file - when available (full route info), otherwise falls back to a single - placeholder per feature. Any failure logs and returns the schema unchanged - so /openapi.json never 500s on a cosmetic injection bug.""" +def loaded_lazy_modules(app: "FastAPI") -> frozenset[str]: + """The set of lazy feature modules whose routers are actually registered + on this app (tracked by _force_load), empty before the middleware ever ran. + sys.modules is the wrong signal: boot code imports several feature modules + (mcp_management, cloudzero, vantage, config_overrides) without mounting + their routers, and their stubs must still be injected.""" + loaded: Final = getattr(app.state, "lazy_loaded", None) + if not isinstance(loaded, set): + return frozenset() + return frozenset(m for m in loaded if isinstance(m, str)) + + +def inject_lazy_stubs( + schema: dict, + loaded_modules: AbstractSet[str], + features: tuple[LazyFeature, ...] = LAZY_FEATURES, +) -> dict: + """Inject openapi entries for features not in loaded_modules. Uses the + snapshot file when available (full route info), otherwise falls back to a + single placeholder per feature. Any failure logs and returns the schema + unchanged so /openapi.json never 500s on a cosmetic injection bug.""" try: from litellm.proxy._lazy_openapi_snapshot import load_snapshot @@ -409,8 +425,8 @@ def inject_lazy_stubs(schema: dict) -> dict: paths: Final = schema.setdefault("paths", {}) schemas: Final = schema.setdefault("components", {}).setdefault("schemas", {}) - for feat in LAZY_FEATURES: - if feat.module_path in sys.modules and not feat.persistent_swagger_stub: + for feat in features: + if feat.module_path in loaded_modules and not feat.persistent_swagger_stub: continue fragment = (snapshot or {}).get(feat.name) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 026a02d6b1d..0e3cbbfd560 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -23538,7 +23538,7 @@ "paths": { "/prompts": { "post": { - "description": "Create a new prompt\n\n\ud83d\udc49 [Prompt docs](https://docs.litellm.ai/docs/proxy/prompt_management)\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/prompts\" \\\n -H \"Authorization: Bearer \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"prompt_id\": \"my_prompt\",\n \"litellm_params\": {\n \"prompt_id\": \"json_prompt\",\n \"prompt_integration\": \"dotprompt\",\n ### EITHER prompt_directory OR prompt_data MUST BE PROVIDED\n \"prompt_directory\": \"/path/to/dotprompt/folder\",\n \"prompt_data\": {\"json_prompt\": {\"content\": \"This is a prompt\", \"metadata\": {\"model\": \"gpt-4\"}}}\n },\n \"prompt_info\": {\n \"prompt_type\": \"config\"\n }\n }'\n```", + "description": "Create a new prompt\n\n\ud83d\udc49 [Prompt docs](https://docs.litellm.ai/docs/proxy/prompt_management)\n\nExample Request:\n```bash\ncurl -X POST \"http://localhost:4000/prompts\" \\\n -H \"Authorization: Bearer \" \\\n -H \"Content-Type: application/json\" \\\n -d '{\n \"prompt_id\": \"my_prompt\",\n \"litellm_params\": {\n \"prompt_id\": \"my_prompt\",\n \"prompt_integration\": \"dotprompt\",\n \"prompt_data\": {\"content\": \"This is a prompt\", \"metadata\": {\"model\": \"gpt-4\"}}\n },\n \"prompt_info\": {\n \"prompt_type\": \"config\"\n }\n }'\n```", "operationId": "create_prompt_prompts_post", "requestBody": { "content": { diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 61acac3ff74..727aea6f539 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -20,6 +20,7 @@ from typing_extensions import NotRequired, ReadOnly, Required, TypedDict from litellm._uuid import uuid from litellm.constants import DEFAULT_STAGGER_WINDOW_SECONDS, MCP_STDIO_ALLOWED_COMMANDS from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( + validate_langfuse_environment_value, validate_no_callback_env_reference, ) from litellm.types.integrations.compression_interception import ( @@ -2027,6 +2028,8 @@ class AddTeamCallback(LiteLLMPydanticObjectBase): raise ValueError(f"Invalid callback variable: {key}. Must be one of {valid_keys}") callback_vars[key] = str(value) validate_no_callback_env_reference(key, callback_vars[key], source="key/team callback metadata") + if key == "langfuse_environment": + validate_langfuse_environment_value(callback_vars[key]) return values diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 366950c00a1..66bbda1ca4e 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2588,13 +2588,29 @@ async def _delete_cache_key_object( user_api_key_cache: UserApiKeyCache, proxy_logging_obj: ProxyLogging | None, ): + """ + Evict one key object, best-effort, matching `delete_cache_team_object` and + `delete_cache_key_objects`. + + Every caller runs this after its own write has already committed, and the in-memory entry is + dropped before the Redis round trip. Letting a cache-backend error raise here therefore reports + failure for work that succeeded without making the cache any less stale; the leftover Redis + entry expires at its TTL either way. + """ key: Final = hashed_token - user_api_key_cache.delete_cache(key=key) + try: + user_api_key_cache.delete_cache(key=key) - ## UPDATE REDIS CACHE ## - if proxy_logging_obj is not None: - await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache(key=key) + ## UPDATE REDIS CACHE ## + if proxy_logging_obj is not None: + await proxy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache(key=key) + except Exception as e: # noqa: BLE001 # best-effort: a cache error must not fail a committed write + verbose_proxy_logger.warning( + "Failed to invalidate cached key entry %s; a stale key object may be served until its TTL expires: %s", + key, + e, + ) async def delete_cache_key_objects( diff --git a/litellm/proxy/common_utils/callback_config_validation.py b/litellm/proxy/common_utils/callback_config_validation.py index 680cc226d18..7ee3bd8d829 100644 --- a/litellm/proxy/common_utils/callback_config_validation.py +++ b/litellm/proxy/common_utils/callback_config_validation.py @@ -14,11 +14,36 @@ _NEWRELIC_VAR_PREFIX: Final = "newrelic_" def callback_config_error(callback_name: str | None, callback_vars: Mapping[str, str] | None) -> str | None: - if callback_name != _NEWRELIC_CALLBACK or not callback_vars: + if not callback_vars: + return None + env_error: Final = _langfuse_environment_error(callback_vars) + if env_error is not None: + return env_error + if callback_name != _NEWRELIC_CALLBACK: return None return _newrelic_config_error(callback_vars) +def _langfuse_environment_error(callback_vars: Mapping[str, str]) -> str | None: + """Reject langfuse_environment values Langfuse ingestion would drop. + + Accepting an invalid value here would 200 the config write and then + silently lose every trace for that key/team at request time. + """ + value: Final = callback_vars.get("langfuse_environment") + if value is None: + return None + from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( + validate_langfuse_environment_value, + ) + + try: + validate_langfuse_environment_value(value) + except ValueError as e: + return str(e) + return None + + def logging_metadata_config_error(metadata: Mapping[str, object] | None) -> str | None: """Validate every ``logging`` entry of a team/key metadata payload.""" if not metadata: diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index 4e12974189f..9b60595838d 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -6,11 +6,15 @@ import random import sys import threading import time -from collections.abc import Mapping -from typing import Final +from collections.abc import Mapping, Sequence +from types import MappingProxyType +from typing import TYPE_CHECKING, Final import litellm +if TYPE_CHECKING: + from litellm.router import Router + logger: Final = logging.getLogger(__name__) from litellm.constants import ( BACKGROUND_HEALTH_CHECK_MAX_TOKENS, @@ -18,16 +22,29 @@ from litellm.constants import ( DEFAULT_HEALTH_CHECK_PROMPT, HEALTH_CHECK_TIMEOUT_SECONDS, ) -from litellm.router_utils.auto_router_model_naming import classify_strategy_router_model +from litellm.router_utils.auto_router_model_naming import ( + StrategyRouterDependency, + classify_strategy_router_model, + strategy_router_dependencies, +) ILLEGAL_DISPLAY_PARAMS: Final = [ "messages", "api_key", "prompt", "input", + "client_secret", + "azure_ad_token", + "azure_username", + "azure_password", "vertex_credentials", + "vertex_ai_credentials", "aws_access_key_id", "aws_secret_access_key", + "aws_session_token", + "aws_web_identity_token", + "extra_headers", + "headers", "exception", # internal; not JSON-serializable, never for display "litellm_metadata", # internal tracking metadata with auth objects; not for display ] @@ -151,7 +168,7 @@ def health_check_filter_kwargs_from_general_settings( def filter_deployments_by_id( - model_list: list, + model_list: Sequence[Mapping[str, object]], ) -> list: seen_ids: Final = set() filtered_deployments: Final = [] @@ -183,12 +200,240 @@ async def run_with_timeout(task, timeout): return {"error": "Timeout exceeded", "exception": timeout_exception} +def _skips_health_checks(deployment: Mapping[str, object]) -> bool: + info: Final = deployment.get("model_info") + return bool(info.get("disable_background_health_check", False)) if isinstance(info, Mapping) else False + + +def _health_check_eligible( + model_list: Sequence[Mapping[str, object]], skip_disabled: bool +) -> tuple[Mapping[str, object], ...]: + """Deployments this run is allowed to contact. + + The one eligibility gate, applied to the requested set and to the pool a router's + dependencies are drawn from alike, so an opted-out deployment cannot re-enter through a + router that depends on it. + """ + return tuple(x for x in model_list if not (skip_disabled and _skips_health_checks(x))) + + +def _deployment_model(deployment: Mapping[str, object]) -> str | None: + params: Final = deployment.get("litellm_params") + return params.get("model") if isinstance(params, Mapping) else None + + +def _narrow_to_target( + model_list: Sequence[Mapping[str, object]], model: str | None, model_id: str | None +) -> tuple[Mapping[str, object], ...]: + """Narrow to the requested deployment. An id matching nothing keeps the whole list.""" + if model_id is not None: + by_id: Final = tuple(x for x in model_list if _deployment_id(x) == model_id) + return by_id or tuple(model_list) + if model is None: + return tuple(model_list) + by_param: Final = tuple(x for x in model_list if _deployment_model(x) == model) + return by_param or tuple(x for x in model_list if x.get("model_name") == model) + + def _is_strategy_router_deployment(litellm_params: Mapping[str, object]) -> bool: """True for strategy-router deployments.""" model: Final[object] = litellm_params.get("model", "") return isinstance(model, str) and classify_strategy_router_model(model) is not None +def _is_marker(deployment: Mapping[str, object]) -> bool: + params: Final = deployment.get("litellm_params") + return isinstance(params, Mapping) and _is_strategy_router_deployment(params) + + +def _deployment_id(deployment: Mapping[str, object]) -> str | None: + info: Final = deployment.get("model_info") + ident: Final = info.get("id") if isinstance(info, Mapping) else None + return str(ident) if ident else None + + +def _resolved_deployment_ids(router: "Router", model_name: str) -> frozenset[str] | None: + """Deployment ids backing `model_name`, or None when the name resolves to nothing. + + `get_model_list` composes every channel the request path itself uses (exact name, + model_group_alias, routing groups, wildcards); a mirror of any one channel would call a + working tier broken. An alias whose target is gone resolves to nothing, which fails a + request exactly like an unknown name. + """ + resolved: Final = router.get_model_list(model_name=model_name) + if not resolved: + return None + return frozenset(ident for entry in resolved if (ident := _deployment_id(entry))) + + +def _dependency_failure( + dependency: StrategyRouterDependency, + router: "Router", + unhealthy_ids: frozenset[str], +) -> str | None: + """Why this dependency makes its router unable to serve, or None when it does not. + + A name reds its router only when *every* deployment behind it is known unhealthy. One + replica this run never judged, hidden from the caller or opted out of health checks, can + still serve what the dead one drops, so partial evidence leaves the verdict green. + """ + resolved: Final = _resolved_deployment_ids(router, dependency.model_name) + if resolved is None: + return f"{dependency.role} model '{dependency.model_name}' matches no deployment on this proxy" + if not resolved or not resolved <= unhealthy_ids: + return None + return f"{dependency.role} model '{dependency.model_name}' has no healthy deployment" + + +def _strategy_router_dependency_error( + deployment: Mapping[str, object], + router: "Router", + unhealthy_ids: frozenset[str], +) -> str | None: + """The first dependency fault that makes this router unable to serve, if any.""" + params: Final = deployment.get("litellm_params") + if not isinstance(params, Mapping): + return None + return next( + ( + failure + for dependency in strategy_router_dependencies(params) + if (failure := _dependency_failure(dependency, router, unhealthy_ids)) + ), + None, + ) + + +def _deployments_by_id( + universe: Sequence[Mapping[str, object]], ids: frozenset[str] +) -> tuple[Mapping[str, object], ...]: + """The deployments for `ids`, one row per id. + + Reuses the requested set's own dedupe rule, so an alias that duplicates a row cannot get + it probed twice or split a single id's verdict across two disagreeing results. + """ + matched: Final = tuple(d for d in universe if (uid := _deployment_id(d)) and uid in ids) + return tuple(filter_deployments_by_id(model_list=matched)) + + +def _dependency_deployments_to_probe( + checked: Sequence[Mapping[str, object]], + universe: Sequence[Mapping[str, object]], + router: "Router", +) -> tuple[Mapping[str, object], ...]: + """Deployments backing the checked routers' dependencies that are not already checked. + + Empty on a full-list run, which therefore gains no probe; it is the targeted + `/health?model_id=` call the dashboard makes per deployment that needs them, + since a router's verdict is a statement about models the request never named. Drawn from + `universe`, the caller's access-filtered list, so no deployment is probed that the caller + was not already granted. Expansion follows routers through routers, one hop per round, + because a child router's own models must be probed for the parent to fail; stopping when + a round adds nothing is what makes a router cycle terminate. + """ + checked_ids: Final = frozenset(cid for d in checked if (cid := _deployment_id(d))) + reached = checked_ids # rebind-ok: the sweep's cursor, one hop wider per round + frontier = tuple(checked) # rebind-ok: the routers whose dependencies the next round expands + for _ in range(len(universe)): + names = frozenset( + dependency.model_name + for deployment in frontier + if isinstance(params := deployment.get("litellm_params"), Mapping) + for dependency in strategy_router_dependencies(params) + ) + fresh_ids = ( + frozenset(ident for name in names for ident in (_resolved_deployment_ids(router, name) or ())) - reached + ) + if not fresh_ids: + break + frontier = _deployments_by_id(universe, fresh_ids) + reached = reached | fresh_ids + return _deployments_by_id(universe, reached - checked_ids) + + +def _strategy_router_verdicts( + healthy_endpoints: Sequence[Mapping[str, object]], + unhealthy_endpoints: Sequence[Mapping[str, object]], + checked: Sequence[Mapping[str, object]], + router: "Router", +) -> Mapping[str, str]: + """The dependency fault, per model id, for every strategy router that cannot serve. + + A marker is filed healthy by `_run_model_health_check` returning `{}`, which says only + that nothing was probed. This is where that placeholder becomes a verdict, derived from + this run's own results rather than a re-probe or a cache that is empty unless + `enable_health_check_routing` is on. A marker never fails a probe of its own, so verdicts + settle over rounds, each feeding the last round's reds back in as unhealthy; without that + the parent of a red child would stay green. Bounded by the marker count, which is what + makes a router cycle terminate green rather than spin. + """ + by_id: Final = MappingProxyType({i: d for d in checked if (i := _deployment_id(d))}) + markers: Final = MappingProxyType( + { + marker_id: by_id[marker_id] + for endpoint in healthy_endpoints + if isinstance(marker_id := endpoint.get("model_id"), str) and marker_id in by_id + if _is_marker(by_id[marker_id]) + } + ) + probe_failures: Final = frozenset( + ident for endpoint in unhealthy_endpoints if isinstance(ident := endpoint.get("model_id"), str) + ) + settled: Mapping[str, str] = MappingProxyType({}) # rebind-ok: the fixed point, a round's verdicts at a time + for _ in range(len(markers)): + fresh = MappingProxyType( + { + marker_id: error + for marker_id, deployment in markers.items() + if marker_id not in settled + if (error := _strategy_router_dependency_error(deployment, router, probe_failures | frozenset(settled))) + } + ) + if not fresh: + break + settled = MappingProxyType({**settled, **fresh}) + return settled + + +def _finalize_strategy_router_endpoints( + healthy_endpoints: Sequence[Mapping[str, object]], + unhealthy_endpoints: Sequence[Mapping[str, object]], + checked: Sequence[Mapping[str, object]], + router: "Router | None", + dependency_probes: Sequence[Mapping[str, object]], +) -> tuple[Sequence[Mapping[str, object]], Sequence[Mapping[str, object]]]: + """Apply router verdicts, then drop the deployments probed only to reach them. + + The probes exist to judge the routers that depend on them; reporting them would answer a + targeted request with deployments the caller never asked about. + """ + verdicts: Final = ( + _strategy_router_verdicts(healthy_endpoints, unhealthy_endpoints, checked, router) + if router is not None + else MappingProxyType({}) + ) + dropped: Final = frozenset(i for d in dependency_probes if (i := _deployment_id(d))) + + def keep(endpoint: Mapping[str, object]) -> bool: + model_id: Final = endpoint.get("model_id") + return not (isinstance(model_id, str) and model_id in dropped) + + def verdict_for(endpoint: Mapping[str, object]) -> str | None: + model_id: Final = endpoint.get("model_id") + return verdicts.get(model_id) if isinstance(model_id, str) else None + + kept_healthy: Final = tuple(e for e in healthy_endpoints if keep(e)) + return ( + tuple(e for e in kept_healthy if verdict_for(e) is None), + tuple(e for e in unhealthy_endpoints if keep(e)) + + tuple( + dict(e, error=error) # mutable-ok: the /health payload must stay a plain JSON-serializable dict + for e in kept_healthy + if (error := verdict_for(e)) is not None + ), + ) + + async def _run_model_health_check(model: dict): litellm_params = model["litellm_params"] model_info: Final = model.get("model_info", {}) @@ -531,6 +776,7 @@ async def perform_health_check( max_concurrency: int | None = None, instrumentation_context: dict | None = None, health_check_skip_disabled_background_models: bool = False, + router: "Router | None" = None, ): """ Perform a health check on the system. @@ -567,23 +813,9 @@ async def perform_health_check( cycle_start_time: Final = time.monotonic() requested_model_count: Final = len(model_list) - - # Filter by model_id first so a single deployment is checked when id is specified - if model_id is not None: - _by_id: Final = [x for x in model_list if (x.get("model_info") or {}).get("id") == model_id] - if _by_id: - model_list = _by_id - elif model is not None: - _new_model_list = [x for x in model_list if x["litellm_params"]["model"] == model] - if _new_model_list == []: - _new_model_list = [x for x in model_list if x["model_name"] == model] - model_list = _new_model_list - - if health_check_skip_disabled_background_models: - model_list = [ - x for x in model_list if not (x.get("model_info") or {}).get("disable_background_health_check", False) - ] - if not model_list: + skip_disabled: Final = health_check_skip_disabled_background_models + narrowed: Final = _health_check_eligible(_narrow_to_target(model_list, model, model_id), skip_disabled) + if not narrowed: if instrumentation_enabled: logger.debug( "health_check_cycle_skipped source=%s cycle_id=%s reason=no_models_after_filter", @@ -592,11 +824,16 @@ async def perform_health_check( ) return [], [], {} - post_filter_model_count: Final = len(model_list) - model_list = filter_deployments_by_id( - model_list=model_list - ) # filter duplicate deployments (e.g. when model alias'es are used) - deduped_model_count: Final = len(model_list) + post_filter_model_count: Final = len(narrowed) + requested: Final = filter_deployments_by_id(model_list=narrowed) + deduped_model_count: Final = len(requested) + + dependency_probes: Final = ( + _dependency_deployments_to_probe(requested, _health_check_eligible(model_list, skip_disabled), router) + if router is not None + else () + ) + checked: Final = requested + list(dependency_probes) # mutable-ok: _perform_health_check takes a list if instrumentation_enabled: logger.debug( @@ -613,15 +850,20 @@ async def perform_health_check( try: ( - healthy_endpoints, - unhealthy_endpoints, + probed_healthy, + probed_unhealthy, exceptions_by_model_id, ) = await _perform_health_check( - model_list, + checked, details, max_concurrency=max_concurrency, instrumentation_context=instrumentation_context, ) + graded_healthy, graded_unhealthy = _finalize_strategy_router_endpoints( + probed_healthy, probed_unhealthy, checked, router, dependency_probes + ) + healthy_endpoints: Final = list(graded_healthy) + unhealthy_endpoints: Final = list(graded_unhealthy) except Exception: if instrumentation_enabled: logger.exception( diff --git a/litellm/proxy/health_check_utils/shared_health_check_manager.py b/litellm/proxy/health_check_utils/shared_health_check_manager.py index 5dca2b6a6f1..f12cee4b636 100644 --- a/litellm/proxy/health_check_utils/shared_health_check_manager.py +++ b/litellm/proxy/health_check_utils/shared_health_check_manager.py @@ -1,7 +1,7 @@ import asyncio import json import time -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final from litellm._logging import verbose_proxy_logger from litellm.caching.redis_cache import RedisCache @@ -12,6 +12,9 @@ from litellm.constants import ( from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy.health_check import perform_health_check +if TYPE_CHECKING: + from litellm.router import Router + class SharedHealthCheckManager: """ @@ -185,6 +188,7 @@ class SharedHealthCheckManager: details: bool = True, max_concurrency: int | None = None, health_check_skip_disabled_background_models: bool = False, + router: "Router | None" = None, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]: """ Perform health check with shared state coordination. @@ -235,6 +239,7 @@ class SharedHealthCheckManager: details=details, max_concurrency=max_concurrency, health_check_skip_disabled_background_models=health_check_skip_disabled_background_models, + router=router, ) # Cache the results @@ -254,6 +259,7 @@ class SharedHealthCheckManager: details=details, max_concurrency=max_concurrency, health_check_skip_disabled_background_models=health_check_skip_disabled_background_models, + router=router, ) # Lock not acquired — poll for cached results until the lock @@ -309,6 +315,7 @@ class SharedHealthCheckManager: details=details, max_concurrency=max_concurrency, health_check_skip_disabled_background_models=health_check_skip_disabled_background_models, + router=router, ) async def is_health_check_in_progress(self) -> bool: diff --git a/litellm/proxy/health_endpoints/_health_endpoints.py b/litellm/proxy/health_endpoints/_health_endpoints.py index cc49ae574cc..72688ade228 100644 --- a/litellm/proxy/health_endpoints/_health_endpoints.py +++ b/litellm/proxy/health_endpoints/_health_endpoints.py @@ -1113,6 +1113,7 @@ async def health_endpoint( user_id=user_api_key_dict.user_id, model_id=model_id, max_concurrency=health_check_concurrency, + router=llm_router, **_hc_filter, ) return _post_process(router_result) diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index 3242c6f6084..9ea0796b680 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -252,6 +252,38 @@ def _raise_on_strategy_router_write_violation( ) +ENFORCE_RPM_TPM_ON_MODEL_ADD_SETTING: Final = "enforce_rpm_tpm_on_model_add" +_REQUIRED_RATE_LIMIT_FIELDS: Final = ("rpm", "tpm") + + +def _raise_if_rate_limits_required_but_missing(*, litellm_params: GenericLiteLLMParams, enforced: bool) -> None: + """Require both rpm and tpm (each a positive value) when the operator opts in via config.yaml. + + Off by default, so deployments keep adding models without limits. When + ``enforce_rpm_tpm_on_model_add: true`` is set under general_settings, a model added + without both rpm and tpm set to a positive value is rejected rather than stored + unbounded (or effectively excluded from routing by a zero/negative limit). + """ + if not enforced: + return + missing: Final = tuple( + field + for field in _REQUIRED_RATE_LIMIT_FIELDS + if (value := getattr(litellm_params, field)) is None or value <= 0 + ) + if not missing: + return + raise ProxyException( + message=( + f"{' and '.join(missing)} must be set to a positive value when " + f"'{ENFORCE_RPM_TPM_ON_MODEL_ADD_SETTING}' is enabled in general_settings" + ), + type=ProxyErrorTypes.validation_error.value, + code=status.HTTP_400_BAD_REQUEST, + param=f"litellm_params.{missing[0]}", + ) + + _PTU_PRICED_PAIR: Final = frozenset({"ptu_count", "cost_per_ptu_per_hour"}) @@ -326,9 +358,10 @@ def _validate_ptu_model_info(model_info: Mapping[str, object]) -> None: raise HTTPException(status_code=400, detail=error) -# The mirrored per-token pricing fields plus the three remaining fields -# Router._inherit_builtin_cache_pricing back-fills from the public cost map. An unset field is -# what that back-fill targets, so a field left out here is one a PTU deployment still bills. +# The mirrored per-token pricing fields plus the remaining rates the public cost map or a +# provider default would otherwise supply (the cache back-fills, the Maps grounding rate). An +# unset field falls back to those sources, so a field left out here is one a PTU deployment +# still bills. # tiered_pricing is the one mirrored field that is a table of ranges, not a rate, so it is stored # empty (see _PTU_EMPTIED_PRICING_FIELDS): its tiers outrank the zeros written beside them, so # dropping it would leave the cost map's tiers billing the traffic the reserved capacity covers. @@ -1748,6 +1781,11 @@ async def add_new_model( existing_params=None, ) + _raise_if_rate_limits_required_but_missing( + litellm_params=model_params.litellm_params, + enforced=bool(general_settings.get(ENFORCE_RPM_TPM_ON_MODEL_ADD_SETTING, False)), + ) + model_response: prisma_models.LiteLLM_ProxyModelTable | LiteLLM_ProxyModelTable | None = None # update DB incoming_model_info: Final = model_params.model_info.model_dump(exclude_none=True) diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 9a2ec38d627..08346983f32 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -262,6 +262,7 @@ async def add_team_callbacks( - langfuse_secret_key: The secret key for the Langfuse callback - langfuse_secret: The secret for the Langfuse callback - langfuse_host: The host for the Langfuse callback + - langfuse_environment: The tracing environment for the Langfuse callback (lowercase; falls back to LANGFUSE_TRACING_ENVIRONMENT) - gcs_bucket_name: The name of the GCS bucket - gcs_path_service_account: The path to the GCS service account - langsmith_api_key: The API key for the Langsmith callback diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index f2327b18914..c6d7975b75e 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -14,6 +14,7 @@ import json import math import traceback from collections.abc import Mapping, Sequence +from collections.abc import Set as AbstractSet from datetime import datetime, timezone from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, Final, NamedTuple, NoReturn, Protocol, TypeVar, cast @@ -494,8 +495,13 @@ class TeamMemberBudgetHandler: team_member_rpm_limit: int | None = None, team_member_tpm_limit: int | None = None, team_member_budget_duration: str | None = None, + explicitly_set_fields: AbstractSet[str] = frozenset(), ) -> dict: - """Create team member budget table with provided limits""" + """Create team member budget table with provided limits. + + The team's own reset period is only inherited when the caller left the + member duration out, so an explicit null means "never resets". + """ from litellm.proxy._types import BudgetNewRequest from litellm.proxy.management_endpoints.budget_management_endpoints import ( new_budget, @@ -509,7 +515,11 @@ class TeamMemberBudgetHandler: # Create budget request with all provided limits budget_request: Final = BudgetNewRequest( budget_id=budget_id, - budget_duration=data.budget_duration or team_member_budget_duration, + budget_duration=( + team_member_budget_duration + if "team_member_budget_duration" in explicitly_set_fields + else data.budget_duration or team_member_budget_duration + ), ) if team_member_budget is not None: @@ -545,8 +555,13 @@ class TeamMemberBudgetHandler: team_member_rpm_limit: int | None = None, team_member_tpm_limit: int | None = None, team_member_budget_duration: str | None = None, + explicitly_set_fields: AbstractSet[str] = frozenset(), ) -> dict: - """Upsert team member budget table with provided limits""" + """Upsert team member budget table with provided limits. + + A field the caller explicitly sent as null is written as null, so a + team can keep a member budget while dropping its reset period. + """ from litellm.proxy._types import BudgetNewRequest from litellm.proxy.management_endpoints.budget_management_endpoints import ( update_budget, @@ -560,14 +575,16 @@ class TeamMemberBudgetHandler: # Budget exists - create update request with only provided values budget_request: Final = BudgetNewRequest(budget_id=team_member_budget_id) - if team_member_budget is not None: + if team_member_budget is not None or "team_member_budget" in explicitly_set_fields: budget_request.max_budget = team_member_budget - if team_member_rpm_limit is not None: + if team_member_rpm_limit is not None or "team_member_rpm_limit" in explicitly_set_fields: budget_request.rpm_limit = team_member_rpm_limit - if team_member_tpm_limit is not None: + if team_member_tpm_limit is not None or "team_member_tpm_limit" in explicitly_set_fields: budget_request.tpm_limit = team_member_tpm_limit - if team_member_budget_duration is not None: + if team_member_budget_duration is not None or "team_member_budget_duration" in explicitly_set_fields: budget_request.budget_duration = team_member_budget_duration + if team_member_budget_duration is None: + budget_request.budget_reset_at = None budget_row: Final = await _as_budget_write(update_budget)( budget_obj=budget_request, @@ -593,6 +610,7 @@ class TeamMemberBudgetHandler: team_member_rpm_limit=team_member_rpm_limit, team_member_tpm_limit=team_member_tpm_limit, team_member_budget_duration=team_member_budget_duration, + explicitly_set_fields=explicitly_set_fields, ) # Remove team member fields from updated_kv @@ -1479,6 +1497,7 @@ async def new_team( team_member_rpm_limit=data.team_member_rpm_limit, team_member_tpm_limit=data.team_member_tpm_limit, team_member_budget_duration=data.team_member_budget_duration, + explicitly_set_fields=data.model_fields_set, ) ## ADD TO TEAM TABLE @@ -2184,6 +2203,7 @@ async def update_team( team_member_rpm_limit=data.team_member_rpm_limit, team_member_tpm_limit=data.team_member_tpm_limit, team_member_budget_duration=data.team_member_budget_duration, + explicitly_set_fields=_team_member_fields_in_request, ) # Backfill team_memberships for members who joined before the # budget was configured — they won't have a membership row yet. diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py index 8fe453ad5e5..a36a365f39a 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/anthropic_passthrough_logging_handler.py @@ -117,8 +117,10 @@ class AnthropicPassthroughLoggingHandler: @staticmethod def _cost_relevant_speed(request_body: Mapping[str, object] | None) -> str | None: """ - Anthropic's ``speed=fast`` multiplies non-cache token cost, and only the request - carries it, so it has to reach the usage-building paths for spend to be right. + Anthropic's ``speed=fast`` multiplies token cost. The response usage carries the + served ``speed`` when the request asked for one, and ``calculate_usage`` prefers + that served value; this request-side value is the fallback when the response + omits it, so it still has to reach the usage-building paths. """ speed: Final = (request_body or {}).get("speed") return speed if isinstance(speed, str) else None @@ -702,6 +704,7 @@ class AnthropicPassthroughLoggingHandler: web_search_requests: int | None = None tool_search_requests: int | None = None inference_geo: str | None = None + speed_from_stream: str | None = None stop_reason: str | None = None found_usage = False resolved_model = model @@ -725,6 +728,8 @@ class AnthropicPassthroughLoggingHandler: cache_creation_1h = _cc.get("ephemeral_1h_input_tokens") if usage.get("inference_geo") is not None: inference_geo = usage.get("inference_geo") + if isinstance(usage.get("speed"), str): + speed_from_stream = usage.get("speed") if usage.get("output_tokens") is not None: output_tokens = usage.get("output_tokens") found_usage = True @@ -745,6 +750,8 @@ class AnthropicPassthroughLoggingHandler: cache_read = usage.get("cache_read_input_tokens") if usage.get("inference_geo") is not None: inference_geo = usage.get("inference_geo") + if isinstance(usage.get("speed"), str): + speed_from_stream = usage.get("speed") found_usage = True if not found_usage: return None @@ -776,6 +783,8 @@ class AnthropicPassthroughLoggingHandler: usage_object["server_tool_use"] = _server_tool_use if inference_geo is not None: usage_object["inference_geo"] = inference_geo + if speed_from_stream is not None: + usage_object["speed"] = speed_from_stream usage_obj: Final = AnthropicConfig().calculate_usage( usage_object=usage_object, reasoning_content=None, speed=speed ) diff --git a/litellm/proxy/prompts/prompt_endpoints.py b/litellm/proxy/prompts/prompt_endpoints.py index a289ed7cbfb..e90adc163e2 100644 --- a/litellm/proxy/prompts/prompt_endpoints.py +++ b/litellm/proxy/prompts/prompt_endpoints.py @@ -323,6 +323,7 @@ def create_versioned_prompt_spec(db_prompt: _PromptRow) -> PromptSpec: prompt_info=prompt_info, created_at=row.created_at, updated_at=row.updated_at, + version=row.version, environment=row.environment, created_by=row.created_by, ) @@ -334,6 +335,21 @@ class Prompt(BaseModel): prompt_info: PromptInfo | None = None +AMBIGUOUS_PROMPT_DATA_ERROR: Final = ( + "litellm_params.prompt_id cannot be combined with prompt_data keyed by template name. " + 'Send a flat template, prompt_data={"content": "...", "metadata": {...}}, together with litellm_params.prompt_id, ' + 'or send prompt_data={"": {"content": "...", "metadata": {...}}} without litellm_params.prompt_id.' +) + + +def is_ambiguous_keyed_prompt_data(litellm_params: PromptLiteLLMParams) -> bool: + extra_fields: Final = litellm_params.model_extra or {} + prompt_data: Final = extra_fields.get("prompt_data") + if not litellm_params.prompt_id or not isinstance(prompt_data, dict): + return False + return bool(prompt_data) and "content" not in prompt_data + + class PatchPromptRequest(BaseModel): litellm_params: PromptLiteLLMParams | None = None prompt_info: PromptInfo | None = None @@ -737,11 +753,9 @@ async def create_prompt( -d '{ "prompt_id": "my_prompt", "litellm_params": { - "prompt_id": "json_prompt", + "prompt_id": "my_prompt", "prompt_integration": "dotprompt", - ### EITHER prompt_directory OR prompt_data MUST BE PROVIDED - "prompt_directory": "/path/to/dotprompt/folder", - "prompt_data": {"json_prompt": {"content": "This is a prompt", "metadata": {"model": "gpt-4"}}} + "prompt_data": {"content": "This is a prompt", "metadata": {"model": "gpt-4"}} }, "prompt_info": { "prompt_type": "config" @@ -763,6 +777,9 @@ async def create_prompt( if prisma_client is None: raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) + if is_ambiguous_keyed_prompt_data(request.litellm_params): + raise HTTPException(status_code=400, detail=AMBIGUOUS_PROMPT_DATA_ERROR) + try: # Extract environment from request environment: Final = ( @@ -857,6 +874,9 @@ async def update_prompt( if prisma_client is None: raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) + if is_ambiguous_keyed_prompt_data(request.litellm_params): + raise HTTPException(status_code=400, detail=AMBIGUOUS_PROMPT_DATA_ERROR) + try: # Strip version suffix from prompt_id if present (e.g., "jack_success.v1" -> "jack_success") base_prompt_id: Final = get_base_prompt_id(prompt_id=prompt_id) @@ -1086,6 +1106,9 @@ async def patch_prompt( if prisma_client is None: raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) + if request.litellm_params is not None and is_ambiguous_keyed_prompt_data(request.litellm_params): + raise HTTPException(status_code=400, detail=AMBIGUOUS_PROMPT_DATA_ERROR) + try: # Resolve the target row: find the latest version in the given environment base_prompt_id: Final = get_base_prompt_id(prompt_id=prompt_id) diff --git a/litellm/proxy/prompts/prompt_registry.py b/litellm/proxy/prompts/prompt_registry.py index 695bdabfe83..46caa30fdab 100644 --- a/litellm/proxy/prompts/prompt_registry.py +++ b/litellm/proxy/prompts/prompt_registry.py @@ -147,6 +147,9 @@ class InMemoryPromptRegistry: prompt_info=prompt.prompt_info or PromptInfo(prompt_type="config"), created_at=prompt.created_at, updated_at=prompt.updated_at, + version=prompt.version, + environment=prompt.environment, + created_by=prompt.created_by, ) # store references to the prompt in memory diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 18cc57cd135..080737f9967 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -1502,9 +1502,9 @@ def get_openapi_schema(): openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema) # Stub unloaded lazy features so they appear as Swagger sections. - from litellm.proxy._lazy_features import inject_lazy_stubs + from litellm.proxy._lazy_features import inject_lazy_stubs, loaded_lazy_modules - openapi_schema = inject_lazy_stubs(openapi_schema) + openapi_schema = inject_lazy_stubs(openapi_schema, loaded_lazy_modules(app)) openapi_schema = ensure_unique_openapi_operation_ids(openapi_schema) # Fix Swagger UI execute path error when server_root_path is set @@ -1534,9 +1534,9 @@ def custom_openapi(): openapi_schema = CustomOpenAPISpec.add_llm_api_request_schema_body(openapi_schema) # Stub unloaded lazy features so they appear as Swagger sections. - from litellm.proxy._lazy_features import inject_lazy_stubs + from litellm.proxy._lazy_features import inject_lazy_stubs, loaded_lazy_modules - openapi_schema = inject_lazy_stubs(openapi_schema) + openapi_schema = inject_lazy_stubs(openapi_schema, loaded_lazy_modules(app)) openapi_schema = ensure_unique_openapi_operation_ids(openapi_schema) # Fix Swagger UI execute path error when server_root_path is set @@ -3393,9 +3393,7 @@ def _rss_mb_for_log() -> str: return f"{rss_mb:.2f}" -def _is_unexpected_keyword_argument_type_error(exc: BaseException) -> bool: - """True when ``exc`` is a TypeError from passing a kwarg the callee does not accept.""" - return isinstance(exc, TypeError) and ("unexpected keyword argument" in str(exc).lower()) +_UNEXPECTED_KWARG: Final = re.compile(r"unexpected keyword argument '(?P[^']+)'") async def _run_direct_health_check_with_instrumentation( @@ -3404,31 +3402,33 @@ async def _run_direct_health_check_with_instrumentation( max_concurrency: int | None, instrumentation_context: dict, ): - """Call ``perform_health_check``, retrying with fewer kwargs on unexpected-kw TypeErrors.""" - _hc_filter: Final = health_check_filter_kwargs_from_general_settings(general_settings) - last_type_error: TypeError | None = None - for extra_kwargs in ( + """Call ``perform_health_check``, dropping exactly the optional kwarg each TypeError names. + + A callee that predates an argument rejects it by name, so only that one is dropped. A + hand-written ladder of combinations would drop working options alongside it, and would + need a new rung every time an argument is added. + """ + optional: Mapping[str, object] = MappingProxyType( # rebind-ok: loses the kwarg the callee rejected { + "router": llm_router, "instrumentation_context": instrumentation_context, - **_hc_filter, - }, - {"instrumentation_context": instrumentation_context}, - dict(_hc_filter), - {}, - ): + **health_check_filter_kwargs_from_general_settings(general_settings), + } + ) + for _ in range(len(optional) + 1): try: return await perform_health_check( model_list=model_list, details=details, max_concurrency=max_concurrency, - **extra_kwargs, + **optional, ) except TypeError as e: - if not _is_unexpected_keyword_argument_type_error(e): + rejected = _UNEXPECTED_KWARG.search(str(e)) + if rejected is None or rejected["name"] not in optional: raise - last_type_error = e - assert last_type_error is not None - raise last_type_error + optional = MappingProxyType({k: v for k, v in optional.items() if k != rejected["name"]}) + raise AssertionError("perform_health_check rejected every optional argument") def _schedule_background_health_check_db_save( @@ -3683,6 +3683,7 @@ async def _run_background_health_check(): model_list=_llm_model_list, details=details_bool, max_concurrency=health_check_concurrency, + router=llm_router, **_hc_filter, ) except Exception as e: @@ -6268,7 +6269,14 @@ class ProxyConfig: ): from litellm.utils import _update_dictionary - combined_router_settings = _update_dictionary(config_router_settings, db_router_settings.param_value) + db_overlay_deferring_empty_lists_to_config: Final = { + k: v + for k, v in db_router_settings.param_value.items() + if not (k in config_router_settings and isinstance(v, list) and len(v) == 0) + } + combined_router_settings = _update_dictionary( + config_router_settings, db_overlay_deferring_empty_lists_to_config + ) elif config_router_settings is not None and isinstance(config_router_settings, dict): combined_router_settings = config_router_settings elif db_router_settings is not None and isinstance(db_router_settings.param_value, dict): diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index e5f6c8328f4..d4b9f4e8cce 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -557,6 +557,34 @@ async def _arealtime( raise ValueError(f"Unsupported model: {model}") +def _is_transcription_only_realtime_model(model: str, custom_llm_provider: str) -> bool: + try: + model_info: Final = litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider) + except Exception: # noqa: BLE001 # get_model_info raises bare Exception for unmapped models + return False + if model_info.get("mode") == "audio_transcription": + return True + return "/v1/realtime/transcription_sessions" in (model_info.get("supported_endpoints") or ()) + + +_TRANSCRIPTION_QUERY_PARAMS: Final[RealtimeQueryParams] = {"intent": "transcription"} + + +def _azure_realtime_health_protocol( + model: str, realtime_protocol: str | None, model_params: Mapping[str, Any] +) -> tuple[str, RealtimeQueryParams | None]: + query_params: Final = _TRANSCRIPTION_QUERY_PARAMS if _is_transcription_only_realtime_model(model, "azure") else None + configured_raw: Final = ( + realtime_protocol or model_params.get("realtime_protocol") or os.environ.get("LITELLM_AZURE_REALTIME_PROTOCOL") + ) + configured: Final = configured_raw if isinstance(configured_raw, str) else None + if configured is not None: + return configured, query_params + if query_params is not None: + return "GA", query_params + return "beta", None + + def _realtime_health_check_auth_headers( custom_llm_provider: str, api_key: str | None, model_params: Mapping[str, Any] ) -> Mapping[str, str | None]: @@ -586,7 +614,9 @@ async def _realtime_health_check( api_version: Optional[str] - api version api_key: str - api key custom_llm_provider: str - custom llm provider - realtime_protocol: Optional[str] - protocol version ("GA"/"v1" for GA path, "beta"/None for beta path) + realtime_protocol: Optional[str] - protocol version ("GA"/"v1" for GA path, "beta" for beta path); + None resolves it for Azure from model_params/env, with transcription-only models probing GA + plus intent=transcription the way real calls do Returns: bool - True if connection is successful, False otherwise @@ -602,11 +632,17 @@ async def _realtime_health_check( model_params=model_params or _EMPTY_MODEL_PARAMS, ) if custom_llm_provider == "azure": + resolved_protocol, azure_query_params = _azure_realtime_health_protocol( + model=model, + realtime_protocol=realtime_protocol, + model_params=model_params or _EMPTY_MODEL_PARAMS, + ) url = azure_realtime._construct_url( api_base=api_base or "", model=model, api_version=api_version or "2024-10-01-preview", - realtime_protocol=realtime_protocol, + realtime_protocol=resolved_protocol, + query_params=azure_query_params, ) elif custom_llm_provider == "openai": url = openai_realtime._construct_url( diff --git a/litellm/responses/main.py b/litellm/responses/main.py index d6ebc44ac52..8a682c701e0 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -1,6 +1,7 @@ import asyncio import contextvars -from collections.abc import Coroutine, Iterable, Mapping +from collections.abc import Coroutine, Generator, Iterable, Mapping +from contextlib import contextmanager from functools import partial from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast @@ -13,6 +14,7 @@ from litellm.completion_extras.litellm_responses_transformation.transformation i LiteLLMResponsesTransformationHandler, ) from litellm.constants import request_timeout +from litellm.integrations.anthropic_cache_control_hook import CARRY_UNMATCHED_MESSAGE_POINTS from litellm.litellm_core_utils.asyncify import run_async_function from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.prompt_templates.common_utils import ( @@ -390,6 +392,60 @@ async def aresponses_api_with_mcp( return response +def _bridges_to_chat_completions( + responses_api_provider_config: BaseResponsesAPIConfig | None, use_chat_completions_api: bool +) -> bool: + """Whether the request reaches its provider as a chat completion, not a Responses call.""" + return responses_api_provider_config is None or use_chat_completions_api is True + + +def _will_bridge_to_chat_completions( + model: str, custom_llm_provider: str | None, use_chat_completions_api: bool +) -> bool: + """``_bridges_to_chat_completions`` for callers running before the provider config is resolved. + + Resolving the config is a pure lookup, so this asks the same question the dispatch + asks rather than restating its condition. Both callers resolve the provider before + this runs, so the only way to be wrong is a prompt manager that moves the model + across the bridge boundary, which would leave the deferred points to a pass that + never comes. + """ + normalized_model: Final = _normalize_openai_chat_completions_responses_model(model) + if custom_llm_provider is None: + return True + return _bridges_to_chat_completions( + ProviderConfigManager.get_provider_responses_api_config( + model=normalized_model[0], provider=custom_llm_provider + ), + use_chat_completions_api or normalized_model[1], + ) + + +@contextmanager +def _prompt_management_sees_a_provisional_message_list( + kwargs: dict[str, Any], # mutable-ok: the signal is read and popped out of the caller's own kwargs + bridged: bool, +) -> Generator[None, None]: + """Tell the cache-control hook that this layer's messages are not the ones sent upstream. + + A Responses request keeps its system prompt in ``instructions``, which only becomes a + system message when the chat-completion bridge builds one, so a role-targeted point + is placed by the bridge's pass rather than this one. + + Only raised for a request that will be bridged. A provider serving Responses natively + gets no second pass, so this layer is the last one that can place anything and handing + a point forward there drops it. + """ + if not bridged: + yield + return + kwargs[CARRY_UNMATCHED_MESSAGE_POINTS] = True + try: + yield + finally: + kwargs.pop(CARRY_UNMATCHED_MESSAGE_POINTS, None) + + @client async def aresponses( input: str | ResponseInputParam, @@ -467,19 +523,25 @@ async def aresponses( client_input: list[AllMessageValues] = [{"role": "user", "content": input}] else: client_input = [item for item in input if isinstance(item, dict) and "role" in item] - ( - model, - merged_input, - merged_optional_params, - ) = await litellm_logging_obj.async_get_chat_completion_prompt( - model=model, - messages=client_input, - non_default_params=kwargs, - prompt_id=prompt_id, - prompt_variables=prompt_variables, - prompt_label=kwargs.get("prompt_label", None), - prompt_version=kwargs.get("prompt_version", None), - ) + with _prompt_management_sees_a_provisional_message_list( + kwargs, + bridged=_will_bridge_to_chat_completions( + model, custom_llm_provider, bool(kwargs.get("use_chat_completions_api")) + ), + ): + ( + model, + merged_input, + merged_optional_params, + ) = await litellm_logging_obj.async_get_chat_completion_prompt( + model=model, + messages=client_input, + non_default_params=kwargs, + prompt_id=prompt_id, + prompt_variables=prompt_variables, + prompt_label=kwargs.get("prompt_label", None), + prompt_version=kwargs.get("prompt_version", None), + ) input = cast( str | ResponseInputParam, ResponsesAPIRequestUtils.merge_prompt_management_input( @@ -566,6 +628,7 @@ def _apply_prompt_management_to_responses_call( litellm_logging_obj: LiteLLMLoggingObj | None, kwargs: dict[str, Any], local_vars: dict[str, object], + use_chat_completions_api: bool, ) -> tuple[str | ResponseInputParam, str, str | None]: async_merged: Final[Mapping[str, object] | None] = kwargs.pop("_async_prompt_merged_params", None) if async_merged is not None: @@ -585,19 +648,23 @@ def _apply_prompt_management_to_responses_call( if isinstance(litellm_logging_obj, LiteLLMLoggingObj) and litellm_logging_obj.should_run_prompt_management_hooks( prompt_id=prompt_id, non_default_params=kwargs ): - ( - model, - merged_input, - merged_optional_params, - ) = litellm_logging_obj.get_chat_completion_prompt( - model=model, - messages=client_input, - non_default_params=kwargs, - prompt_id=prompt_id, - prompt_variables=prompt_variables, - prompt_label=kwargs.get("prompt_label", None), - prompt_version=kwargs.get("prompt_version", None), - ) + with _prompt_management_sees_a_provisional_message_list( + kwargs, + bridged=_will_bridge_to_chat_completions(model, custom_llm_provider, use_chat_completions_api), + ): + ( + model, + merged_input, + merged_optional_params, + ) = litellm_logging_obj.get_chat_completion_prompt( + model=model, + messages=client_input, + non_default_params=kwargs, + prompt_id=prompt_id, + prompt_variables=prompt_variables, + prompt_label=kwargs.get("prompt_label", None), + prompt_version=kwargs.get("prompt_version", None), + ) input = cast( str | ResponseInputParam, ResponsesAPIRequestUtils.merge_prompt_management_input( @@ -961,6 +1028,7 @@ def responses( litellm_logging_obj=litellm_logging_obj, kwargs=kwargs, local_vars=local_vars, + use_chat_completions_api=use_chat_completions_api, ) ######################################################### @@ -1063,7 +1131,7 @@ def responses( if _file_search_dispatch is not None: return _file_search_dispatch - if responses_api_provider_config is None or use_chat_completions_api is True: + if _bridges_to_chat_completions(responses_api_provider_config, use_chat_completions_api): return litellm_completion_transformation_handler.response_api_handler( model=model, input=input, diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index 5c0d6fc536e..368fd481e63 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -573,13 +573,16 @@ class BaseResponsesAPIStreamingIterator: ): return - if litellm.cache is None: + cache: Final = litellm.cache + if cache is None: return cached_response: Final = response_obj.model_dump_json() if is_async: - cache_write_task: Final = asyncio.create_task( - litellm.cache.async_add_cache( + from litellm.caching.caching_handler import create_cache_write_task + + cache_write_task: Final = create_cache_write_task( + lambda: cache.async_add_cache( cached_response, dynamic_cache_object=getattr(caching_handler, "dual_cache", None), **request_kwargs, @@ -592,7 +595,7 @@ class BaseResponsesAPIStreamingIterator: ) ) else: - litellm.cache.add_cache( + cache.add_cache( cached_response, dynamic_cache_object=getattr(caching_handler, "dual_cache", None), **request_kwargs, diff --git a/litellm/router.py b/litellm/router.py index fc9743ff43d..2dc79670c07 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -205,6 +205,7 @@ from litellm.types.router import ( PreRoutingStrategy, RetryPolicy, RouterCacheEnum, + RouterErrors, RouterGeneralSettings, RouterModelGroupAliasItem, RouterRateLimitError, @@ -11520,10 +11521,8 @@ class Router: # If still no deployments after checking for fallbacks, raise an error if len(healthy_deployments) == 0: - message: Final = f"You passed in model={model}. There are no healthy deployments for this model" - raise litellm.BadRequestError( - message=message, + message=f"You passed in model={model}. {RouterErrors.no_healthy_deployments.value}", model=model, llm_provider="", ) @@ -11534,11 +11533,18 @@ class Router: ] # update the model to the actual value if an alias has been passed in marker_flags: Final = tuple(self._is_strategy_marker_deployment(d) for d in healthy_deployments) - if all(marker_flags) or not any(marker_flags): + if not any(marker_flags): return model, healthy_deployments - return model, [ # mutable-ok: matches this function's list contract expected by downstream filters + selectable: Final = [ # mutable-ok: matches this function's list contract expected by downstream filters d for d, is_marker in zip(healthy_deployments, marker_flags, strict=True) if not is_marker ] + if not selectable: + raise litellm.BadRequestError( + message=f"You passed in model={model}. {RouterErrors.only_strategy_marker_deployments.value}", + model=model, + llm_provider="", + ) + return model, selectable def _filter_deployments_by_model_access_groups( self, @@ -12127,7 +12133,15 @@ class Router: This hook is called before the routing decision is made. Used for the litellm auto-router to modify the request before the routing decision is made. + + `model` is whatever the caller asked for, which may be a `model_group_alias` key, while the + strategy registries and the marker deployment are keyed by the marker's own `model_name`, so + every lookup below resolves the alias first. Only the lookups: the caller-facing name stays + the alias, since spend metadata is stamped before routing and the response carries the tier + group the strategy picked. """ + registered_model_name: Final = self._get_model_from_alias(model=model) or model + ######################################################### # Run the routing-plugin pipeline, if any plugins are configured. # Plugins narrow the candidate deployment pool (consumed later by @@ -12135,9 +12149,13 @@ class Router: # downstream strategies (auto-router, complexity-router, ...) to read. ######################################################### if self.routing_plugins: - await self._run_routing_plugins(model=model, request_kwargs=request_kwargs, messages=messages) + await self._run_routing_plugins( + model=registered_model_name, request_kwargs=request_kwargs, messages=messages + ) - selected_strategy: Final = self._select_pre_routing_strategy(model=model, request_kwargs=request_kwargs) + selected_strategy: Final = self._select_pre_routing_strategy( + model=registered_model_name, request_kwargs=request_kwargs + ) if selected_strategy is None: self._record_routing_decision(request_kwargs=request_kwargs, routing_decision=None) self._stamp_or_clear_metadata_key( @@ -12152,7 +12170,7 @@ class Router: return None pre_routing_hook_response: Final = await selected_strategy.strategy.async_pre_routing_hook( - model=model, + model=registered_model_name, request_kwargs=request_kwargs, messages=messages, input=input, @@ -12204,7 +12222,7 @@ class Router: # Per-tier `litellm_params` on the hook response are deliberate overrides # the caller applies on top, so those keys are never forwarded here. marker_params: Final = ( - self._forwardable_alias_marker_params(model=model, strategy_tags=selected_strategy.tags) + self._forwardable_alias_marker_params(model=registered_model_name, strategy_tags=selected_strategy.tags) if pre_routing_hook_response is not None else () ) diff --git a/litellm/router_utils/auto_router_model_naming.py b/litellm/router_utils/auto_router_model_naming.py index f17e09da5f9..29c34057e52 100644 --- a/litellm/router_utils/auto_router_model_naming.py +++ b/litellm/router_utils/auto_router_model_naming.py @@ -10,13 +10,26 @@ the router silently dropping the deployment at load time under ``ignore_invalid_deployments``. """ -from collections.abc import Mapping -from typing import Final, Literal +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType +from typing import Final, Literal, TypeAlias AUTO_ROUTER_MODEL_PREFIX: Final = "auto_router/" StrategyRouterKind = Literal["semantic", "complexity", "adaptive", "quality"] +StrategyRouterDependencyRole: TypeAlias = Literal["tier", "default", "classifier", "embedding"] + + +@dataclass(frozen=True, slots=True) +class StrategyRouterDependency: + """A model name a strategy router must be able to reach to do its job.""" + + model_name: str + role: StrategyRouterDependencyRole + + STRATEGY_ROUTER_PARAM_FIELDS: Final[frozenset[str]] = frozenset( { "auto_router_config", @@ -63,6 +76,84 @@ def classify_strategy_router_model(model: str) -> StrategyRouterKind | None: return "semantic" +def _named(value: object, role: StrategyRouterDependencyRole) -> tuple[StrategyRouterDependency, ...]: + """One dependency from a scalar field, or none when it is absent or not a name.""" + return (StrategyRouterDependency(value, role),) if isinstance(value, str) and value else () + + +def _pool(value: object, role: StrategyRouterDependencyRole) -> tuple[StrategyRouterDependency, ...]: + """Dependencies from a field holding either a single name or a pool of them.""" + if isinstance(value, str): + return _named(value, role) + if isinstance(value, Sequence): + return tuple(dep for entry in value for dep in _named(entry, role)) + return () + + +_NO_CONFIG: Final[Mapping[str, object]] = MappingProxyType({}) + + +def _mapping(value: object) -> Mapping[str, object]: + return value if isinstance(value, Mapping) else _NO_CONFIG + + +def strategy_router_dependencies( + litellm_params: Mapping[str, object], +) -> tuple[StrategyRouterDependency, ...]: + """The model names a strategy-router deployment must reach, in no particular order. + + A field is a dependency only under the condition the runtime itself reads it: the + classifier model needs `classifier_type: llm`, and the complexity embedding model needs + `semantic_keyword_matching`. Listing one the router never calls reds a working deployment. + + The two default-model spellings are not symmetric. A quality router falls back to its + config's `default_model`, so both are read. A complexity router ignores that field and + derives its default from the tiers instead (`fallback_tier`, then MEDIUM, then SIMPLE), + overwriting the config value at init, so only the `litellm_params` spelling is a + dependency here; the derived one is already covered as a tier. + + Returns empty for a regular deployment, and for any name this module cannot reach from + the deployment dict alone: a semantic router's routes live in an `auto_router_config` + JSON string or an `auto_router_config_path` file, so only its default and embedding + models are enumerable here. Every field is read defensively, since a caller may hold a + config the router itself would refuse, and a health check must not raise on one. + """ + kind: Final = classify_strategy_router_model(str(litellm_params.get("model", ""))) + if kind is None: + return () + if kind == "semantic": + return _named(litellm_params.get("auto_router_default_model"), "default") + _named( + litellm_params.get("auto_router_embedding_model"), "embedding" + ) + if kind == "adaptive": + return _pool(_mapping(litellm_params.get("adaptive_router_config")).get("available_models"), "tier") + if kind == "quality": + quality: Final = _mapping(litellm_params.get("quality_router_config")) + return tuple( + dict.fromkeys( + _pool(quality.get("available_models"), "tier") + + _named( + litellm_params.get("quality_router_default_model") or quality.get("default_model"), + "default", + ) + ) + ) + complexity: Final = _mapping(litellm_params.get("complexity_router_config")) + classifier: Final = _mapping(complexity.get("classifier_llm_config")) + return tuple( + dict.fromkeys( + tuple(dep for tier in _mapping(complexity.get("tiers")).values() for dep in _pool(tier, "tier")) + + _named(litellm_params.get("complexity_router_default_model"), "default") + + (_named(classifier.get("model"), "classifier") if complexity.get("classifier_type") == "llm" else ()) + + ( + _named(complexity.get("embedding_model"), "embedding") + if complexity.get("semantic_keyword_matching") + else () + ) + ) + ) + + def validate_complexity_router_config_write(complexity_router_config: Mapping[str, object] | None) -> str | None: """Reject a complexity config the router would refuse to build a deployment from. diff --git a/litellm/types/integrations/langfuse.py b/litellm/types/integrations/langfuse.py index 066cd760d74..6742aefea39 100644 --- a/litellm/types/integrations/langfuse.py +++ b/litellm/types/integrations/langfuse.py @@ -1,10 +1,11 @@ -from typing_extensions import TypedDict +from typing_extensions import ReadOnly, TypedDict class LangfuseLoggingConfig(TypedDict): langfuse_secret: str | None langfuse_public_key: str | None langfuse_host: str | None + langfuse_environment: ReadOnly[str | None] class LangfuseUsageDetails(TypedDict): diff --git a/litellm/types/litellm_core_utils/streaming_chunk_builder_utils.py b/litellm/types/litellm_core_utils/streaming_chunk_builder_utils.py index 893b0bdbb9f..f981089d370 100644 --- a/litellm/types/litellm_core_utils/streaming_chunk_builder_utils.py +++ b/litellm/types/litellm_core_utils/streaming_chunk_builder_utils.py @@ -1,4 +1,4 @@ -from typing_extensions import TypedDict +from typing_extensions import ReadOnly, TypedDict from ..utils import CompletionTokensDetails, PromptTokensDetailsWrapper, ServerToolUse @@ -10,6 +10,7 @@ class UsagePerChunk(TypedDict): cache_read_input_tokens: int | None server_tool_use: ServerToolUse | None web_search_requests: int | None + google_maps_grounding_requests: ReadOnly[int | None] completion_tokens_details: CompletionTokensDetails | None prompt_tokens_details: PromptTokensDetailsWrapper | None cost: float | None diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index ed23db597ae..bed0ba3dc08 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -221,14 +221,22 @@ class ConverseResponseOutputBlock(TypedDict): message: MessageBlock | None -class ConverseTokenUsageBlock(TypedDict): - inputTokens: int - outputTokens: int - totalTokens: int - cacheReadInputTokenCount: int - cacheReadInputTokens: int - cacheWriteInputTokenCount: int - cacheWriteInputTokens: int +class CacheDetailBlock(TypedDict): + """Per-TTL cache-write breakdown, read-only AWS response data. https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_CacheDetail.html""" + + inputTokens: ReadOnly[int] + ttl: ReadOnly[Literal["5m", "1h"]] + + +class ConverseTokenUsageBlock(TypedDict, total=False): + inputTokens: Required[ReadOnly[int]] + outputTokens: Required[ReadOnly[int]] + totalTokens: Required[ReadOnly[int]] + cacheReadInputTokenCount: ReadOnly[int] + cacheReadInputTokens: ReadOnly[int] + cacheWriteInputTokenCount: ReadOnly[int] + cacheWriteInputTokens: ReadOnly[int] + cacheDetails: ReadOnly[list[CacheDetailBlock]] # mutable-ok: AWS response array, never mutated after parsing class ServiceTierBlock(TypedDict): diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 4a6c4a5bbb5..45f6b5c55a9 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -107,7 +107,17 @@ EmbeddingInput = str | list[str] class HttpxBinaryResponseContent(_HttpxBinaryResponseContent): - _hidden_params: dict = {} + _hidden_params: dict + + def __init__(self, response: httpx.Response) -> None: + super().__init__(response) + self._hidden_params = {} # mutable-ok: mutable-dict contract shared with ModelResponse logging consumers + + def set_response_cost(self, response_cost: float | None) -> None: + if response_cost is None: + self._hidden_params.pop("response_cost", None) + return + self._hidden_params["response_cost"] = response_cost class NotGiven: diff --git a/litellm/types/router.py b/litellm/types/router.py index d4c735387a5..a3335be2b2b 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -576,6 +576,11 @@ class RouterErrors(enum.Enum): no_deployments_available = "No deployments available for selected model" no_deployments_with_tag_routing = "Not allowed to access model due to tags configuration" no_deployments_with_provider_budget_routing = "No deployments available - crossed budget" + no_healthy_deployments = "There are no healthy deployments for this model" + only_strategy_marker_deployments = ( + "Every deployment for it is a strategy router marker (auto_router/...), which is not a callable " + "model, and no pre-routing strategy selected a deployment for this request" + ) class AllowedFailsPolicy(BaseModel): diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 73f46bd2181..ef3586f2559 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -287,6 +287,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): web_search_billing_unit: ( Literal["per_query", "per_prompt"] | None ) # "per_query" (Gemini 3.x) or "per_prompt" (Gemini 2.x) + google_maps_grounding_cost_per_query: ReadOnly[float | None] citation_cost_per_token: float | None # Cost per citation token for Perplexity tiered_pricing: list[dict[str, Any]] | None # Tiered pricing structure for models like Dashscope litellm_provider: Required[str] @@ -1613,6 +1614,9 @@ class PromptTokensDetailsWrapper( web_search_requests: int | None = None """Number of web search requests made by the tool call. Used for Anthropic to calculate web search cost.""" + google_maps_grounding_requests: int | None = None + """Number of Grounding with Google Maps requests made by the tool call. Used for Gemini to calculate Maps cost.""" + tool_use_tokens: int | None = None """Prompt tokens consumed by server-side tool use (e.g. Gemini grounding via googleSearch).""" @@ -1671,6 +1675,8 @@ class PromptTokensDetailsWrapper( del self.audio_length_seconds if self.web_search_requests is None: del self.web_search_requests + if self.google_maps_grounding_requests is None: + del self.google_maps_grounding_requests if self.tool_use_tokens is None: del self.tool_use_tokens if self.cache_write_tokens is None: @@ -3272,6 +3278,7 @@ class StandardCallbackDynamicParams(TypedDict, total=False): langfuse_secret: str | None langfuse_secret_key: str | None langfuse_host: str | None + langfuse_environment: ReadOnly[str | None] # Langfuse prompt version langfuse_prompt_version: int | None @@ -3405,6 +3412,7 @@ class CustomPricingLiteLLMParams(MirroredPricingParams): output_cost_per_video_per_second: float | None = None output_cost_per_audio_per_second: float | None = None search_context_cost_per_query: dict[str, Any] | None = None + google_maps_grounding_cost_per_query: float | None = None citation_cost_per_token: float | None = None cache_read_input_token_cost_above_272k_tokens: float | None = None cache_read_input_token_cost_above_512k_tokens: float | None = None diff --git a/litellm/utils.py b/litellm/utils.py index 1b672018507..9cab81e1ba7 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5845,6 +5845,7 @@ def _get_model_info_helper( tiered_pricing=_model_info.get("tiered_pricing", None), litellm_provider=_model_info.get("litellm_provider", custom_llm_provider), mode=_model_info.get("mode"), + supported_endpoints=_model_info.get("supported_endpoints", None), supports_system_messages=_model_info.get("supports_system_messages", None), supports_response_schema=_model_info.get("supports_response_schema", None), supports_vision=_model_info.get("supports_vision", None), @@ -5877,6 +5878,7 @@ def _get_model_info_helper( supports_computer_use=_model_info.get("supports_computer_use", None), search_context_cost_per_query=_model_info.get("search_context_cost_per_query", None), web_search_billing_unit=_model_info.get("web_search_billing_unit", None), + google_maps_grounding_cost_per_query=_model_info.get("google_maps_grounding_cost_per_query", None), tpm=_model_info.get("tpm", None), rpm=_model_info.get("rpm", None), ocr_cost_per_page=_model_info.get("ocr_cost_per_page", None), diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index c05aefee15e..ad19818a5ce 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -1428,7 +1428,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 512 }, "global.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.25e-05, @@ -1465,7 +1465,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 512 }, "us.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -1502,7 +1502,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 512 }, "eu.anthropic.claude-fable-5": { "cache_creation_input_token_cost": 1.375e-05, @@ -1539,7 +1539,7 @@ "supports_output_config": true, "bedrock_output_config_effort_ceiling": "xhigh", "supports_parallel_tool_use_config": true, - "prompt_cache_min_tokens": 1024 + "prompt_cache_min_tokens": 512 }, "anthropic.claude-opus-5": { "bedrock_converse_supports_strict_tools": false, @@ -2933,7 +2933,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 4096 }, "azure_ai/claude-opus-4-5": { "deprecation_date": "2026-10-19", @@ -2956,7 +2957,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 4096 }, "azure_ai/claude-opus-4-6": { "deprecation_date": "2027-02-02", @@ -2987,7 +2989,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_output_config": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 4096 }, "azure_ai/claude-opus-4-7": { "deprecation_date": "2027-04-06", @@ -3018,7 +3021,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 2048 }, "azure_ai/claude-fable-5": { "supports_mid_conversation_system": true, @@ -3050,7 +3054,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 }, "azure_ai/claude-opus-5": { "supports_mid_conversation_system": true, @@ -3113,7 +3118,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 1024 }, "azure_ai/claude-opus-4-1": { "deprecation_date": "2026-08-05", @@ -3135,7 +3141,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "azure_ai/claude-sonnet-4-5": { "deprecation_date": "2026-10-19", @@ -3157,7 +3164,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "azure_ai/claude-sonnet-5": { "supports_mid_conversation_system": true, @@ -3188,7 +3196,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 1024 }, "azure_ai/claude-sonnet-4-6": { "deprecation_date": "2027-02-10", @@ -3214,7 +3223,8 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 1024 }, "azure/computer-use-preview": { "input_cost_per_token": 3e-06, @@ -6592,6 +6602,10 @@ "supports_web_search": true }, "azure/gpt-5.6": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_creation_input_token_cost_priority": 1.25e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.5e-05, "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_priority": 1e-06, @@ -6642,6 +6656,10 @@ "supports_minimal_reasoning_effort": false }, "azure/gpt-5.6-sol": { + "cache_creation_input_token_cost": 6.25e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05, + "cache_creation_input_token_cost_priority": 1.25e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.5e-05, "cache_read_input_token_cost": 5e-07, "cache_read_input_token_cost_above_272k_tokens": 1e-06, "cache_read_input_token_cost_priority": 1e-06, @@ -6693,6 +6711,10 @@ "supports_minimal_reasoning_effort": false }, "azure/gpt-5.6-terra": { + "cache_creation_input_token_cost": 2.5e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5e-06, + "cache_creation_input_token_cost_priority": 5e-06, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-05, "cache_read_input_token_cost": 2e-07, "cache_read_input_token_cost_above_272k_tokens": 4e-07, "cache_read_input_token_cost_priority": 4e-07, @@ -6744,6 +6766,10 @@ "supports_minimal_reasoning_effort": false }, "azure/gpt-5.6-luna": { + "cache_creation_input_token_cost": 2.5e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5e-07, + "cache_creation_input_token_cost_priority": 5e-07, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1e-06, "cache_read_input_token_cost": 2e-08, "cache_read_input_token_cost_above_272k_tokens": 4e-08, "cache_read_input_token_cost_priority": 4e-08, @@ -6795,12 +6821,18 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6": { + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, + "cache_creation_input_token_cost_priority": 1.375e-05, "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.375e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, + "cache_read_input_token_cost_priority": 1.1e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_priority": 1.375e-05, + "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, + "input_cost_per_token_priority": 1.1e-05, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -6808,7 +6840,8 @@ "mode": "chat", "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_priority": 8.25e-05, + "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, + "output_cost_per_token_priority": 6.6e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -6842,13 +6875,19 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6-sol": { + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, + "cache_creation_input_token_cost_priority": 1.375e-05, "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.375e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, + "cache_read_input_token_cost_priority": 1.1e-06, "deprecation_date": "2028-01-11", "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_priority": 1.375e-05, + "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, + "input_cost_per_token_priority": 1.1e-05, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -6856,7 +6895,8 @@ "mode": "chat", "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_priority": 8.25e-05, + "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, + "output_cost_per_token_priority": 6.6e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -6890,13 +6930,19 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6-terra": { + "cache_creation_input_token_cost": 2.75e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5.5e-06, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1.1e-05, + "cache_creation_input_token_cost_priority": 5.5e-06, "cache_read_input_token_cost": 2.2e-07, "cache_read_input_token_cost_above_272k_tokens": 4.4e-07, - "cache_read_input_token_cost_priority": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 8.8e-07, + "cache_read_input_token_cost_priority": 4.4e-07, "deprecation_date": "2028-01-11", "input_cost_per_token": 2.2e-06, "input_cost_per_token_above_272k_tokens": 4.4e-06, - "input_cost_per_token_priority": 5.5e-06, + "input_cost_per_token_above_272k_tokens_priority": 8.8e-06, + "input_cost_per_token_priority": 4.4e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -6904,7 +6950,8 @@ "mode": "chat", "output_cost_per_token": 1.32e-05, "output_cost_per_token_above_272k_tokens": 1.98e-05, - "output_cost_per_token_priority": 3.3e-05, + "output_cost_per_token_above_272k_tokens_priority": 3.96e-05, + "output_cost_per_token_priority": 2.64e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -6938,13 +6985,19 @@ "supports_minimal_reasoning_effort": false }, "azure/us/gpt-5.6-luna": { + "cache_creation_input_token_cost": 2.75e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5.5e-07, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1.1e-06, + "cache_creation_input_token_cost_priority": 5.5e-07, "cache_read_input_token_cost": 2.2e-08, "cache_read_input_token_cost_above_272k_tokens": 4.4e-08, - "cache_read_input_token_cost_priority": 5.5e-08, + "cache_read_input_token_cost_above_272k_tokens_priority": 8.8e-08, + "cache_read_input_token_cost_priority": 4.4e-08, "deprecation_date": "2028-01-11", "input_cost_per_token": 2.2e-07, "input_cost_per_token_above_272k_tokens": 4.4e-07, - "input_cost_per_token_priority": 5.5e-07, + "input_cost_per_token_above_272k_tokens_priority": 8.8e-07, + "input_cost_per_token_priority": 4.4e-07, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -6952,7 +7005,8 @@ "mode": "chat", "output_cost_per_token": 1.32e-06, "output_cost_per_token_above_272k_tokens": 1.98e-06, - "output_cost_per_token_priority": 3.3e-06, + "output_cost_per_token_above_272k_tokens_priority": 3.96e-06, + "output_cost_per_token_priority": 2.64e-06, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -6986,12 +7040,18 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6": { + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, + "cache_creation_input_token_cost_priority": 1.375e-05, "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.375e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, + "cache_read_input_token_cost_priority": 1.1e-06, "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_priority": 1.375e-05, + "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, + "input_cost_per_token_priority": 1.1e-05, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -6999,7 +7059,8 @@ "mode": "chat", "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_priority": 8.25e-05, + "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, + "output_cost_per_token_priority": 6.6e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -7033,13 +7094,19 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6-sol": { + "cache_creation_input_token_cost": 6.875e-06, + "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens_priority": 2.75e-05, + "cache_creation_input_token_cost_priority": 1.375e-05, "cache_read_input_token_cost": 5.5e-07, "cache_read_input_token_cost_above_272k_tokens": 1.1e-06, - "cache_read_input_token_cost_priority": 1.375e-06, + "cache_read_input_token_cost_above_272k_tokens_priority": 2.2e-06, + "cache_read_input_token_cost_priority": 1.1e-06, "deprecation_date": "2028-01-11", "input_cost_per_token": 5.5e-06, "input_cost_per_token_above_272k_tokens": 1.1e-05, - "input_cost_per_token_priority": 1.375e-05, + "input_cost_per_token_above_272k_tokens_priority": 2.2e-05, + "input_cost_per_token_priority": 1.1e-05, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -7047,7 +7114,8 @@ "mode": "chat", "output_cost_per_token": 3.3e-05, "output_cost_per_token_above_272k_tokens": 4.95e-05, - "output_cost_per_token_priority": 8.25e-05, + "output_cost_per_token_above_272k_tokens_priority": 9.9e-05, + "output_cost_per_token_priority": 6.6e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -7081,13 +7149,19 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6-terra": { + "cache_creation_input_token_cost": 2.75e-06, + "cache_creation_input_token_cost_above_272k_tokens": 5.5e-06, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1.1e-05, + "cache_creation_input_token_cost_priority": 5.5e-06, "cache_read_input_token_cost": 2.2e-07, "cache_read_input_token_cost_above_272k_tokens": 4.4e-07, - "cache_read_input_token_cost_priority": 5.5e-07, + "cache_read_input_token_cost_above_272k_tokens_priority": 8.8e-07, + "cache_read_input_token_cost_priority": 4.4e-07, "deprecation_date": "2028-01-11", "input_cost_per_token": 2.2e-06, "input_cost_per_token_above_272k_tokens": 4.4e-06, - "input_cost_per_token_priority": 5.5e-06, + "input_cost_per_token_above_272k_tokens_priority": 8.8e-06, + "input_cost_per_token_priority": 4.4e-06, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -7095,7 +7169,8 @@ "mode": "chat", "output_cost_per_token": 1.32e-05, "output_cost_per_token_above_272k_tokens": 1.98e-05, - "output_cost_per_token_priority": 3.3e-05, + "output_cost_per_token_above_272k_tokens_priority": 3.96e-05, + "output_cost_per_token_priority": 2.64e-05, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -7129,13 +7204,19 @@ "supports_minimal_reasoning_effort": false }, "azure/eu/gpt-5.6-luna": { + "cache_creation_input_token_cost": 2.75e-07, + "cache_creation_input_token_cost_above_272k_tokens": 5.5e-07, + "cache_creation_input_token_cost_above_272k_tokens_priority": 1.1e-06, + "cache_creation_input_token_cost_priority": 5.5e-07, "cache_read_input_token_cost": 2.2e-08, "cache_read_input_token_cost_above_272k_tokens": 4.4e-08, - "cache_read_input_token_cost_priority": 5.5e-08, + "cache_read_input_token_cost_above_272k_tokens_priority": 8.8e-08, + "cache_read_input_token_cost_priority": 4.4e-08, "deprecation_date": "2028-01-11", "input_cost_per_token": 2.2e-07, "input_cost_per_token_above_272k_tokens": 4.4e-07, - "input_cost_per_token_priority": 5.5e-07, + "input_cost_per_token_above_272k_tokens_priority": 8.8e-07, + "input_cost_per_token_priority": 4.4e-07, "litellm_provider": "azure", "max_input_tokens": 922000, "max_output_tokens": 128000, @@ -7143,7 +7224,8 @@ "mode": "chat", "output_cost_per_token": 1.32e-06, "output_cost_per_token_above_272k_tokens": 1.98e-06, - "output_cost_per_token_priority": 3.3e-06, + "output_cost_per_token_above_272k_tokens_priority": 3.96e-06, + "output_cost_per_token_priority": 2.64e-06, "search_context_cost_per_query": { "search_context_size_high": 0.01, "search_context_size_low": 0.01, @@ -12727,8 +12809,7 @@ "supports_tool_choice": true, "supports_vision": true, "provider_specific_entry": { - "us": 1.1, - "fast": 6.0 + "us": 1.1 }, "supports_output_config": true, "supports_max_reasoning_effort": true, @@ -12765,8 +12846,7 @@ "supports_tool_choice": true, "supports_vision": true, "provider_specific_entry": { - "us": 1.1, - "fast": 6.0 + "us": 1.1 }, "supports_max_reasoning_effort": true, "supports_output_config": true, @@ -12805,8 +12885,7 @@ "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true, "provider_specific_entry": { - "us": 1.1, - "fast": 6.0 + "us": 1.1 }, "supports_output_config": true, "supports_speed": true, @@ -12844,8 +12923,7 @@ "supports_xhigh_reasoning_effort": true, "supports_max_reasoning_effort": true, "provider_specific_entry": { - "us": 1.1, - "fast": 6.0 + "us": 1.1 }, "supports_output_config": true, "supports_speed": true, @@ -14656,7 +14734,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 4096 }, "databricks/databricks-claude-opus-4": { "cache_creation_input_token_cost": 1.874999e-05, @@ -14678,7 +14757,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 1024 }, "databricks/databricks-claude-opus-4-1": { "cache_creation_input_token_cost": 1.874999e-05, @@ -14700,7 +14780,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 1024 }, "databricks/databricks-claude-opus-4-5": { "cache_creation_input_token_cost": 6.25002e-06, @@ -14723,7 +14804,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 4096 }, "databricks/databricks-claude-opus-4-6": { "cache_creation_input_token_cost": 6.25002e-06, @@ -14746,7 +14828,8 @@ "supports_legacy_thinking": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 4096 }, "databricks/databricks-claude-opus-4-7": { "cache_creation_input_token_cost": 6.25002e-06, @@ -14848,7 +14931,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 1024 }, "databricks/databricks-claude-sonnet-4-1": { "cache_creation_input_token_cost": 3.74997e-06, @@ -14892,7 +14976,8 @@ "supports_function_calling": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 1024 }, "databricks/databricks-claude-sonnet-4-6": { "cache_creation_input_token_cost": 3.74997e-06, @@ -14915,7 +15000,8 @@ "supports_legacy_thinking": true, "supports_prompt_caching": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "prompt_cache_min_tokens": 1024 }, "databricks/databricks-claude-sonnet-5": { "cache_creation_input_token_cost": 3.74997e-06, @@ -19710,6 +19796,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini-2.5-flash-image": { @@ -19999,7 +20086,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3.1-flash-lite": { "deprecation_date": "2027-05-07", @@ -20056,7 +20144,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", @@ -20112,7 +20201,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -20192,6 +20282,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini-2.5-flash-lite-preview-09-2025": { @@ -20237,6 +20328,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini-2.5-flash-preview-09-2025": { @@ -20282,6 +20374,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini-live-2.5-flash-preview-native-audio-09-2025": { @@ -20418,6 +20511,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini-2.5-pro": { @@ -20463,7 +20557,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini-3-pro-preview": { "deprecation_date": "2026-03-26", @@ -20577,7 +20672,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3.1-pro-preview-customtools": { "prompt_cache_min_tokens": 4096, @@ -20629,7 +20725,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3-pro-preview": { "cache_read_input_token_cost": 2e-07, @@ -20732,7 +20829,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, @@ -20787,6 +20885,7 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_token_batches": 7.5e-07, "output_cost_per_token_batches": 4.5e-06, "input_cost_per_token_flex": 7.5e-07, @@ -20847,7 +20946,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, @@ -20903,7 +21003,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.1-pro-preview": { "prompt_cache_min_tokens": 4096, @@ -20961,7 +21062,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.1-pro-preview-customtools": { "prompt_cache_min_tokens": 4096, @@ -21019,7 +21121,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -21558,6 +21661,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini/gemini-2.5-flash-image": { @@ -21905,6 +22009,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini/gemini-2.5-flash-lite-preview-09-2025": { @@ -21953,6 +22058,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini/gemini-2.5-flash-preview-09-2025": { @@ -22001,6 +22107,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini/gemini-flash-latest": { @@ -22047,7 +22154,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini/gemini-flash-lite-latest": { "cache_read_input_token_cost": 2.5e-08, @@ -22093,7 +22201,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini/gemini-2.5-flash-lite-preview-06-17": { "deprecation_date": "2025-11-18", @@ -22141,6 +22250,7 @@ "search_context_size_medium": 0.035, "search_context_size_high": 0.035 }, + "google_maps_grounding_cost_per_query": 0.025, "supports_image_size": false }, "gemini/gemini-2.5-flash-preview-tts": { @@ -22202,7 +22312,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini/gemini-2.5-computer-use-preview-10-2025": { "input_cost_per_token": 1.25e-06, @@ -22339,7 +22450,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.1-flash-lite": { "cache_read_input_token_cost": 2.5e-08, @@ -22398,7 +22510,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.5-flash-lite": { "cache_read_input_token_cost": 3e-08, @@ -22455,7 +22568,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -22507,7 +22621,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.5-flash": { "prompt_cache_min_tokens": 4096, @@ -22563,6 +22678,7 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_token_batches": 7.5e-07, "output_cost_per_token_batches": 4.5e-06, "input_cost_per_token_flex": 7.5e-07, @@ -22625,7 +22741,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, @@ -22683,7 +22800,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-omni-flash-preview": { "input_cost_per_audio_token": 1.5e-06, @@ -22774,7 +22892,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-3.1-pro-preview-customtools": { "prompt_cache_min_tokens": 4096, @@ -22832,7 +22951,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3-flash-preview": { "cache_read_input_token_cost": 5e-08, @@ -22882,7 +23002,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-omni-flash-preview": { "input_cost_per_audio_token": 1.5e-06, @@ -22968,6 +23089,7 @@ "search_context_size_high": 0.014 }, "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014, "input_cost_per_token_batches": 7.5e-07, "output_cost_per_token_batches": 4.5e-06, "input_cost_per_token_flex": 7.5e-07, @@ -23028,7 +23150,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini-3.7-flash": { "prompt_cache_min_tokens": 4096, @@ -23084,7 +23207,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "gemini/gemini-2.5-pro-preview-tts": { "cache_read_input_token_cost": 1.25e-07, @@ -33793,7 +33917,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "openrouter/anthropic/claude-opus-4.1": { "input_cost_per_image": 0.0048, @@ -33813,7 +33938,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "openrouter/anthropic/claude-sonnet-4": { "input_cost_per_image": 0.0048, @@ -33836,7 +33962,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "openrouter/anthropic/claude-sonnet-4.6": { "supports_adaptive_thinking": true, @@ -33862,7 +33989,8 @@ "supports_reasoning": true, "supports_max_reasoning_effort": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "openrouter/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, @@ -33881,7 +34009,8 @@ "supports_reasoning": true, "supports_tool_choice": true, "supports_vision": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 4096 }, "openrouter/anthropic/claude-opus-4.6": { "supports_adaptive_thinking": true, @@ -33902,7 +34031,8 @@ "supports_reasoning": true, "supports_max_reasoning_effort": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 4096 }, "openrouter/anthropic/claude-sonnet-4.5": { "input_cost_per_image": 0.0048, @@ -33925,7 +34055,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "openrouter/anthropic/claude-haiku-4.5": { "cache_creation_input_token_cost": 1.25e-06, @@ -33943,7 +34074,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 4096 }, "openrouter/anthropic/claude-opus-4.7": { "supports_adaptive_thinking": true, @@ -33966,7 +34098,8 @@ "supports_max_reasoning_effort": true, "supports_tool_choice": true, "supports_vision": true, - "supports_xhigh_reasoning_effort": true + "supports_xhigh_reasoning_effort": true, + "prompt_cache_min_tokens": 2048 }, "openrouter/anthropic/claude-opus-5": { "prompt_cache_min_tokens": 512, @@ -36433,7 +36566,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_response_schema": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "prompt_cache_min_tokens": 4096 }, "replicate/ibm-granite/granite-3.3-8b-instruct": { "input_cost_per_token": 3e-08, @@ -36515,7 +36649,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_response_schema": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "prompt_cache_min_tokens": 1024 }, "replicate/deepseek-ai/deepseek-v3": { "input_cost_per_token": 1.45e-06, @@ -36590,7 +36725,8 @@ "supports_system_messages": true, "supports_tool_choice": true, "supports_response_schema": true, - "supports_prompt_caching": true + "supports_prompt_caching": true, + "prompt_cache_min_tokens": 1024 }, "replicate/openai/gpt-4.1": { "input_cost_per_token": 2e-06, @@ -39619,7 +39755,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 4096 }, "vercel_ai_gateway/anthropic/claude-opus-4": { "cache_creation_input_token_cost": 1.875e-05, @@ -39638,7 +39775,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "vercel_ai_gateway/anthropic/claude-opus-4.1": { "cache_creation_input_token_cost": 1.875e-05, @@ -39657,7 +39795,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "vercel_ai_gateway/anthropic/claude-opus-4.5": { "cache_creation_input_token_cost": 6.25e-06, @@ -39677,7 +39816,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 4096 }, "vercel_ai_gateway/anthropic/claude-opus-4.6": { "supports_adaptive_thinking": true, @@ -39699,7 +39839,8 @@ "supports_response_schema": true, "supports_tool_choice": true, "supports_vision": true, - "supports_output_config": true + "supports_output_config": true, + "prompt_cache_min_tokens": 4096 }, "vercel_ai_gateway/anthropic/claude-sonnet-4": { "cache_creation_input_token_cost": 3.75e-06, @@ -39718,7 +39859,8 @@ "supports_reasoning": true, "supports_response_schema": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "vercel_ai_gateway/anthropic/claude-sonnet-4.5": { "cache_creation_input_token_cost": 3.75e-06, @@ -39736,7 +39878,8 @@ "supports_prompt_caching": true, "supports_reasoning": true, "supports_tool_choice": true, - "supports_vision": true + "supports_vision": true, + "prompt_cache_min_tokens": 1024 }, "vercel_ai_gateway/cohere/command-a": { "input_cost_per_token": 2.5e-06, @@ -41101,7 +41244,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 }, "vertex_ai/claude-fable-5@default": { "deprecation_date": "2027-06-08", @@ -41135,7 +41279,8 @@ "supports_tool_choice": true, "supports_vision": true, "supports_xhigh_reasoning_effort": true, - "supports_max_reasoning_effort": true + "supports_max_reasoning_effort": true, + "prompt_cache_min_tokens": 512 }, "vertex_ai/claude-opus-5": { "deprecation_date": "2027-01-24", @@ -41817,7 +41962,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.1-flash-lite": { "deprecation_date": "2027-05-07", @@ -41875,7 +42021,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/gemini-3.5-flash-lite": { "deprecation_date": "2027-07-21", @@ -41932,7 +42079,8 @@ "search_context_size_medium": 0.014, "search_context_size_high": 0.014 }, - "web_search_billing_unit": "per_query" + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014 }, "vertex_ai/deep-research-pro-preview-12-2025": { "input_cost_per_image": 0.0011, @@ -49028,7 +49176,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini-flash-lite-latest": { "cache_read_input_token_cost": 1e-08, @@ -49074,7 +49223,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini-pro-latest": { "cache_read_input_token_cost": 1.25e-07, @@ -49119,7 +49269,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini/gemini-pro-latest": { "cache_read_input_token_cost": 1.25e-07, @@ -49164,7 +49315,8 @@ "search_context_size_low": 0.035, "search_context_size_medium": 0.035, "search_context_size_high": 0.035 - } + }, + "google_maps_grounding_cost_per_query": 0.025 }, "gemini-exp-1206": { "cache_read_input_token_cost": 3e-08, @@ -50039,7 +50191,8 @@ "supports_vision": true, "supports_prompt_caching": true, "supports_system_messages": true, - "supports_response_schema": true + "supports_response_schema": true, + "prompt_cache_min_tokens": 1024 }, "snowflake/claude-sonnet-4-6": { "supports_adaptive_thinking": true, @@ -50056,7 +50209,8 @@ "supports_vision": true, "supports_prompt_caching": true, "supports_system_messages": true, - "supports_response_schema": true + "supports_response_schema": true, + "prompt_cache_min_tokens": 1024 }, "snowflake/claude-4-sonnet": { "max_tokens": 16384, @@ -50071,7 +50225,8 @@ "supports_vision": true, "supports_prompt_caching": true, "supports_system_messages": true, - "supports_response_schema": true + "supports_response_schema": true, + "prompt_cache_min_tokens": 1024 }, "snowflake/claude-4-opus": { "max_tokens": 16384, @@ -50087,7 +50242,8 @@ "supports_prompt_caching": true, "supports_system_messages": true, "supports_reasoning": true, - "supports_response_schema": true + "supports_response_schema": true, + "prompt_cache_min_tokens": 1024 }, "snowflake/claude-haiku-4-5": { "max_tokens": 16384, @@ -50102,7 +50258,8 @@ "supports_vision": true, "supports_prompt_caching": true, "supports_system_messages": true, - "supports_response_schema": true + "supports_response_schema": true, + "prompt_cache_min_tokens": 4096 }, "snowflake/claude-3-7-sonnet": { "max_tokens": 16384, @@ -50413,6 +50570,32 @@ "supports_tool_choice": true, "supports_vision": false }, + "deepseek-v4-flash-vision-exp": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 1.4e-08, + "input_cost_per_token": 4.4e-07, + "input_cost_per_token_cache_hit": 1.4e-08, + "litellm_provider": "deepseek", + "max_input_tokens": 1000000, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 1.32e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, "deepseek-v4-pro": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 4.4e-08, @@ -50465,6 +50648,32 @@ "supports_tool_choice": true, "supports_vision": false }, + "deepseek/deepseek-v4-flash-vision-exp": { + "cache_creation_input_token_cost": 0.0, + "cache_read_input_token_cost": 1.4e-08, + "input_cost_per_token": 4.4e-07, + "input_cost_per_token_cache_hit": 1.4e-08, + "litellm_provider": "deepseek", + "max_input_tokens": 1000000, + "max_output_tokens": 393216, + "max_tokens": 393216, + "mode": "chat", + "output_cost_per_token": 1.32e-06, + "source": "https://api-docs.deepseek.com/quick_start/pricing", + "supported_endpoints": [ + "/v1/chat/completions" + ], + "supports_assistant_prefill": true, + "supports_function_calling": true, + "supports_native_streaming": true, + "supports_parallel_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true + }, "deepseek/deepseek-v4-pro": { "cache_creation_input_token_cost": 0.0, "cache_read_input_token_cost": 4.4e-08, diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index f7f60c7666d..f68644705b6 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -104,6 +104,11 @@ "minimum": 0, "description": "Flex service-tier rate for the same-named base field." }, + "cache_creation_input_token_cost_above_272k_tokens_priority": { + "type": "number", + "minimum": 0, + "description": "Priority service-tier rate for the same-named base field." + }, "cache_creation_input_token_cost_flex": { "type": "number", "minimum": 0, @@ -186,6 +191,11 @@ "gemini_native_audio": { "type": "boolean" }, + "google_maps_grounding_cost_per_query": { + "type": "number", + "minimum": 0, + "description": "USD per Grounding with Google Maps request; billed per query or per prompt per web_search_billing_unit." + }, "guardrail_cost_per_unit": { "type": "object", "description": "USD cost per billable guardrail unit, keyed by the provider's usage counter name (e.g. Bedrock's contentPolicyUnits).", diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index d5d24904a71..149c44ed083 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -57,7 +57,7 @@ "limit": 3 }, "BLE001": { - "limit": 2918 + "limit": 2917 }, "C401": { "limit": 8 @@ -108,7 +108,7 @@ "limit": 3 }, "F401": { - "limit": 14 + "limit": 13 }, "LOG015": { "limit": 5 @@ -171,7 +171,7 @@ "limit": 175 }, "RUF012": { - "limit": 240 + "limit": 239 }, "RUF015": { "limit": 8 @@ -183,13 +183,13 @@ "limit": 4 }, "RUF059": { - "limit": 67 + "limit": 66 }, "RUF100": { "limit": 0 }, "S110": { - "limit": 218 + "limit": 217 }, "S112": { "limit": 22 diff --git a/ruff-tests.toml b/ruff-tests.toml index e036fb4946c..d75f10b9605 100644 --- a/ruff-tests.toml +++ b/ruff-tests.toml @@ -55,6 +55,40 @@ # never fires and a subprocess still reads the real keys the test believes it # cleared. The manual restore underneath is skipped whenever the body raises, # so every later test in that worker inherits a plain dict for an environment +# PGH005 an assertion on a mock attribute the library never defines. `assert +# m.called_once` and a bare `m.assert_called_once` both read as checks and +# neither is one: a Mock invents whatever attribute it is asked for, so the +# first is always truthy and the second is an attribute nobody calls +# F631 `assert (cond, "message")` asserts a two-element tuple, which is always +# truthy. The message meant to explain the failure is what stops the assertion +# from ever having one +# F634 `if (a, b):` branches on a tuple, so the branch is always taken and the +# condition it was written to test is never evaluated +# PT010 `pytest.raises()` with no exception type accepts anything the block raises, +# including the TypeError a refactor introduced +# PT030 the `pytest.warns` twin of PT011. `Warning` or `UserWarning` with no `match=` +# passes on any warning that broad +# PT031 the `pytest.warns` twin of PT012. Everything after the warning call is dead, +# so an `assert` sitting there is never checked +# B012 a `return`, `break` or `continue` inside `finally` discards whatever exception +# was in flight, so the AssertionError the test just raised is thrown away and +# the test reports green +# B013 a one-element tuple where the exception class was meant, which reads as a +# wider handler than it is +# B014 an exception named twice in one handler, or a subclass beside its parent. The +# second name does nothing, and it is usually the one someone meant to change +# B016 `raise "message"` raises a str, so the failure the test set up is replaced by +# a TypeError from the raise itself +# B022 `contextlib.suppress()` with no arguments suppresses nothing, so the call it +# wraps still raises +# B029 `except ():` catches nothing, so the recovery or skip written in that handler +# never happens +# B030 an `except` naming something that is not an exception class raises TypeError +# while unwinding, replacing the error under test +# F707 a bare `except:` ahead of another handler makes every handler below it +# unreachable +# PLE0704 a bare `raise` outside an except block raises RuntimeError instead of +# re-raising anything # # No target-version here on purpose: it resolves from requires-python (>=3.10), so # 3.11-only builtins like BaseExceptionGroup are correctly flagged in a tree that @@ -83,4 +117,19 @@ lint.select = [ "B025", "F632", "B003", + "PGH005", + "F631", + "F634", + "PT010", + "PT030", + "PT031", + "B012", + "B013", + "B014", + "B016", + "B022", + "B029", + "B030", + "F707", + "PLE0704", ] diff --git a/tests/proxy_unit_tests/test_proxy_server.py b/tests/proxy_unit_tests/test_proxy_server.py index ceaabf0a70f..375e1117371 100644 --- a/tests/proxy_unit_tests/test_proxy_server.py +++ b/tests/proxy_unit_tests/test_proxy_server.py @@ -2655,6 +2655,35 @@ async def test_run_direct_health_check_with_instrumentation_accepts_filter_only( assert seen[0] is False +@pytest.mark.asyncio +async def test_run_direct_health_check_drops_only_the_rejected_kwarg(monkeypatch): + """A callee that predates `router` must still get the skip-disabled filter: dropping the + rejected argument alongside working ones would probe deployments the operator opted out.""" + import litellm.proxy.proxy_server as proxy_server + + seen: list = [] + + async def fake_perform_health_check( + model_list, + details, + max_concurrency=None, + instrumentation_context=None, + health_check_skip_disabled_background_models=False, + ): + seen.append((instrumentation_context, health_check_skip_disabled_background_models)) + return ([], [], {}) + + monkeypatch.setattr(proxy_server, "perform_health_check", fake_perform_health_check) + monkeypatch.setattr( + proxy_server, + "general_settings", + {"health_check_skip_disabled_background_models": True}, + ) + await proxy_server._run_direct_health_check_with_instrumentation([], True, 1, {"cycle_id": "c3"}) + + assert seen == [({"cycle_id": "c3"}, True)] + + @pytest.mark.asyncio async def test_run_direct_health_check_with_instrumentation_non_kw_typeerror_reraises( monkeypatch, diff --git a/tests/test_litellm/caching/test_caching_handler.py b/tests/test_litellm/caching/test_caching_handler.py index 6c60aa6e220..8e0bc200012 100644 --- a/tests/test_litellm/caching/test_caching_handler.py +++ b/tests/test_litellm/caching/test_caching_handler.py @@ -617,3 +617,44 @@ def test_request_kwargs_does_not_retain_logging_obj(): assert "litellm_logging_obj" not in handler.request_kwargs assert handler.request_kwargs["messages"] == kwargs["messages"] assert handler.request_kwargs["model"] == "gpt-4o" + + +def test_async_cache_write_completes_when_asyncio_run_closes_the_loop(monkeypatch): + """ + Regression test for the SDK losing async cache writes in short-lived scripts: + async_set_cache dispatched the write as a bare fire-and-forget task, so + asyncio.run cancelled it at loop close before the write landed (LIT-6184, + deterministic with hiredis installed). The write must survive loop shutdown. + """ + import litellm + + writes = [] + + class _SlowWriteCache: + supported_call_types = ["acompletion"] + cache = None + + async def async_add_cache(self, result, dynamic_cache_object=None, **kwargs): + await asyncio.sleep(0.2) + writes.append(result) + + async def acompletion(**kwargs): + return None + + handler = LLMCachingHandler( + original_function=acompletion, + request_kwargs={}, + start_time=datetime.now(), + ) + monkeypatch.setattr(litellm, "cache", _SlowWriteCache()) + + async def _short_lived_script(): + await handler.async_set_cache( + result=litellm.ModelResponse(), + original_function=acompletion, + kwargs={}, + ) + + asyncio.run(_short_lived_script()) + + assert len(writes) == 1 diff --git a/tests/test_litellm/caching/test_redis_cache.py b/tests/test_litellm/caching/test_redis_cache.py index decf59130fe..487a64797d1 100644 --- a/tests/test_litellm/caching/test_redis_cache.py +++ b/tests/test_litellm/caching/test_redis_cache.py @@ -17,6 +17,31 @@ def redis_no_ping(): yield +@pytest.mark.parametrize( + ("namespace", "key", "expected"), + [ + ("litellm", "litellm_spend_update_buffer", "litellm:litellm_spend_update_buffer"), + ("litellm", "litellm_config:param:general_settings", "litellm:litellm_config:param:general_settings"), + ("litellm", "litellm:3997c4abcdef", "litellm:3997c4abcdef"), + ("litellm", "spend:key:3997c4abcdef", "litellm:spend:key:3997c4abcdef"), + (None, "litellm_spend_update_buffer", "litellm_spend_update_buffer"), + ("", "litellm_spend_update_buffer", "litellm_spend_update_buffer"), + ], +) +def test_check_and_fix_namespace_prefixes_keys_sharing_the_namespace_prefix( + namespace, key, expected, monkeypatch, redis_no_ping +): + """A key whose name merely begins with the namespace string (e.g. + litellm_spend_update_buffer under namespace "litellm") is not namespaced + yet and must still get the "namespace:" prefix; only a key already carrying + the delimited prefix is left alone. Without this, spend update buffers and + litellm_config:param:* keys reach Redis unprefixed and NOPERM under an ACL + scoped to the namespace pattern.""" + monkeypatch.setenv("REDIS_HOST", "https://my-test-host") + redis_cache = RedisCache(namespace=namespace) + assert redis_cache.check_and_fix_namespace(key=key) == expected + + @pytest.mark.parametrize("namespace", [None, "litellm"]) @pytest.mark.asyncio async def test_async_delete_cache_applies_namespace( diff --git a/tests/test_litellm/integrations/dotprompt/test_prompt_manager.py b/tests/test_litellm/integrations/dotprompt/test_prompt_manager.py index b92ed13302e..3c10e0db8d7 100644 --- a/tests/test_litellm/integrations/dotprompt/test_prompt_manager.py +++ b/tests/test_litellm/integrations/dotprompt/test_prompt_manager.py @@ -577,3 +577,83 @@ async def test_dotprompt_with_prompt_version(): ) assert "Version 2:" in v2_rendered assert "Test v2" in v2_rendered + + +def test_keyed_prompt_data_with_prompt_id_keeps_real_content(): + prompt_data = { + "json_prompt": { + "content": "You are a pirate. Begin every reply with AHOY.", + "metadata": {"model": "gpt-4o-mini"}, + } + } + + manager = PromptManager(prompt_data=prompt_data, prompt_id="agent-prompt") + + template = manager.get_prompt("json_prompt") + assert template is not None + assert template.content == "You are a pirate. Begin every reply with AHOY." + assert template.model == "gpt-4o-mini" + assert "agent-prompt" not in manager.prompts + + +def test_flat_prompt_data_with_prompt_id_registers_under_prompt_id(): + manager = PromptManager( + prompt_data={"content": "Hello {{name}}", "metadata": {"model": "gpt-4o-mini"}}, + prompt_id="flat-prompt", + ) + + template = manager.get_prompt("flat-prompt") + assert template is not None + assert template.content == "Hello {{name}}" + assert manager.render("flat-prompt", {"name": "world"}) == "Hello world" + + +def test_get_prompt_falls_back_to_base_id_for_versioned_id(): + manager = PromptManager( + prompt_data={"content": "Hi", "metadata": {}}, + prompt_id="my-prompt", + ) + + assert manager.get_prompt("my-prompt.v1") is not None + assert manager.get_prompt("my-prompt.v12") is not None + assert manager.get_prompt("my-prompt.vx") is None + assert manager.get_prompt("other-prompt.v1") is None + + +def test_should_run_prompt_management_accepts_versioned_id(): + from litellm.integrations.dotprompt import DotpromptManager + + dotprompt_manager = DotpromptManager( + prompt_data={"content": "Hi", "metadata": {}}, + prompt_id="versioned-prompt", + ) + + assert dotprompt_manager.should_run_prompt_management("versioned-prompt", None, {}) is True + assert dotprompt_manager.should_run_prompt_management("versioned-prompt.v1", None, {}) is True + assert dotprompt_manager.should_run_prompt_management("missing-prompt", None, {}) is False + + +def test_prompt_initializer_registers_flat_db_prompt_under_base_id(): + from litellm.integrations.dotprompt import DotpromptManager, prompt_initializer + from litellm.types.prompts.init_prompts import ( + PromptInfo, + PromptLiteLLMParams, + PromptSpec, + ) + + litellm_params = PromptLiteLLMParams( + prompt_integration="dotprompt", + prompt_data={"content": "AHOY {{name}}", "metadata": {"model": "gpt-4o-mini"}}, + ) + prompt_spec = PromptSpec( + prompt_id="agent-prompt.v1", + litellm_params=litellm_params, + prompt_info=PromptInfo(prompt_type="db"), + ) + + dotprompt_manager = prompt_initializer(litellm_params, prompt_spec) + + assert isinstance(dotprompt_manager, DotpromptManager) + template = dotprompt_manager.prompt_manager.get_prompt("agent-prompt") + assert template is not None + assert template.content == "AHOY {{name}}" diff --git a/tests/test_litellm/integrations/test_langfuse.py b/tests/test_litellm/integrations/test_langfuse.py index 747f733a46d..f153ec1193c 100644 --- a/tests/test_litellm/integrations/test_langfuse.py +++ b/tests/test_litellm/integrations/test_langfuse.py @@ -1179,6 +1179,14 @@ def test_max_langfuse_clients_limit(): class _RecordingLangfuse: last_parameters: Optional[dict] = None + def __init__(self, environment=None, **parameters): + type(self).last_parameters = {"environment": environment, **parameters} + self.client = MagicMock() + + +class _RecordingLangfuseWithoutEnvironment: + last_parameters: Optional[dict] = None + def __init__(self, **parameters): type(self).last_parameters = parameters self.client = MagicMock() @@ -1195,6 +1203,62 @@ def _build_langfuse_logger(monkeypatch) -> LangFuseLogger: ) +def test_langfuse_environment_is_passed_to_sdk_client(monkeypatch): + monkeypatch.setenv("LANGFUSE_MOCK", "false") + monkeypatch.delenv("LANGFUSE_TRACING_ENVIRONMENT", raising=False) + monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) + with patch("langfuse.Langfuse", _RecordingLangfuse): + logger = LangFuseLogger( + langfuse_public_key="pk-env", + langfuse_secret="sk-env", + langfuse_host="https://test.langfuse.com", + langfuse_environment="staging", + ) + assert logger.langfuse_environment == "staging" + assert _RecordingLangfuse.last_parameters["environment"] == "staging" + + +def test_langfuse_environment_falls_back_to_deployment_env_var(monkeypatch): + monkeypatch.setenv("LANGFUSE_MOCK", "false") + monkeypatch.setenv("LANGFUSE_TRACING_ENVIRONMENT", "deployment-wide") + monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) + with patch("langfuse.Langfuse", _RecordingLangfuse): + logger = LangFuseLogger( + langfuse_public_key="pk-env", + langfuse_secret="sk-env", + langfuse_host="https://test.langfuse.com", + ) + assert logger.langfuse_environment == "deployment-wide" + assert _RecordingLangfuse.last_parameters["environment"] == "deployment-wide" + + +def test_langfuse_environment_omitted_for_old_sdk_versions(monkeypatch): + monkeypatch.setenv("LANGFUSE_MOCK", "false") + monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) + with patch("langfuse.Langfuse", _RecordingLangfuseWithoutEnvironment): + LangFuseLogger( + langfuse_public_key="pk-env", + langfuse_secret="sk-env", + langfuse_host="https://test.langfuse.com", + langfuse_environment="staging", + ) + assert "environment" not in _RecordingLangfuseWithoutEnvironment.last_parameters + + +def test_dynamic_langfuse_environment_triggers_dynamic_logger(): + from litellm.integrations.langfuse.langfuse_handler import LangFuseHandler + from litellm.types.utils import StandardCallbackDynamicParams + + params = StandardCallbackDynamicParams(langfuse_environment="team-a-env") + + assert LangFuseHandler._dynamic_langfuse_credentials_are_passed(params) is True + + config = LangFuseHandler.get_dynamic_langfuse_logging_config( + standard_callback_dynamic_params=params + ) + assert config["langfuse_environment"] == "team-a-env" + + def test_langfuse_sdk_client_survives_httpx_cache_eviction(monkeypatch): import gc import weakref @@ -1408,3 +1472,52 @@ def test_update_trace_keys_matches_whole_keys_not_substrings(): ) assert "input" not in trace_params + + +def test_langfuse_environment_is_coerced_and_validated(monkeypatch): + monkeypatch.setenv("LANGFUSE_MOCK", "false") + monkeypatch.delenv("LANGFUSE_TRACING_ENVIRONMENT", raising=False) + monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) + with patch("langfuse.Langfuse", _RecordingLangfuse): + logger = LangFuseLogger( + langfuse_public_key="pk-env", + langfuse_secret="sk-env", + langfuse_host="https://test.langfuse.com", + langfuse_environment=123, # non-string: must coerce, not crash + ) + assert logger.langfuse_environment == "123" + + with pytest.raises(ValueError, match="langfuse_environment"): + LangFuseLogger( + langfuse_public_key="pk-env", + langfuse_secret="sk-env", + langfuse_host="https://test.langfuse.com", + langfuse_environment="Production", + ) + + +def test_langfuse_empty_environment_falls_back_and_is_not_dynamic(monkeypatch): + from litellm.integrations.langfuse.langfuse_handler import LangFuseHandler + from litellm.types.utils import StandardCallbackDynamicParams + + monkeypatch.setenv("LANGFUSE_TRACING_ENVIRONMENT", "production") + + # '' falls back to the deployment env var at init + monkeypatch.setenv("LANGFUSE_MOCK", "false") + monkeypatch.setattr(litellm, "initialized_langfuse_clients", 0) + with patch("langfuse.Langfuse", _RecordingLangfuse): + logger = LangFuseLogger( + langfuse_public_key="pk-env", + langfuse_secret="sk-env", + langfuse_host="https://test.langfuse.com", + langfuse_environment="", + ) + assert logger.langfuse_environment == "production" + + # env-only params that add nothing do not select a dynamic logger + for redundant in ["", " ", "production"]: + params = StandardCallbackDynamicParams(langfuse_environment=redundant) + assert LangFuseHandler._dynamic_langfuse_credentials_are_passed(params) is False + + params = StandardCallbackDynamicParams(langfuse_environment="team-a-prod") + assert LangFuseHandler._dynamic_langfuse_credentials_are_passed(params) is True diff --git a/tests/test_litellm/integrations/test_langfuse_otel.py b/tests/test_litellm/integrations/test_langfuse_otel.py index 89607494920..0a9ce55fe16 100644 --- a/tests/test_litellm/integrations/test_langfuse_otel.py +++ b/tests/test_litellm/integrations/test_langfuse_otel.py @@ -137,6 +137,32 @@ class TestLangfuseOtelIntegration: mock_span, "langfuse.environment", test_env ) + def test_set_langfuse_environment_attribute_prefers_dynamic_param(self): + """Per-key/team langfuse_environment beats the deployment env var.""" + + class _RecordingSpan: + def __init__(self): + self.attributes = {} + + def set_attribute(self, key, value): + self.attributes[key] = value + + span = _RecordingSpan() + mock_kwargs = { + "standard_callback_dynamic_params": { + "langfuse_environment": "team-a-env" + } + } + + with patch.dict( + os.environ, {"LANGFUSE_TRACING_ENVIRONMENT": "deployment-wide"} + ): + LangfuseOtelLogger._set_langfuse_specific_attributes( + span, mock_kwargs, {} + ) + + assert span.attributes["langfuse.environment"] == "team-a-env" + def test_extract_langfuse_metadata_basic(self): """Ensure metadata is correctly pulled from litellm_params.""" metadata_in = {"generation_name": "my-gen", "custom": "data"} diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py index 9bdded94513..fd795ffcc96 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_tool_call_cost_tracking.py @@ -512,6 +512,95 @@ def test_gemini_3x_web_search_billed_per_query(model, local_model_cost_map): ) +@pytest.mark.parametrize( + "model,custom_llm_provider", + [ + ("gemini/gemini-2.5-flash", "gemini"), + ("vertex_ai/gemini-2.5-flash", "vertex_ai"), + ], +) +def test_gemini_2x_maps_grounding_billed_at_maps_rate(model, custom_llm_provider, local_model_cost_map): + """ + Grounding with Google Maps is its own SKU: a Maps-only grounded prompt on Gemini 2.x bills the + $0.025 Maps per-prompt fee, not the $0.035 Google Search fee it was previously conflated with, + and not $0 as on Vertex AI where webSearchQueries is never populated for Maps. + Regression for https://github.com/BerriAI/litellm/issues/35906 + """ + from litellm.types.utils import PromptTokensDetailsWrapper, Usage + + model_info = litellm.get_model_info(model) + expected_cost = model_info["google_maps_grounding_cost_per_query"] + assert expected_cost == pytest.approx(0.025) + + usage = Usage( + prompt_tokens=15, + completion_tokens=100, + total_tokens=115, + prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=15, google_maps_grounding_requests=1), + ) + cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools( + model=model, + usage=usage, + response_object=None, + custom_llm_provider=custom_llm_provider, + standard_built_in_tools_params=None, + ) + assert cost == pytest.approx(expected_cost) + + +def test_gemini_3x_maps_grounding_billed_per_query(local_model_cost_map): + """Gemini 3.x bills Maps grounding per executed query: N queries cost N * $0.014.""" + from litellm.types.utils import PromptTokensDetailsWrapper, Usage + + model = "vertex_ai/gemini-3.5-flash" + model_info = litellm.get_model_info(model) + assert model_info["web_search_billing_unit"] == "per_query" + expected_cost = model_info["google_maps_grounding_cost_per_query"] * 2 + + usage = Usage( + prompt_tokens=15, + completion_tokens=100, + total_tokens=115, + prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=15, google_maps_grounding_requests=2), + ) + cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools( + model=model, + usage=usage, + response_object=None, + custom_llm_provider="vertex_ai", + standard_built_in_tools_params=None, + ) + assert cost == pytest.approx(expected_cost) + assert cost == pytest.approx(0.028) + + +def test_gemini_combined_search_and_maps_costs_are_additive(local_model_cost_map): + """A prompt grounded with both Google Search and Google Maps pays both fees.""" + from litellm.types.utils import PromptTokensDetailsWrapper, Usage + + model = "gemini/gemini-3.5-flash" + model_info = litellm.get_model_info(model) + search_rate = model_info["search_context_cost_per_query"]["search_context_size_medium"] + maps_rate = model_info["google_maps_grounding_cost_per_query"] + + usage = Usage( + prompt_tokens=15, + completion_tokens=100, + total_tokens=115, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=15, web_search_requests=2, google_maps_grounding_requests=1 + ), + ) + cost = StandardBuiltInToolCostTracking.get_cost_for_built_in_tools( + model=model, + usage=usage, + response_object=None, + custom_llm_provider="gemini", + standard_built_in_tools_params=None, + ) + assert cost == pytest.approx(search_rate * 2 + maps_rate) + + def test_gemini_2x_web_search_still_billed_per_prompt(local_model_cost_map): """ Gemini 2.x bills web search per grounded prompt: multiple internal queries are one flat diff --git a/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py b/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py index c2e2f92ad8a..ee2a31beff7 100644 --- a/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py @@ -1,19 +1,41 @@ """Test health check helper functions""" +import struct +import zlib from unittest.mock import AsyncMock, MagicMock, patch import pytest - +import litellm from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME -from litellm.litellm_core_utils.health_check_helpers import HealthCheckHelpers +from litellm.litellm_core_utils.health_check_helpers import ( + IMAGE_EDIT_HEALTH_CHECK_PROMPT, + HealthCheckHelpers, +) from litellm.main import ahealth_check from litellm.proxy._types import UserAPIKeyAuth from litellm.types.utils import LIST_BATCHES_SUPPORTED_PROVIDERS +def _png_chunks(png: bytes, offset: int = 8) -> tuple[tuple[bytes, bytes], ...]: + if offset >= len(png): + return () + (length,) = struct.unpack(">I", png[offset : offset + 4]) + chunk = (png[offset + 4 : offset + 8], png[offset + 8 : offset + 8 + length]) + return (chunk, *_png_chunks(png, offset + 12 + length)) + + +def _distinct_rgb_colors(png: bytes) -> set[bytes]: + width = int.from_bytes(png[16:20], "big") + raw = zlib.decompress(b"".join(data for tag, data in _png_chunks(png) if tag == b"IDAT")) + row_size = 1 + width * 3 + rows = tuple(raw[i : i + row_size] for i in range(0, len(raw), row_size)) + assert all(row[0] == 0 for row in rows) + return {bytes(row[i : i + 3]) for row in rows for i in range(1, row_size, 3)} + + @pytest.mark.asyncio -async def test_image_edit_health_check_handler_uses_png_and_prompt(): +async def test_image_edit_health_check_handler_uses_descriptive_prompt_and_multicolor_png(): model_params = {"model": "openai/gpt-image-1", "api_key": "sk-test"} mode_handlers = HealthCheckHelpers.get_mode_handlers( model="gpt-image-1", @@ -31,20 +53,76 @@ async def test_image_edit_health_check_handler_uses_png_and_prompt(): model="gpt-image-1", custom_llm_provider="openai", model_params=model_params, - prompt="edit this image", + prompt="test from litellm", )["image_edit"]() assert mock_aimage_edit.call_count == 2 - default_call = mock_aimage_edit.call_args_list[0].kwargs - explicit_call = mock_aimage_edit.call_args_list[1].kwargs - assert default_call["model"] == "openai/gpt-image-1" - assert default_call["prompt"] == "test" - assert explicit_call["prompt"] == "edit this image" - image = default_call["image"] + for handler_call in mock_aimage_edit.call_args_list: + assert handler_call.kwargs["model"] == "openai/gpt-image-1" + assert handler_call.kwargs["prompt"] == IMAGE_EDIT_HEALTH_CHECK_PROMPT + image = mock_aimage_edit.call_args_list[0].kwargs["image"] assert isinstance(image, bytes) assert image.startswith(b"\x89PNG") assert int.from_bytes(image[16:20], "big") == 512 assert int.from_bytes(image[20:24], "big") == 512 + assert len(_distinct_rgb_colors(image)) >= 2 + + +@pytest.mark.asyncio +async def test_ahealth_check_image_edit_treats_content_policy_violation_as_healthy(): + moderation_error = litellm.ContentPolicyViolationError( + message="Your request was rejected as a result of our safety system.", + model="gpt-image-1", + llm_provider="openai", + ) + with patch( # test-quality-ok: the public health-check path has no dependency injection seam + "litellm.aimage_edit", new_callable=AsyncMock, side_effect=moderation_error + ): + result = await ahealth_check( + {"model": "gpt-image-1", "api_key": "sk-test"}, + mode="image_edit", + ) + + assert "error" not in result + + +@pytest.mark.asyncio +async def test_ahealth_check_image_edit_treats_moderation_blocked_code_as_healthy(): + moderation_blocked = litellm.BadRequestError( + message=( + '{"error": {"code": "moderation_blocked", "message": "Your request was blocked", ' + '"moderation_stage": "output", "type": "invalid_request_error"}}' + ), + model="gpt-image-1", + llm_provider="openai", + ) + with patch( # test-quality-ok: the public health-check path has no dependency injection seam + "litellm.aimage_edit", new_callable=AsyncMock, side_effect=moderation_blocked + ): + result = await ahealth_check( + {"model": "gpt-image-1", "api_key": "sk-test"}, + mode="image_edit", + ) + + assert "error" not in result + + +@pytest.mark.asyncio +async def test_ahealth_check_image_edit_still_fails_on_non_moderation_errors(): + auth_error = litellm.AuthenticationError( + message="Incorrect API key provided", + llm_provider="openai", + model="gpt-image-1", + ) + with patch( # test-quality-ok: the public health-check path has no dependency injection seam + "litellm.aimage_edit", new_callable=AsyncMock, side_effect=auth_error + ): + result = await ahealth_check( + {"model": "gpt-image-1", "api_key": "sk-bad"}, + mode="image_edit", + ) + + assert "error" in result @pytest.mark.asyncio @@ -88,9 +166,7 @@ def test_update_model_params_with_health_check_tracking_information(): # Verify that litellm_metadata was added assert "litellm_metadata" in result - assert result["litellm_metadata"]["tags"] == [ - LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME - ] + assert result["litellm_metadata"]["tags"] == [LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME] # Verify the auth setup was called mock_add_auth.assert_called_once() @@ -169,16 +245,12 @@ async def test_ahealth_check_failure_masks_raw_request_headers(): if "Authorization" in headers: auth_header = headers["Authorization"] # Should be masked (e.g., "Be****90" or similar) - assert ( - auth_header != f"Bearer {test_api_key}" - ), "Authorization header must be masked" - assert ( - auth_header != test_api_key - ), "API key must not appear in Authorization header" + assert auth_header != f"Bearer {test_api_key}", "Authorization header must be masked" + assert auth_header != test_api_key, "API key must not appear in Authorization header" # Masked headers typically have asterisks or are truncated - assert "*" in auth_header or len(auth_header) < len( - f"Bearer {test_api_key}" - ), f"Authorization header should be masked but got: {auth_header}" + assert "*" in auth_header or len(auth_header) < len(f"Bearer {test_api_key}"), ( + f"Authorization header should be masked but got: {auth_header}" + ) # Content-Type should remain unmasked (not sensitive) if "Content-Type" in headers: @@ -257,9 +329,7 @@ async def test_batch_health_check_skips_bridge_when_no_logging_obj(): "litellm_metadata": litellm_metadata, } - with patch( - "litellm.alist_batches", new_callable=AsyncMock, return_value={} - ) as mock_alist: + with patch("litellm.alist_batches", new_callable=AsyncMock, return_value={}) as mock_alist: await HealthCheckHelpers._batch_health_check( custom_llm_provider="openai", model_params={"model": "openai/gpt-4"}, @@ -283,9 +353,7 @@ async def test_batch_health_check_uses_alist_batches_for_supported_providers(): "litellm_metadata": litellm_metadata, } - with patch( - "litellm.alist_batches", new_callable=AsyncMock, return_value={} - ) as mock_alist: + with patch("litellm.alist_batches", new_callable=AsyncMock, return_value={}) as mock_alist: await HealthCheckHelpers._batch_health_check( custom_llm_provider=provider, model_params={"model": f"{provider}/some-model"}, @@ -344,9 +412,7 @@ async def test_realtime_health_check_uses_model_level_vertex_params(): fake_vertex_base = MagicMock() fake_vertex_base.get_vertex_region = MagicMock(return_value="us-central1") - fake_vertex_base._ensure_access_token_async = AsyncMock( - return_value=("model-level-token", "model-level-project") - ) + fake_vertex_base._ensure_access_token_async = AsyncMock(return_value=("model-level-token", "model-level-project")) connect_calls = [] with ( @@ -381,8 +447,7 @@ async def test_realtime_health_check_uses_model_level_vertex_params(): custom_llm_provider="vertex_ai", ) assert connect_calls[0]["url"] == ( - "wss://us-central1-aiplatform.googleapis.com/ws/" - "google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent" + "wss://us-central1-aiplatform.googleapis.com/ws/google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent" ) assert connect_calls[0]["additional_headers"] == { "Authorization": "Bearer model-level-token", diff --git a/tests/test_litellm/litellm_core_utils/test_initialize_dynamic_callback_params.py b/tests/test_litellm/litellm_core_utils/test_initialize_dynamic_callback_params.py index 9b2bd5e2585..fe965f75f8f 100644 --- a/tests/test_litellm/litellm_core_utils/test_initialize_dynamic_callback_params.py +++ b/tests/test_litellm/litellm_core_utils/test_initialize_dynamic_callback_params.py @@ -233,3 +233,18 @@ def test_trusted_vars_overlay_uses_shared_parser_semantics(): ) assert params.get("newrelic_api_key") == "12345" + + +def test_validate_langfuse_environment_value(): + import pytest + + from litellm.litellm_core_utils.initialize_dynamic_callback_params import ( + validate_langfuse_environment_value, + ) + + validate_langfuse_environment_value("team-a-prod") + validate_langfuse_environment_value("staging_2") + + for bad in ["Production", "langfuse-eu", "", "team a"]: + with pytest.raises(ValueError, match="langfuse_environment"): + validate_langfuse_environment_value(bad) diff --git a/tests/test_litellm/litellm_core_utils/test_logging_worker.py b/tests/test_litellm/litellm_core_utils/test_logging_worker.py index 80c9585dae9..eb4e893adb8 100644 --- a/tests/test_litellm/litellm_core_utils/test_logging_worker.py +++ b/tests/test_litellm/litellm_core_utils/test_logging_worker.py @@ -97,6 +97,32 @@ class TestLoggingWorker: logging.raiseExceptions = previous_raise_exceptions logger.removeHandler(handler) + def test_flush_on_exit_rescues_dequeued_coroutine_never_started(self): + """ + Regression test for cache-hit success callbacks lost in short-lived SDK scripts: + the worker loop dequeues the task, then ``asyncio.run`` cancels the processing + task before it ever runs, so the coroutine leaves the queue without being + awaited and the atexit flush used to find an empty queue and rescue nothing. + """ + worker = LoggingWorker(timeout=1.0, max_queue_size=10) + fired = [] + + async def marker(): + fired.append(True) + + async def short_lived_script(): + worker.ensure_initialized_and_enqueue(marker()) + + asyncio.run(short_lived_script()) + + assert worker._queue is not None + assert worker._queue.qsize() == 0, "precondition: the worker loop dequeued the task before loop close" + assert fired == [], "precondition: the callback never ran before loop close" + + worker._flush_on_exit() + + assert fired == [True] + def test_flush_on_exit_swallows_errors_and_drains_remaining(self): """A failing queued coroutine must not abort the atexit drain of later events.""" worker = LoggingWorker(timeout=1.0, max_queue_size=10) @@ -118,6 +144,53 @@ class TestLoggingWorker: assert processed == ["ran"] assert worker._queue.empty() + def test_loop_change_revives_dequeued_coroutine_on_new_loop(self): + """ + A callback dequeued but never started before its loop closed must run on the + next event loop's worker instead of staying stranded until process exit. + """ + worker = LoggingWorker(timeout=1.0, max_queue_size=10) + fired = [] + + async def marker(name): + fired.append(name) + + async def first_script(): + worker.ensure_initialized_and_enqueue(marker("first")) + + asyncio.run(first_script()) + assert fired == [], "precondition: the callback was dequeued but never ran before loop close" + + async def second_script(): + worker.ensure_initialized_and_enqueue(marker("second")) + assert worker._queue is not None + await asyncio.wait_for(worker._queue.join(), timeout=5) + + asyncio.run(second_script()) + + assert sorted(fired) == ["first", "second"] + + def test_flush_on_exit_swallows_cancellation_and_drains_remaining(self): + """A callback raising CancelledError must not abort the atexit flush of later events.""" + worker = LoggingWorker(timeout=1.0, max_queue_size=10) + worker._queue = asyncio.Queue(maxsize=10) + + processed = [] + + async def cancels_during_flush(): + raise asyncio.CancelledError() + + async def records_during_flush(): + processed.append("ran") + + worker.enqueue(cancels_during_flush()) + worker.enqueue(records_during_flush()) + + worker._flush_on_exit() + + assert processed == ["ran"] + assert worker._queue.empty() + @pytest.mark.asyncio async def test_worker_handles_cancellation_gracefully(self, logging_worker): """Test that the worker handles cancellation without throwing exceptions.""" diff --git a/tests/test_litellm/litellm_core_utils/test_ptu_pricing.py b/tests/test_litellm/litellm_core_utils/test_ptu_pricing.py index f5339daad20..b8fb372d537 100644 --- a/tests/test_litellm/litellm_core_utils/test_ptu_pricing.py +++ b/tests/test_litellm/litellm_core_utils/test_ptu_pricing.py @@ -134,6 +134,15 @@ def test_the_search_context_table_is_zeroed_in_place_on_every_deployment(): assert dict(override[field]) == dict.fromkeys(SEARCH_CONTEXT_SIZES, 0.0) +def test_the_maps_grounding_rate_is_zeroed_on_every_deployment(): + """An absent rate falls back to the Maps default rather than free, so it is written + even when the deployment never declared one.""" + override = _with_flag(_VALID) + + assert override is not None + assert override["google_maps_grounding_cost_per_query"] == 0.0 + + def test_a_declared_table_does_not_become_a_scalar(): """Zeroing it as a plain 0.0 would leave the provider's reader without a table to consult, which is the same as absent.""" diff --git a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py index 4b5b51cb4b8..8ac050a04f9 100644 --- a/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py @@ -711,6 +711,66 @@ def test_stream_chunk_builder_anthropic_web_search(): assert usage.server_tool_use.web_search_requests == 2 +def test_calculate_usage_carries_google_maps_grounding_requests(): + """ + The Maps grounding counter set on a streamed usage chunk must survive the stream rebuild even + when a later chunk carries its own prompt_tokens_details, or Maps grounding on streaming + requests silently bills $0. + """ + from litellm.types.utils import PromptTokensDetailsWrapper + + chunk1 = ModelResponseStream( + id="chatcmpl-maps-usage-0", + created=1745513207, + model="gemini-2.5-flash", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason=None, + index=0, + delta=Delta(content="Here"), + logprobs=None, + ) + ], + stream_options={"include_usage": True}, + usage=Usage( + completion_tokens=0, + prompt_tokens=15, + total_tokens=15, + prompt_tokens_details=PromptTokensDetailsWrapper(google_maps_grounding_requests=1), + ), + ) + + chunk2 = ModelResponseStream( + id="chatcmpl-maps-usage-0", + created=1745513207, + model="gemini-2.5-flash", + object="chat.completion.chunk", + choices=[ + StreamingChoices( + finish_reason="stop", + index=0, + delta=Delta(content=None), + logprobs=None, + ) + ], + stream_options={"include_usage": True}, + usage=Usage( + completion_tokens=27, + prompt_tokens=0, + total_tokens=27, + prompt_tokens_details=PromptTokensDetailsWrapper(text_tokens=0), + ), + ) + + chunks = [chunk1, chunk2] + processor = ChunkProcessor(chunks=chunks) + + usage = processor.calculate_usage(chunks=chunks, model="gemini-2.5-flash", completion_output="") + + assert usage.prompt_tokens_details.google_maps_grounding_requests == 1 + + def test_sort_chunks_handles_dict_hidden_params_created_at(): chunks = [ { diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 0933638635b..25e2c3cda80 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -100,6 +100,48 @@ def test_calculate_usage(): assert usage._cache_read_input_tokens == 0 +def test_calculate_usage_prefers_served_speed_from_response_usage(): + """ + Anthropic reports the speed a request was actually served at in the response + usage (a fast request on a model without fast mode comes back + ``"speed": "standard"``), so the served value must beat the requested one or + spend gets multiplied for fast service that never happened. + """ + config = AnthropicConfig() + + served_standard = config.calculate_usage( + usage_object={"input_tokens": 12, "output_tokens": 1, "speed": "standard"}, + reasoning_content=None, + speed="fast", + ) + assert served_standard.speed == "standard" + + no_response_speed = config.calculate_usage( + usage_object={"input_tokens": 12, "output_tokens": 1}, + reasoning_content=None, + speed="fast", + ) + assert no_response_speed.speed == "fast" + + +def test_streaming_iterator_persists_served_speed_across_usage_chunks(): + """ + Only ``message_start`` usage carries the served speed; the final + ``message_delta`` usage does not. The iterator must remember the served + value so the last usage chunk, which wins in the stream chunk builder, does + not fall back to the requested speed. + """ + from litellm.llms.anthropic.chat.handler import ModelResponseIterator + + iterator = ModelResponseIterator(None, sync_stream=True, speed="fast") + + start_usage = iterator._handle_usage({"input_tokens": 12, "output_tokens": 1, "speed": "standard"}) + delta_usage = iterator._handle_usage({"output_tokens": 5}) + + assert start_usage.speed == "standard" + assert delta_usage.speed == "standard" + + def test_calculate_usage_aggregates_cache_creation_split_across_iterations(): """ In the iterations path each iteration can carry the 5m/1h cache_creation diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index c5ab6027f45..f7f569ec14e 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -47,6 +47,97 @@ def test_transform_usage(): assert openai_usage.completion_tokens_details.text_tokens == usage["outputTokens"] +def test_transform_usage_with_cache_details(): + """cacheDetails should split cacheWriteInputTokens into the 5m/1h TTL breakdown + so cost calc can bill the 1h portion at its own (higher) rate instead of + defaulting the whole write to the 5m rate. See issue #36760.""" + usage = ConverseTokenUsageBlock( + **{ + "inputTokens": 76, + "outputTokens": 259, + "totalTokens": 335, + "cacheWriteInputTokens": 362, + "cacheDetails": [ + {"inputTokens": 74, "ttl": "1h"}, + {"inputTokens": 288, "ttl": "5m"}, + ], + } + ) + config = AmazonConverseConfig() + openai_usage = config.transform_usage(usage) + details = openai_usage.prompt_tokens_details.cache_creation_token_details + assert details is not None + assert details.ephemeral_1h_input_tokens == 74 + assert details.ephemeral_5m_input_tokens == 288 + + +def test_transform_usage_with_mismatched_cache_details_falls_back(): + """An unrecognized ttl or partial breakdown must not silently understate + cache-write cost, so the split is only used when it fully accounts for + cacheWriteInputTokens.""" + usage = ConverseTokenUsageBlock( + **{ + "inputTokens": 76, + "outputTokens": 259, + "totalTokens": 335, + "cacheWriteInputTokens": 362, + "cacheDetails": [{"inputTokens": 74, "ttl": "1h"}], # missing the 5m entry + } + ) + config = AmazonConverseConfig() + openai_usage = config.transform_usage(usage) + assert ( + getattr(openai_usage.prompt_tokens_details, "cache_creation_token_details", None) + is None + ) + + +def test_transform_usage_without_cache_details_stays_none(): + """No cacheDetails in the response (older models/regions) should leave + cache_creation_token_details unset, same as before this field existed.""" + usage = ConverseTokenUsageBlock( + **{ + "inputTokens": 3, + "outputTokens": 401, + "totalTokens": 2193, + "cacheWriteInputTokens": 1789, + } + ) + config = AmazonConverseConfig() + openai_usage = config.transform_usage(usage) + assert ( + getattr(openai_usage.prompt_tokens_details, "cache_creation_token_details", None) + is None + ) + + +def test_bedrock_converse_1h_cache_write_billed_at_1h_rate(monkeypatch): + """Regression for issue #36760: without the cacheDetails split, the whole + write is billed at the (cheaper) 5m rate.""" + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url="")) + usage = ConverseTokenUsageBlock( + **{ + "inputTokens": 16, + "outputTokens": 4, + "totalTokens": 11652, + "cacheReadInputTokens": 0, + "cacheWriteInputTokens": 11632, + "cacheDetails": [{"inputTokens": 11632, "ttl": "1h"}], + } + ) + openai_usage = AmazonConverseConfig().transform_usage(usage) + model = "bedrock/converse/global.anthropic.claude-opus-4-8" + prompt_cost, completion_cost = litellm.cost_calculator.cost_per_token(model=model, usage_object=openai_usage) + model_info = litellm.get_model_info(model=model) + expected_prompt_cost = ( + 16 * model_info["input_cost_per_token"] + 11632 * model_info["cache_creation_input_token_cost_above_1hr"] + ) + assert prompt_cost == pytest.approx(expected_prompt_cost) + assert prompt_cost > 16 * model_info["input_cost_per_token"] + 11632 * model_info["cache_creation_input_token_cost"] + assert completion_cost == pytest.approx(4 * model_info["output_cost_per_token"]) + + def test_transform_usage_with_reasoning_content(): """Test that completion_tokens_details correctly tracks reasoning vs text tokens.""" usage = ConverseTokenUsageBlock( diff --git a/tests/test_litellm/llms/custom_httpx/test_aiohttp_handler.py b/tests/test_litellm/llms/custom_httpx/test_aiohttp_handler.py index 763647aa463..c58e6d6cf5c 100644 --- a/tests/test_litellm/llms/custom_httpx/test_aiohttp_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_aiohttp_handler.py @@ -3,7 +3,7 @@ from unittest.mock import AsyncMock, Mock, patch import aiohttp import pytest - +import litellm from litellm.llms.custom_httpx.aiohttp_handler import BaseLLMAIOHTTPHandler from litellm.llms.custom_httpx.aiohttp_transport import LiteLLMAiohttpTransport @@ -318,19 +318,47 @@ class TestBaseLLMAIOHTTPHandler: mock_client_session.assert_called_once_with(connector=mock_connector) assert result is mock_session_instance - @patch("aiohttp.ClientSession") - def test_create_client_session_default(self, mock_client_session): - """Test default session creation when no transport/connector provided""" - mock_session_instance = Mock() - mock_client_session.return_value = mock_session_instance + @pytest.mark.asyncio + async def test_create_client_session_default_honors_global_ssl_verify_false( + self, monkeypatch: pytest.MonkeyPatch + ): + """Regression test for LIT-3369: `litellm.ssl_verify = False` (set via + `litellm_settings.ssl_verify: false`) must reach the default session's + connector instead of being ignored by a bare `aiohttp.ClientSession()`.""" + monkeypatch.setattr(litellm, "ssl_verify", False) handler = BaseLLMAIOHTTPHandler() + session = handler._create_client_session_with_transport() + try: + assert isinstance(session.connector, aiohttp.TCPConnector) + assert session.connector._ssl is False + finally: + await session.close() + await handler.close() - result = handler._create_client_session_with_transport() + @pytest.mark.asyncio + async def test_create_client_session_default_keeps_ssl_verification(self): + """Default `ssl_verify=True` must not collapse to `ssl=False`.""" + handler = BaseLLMAIOHTTPHandler() + session = handler._create_client_session_with_transport() + try: + assert isinstance(session.connector, aiohttp.TCPConnector) + assert session.connector._ssl is not False + finally: + await session.close() + await handler.close() - # Should create default session - mock_client_session.assert_called_once_with() - assert result is mock_session_instance + def test_get_or_create_transport_resolves_global_ssl_verify( + self, monkeypatch: pytest.MonkeyPatch + ): + """The lazily created transport must carry the resolved global ssl config.""" + monkeypatch.setattr(litellm, "ssl_verify", False) + + handler = BaseLLMAIOHTTPHandler() + transport = handler._get_or_create_transport() + + assert transport is not None + assert transport._ssl_verify is False def test_get_or_create_transport(self): """Test that _get_or_create_transport creates or returns a transport. diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py index 177c71eeae7..18d1aa949a8 100644 --- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py +++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py @@ -2757,3 +2757,176 @@ def test_video_generation_with_input_reference_keeps_file_multipart(): "seconds": "4", } assert result.status == "queued" + + +AZURE_AI_BASE = "https://myfoundry.services.ai.azure.com" +AZURE_AI_CHAT_COMPLETIONS_URL = f"{AZURE_AI_BASE}/models/chat/completions" + +def _a_tool_with_an_unsupported_field() -> dict: + return { + "type": "function", + "function": {"name": "lookup", "parameters": {"type": "object", "properties": {}}}, + "strict": True, + } + +A_COMPLETION = { + "id": "chatcmpl-1", + "object": "chat.completion", + "created": 1, + "model": "grok-3", + "choices": [ + {"index": 0, "message": {"role": "assistant", "content": "sent"}, "finish_reason": "stop"} + ], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, +} + +TOOL_LEVEL_REJECTION = "Extra inputs are not permitted: tools[0].strict" +UNRELATED_REJECTION = "Extra inputs are not permitted: temperature" +A_REJECTION_THE_PROVIDER_CANNOT_FIX = "The model is not available in this region" + + +class _RecordedAzureAI: + def __init__(self, responses: list[httpx.Response]) -> None: + self._responses = responses + self.bodies: list[dict] = [] + + def __call__(self, request: httpx.Request) -> httpx.Response: + self.bodies.append(json.loads(request.content)) + return self._responses[min(len(self.bodies) - 1, len(self._responses) - 1)] + + +@pytest.fixture +def httpx_transport(monkeypatch): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + + +def _rejection(message: str) -> httpx.Response: + return httpx.Response(422, json={"error": {"message": message}}) + + +def _call_azure_ai(recorder: _RecordedAzureAI, **overrides): + import respx + + with respx.mock(assert_all_called=True) as router: + router.post(AZURE_AI_CHAT_COMPLETIONS_URL).mock(side_effect=recorder) + return litellm.completion( + model="azure_ai/grok-3", + messages=[{"role": "user", "content": "hi"}], + tools=[_a_tool_with_an_unsupported_field()], + api_base=AZURE_AI_BASE, + api_key="fake-key", + **overrides, + ) + + +def test_a_tool_field_the_provider_rejects_is_dropped_and_the_call_retried(): + recorder = _RecordedAzureAI( + [_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)] + ) + + response = _call_azure_ai(recorder) + + assert len(recorder.bodies) == 2 + assert recorder.bodies[0]["tools"][0]["strict"] is True + assert "strict" not in recorder.bodies[1]["tools"][0] + assert response.choices[0].message.content == "sent" + + +def test_the_retry_changes_only_the_field_the_provider_named(): + recorder = _RecordedAzureAI( + [_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)] + ) + + _call_azure_ai(recorder) + + first, second = recorder.bodies + assert second["messages"] == first["messages"] + assert second["model"] == first["model"] + assert second["tools"][0]["function"] == first["tools"][0]["function"] + + +def test_a_provider_that_keeps_rejecting_is_not_retried_forever(): + recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION)]) + + with pytest.raises(litellm.BadRequestError) as raised: + _call_azure_ai(recorder) + + assert len(recorder.bodies) == 2 + assert raised.value.status_code == 422 + + +def test_a_rejection_the_provider_cannot_fix_is_not_retried_at_all(): + recorder = _RecordedAzureAI([_rejection(A_REJECTION_THE_PROVIDER_CANNOT_FIX)]) + + with pytest.raises(litellm.BadRequestError): + _call_azure_ai(recorder) + + assert len(recorder.bodies) == 1 + + +def test_an_extra_input_outside_a_tool_is_not_retried_unless_dropping_params_was_asked_for(): + recorder = _RecordedAzureAI([_rejection(UNRELATED_REJECTION)]) + + with pytest.raises(litellm.BadRequestError): + _call_azure_ai(recorder) + + assert len(recorder.bodies) == 1 + + +def test_an_extra_input_outside_a_tool_is_retried_when_dropping_params_was_asked_for(): + recorder = _RecordedAzureAI( + [_rejection(UNRELATED_REJECTION), httpx.Response(200, json=A_COMPLETION)] + ) + + response = _call_azure_ai(recorder, drop_params=True) + + assert len(recorder.bodies) == 2 + assert response.choices[0].message.content == "sent" + + +@pytest.mark.asyncio +async def test_a_tool_field_the_provider_rejects_is_dropped_and_retried_on_the_async_path( + httpx_transport, +): + import respx + + recorder = _RecordedAzureAI( + [_rejection(TOOL_LEVEL_REJECTION), httpx.Response(200, json=A_COMPLETION)] + ) + + with respx.mock(assert_all_called=True) as router: + router.post(AZURE_AI_CHAT_COMPLETIONS_URL).mock(side_effect=recorder) + response = await litellm.acompletion( + model="azure_ai/grok-3", + messages=[{"role": "user", "content": "hi"}], + tools=[_a_tool_with_an_unsupported_field()], + api_base=AZURE_AI_BASE, + api_key="fake-key", + ) + + assert len(recorder.bodies) == 2 + assert recorder.bodies[0]["tools"][0]["strict"] is True + assert "strict" not in recorder.bodies[1]["tools"][0] + assert response.choices[0].message.content == "sent" + + +@pytest.mark.asyncio +async def test_a_provider_that_keeps_rejecting_is_not_retried_forever_on_the_async_path( + httpx_transport, +): + import respx + + recorder = _RecordedAzureAI([_rejection(TOOL_LEVEL_REJECTION)]) + + with respx.mock(assert_all_called=True) as router: + router.post(AZURE_AI_CHAT_COMPLETIONS_URL).mock(side_effect=recorder) + with pytest.raises(litellm.BadRequestError): + await litellm.acompletion( + model="azure_ai/grok-3", + messages=[{"role": "user", "content": "hi"}], + tools=[_a_tool_with_an_unsupported_field()], + api_base=AZURE_AI_BASE, + api_key="fake-key", + ) + + assert len(recorder.bodies) == 2 diff --git a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py index fa6f23dc7ff..3f93264d0d0 100644 --- a/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py +++ b/tests/test_litellm/llms/deepseek/chat/test_deepseek_chat_transformation.py @@ -1,3 +1,4 @@ +import litellm from litellm.llms.deepseek.chat.transformation import DeepSeekChatConfig @@ -108,6 +109,284 @@ def test_thinking_mode_active_bool_thinking_returns_false_without_crashing(): assert config._thinking_mode_active(model="deepseek-reasoner", optional_params={"thinking": True}) is False +class TestDeepSeekVisionMultimodalContent: + """Image content lists are forwarded only for user messages on vision models.""" + + VISION_MODEL = "deepseek/deepseek-v4-flash-vision-exp" + NON_VISION_MODEL = "deepseek/deepseek-chat" + + def setup_method(self): + self.config = DeepSeekChatConfig() + prior_entry = litellm.model_cost.get(self.VISION_MODEL) + self._prior_registry_entry = dict(prior_entry) if prior_entry is not None else None + litellm.register_model( + { + "deepseek/deepseek-v4-flash-vision-exp": { + "litellm_provider": "deepseek", + "mode": "chat", + "input_cost_per_token": 4.4e-07, + "output_cost_per_token": 1.32e-06, + "supports_vision": True, + } + } + ) + + def teardown_method(self): + if self._prior_registry_entry is None: + litellm.model_cost.pop(self.VISION_MODEL, None) + else: + litellm.model_cost[self.VISION_MODEL] = self._prior_registry_entry + + @staticmethod + def _image_message(role="user"): + return { + "role": role, + "content": [ + {"type": "text", "text": "what is in this image?"}, + { + "type": "image_url", + "image_url": {"url": "https://example.com/image.jpg", "detail": "auto"}, + }, + ], + } + + def test_user_image_list_forwarded_on_vision_model(self): + result = self.config._transform_messages([self._image_message()], model=self.VISION_MODEL) + + assert isinstance(result[0]["content"], list) + assert result[0]["content"][0]["type"] == "text" + assert result[0]["content"][1]["type"] == "image_url" + assert result[0]["content"][1]["image_url"]["url"] == "https://example.com/image.jpg" + + def test_image_list_collapsed_on_non_vision_model(self): + result = self.config._transform_messages([self._image_message()], model=self.NON_VISION_MODEL) + + assert result[0]["content"] == "what is in this image?" + + def test_image_list_collapsed_on_non_user_roles_even_on_vision_model(self): + for role in ("assistant", "system"): + result = self.config._transform_messages([self._image_message(role=role)], model=self.VISION_MODEL) + + assert result[0]["content"] == "what is in this image?" + + def test_audio_block_collapsed_even_on_vision_model(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "transcribe this"}, + {"type": "input_audio", "input_audio": {"data": "UklGRg==", "format": "wav"}}, + ], + } + ] + + result = self.config._transform_messages(messages, model=self.VISION_MODEL) + + assert result[0]["content"] == "transcribe this" + + def test_typeless_image_block_collapses(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this"}, + {"image_url": {"url": "https://example.com/image.jpg"}}, + ], + } + ] + + result = self.config._transform_messages(messages, model=self.VISION_MODEL) + + assert result[0]["content"] == "what is this" + + def test_text_only_content_list_collapses(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Hello "}, + {"type": "text", "text": "world"}, + ], + } + ] + + result = self.config._transform_messages(messages, model=self.VISION_MODEL) + + assert isinstance(result[0]["content"], str) + assert result[0]["content"] == "Hello world" + + def test_search_results_text_appended_on_forwarded_message(self): + message = self._image_message() + message["search_results"] = [{"source": "kb", "content": [{"text": "article body"}]}] + + result = self.config._transform_messages([message], model=self.VISION_MODEL) + + content = result[0]["content"] + assert isinstance(content, list) + assert content[-1] == {"type": "text", "text": "kbarticle body"} + assert any(block.get("type") == "image_url" for block in content) + assert "search_results" not in result[0] + + def test_search_results_text_kept_on_collapse(self): + messages = [ + { + "role": "user", + "content": [{"type": "text", "text": "context: "}], + "search_results": [{"source": "kb", "content": [{"text": "article body"}]}], + } + ] + + result = self.config._transform_messages(messages, model=self.NON_VISION_MODEL) + + assert result[0]["content"] == "context: kbarticle body" + + def test_responses_shape_blocks_collapse_even_on_vision_model(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "input_text", "text": "what is this?"}, + {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}, + ], + } + ] + + result = self.config._transform_messages(messages, model=self.VISION_MODEL) + + assert result[0]["content"] == "what is this?" + + def test_image_block_missing_payload_collapses(self): + messages = [ + { + "role": "user", + "content": [{"type": "text", "text": "hi"}, {"type": "image_url"}], + } + ] + + result = self.config._transform_messages(messages, model=self.VISION_MODEL) + + assert result[0]["content"] == "hi" + + def test_image_block_empty_payload_object_collapses(self): + for payload in ({}, {"url": ""}, {"detail": "auto"}, None, 42): + messages = [ + { + "role": "user", + "content": [{"type": "text", "text": "hi"}, {"type": "image_url", "image_url": payload}], + } + ] + + result = self.config._transform_messages(messages, model=self.VISION_MODEL) + + assert result[0]["content"] == "hi" + + def test_image_block_string_payload_forwarded(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this?"}, + {"type": "image_url", "image_url": "https://example.com/image.jpg"}, + ], + } + ] + + result = self.config._transform_messages(messages, model=self.VISION_MODEL) + + content = result[0]["content"] + assert isinstance(content, list) + assert content[1]["image_url"] == {"url": "https://example.com/image.jpg"} + + def test_text_block_missing_text_field_collapses(self): + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "hi"}, + {"type": "text"}, + {"type": "image_url", "image_url": {"url": "https://example.com/image.jpg"}}, + ], + } + ] + + result = self.config._transform_messages(messages, model=self.VISION_MODEL) + + assert result[0]["content"] == "hi" + + def test_string_content_search_results_folded_into_string(self): + messages = [ + { + "role": "tool", + "tool_call_id": "call_1", + "content": "summarize the docs", + "search_results": [{"source": "kb", "content": [{"text": "article body"}]}], + } + ] + + result = self.config._transform_messages(messages, model=self.NON_VISION_MODEL) + + assert result[0]["content"] == "summarize the docskbarticle body" + + def test_plain_string_content_message_unchanged(self): + messages = [{"role": "user", "content": "hello"}] + + result = self.config._transform_messages(messages, model=self.VISION_MODEL) + + assert result[0] is messages[0] + + def test_empty_content_list_untouched(self): + messages = [{"role": "user", "content": []}] + + result = self.config._transform_messages(messages, model=self.NON_VISION_MODEL) + + assert result[0]["content"] == [] + + def test_later_messages_still_collapsed_after_forwarded_one(self): + messages = [ + self._image_message(), + { + "role": "user", + "content": [ + {"type": "text", "text": "and "}, + {"type": "text", "text": "then?"}, + ], + }, + self._image_message(), + ] + + result = self.config._transform_messages(messages, model=self.VISION_MODEL) + + assert isinstance(result[0]["content"], list) + assert result[1]["content"] == "and then?" + assert isinstance(result[2]["content"], list) + + def test_transform_request_preserves_image_url_block(self): + body = self.config.transform_request( + model=self.VISION_MODEL, + messages=[self._image_message()], + optional_params={}, + litellm_params={}, + headers={}, + ) + + content = body["messages"][0]["content"] + assert isinstance(content, list) + assert any(block.get("type") == "image_url" for block in content) + + async def test_async_transform_request_preserves_image_url_block(self): + body = await self.config.async_transform_request( + model=self.VISION_MODEL, + messages=[self._image_message()], + optional_params={}, + litellm_params={}, + headers={}, + ) + + content = body["messages"][0]["content"] + assert isinstance(content, list) + assert any(block.get("type") == "image_url" for block in content) + + class TestDeepSeekThinkingParams: """Test thinking and reasoning_effort parameter handling for DeepSeek.""" @@ -282,8 +561,6 @@ class TestDeepSeekThinkingParams: result = self.config._drop_unsupported_tools(optional_params) - assert result["tools"] == [ - {"type": "function", "function": {"name": "get_weather"}} - ] + assert result["tools"] == [{"type": "function", "function": {"name": "get_weather"}}] assert "tool_choice" not in result assert result["parallel_tool_calls"] is True diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py index 63a749dab84..95ec183792d 100644 --- a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py @@ -73,7 +73,13 @@ def test_validate_environment_sets_session_affinity_from_session_id(): assert headers["x-session-affinity"] == "session-id-123" -def test_validate_environment_sets_session_affinity_from_trace_id(): +def test_validate_environment_ignores_trace_id_for_session_affinity(): + """A trace id must not become the session id. + + litellm_trace_id defaults to a fresh uuid4 per request, so pinning + x-session-affinity to it sent every request to a different Fireworks node and + prompt caching never hit (cached_tokens stayed 0 across identical prompts). + """ config = FireworksAIConfig() headers = config.validate_environment( @@ -85,7 +91,25 @@ def test_validate_environment_sets_session_affinity_from_trace_id(): api_key="test-key", ) - assert headers["x-session-affinity"] == "trace-id-123" + assert "x-session-affinity" not in headers + + +def test_validate_environment_prefers_session_id_over_trace_id(): + config = FireworksAIConfig() + + headers = config.validate_environment( + headers={}, + model="accounts/fireworks/models/test-model", + messages=[], + optional_params={}, + litellm_params={ + "litellm_session_id": "session-123", + "litellm_trace_id": "trace-id-123", + }, + api_key="test-key", + ) + + assert headers["x-session-affinity"] == "session-123" def test_validate_environment_does_not_set_session_affinity_without_session_id(): diff --git a/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py b/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py index deb148a07c0..42e330925a0 100644 --- a/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py +++ b/tests/test_litellm/llms/gemini/realtime/test_gemini_realtime_transformation.py @@ -1298,8 +1298,7 @@ def test_gemini_realtime_pipecat_ga_session_voice_and_tools(patch_gemini_audio_c assert len(messages) == 1 setup = json.loads(messages[0])["setup"] assert setup["generationConfig"]["responseModalities"] == ["AUDIO"] - # Native-audio Live rejects speechConfig on setup (see _finalize_gemini_live_setup). - assert "speechConfig" not in setup.get("generationConfig", {}) + assert setup["generationConfig"]["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore" assert setup["tools"][0]["function_declarations"][0]["name"] == "terminate_call" assert setup["realtimeInputConfig"]["automaticActivityDetection"]["disabled"] is False @@ -1843,20 +1842,6 @@ def test_is_audio_only_live_model_uses_cost_map(model, expected, patch_gemini_au assert GeminiRealtimeConfig._is_audio_only_live_model(model) == expected -@pytest.mark.parametrize( - "model,expected", - [ - ("gemini-2.5-flash-native-audio-latest", True), - ("gemini/gemini-2.5-flash-native-audio-latest", True), - ("gemini-3.1-flash-live-preview", False), - ("gemini/gemini-3.1-flash-live-preview", False), - ("gemini-2.0-flash", False), - ], -) -def test_is_native_audio_model_uses_cost_map(model, expected, patch_gemini_audio_cost_map_entries): - assert GeminiRealtimeConfig._is_native_audio_model(model) == expected - - def test_is_setup_message_and_is_content_message(): config = GeminiRealtimeConfig() assert config.is_setup_message({"setup": {}}) is True @@ -1865,3 +1850,17 @@ def test_is_setup_message_and_is_content_message(): assert config.is_content_message({"clientContent": {}}) is True assert config.is_content_message({"toolResponse": {}}) is True assert config.is_content_message({"setup": {}}) is False + + +def test_map_openai_params_drops_stock_voice_case_insensitively(): + """Regression: OpenAI stock voices are dropped regardless of casing so Gemini Live keeps its default voice. + + Non-OpenAI names pass through verbatim. + """ + cfg = GeminiRealtimeConfig() + + dropped = cfg.map_openai_params(optional_params={}, non_default_params={"voice": "Alloy"}) + assert "speechConfig" not in dropped.get("generationConfig", {}) + + passthrough = cfg.map_openai_params(optional_params={}, non_default_params={"voice": "Kore"}) + assert passthrough["generationConfig"]["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore" diff --git a/tests/test_litellm/llms/gemini/test_cost_calculator.py b/tests/test_litellm/llms/gemini/test_cost_calculator.py index fc8d71afaa9..1e70803d39e 100644 --- a/tests/test_litellm/llms/gemini/test_cost_calculator.py +++ b/tests/test_litellm/llms/gemini/test_cost_calculator.py @@ -3,7 +3,10 @@ import os import pytest import litellm -from litellm.llms.gemini.cost_calculator import cost_per_web_search_request +from litellm.llms.gemini.cost_calculator import ( + cost_per_google_maps_grounding_request, + cost_per_web_search_request, +) from litellm.llms.gemini.image_edit.cost_calculator import ( cost_calculator as gemini_image_edit_cost_calculator, ) @@ -81,6 +84,63 @@ def test_no_usage_details(): assert cost == 0.0 +def _make_maps_usage(google_maps_grounding_requests: int) -> Usage: + return Usage( + prompt_tokens=100, + completion_tokens=50, + total_tokens=150, + prompt_tokens_details=PromptTokensDetailsWrapper( + google_maps_grounding_requests=google_maps_grounding_requests, + ), + ) + + +def test_maps_per_query_billing(): + """web_search_billing_unit=per_query charges per Maps query.""" + model_info = { + "key": "gemini/gemini-3.5-flash", + "web_search_billing_unit": "per_query", + "google_maps_grounding_cost_per_query": 0.014, + } + cost = cost_per_google_maps_grounding_request(usage=_make_maps_usage(3), model_info=model_info) + assert cost == pytest.approx(0.014 * 3) + + +def test_maps_per_prompt_billing_clamps_to_one(): + """Without web_search_billing_unit, Maps grounding is one flat fee per grounded prompt.""" + model_info = { + "key": "gemini/gemini-2.5-flash", + "google_maps_grounding_cost_per_query": 0.025, + } + cost = cost_per_google_maps_grounding_request(usage=_make_maps_usage(3), model_info=model_info) + assert cost == pytest.approx(0.025) + + +def test_maps_default_rate_per_query(): + """A per_query model missing the pricing key falls back to Google's $14/1K queries.""" + model_info = {"key": "gemini/gemini-3.9-flash", "web_search_billing_unit": "per_query"} + cost = cost_per_google_maps_grounding_request(usage=_make_maps_usage(2), model_info=model_info) + assert cost == pytest.approx(0.014 * 2) + + +def test_maps_default_rate_per_prompt(): + """A per_prompt model missing the pricing key falls back to Google's $25/1K grounded prompts.""" + model_info = {"key": "gemini/gemini-2.6-flash"} + cost = cost_per_google_maps_grounding_request(usage=_make_maps_usage(2), model_info=model_info) + assert cost == pytest.approx(0.025) + + +def test_maps_zero_requests(): + model_info = {"key": "gemini/gemini-3.5-flash", "web_search_billing_unit": "per_query"} + assert cost_per_google_maps_grounding_request(usage=_make_maps_usage(0), model_info=model_info) == 0.0 + + +def test_maps_no_usage_details(): + usage = Usage(prompt_tokens=100, completion_tokens=50, total_tokens=150) + model_info = {"key": "gemini/gemini-3.5-flash"} + assert cost_per_google_maps_grounding_request(usage=usage, model_info=model_info) == 0.0 + + def test_gemini_image_edit_cost_prefers_token_usage_metadata(monkeypatch): monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") litellm.model_cost = litellm.get_model_cost_map(url="") @@ -301,3 +361,33 @@ def test_gemini_image_generation_cost_no_web_search_when_absent(monkeypatch): ) assert cost_zero == cost_none + + +@pytest.mark.parametrize( + "traffic_type, expected_service_tier", + [ + ("ON_DEMAND", None), + ("ON_DEMAND_PRIORITY", "priority"), + ("FLEX", "flex"), + ("BATCH", "flex"), + # Vertex AI reports flex/shared-capacity traffic as ON_DEMAND_FLEX. + ("ON_DEMAND_FLEX", "flex"), + # trafficType is matched case-insensitively. + ("on_demand_flex", "flex"), + (None, None), + ("SOMETHING_UNKNOWN", None), + ], +) +def test_map_traffic_type_to_service_tier( + traffic_type: str | None, expected_service_tier: str | None +): + """ + Gemini/Vertex usageMetadata.trafficType maps to the LiteLLM service_tier + that selects flex/priority cost keys. ON_DEMAND_FLEX (Vertex's flex opt-in + value) must map to "flex" so flex-tier requests are not billed as standard. + """ + from litellm.cost_calculator import _map_traffic_type_to_service_tier + + assert ( + _map_traffic_type_to_service_tier(traffic_type) == expected_service_tier + ) diff --git a/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py b/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py index c761d084da8..0174465b0cc 100644 --- a/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py +++ b/tests/test_litellm/llms/github_copilot/responses/test_github_copilot_responses_transformation.py @@ -436,10 +436,9 @@ class TestGithubCopilotResponsesAPIRouting: catalog entries that lack ``mode``). Exercises the real ``_cached_get_model_info_helper`` plumbing via - ``register_model`` (no mock). ``supported_endpoints`` is not carried on - the normalized ``ModelInfoBase`` the helper returns, so the gate must - read it from the raw ``litellm.model_cost`` entry; a mock-based test - would mask that. + ``register_model`` (no mock). The gate reads ``supported_endpoints`` + from the raw ``litellm.model_cost`` entry; a mock-based test would + mask that. """ litellm.register_model( { diff --git a/tests/test_litellm/llms/minimax/messages/test_transformation.py b/tests/test_litellm/llms/minimax/messages/test_transformation.py index 01d32221fe5..c7435a52890 100644 --- a/tests/test_litellm/llms/minimax/messages/test_transformation.py +++ b/tests/test_litellm/llms/minimax/messages/test_transformation.py @@ -142,3 +142,33 @@ if __name__ == "__main__": print("✓ Provider config manager test passed") print("\n✅ All basic tests passed!") + + +def test_minimax_messages_env_key_attached(monkeypatch): + """Regression: an env-only MINIMAX_API_KEY must be attached on /v1/messages validation""" + monkeypatch.setenv("MINIMAX_API_KEY", "test-minimax-env-key") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_AUTH_TOKEN", raising=False) + config = MinimaxMessagesConfig() + headers, _ = config.validate_anthropic_messages_environment( + headers={}, + model="MiniMax-M2.1", + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + ) + assert headers["x-api-key"] == "test-minimax-env-key" + + +def test_minimax_messages_explicit_key_wins_over_env(monkeypatch): + monkeypatch.setenv("MINIMAX_API_KEY", "env-key") + config = MinimaxMessagesConfig() + headers, _ = config.validate_anthropic_messages_environment( + headers={}, + model="MiniMax-M2.1", + messages=[{"role": "user", "content": "hi"}], + optional_params={}, + litellm_params={}, + api_key="param-key", + ) + assert headers["x-api-key"] == "param-key" diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_grounding_requests.py b/tests/test_litellm/llms/vertex_ai/gemini/test_grounding_requests.py new file mode 100644 index 00000000000..ec9642bdef6 --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_grounding_requests.py @@ -0,0 +1,102 @@ +from litellm.llms.vertex_ai.gemini.grounding_requests import ( + GroundingRequests, + calculate_grounding_requests, +) + + +def test_search_only_counts_non_empty_queries_as_web_requests(): + result = calculate_grounding_requests( + [ + { + "webSearchQueries": ["", "capital of France", "France capital"], + "groundingChunks": [{"web": {"uri": "https://example.com", "title": "Example"}}], + } + ] + ) + assert result == GroundingRequests(web_search_requests=2, google_maps_grounding_requests=None) + + +def test_gemini_api_maps_only_counts_queries_as_maps_requests(): + result = calculate_grounding_requests( + [ + { + "webSearchQueries": ["coffee shops near the Louvre"], + "groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}], + } + ] + ) + assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=1) + + +def test_vertex_maps_only_without_queries_counts_one_maps_request(): + result = calculate_grounding_requests( + [ + { + "groundingChunks": [ + {"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}, + {"maps": {"uri": "https://maps.google.com/?cid=2", "placeId": "p2"}}, + ], + "groundingSupports": [], + } + ] + ) + assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=1) + + +def test_widget_context_token_alone_counts_one_maps_request(): + result = calculate_grounding_requests([{"googleMapsWidgetContextToken": "widget-token"}]) + assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=1) + + +def test_combined_web_and_maps_chunks_split_between_both_counters(): + result = calculate_grounding_requests( + [ + { + "webSearchQueries": ["q1", "q2"], + "groundingChunks": [ + {"web": {"uri": "https://example.com"}}, + {"maps": {"uri": "https://maps.google.com/?cid=1"}}, + ], + } + ] + ) + assert result == GroundingRequests(web_search_requests=2, google_maps_grounding_requests=1) + + +def test_url_context_grounding_chunks_without_queries_count_nothing(): + result = calculate_grounding_requests([{"groundingChunks": [{"web": {"uri": "https://example.com"}}]}]) + assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=None) + + +def test_counters_count_distinct_queries_across_candidates(): + result = calculate_grounding_requests( + [ + {"webSearchQueries": ["a"]}, + {"groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1"}}]}, + {"webSearchQueries": ["b", "c"], "groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=2"}}]}, + ] + ) + assert result == GroundingRequests(web_search_requests=1, google_maps_grounding_requests=2) + + +def test_duplicate_queries_across_candidates_collapse_per_bucket(): + result = calculate_grounding_requests( + [ + {"webSearchQueries": ["shared", "web only"], "groundingChunks": [{"web": {"uri": "https://e.com"}}]}, + {"webSearchQueries": ["shared"], "groundingChunks": [{"web": {"uri": "https://e.com"}}]}, + {"webSearchQueries": ["maps q", "maps q"], "groundingChunks": [{"maps": {"uri": "https://m.com"}}]}, + {"webSearchQueries": ["maps q"], "groundingChunks": [{"maps": {"uri": "https://m.com"}}]}, + ] + ) + assert result == GroundingRequests(web_search_requests=2, google_maps_grounding_requests=1) + + +def test_empty_metadata_counts_nothing(): + result = calculate_grounding_requests([]) + assert result == GroundingRequests(web_search_requests=None, google_maps_grounding_requests=None) + + +def test_has_billable_grounding(): + assert GroundingRequests(web_search_requests=None, google_maps_grounding_requests=1).has_billable_grounding() + assert GroundingRequests(web_search_requests=1, google_maps_grounding_requests=None).has_billable_grounding() + assert not GroundingRequests(web_search_requests=None, google_maps_grounding_requests=None).has_billable_grounding() diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py index 790a32506f2..bd07bec900f 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_vertex_and_google_ai_studio_gemini.py @@ -2,7 +2,7 @@ import asyncio import json import re from copy import deepcopy -from typing import List, cast +from typing import Final, List, cast from unittest.mock import MagicMock, patch import pytest @@ -549,9 +549,10 @@ def test_vertex_ai_non_grounded_usage_omits_tool_use_tokens(): def test_response_has_search_grounding_detection(): """ - Only groundingMetadata.webSearchQueries signals an actual Google Search. URL context also - emits groundingMetadata (groundingChunks but no webSearchQueries) and must not be treated - as search grounding. + groundingMetadata.webSearchQueries signals an actual Google Search and + groundingMetadata.groundingChunks[].maps signals a Google Maps lookup. URL context also + emits groundingMetadata (web groundingChunks but no webSearchQueries) and must not be + treated as billable grounding. """ assert ( VertexGeminiConfig._response_has_search_grounding( @@ -580,6 +581,101 @@ def test_response_has_search_grounding_detection(): ) assert VertexGeminiConfig._response_has_search_grounding({"candidates": []}) is False assert VertexGeminiConfig._response_has_search_grounding({}) is False + assert ( + VertexGeminiConfig._response_has_search_grounding( + { + "candidates": [ + { + "groundingMetadata": { + "groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}] + } + } + ] + } + ) + is True + ) + + +def test_vertex_ai_maps_grounding_tool_use_tokens_excluded_from_prompt_tokens(): + """ + Grounding with Google Maps retrieved tokens are billed like Google Search grounding: a + separate per-request / per-query fee, with toolUsePromptTokenCount surfaced on + prompt_tokens_details.tool_use_tokens but excluded from prompt_tokens. Before Maps detection + existed, a Vertex AI Maps-only response folded the 120 tool-use tokens into prompt_tokens. + Regression for https://github.com/BerriAI/litellm/issues/35906 + """ + v = VertexGeminiConfig() + completion_response = { + "candidates": [ + { + "groundingMetadata": { + "groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}] + } + } + ], + "usageMetadata": UsageMetadata( + promptTokenCount=15, + candidatesTokenCount=100, + toolUsePromptTokenCount=120, + totalTokenCount=235, + ), + } + + usage = v._calculate_usage(completion_response=completion_response) + + assert usage.prompt_tokens == 15 + assert usage.completion_tokens == 100 + assert usage.total_tokens == 235 + assert usage.prompt_tokens_details.tool_use_tokens == 120 + + +def test_vertex_ai_maps_grounding_sets_google_maps_grounding_requests_non_streaming(): + """ + A Vertex AI Maps-only response (groundingChunks[].maps, no webSearchQueries) must set + google_maps_grounding_requests and leave web_search_requests unset, so the Maps fee is + billed instead of nothing (Vertex) or the Google Search fee (Gemini API). + """ + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + VertexGeminiConfig, + ) + + completion_response = { + "candidates": [ + { + "content": {"parts": [{"text": "Here are some coffee shops"}], "role": "model"}, + "finishReason": "STOP", + "groundingMetadata": { + "groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}], + "groundingSupports": [], + }, + } + ], + "usageMetadata": { + "promptTokenCount": 15, + "candidatesTokenCount": 100, + "totalTokenCount": 115, + }, + } + + raw_response = MagicMock() + raw_response.json.return_value = completion_response + + result = VertexGeminiConfig().transform_response( + model="gemini-2.5-flash", + raw_response=raw_response, + model_response=ModelResponse(), + logging_obj=MagicMock(), + request_data={}, + messages=[], + optional_params={}, + litellm_params={}, + encoding=None, + ) + + usage = result.usage + assert usage.prompt_tokens_details.google_maps_grounding_requests == 1 + assert not hasattr(usage.prompt_tokens_details, "web_search_requests") def test_vertex_ai_search_grounding_tool_use_tokens_excluded_from_prompt_tokens(): @@ -1292,6 +1388,66 @@ def test_vertex_ai_streaming_usage_web_search_calculation(): assert usage.prompt_tokens_details.web_search_requests == 2 +def test_vertex_ai_maps_grounding_chunk_parser_sets_maps_requests(): + """A Vertex-shaped Maps-only streaming chunk sets the Maps counter and not the Search one.""" + from unittest.mock import MagicMock + + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + ModelResponseIterator, + ) + + chunk = { + "candidates": [ + { + "content": {"parts": [{"text": "Here"}]}, + "groundingMetadata": { + "groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}], + "groundingSupports": [], + }, + } + ], + "usageMetadata": {"promptTokenCount": 15, "candidatesTokenCount": 10, "totalTokenCount": 25}, + } + + iterator = ModelResponseIterator(streaming_response=[], sync_stream=True, logging_obj=MagicMock()) + completed_response = iterator.chunk_parser(chunk) + + usage = completed_response.usage + assert usage.prompt_tokens_details.google_maps_grounding_requests == 1 + assert not hasattr(usage.prompt_tokens_details, "web_search_requests") + + +def test_gemini_api_maps_grounding_chunk_parser_counts_queries_as_maps_requests(): + """A Gemini-API-shaped Maps chunk (webSearchQueries plus maps chunks) bills Maps, not Search.""" + from unittest.mock import MagicMock + + from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( + ModelResponseIterator, + ) + + chunk = { + "candidates": [ + { + "content": {"parts": [{"text": "Here"}]}, + "groundingMetadata": [ + { + "webSearchQueries": ["coffee shops near the Louvre"], + "groundingChunks": [{"maps": {"uri": "https://maps.google.com/?cid=1", "placeId": "p1"}}], + } + ], + } + ], + "usageMetadata": {"promptTokenCount": 15, "candidatesTokenCount": 10, "totalTokenCount": 25}, + } + + iterator = ModelResponseIterator(streaming_response=[], sync_stream=True, logging_obj=MagicMock()) + completed_response = iterator.chunk_parser(chunk) + + usage = completed_response.usage + assert usage.prompt_tokens_details.google_maps_grounding_requests == 1 + assert not hasattr(usage.prompt_tokens_details, "web_search_requests") + + def test_vertex_ai_transform_parts(): """ Test the _transform_parts method for converting Vertex AI function calls @@ -5579,3 +5735,25 @@ def test_accumulated_json_async_end_of_stream_drains_buffered_value(): result = asyncio.run(iterator.__anext__()) assert result is not None assert result.choices[0].delta.content == "a" + + +def test_calculate_web_search_requests_counts_unique_queries(): + """Gemini 3 per_query billing charges per unique query executed, not per emitted string. + + Regression for #36377: duplicate webSearchQueries within and across grounding + metadata items must collapse to the distinct-query count, and empty strings must + be ignored, matching Google's documented Grounding-with-Search billing rule. + """ + duplicates_in_one_item: Final = [ + {"webSearchQueries": ["euro 2024 winner", "euro 2024 winner", "spain england final", ""]} + ] + assert VertexGeminiConfig._calculate_web_search_requests(duplicates_in_one_item) == 2 + + duplicates_across_items: Final = [ + {"webSearchQueries": ["euro 2024 winner"]}, + {"webSearchQueries": ["euro 2024 winner", "spain england final"]}, + ] + assert VertexGeminiConfig._calculate_web_search_requests(duplicates_across_items) == 2 + + assert VertexGeminiConfig._calculate_web_search_requests([]) is None + assert VertexGeminiConfig._calculate_web_search_requests([{"webSearchQueries": ["", ""]}]) is None diff --git a/tests/test_litellm/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py b/tests/test_litellm/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py index 720c629cbf7..d4cf58bc0b4 100644 --- a/tests/test_litellm/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/realtime/test_vertex_ai_realtime_transformation.py @@ -15,7 +15,6 @@ from unittest.mock import AsyncMock, MagicMock import pytest import websockets.exceptions # registers websockets.exceptions on the websockets namespace - import litellm from litellm.llms.vertex_ai.realtime.transformation import VertexAIRealtimeConfig @@ -278,7 +277,7 @@ async def test_vertex_realtime_text_in_text_out(): SERVER_TURN_COMPLETE, ] - async def _backend_recv(decode=True): # noqa: ARG001 + async def _backend_recv(decode=True): if not upstream_messages: # Signal normal connection close so the loop exits cleanly raise websockets.exceptions.ConnectionClosedOK(None, None) # type: ignore[arg-type] @@ -462,3 +461,98 @@ def test_vertex_function_call_output_omits_id(): assert "id" not in function_response assert function_response["name"] == "terminate_call" assert function_response["response"] == {"status": "ok"} + + +def test_vertex_native_audio_keeps_requested_voice(patch_native_audio_cost_map_entry): + """Regression: Vertex Live accepts speechConfig on native audio, so the client's voice must survive. + + Stripping it silently dropped voice selection for every Vertex native-audio + session. TEXT is still coerced away, which Vertex does reject. + """ + cfg = VertexAIRealtimeConfig( + access_token="tok", project="my-proj", location="us-central1" + ) + session_update = { + "type": "session.update", + "session": { + "output_modalities": ["text"], + "audio": {"output": {"voice": "Aoede"}}, + }, + } + + messages = cfg.transform_realtime_request( + json.dumps(session_update), + _NATIVE_AUDIO_MODEL, + session_configuration_request=None, + ) + + generation_config = json.loads(messages[0])["setup"]["generationConfig"] + assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Aoede" + assert generation_config["responseModalities"] == ["AUDIO"] + + +def test_google_ai_studio_native_audio_keeps_requested_voice(patch_native_audio_cost_map_entry): + """Regression: AI Studio native-audio Live accepts speechConfig too, so the voice survives on both providers.""" + from litellm.llms.gemini.realtime.transformation import GeminiRealtimeConfig + + messages = GeminiRealtimeConfig().transform_realtime_request( + json.dumps( + { + "type": "session.update", + "session": { + "output_modalities": ["audio"], + "audio": {"output": {"voice": "Aoede"}}, + }, + } + ), + _NATIVE_AUDIO_MODEL, + session_configuration_request=None, + ) + + generation_config = json.loads(messages[0])["setup"]["generationConfig"] + assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Aoede" + + +def test_vertex_native_audio_drops_openai_stock_voice(patch_native_audio_cost_map_entry): + """Regression: OpenAI stock voice names must be dropped, not forwarded verbatim. + + Vertex Live closes the socket with 1007 on an unknown voice name, so a + client sending OpenAI's default voice would lose the session entirely. + Dropping the voice keeps the session alive on the model's default voice. + """ + cfg = VertexAIRealtimeConfig( + access_token="tok", project="my-proj", location="us-central1" + ) + session_update = { + "type": "session.update", + "session": {"audio": {"output": {"voice": "alloy"}}}, + } + + messages = cfg.transform_realtime_request( + json.dumps(session_update), + _NATIVE_AUDIO_MODEL, + session_configuration_request=None, + ) + + generation_config = json.loads(messages[0])["setup"]["generationConfig"] + assert "speechConfig" not in generation_config + + +def test_vertex_native_audio_unmapped_voice_passes_through(patch_native_audio_cost_map_entry): + """A voice name outside the OpenAI stock set is forwarded verbatim so Gemini-native names keep working.""" + cfg = VertexAIRealtimeConfig( + access_token="tok", project="my-proj", location="us-central1" + ) + session_update = { + "type": "session.update", + "session": {"audio": {"output": {"voice": "Kore"}}}, + } + + messages = cfg.transform_realtime_request( + json.dumps(session_update), + _NATIVE_AUDIO_MODEL, + session_configuration_request=None, + ) + + generation_config = json.loads(messages[0])["setup"]["generationConfig"] + assert generation_config["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore" diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 7d2e3f45c04..3dea89ed67b 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -7394,3 +7394,57 @@ async def test_invalidate_team_member_spend_state_self_delivered_broadcast_does_ assert ( local_spend_counter_cache.in_memory_cache.get_cache("spend_db_floor:spend:team_member:user-1:team-1") == 0.0 ), "the handler's self-delivered broadcast erased the post-reset floor marker, reopening the stale-floor race" + + +@pytest.mark.asyncio +async def test_delete_cache_key_object_is_best_effort_when_the_cache_backend_fails(caplog): + """ + LIT-5898: `_delete_cache_key_object` must not propagate a cache-backend error. + + Every caller runs it after its own write has committed, so a raise here turned a persisted + `/key/update` into `400 Authentication Error` (and `/key/block`, `/key/regenerate` into 500s) + for operators whose Redis ACL denies `DEL` on LiteLLM's unprefixed token-hash keys. The + in-memory entry is already dropped by then, so raising never made the cache less stale. + """ + import logging + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy.auth.auth_checks import _delete_cache_key_object + + hashed_token = "a" * 64 + caplog.set_level(logging.WARNING, logger="LiteLLM Proxy") + + failing_cache = MagicMock() + failing_cache.delete_cache = MagicMock() + failing_logging_obj = MagicMock() + failing_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock( + side_effect=Exception("No permissions to access a key") + ) + + await _delete_cache_key_object( + hashed_token=hashed_token, + user_api_key_cache=failing_cache, + proxy_logging_obj=failing_logging_obj, + ) + + failing_cache.delete_cache.assert_called_once_with(key=hashed_token) + failing_logging_obj.internal_usage_cache.dual_cache.async_delete_cache.assert_awaited_once_with(key=hashed_token) + assert any("Failed to invalidate cached key entry" in record.getMessage() for record in caplog.records), ( + "a swallowed cache-eviction failure must still be logged, or a stale auth entry goes unnoticed" + ) + + caplog.clear() + healthy_cache = MagicMock() + healthy_cache.delete_cache = MagicMock() + healthy_logging_obj = MagicMock() + healthy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock() + + await _delete_cache_key_object( + hashed_token=hashed_token, + user_api_key_cache=healthy_cache, + proxy_logging_obj=healthy_logging_obj, + ) + + healthy_cache.delete_cache.assert_called_once_with(key=hashed_token) + healthy_logging_obj.internal_usage_cache.dual_cache.async_delete_cache.assert_awaited_once_with(key=hashed_token) + assert caplog.records == [], "a healthy eviction must stay silent, and must still reach both caches" diff --git a/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py b/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py new file mode 100644 index 00000000000..5a06bb92059 --- /dev/null +++ b/tests/test_litellm/proxy/common_utils/test_callback_config_validation.py @@ -0,0 +1,12 @@ +from litellm.proxy.common_utils.callback_config_validation import ( + callback_config_error, +) + + +def test_callback_config_error_rejects_invalid_langfuse_environment(): + for callback in ["langfuse", "langfuse_otel"]: + error = callback_config_error(callback, {"langfuse_environment": "Production"}) + assert error is not None and "langfuse_environment" in error + + assert callback_config_error("langfuse", {"langfuse_environment": "team-a-prod"}) is None + assert callback_config_error("langfuse", {"langfuse_public_key": "pk"}) is None diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index b75d8809c31..e70a421379c 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -2419,6 +2419,74 @@ def test_clean_endpoint_data_strips_credentials_keeps_routing_fields(): assert cleaned.get("api_version") == "2024-10-21" +def test_clean_endpoint_data_strips_extra_headers_and_aws_session_token(): + """ + gh-36898: GET /health must not leak provider credentials that live in + `extra_headers` / `headers` / `aws_session_token`. Before the fix these + were returned in plaintext (api_key was stripped, but these were not). + """ + from litellm.proxy.health_check import _clean_endpoint_data + + raw = { + "model": "openai/gpt-4o", + "api_base": "https://example.test/v1", + "extra_headers": { + "Authorization": "Bearer CANARY_EXTRA_HEADERS_AUTHORIZATION", + "x-goog-api-key": "CANARY_X_GOOG_API_KEY_VALUE", + "api-key": "CANARY_AZURE_STYLE_API_KEY", + }, + "headers": {"X-Custom": "CANARY_HEADER_VALUE"}, + "aws_session_token": "CANARY_AWS_SESSION_TOKEN_VALUE", + } + + cleaned = _clean_endpoint_data(raw, details=True) + + assert "extra_headers" not in cleaned + assert "headers" not in cleaned + assert "aws_session_token" not in cleaned + assert cleaned.get("api_base") == "https://example.test/v1" + + +@pytest.mark.parametrize( + "credential_field", + [ + "api_key", + "client_secret", + "azure_ad_token", + "azure_username", + "azure_password", + "aws_access_key_id", + "aws_secret_access_key", + "aws_session_token", + "aws_web_identity_token", + "vertex_credentials", + "vertex_ai_credentials", + "extra_headers", + "headers", + ], +) +@pytest.mark.parametrize("details", [True, False, None]) +def test_clean_endpoint_data_never_displays_credential_fields(credential_field, details): + """ + LIT-6239 / gh-36898: /health entries, healthy and unhealthy alike, must never + carry credential-bearing litellm_params, with or without details. + """ + from litellm.proxy.health_check import _clean_endpoint_data + + canary = f"CANARY-{credential_field}-VALUE" + cleaned = _clean_endpoint_data( + { + "model": "azure/gpt-5-mini", + "api_base": "https://example.test/v1", + credential_field: canary, + }, + details=details, + ) + + assert credential_field not in cleaned + assert canary not in str(cleaned) + + class TestConfigBaseForHealthCheck: """A request that sets its own connection fields gets a base without the configuration's credentials; anything it leaves unset still comes from diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py index eeb1b2d50e6..dd7b36dd909 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py @@ -20,6 +20,7 @@ from litellm.proxy._types import ( from litellm.proxy.management_endpoints.model_management_endpoints import ( ModelManagementAuthChecks, _get_team_deployments, + _raise_if_rate_limits_required_but_missing, clear_cache, delete_team_models, ) @@ -4256,3 +4257,41 @@ class TestAutoRouterClassifierDefaultPrompt: for empty in (None, "", "{}"): response = await get_auto_router_classifier_default_prompt(context_window_size=5, tier_labels=empty) assert response.system_prompt == classification_system_prompt(5) + + +class TestEnforceRpmTpmOnModelAdd: + def test_passes_when_disabled_even_without_limits(self): + assert ( + _raise_if_rate_limits_required_but_missing( + litellm_params=LiteLLM_Params(model="azure/gpt-5.2"), + enforced=False, + ) + is None + ) + + def test_passes_when_enabled_and_both_set(self): + assert ( + _raise_if_rate_limits_required_but_missing( + litellm_params=LiteLLM_Params(model="azure/gpt-5.2", rpm=10, tpm=1000), + enforced=True, + ) + is None + ) + + @pytest.mark.parametrize( + "params, expected_missing", + [ + (LiteLLM_Params(model="azure/gpt-5.2"), "rpm and tpm"), + (LiteLLM_Params(model="azure/gpt-5.2", rpm=10), "tpm"), + (LiteLLM_Params(model="azure/gpt-5.2", tpm=1000), "rpm"), + (LiteLLM_Params(model="azure/gpt-5.2", rpm=0, tpm=1000), "rpm"), + (LiteLLM_Params(model="azure/gpt-5.2", rpm=10, tpm=-1), "tpm"), + ], + ) + def test_raises_when_enabled_and_missing(self, params, expected_missing): + from litellm.proxy._types import ProxyException + + with pytest.raises(ProxyException) as exc_info: + _raise_if_rate_limits_required_but_missing(litellm_params=params, enforced=True) + assert expected_missing in str(exc_info.value.message) + assert exc_info.value.code == "400" diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 6461245bb2c..ffa6bc601e9 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -2366,6 +2366,7 @@ async def test_update_team_team_member_budget_not_passed_to_db( team_member_rpm_limit=None, team_member_tpm_limit=None, team_member_budget_duration=None, + explicitly_set_fields=frozenset(), ): # Remove team_member_budget from updated_kv as the real function does result_kv = updated_kv.copy() @@ -2738,6 +2739,138 @@ async def test_upsert_team_member_budget_table_no_existing_budget(): assert "team_member_budget_duration" not in result +@pytest.mark.asyncio +async def test_upsert_team_member_budget_table_clears_duration_kept_budget(mock_db_client): + """ + A request that keeps team_member_budget but explicitly nulls + team_member_budget_duration must clear the reset period and its reset time. + """ + from litellm.proxy.management_endpoints.team_endpoints import ( + TeamMemberBudgetHandler, + ) + + mock_user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id" + ) + + team_table = MagicMock(spec=LiteLLM_TeamTable) + team_table.metadata = {"team_member_budget_id": "existing_budget_123"} + + mock_db_client.db.litellm_budgettable.update = AsyncMock( + side_effect=lambda where, data: SimpleNamespace(**data) + ) + + result = await TeamMemberBudgetHandler.upsert_team_member_budget_table( + team_table=team_table, + user_api_key_dict=mock_user_api_key_dict, + updated_kv={ + "team_id": "test_team_id", + "team_member_budget": 100.0, + "team_member_budget_duration": None, + }, + team_member_budget=100.0, + team_member_budget_duration=None, + explicitly_set_fields={ + "team_member_budget", + "team_member_budget_duration", + }, + ) + + written = mock_db_client.db.litellm_budgettable.update.call_args.kwargs["data"] + assert written["max_budget"] == 100.0 + assert written["budget_duration"] is None + assert written["budget_reset_at"] is None + assert "rpm_limit" not in written + assert "tpm_limit" not in written + assert result["metadata"]["team_member_budget_id"] == "existing_budget_123" + assert "team_member_budget" not in result + assert "team_member_budget_duration" not in result + + +@pytest.mark.asyncio +async def test_create_team_member_budget_table_explicit_null_duration_does_not_inherit_team_duration( + mock_db_client, +): + """ + A first-time member budget with an explicitly null duration must never + reset, even when the team itself has a reset period. + """ + from litellm.proxy.management_endpoints.team_endpoints import ( + TeamMemberBudgetHandler, + ) + + mock_user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id" + ) + + team_table = MagicMock(spec=LiteLLM_TeamTable) + team_table.metadata = {} + team_table.team_alias = "Test Team" + team_table.budget_duration = "30d" + + mock_db_client.db.litellm_budgettable.create = AsyncMock( + side_effect=lambda data: SimpleNamespace(**data) + ) + + result = await TeamMemberBudgetHandler.create_team_member_budget_table( + data=team_table, + new_team_data_json={"team_id": "test_team_id"}, + user_api_key_dict=mock_user_api_key_dict, + team_member_budget=100.0, + team_member_budget_duration=None, + explicitly_set_fields={ + "team_member_budget", + "team_member_budget_duration", + }, + ) + + written = mock_db_client.db.litellm_budgettable.create.call_args.kwargs["data"] + assert written["max_budget"] == 100.0 + assert "budget_duration" not in written + assert "budget_reset_at" not in written + assert result["metadata"]["team_member_budget_id"] == written["budget_id"] + assert "team_member_budget" not in result + + +@pytest.mark.asyncio +async def test_create_team_member_budget_table_inherits_team_duration_when_duration_omitted( + mock_db_client, +): + """ + Omitting team_member_budget_duration keeps the existing inheritance of the + team's own reset period. + """ + from litellm.proxy.management_endpoints.team_endpoints import ( + TeamMemberBudgetHandler, + ) + + mock_user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id" + ) + + team_table = MagicMock(spec=LiteLLM_TeamTable) + team_table.metadata = {} + team_table.team_alias = "Test Team" + team_table.budget_duration = "30d" + + mock_db_client.db.litellm_budgettable.create = AsyncMock( + side_effect=lambda data: SimpleNamespace(**data) + ) + + result = await TeamMemberBudgetHandler.create_team_member_budget_table( + data=team_table, + new_team_data_json={"team_id": "test_team_id"}, + user_api_key_dict=mock_user_api_key_dict, + team_member_budget=100.0, + explicitly_set_fields={"team_member_budget"}, + ) + + written = mock_db_client.db.litellm_budgettable.create.call_args.kwargs["data"] + assert written["budget_duration"] == "30d" + assert written["budget_reset_at"] is not None + assert result["metadata"]["team_member_budget_id"] == written["budget_id"] + + @pytest.mark.asyncio async def test_update_team_with_team_member_budget_duration( disable_audit_logging_for_mocked_team, @@ -2799,6 +2932,7 @@ async def test_update_team_with_team_member_budget_duration( team_member_rpm_limit=None, team_member_tpm_limit=None, team_member_budget_duration=None, + explicitly_set_fields=frozenset(), ): result_kv = updated_kv.copy() result_kv.pop("team_member_budget", None) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py index 8163d009fef..19bca05fb84 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/llm_provider_handlers/test_anthropic_passthrough_logging_handler.py @@ -2317,10 +2317,10 @@ class TestAnthropicResponseCostRecordedOnModelCallDetails: class TestAnthropicPassthroughFastMode: - """Anthropic charges a provider-specific multiplier for ``speed=fast``, and the - multiplier is applied off ``usage.speed``. The pass-through handler only sees the - speed in the request body, so it has to thread it into every usage-building path or - fast-mode pass-through spend is under-reported.""" + """Anthropic charges a provider-specific multiplier for ``speed=fast``, applied off + ``usage.speed`` and covering every token type, cache included. The response usage + carries the served speed when the request asked for one; the request body's value is + the fallback, so the handler still threads it into every usage-building path.""" MODEL = "claude-opus-4-8" STREAM_CHUNKS = [ @@ -2358,11 +2358,7 @@ class TestAnthropicPassthroughFastMode: return litellm.completion_cost(completion_response=response, model=f"anthropic/{self.MODEL}") def _expected_fast_cost(self, standard_cost: float) -> float: - import litellm - - model_info = litellm.get_model_info(model=self.MODEL, custom_llm_provider="anthropic") - cache_read_cost = 200 * (model_info.get("cache_read_input_token_cost") or 0.0) - return (standard_cost - cache_read_cost) * 2.0 + cache_read_cost + return standard_cost * 2.0 def test_non_streaming_applies_fast_multiplier(self): import httpx @@ -2427,3 +2423,21 @@ class TestAnthropicPassthroughFastMode: assert fast.usage.speed == "fast" assert self._cost(fast) == pytest.approx(self._expected_fast_cost(self._cost(standard))) + + def test_usage_only_fallback_prefers_served_speed_from_stream(self): + served_standard_chunks = [ + chunk.replace('"usage": {"input_tokens": 1000', '"usage": {"speed": "standard", "input_tokens": 1000') + for chunk in self.STREAM_CHUNKS + ] + served_standard = AnthropicPassthroughLoggingHandler._build_usage_only_response_from_chunks( + all_chunks=served_standard_chunks, + model=self.MODEL, + speed="fast", + ) + standard = AnthropicPassthroughLoggingHandler._build_usage_only_response_from_chunks( + all_chunks=self.STREAM_CHUNKS, + model=self.MODEL, + ) + + assert served_standard.usage.speed == "standard" + assert self._cost(served_standard) == pytest.approx(self._cost(standard)) diff --git a/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py b/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py index 3e8e1e9dff8..67113ed9145 100644 --- a/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py +++ b/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py @@ -1,3 +1,4 @@ +import json import pytest from unittest.mock import MagicMock, AsyncMock, patch from litellm.proxy._types import UserAPIKeyAuth, LitellmUserRoles @@ -246,3 +247,241 @@ async def test_patch_prompt_row_deleted_mid_update_returns_404(): exc_info.value.detail == "Prompt with ID test_prompt not found in environment development" ) + + +def test_is_ambiguous_keyed_prompt_data_shapes(): + from litellm.proxy.prompts.prompt_endpoints import is_ambiguous_keyed_prompt_data + + keyed_with_id = PromptLiteLLMParams( + prompt_id="agent-prompt", + prompt_integration="dotprompt", + prompt_data={"json_prompt": {"content": "AHOY", "metadata": {}}}, + ) + flat_with_id = PromptLiteLLMParams( + prompt_id="agent-prompt", + prompt_integration="dotprompt", + prompt_data={"content": "AHOY", "metadata": {}}, + ) + keyed_without_id = PromptLiteLLMParams( + prompt_integration="dotprompt", + prompt_data={"json_prompt": {"content": "AHOY", "metadata": {}}}, + ) + no_prompt_data = PromptLiteLLMParams( + prompt_id="agent-prompt", prompt_integration="dotprompt" + ) + empty_prompt_data = PromptLiteLLMParams( + prompt_id="agent-prompt", prompt_integration="dotprompt", prompt_data={} + ) + + assert is_ambiguous_keyed_prompt_data(keyed_with_id) is True + assert is_ambiguous_keyed_prompt_data(flat_with_id) is False + assert is_ambiguous_keyed_prompt_data(keyed_without_id) is False + assert is_ambiguous_keyed_prompt_data(no_prompt_data) is False + assert is_ambiguous_keyed_prompt_data(empty_prompt_data) is False + + +@pytest.mark.asyncio +async def test_create_prompt_rejects_keyed_prompt_data_with_prompt_id(): + from fastapi import HTTPException + + from litellm.proxy.prompts.prompt_endpoints import ( + AMBIGUOUS_PROMPT_DATA_ERROR, + Prompt, + create_prompt, + ) + + mock_user_auth = UserAPIKeyAuth( + api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN + ) + request = Prompt( + prompt_id="agent-prompt", + litellm_params=PromptLiteLLMParams( + prompt_id="agent-prompt", + prompt_integration="dotprompt", + prompt_data={"json_prompt": {"content": "AHOY", "metadata": {}}}, + ), + ) + + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()): # test-quality-ok: proxy_server module global is the endpoint's only injection point + with pytest.raises(HTTPException) as exc_info: + await create_prompt(request=request, user_api_key_dict=mock_user_auth) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == AMBIGUOUS_PROMPT_DATA_ERROR + + +@pytest.mark.asyncio +async def test_patch_prompt_rejects_keyed_prompt_data_with_prompt_id(): + from fastapi import HTTPException + + from litellm.proxy.prompts.prompt_endpoints import ( + AMBIGUOUS_PROMPT_DATA_ERROR, + PatchPromptRequest, + patch_prompt, + ) + + mock_user_auth = UserAPIKeyAuth( + api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN + ) + request = PatchPromptRequest( + litellm_params=PromptLiteLLMParams( + prompt_id="agent-prompt", + prompt_integration="dotprompt", + prompt_data={"json_prompt": {"content": "AHOY", "metadata": {}}}, + ), + ) + + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()): # test-quality-ok: proxy_server module global is the endpoint's only injection point + with pytest.raises(HTTPException) as exc_info: + await patch_prompt( + prompt_id="agent-prompt", + request=request, + user_api_key_dict=mock_user_auth, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == AMBIGUOUS_PROMPT_DATA_ERROR + + +@pytest.mark.asyncio +async def test_patch_prompt_info_only_keeps_legacy_keyed_row_patchable(): + from litellm.proxy.prompts.prompt_endpoints import PatchPromptRequest, patch_prompt + + mock_user_auth = UserAPIKeyAuth( + api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN + ) + legacy_params = PromptLiteLLMParams( + prompt_id="agent-prompt", + prompt_integration="dotprompt", + prompt_data={"json_prompt": {"content": "AHOY", "metadata": {}}}, + ) + target_row = MagicMock() + target_row.id = "row-1" + target_row.version = 1 + updated_row = MagicMock() + updated_row.model_dump.return_value = { + "prompt_id": "agent-prompt", + "version": 1, + "environment": "production", + "created_by": None, + "litellm_params": legacy_params.model_dump_json(), + "prompt_info": PromptInfo(prompt_type="db", environment="production").model_dump_json(), + } + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_prompttable.find_many = AsyncMock( + return_value=[target_row] + ) + mock_prisma_client.db.litellm_prompttable.update = AsyncMock(return_value=updated_row) + + existing_prompt = PromptSpec( + prompt_id="agent-prompt.v1", + litellm_params=legacy_params, + prompt_info=PromptInfo(prompt_type="db"), + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), # test-quality-ok: proxy_server module global is the endpoint's only injection point + patch( # test-quality-ok: keeps the registry reload from touching global callback state + "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" + ) as mock_registry, + ): + mock_registry.get_prompt_by_id.return_value = existing_prompt + + await patch_prompt( + prompt_id="agent-prompt", + request=PatchPromptRequest(prompt_info=PromptInfo(prompt_type="db", environment="production")), + user_api_key_dict=mock_user_auth, + ) + + update_kwargs = mock_prisma_client.db.litellm_prompttable.update.await_args.kwargs + assert update_kwargs["where"] == {"id": "row-1"} + assert json.loads(update_kwargs["data"]["prompt_info"])["environment"] == "production" + assert json.loads(update_kwargs["data"]["litellm_params"])["prompt_data"] == { + "json_prompt": {"content": "AHOY", "metadata": {}} + } + + +@pytest.mark.asyncio +async def test_update_prompt_rejects_keyed_prompt_data_with_prompt_id(): + from fastapi import HTTPException + + from litellm.proxy.prompts.prompt_endpoints import ( + AMBIGUOUS_PROMPT_DATA_ERROR, + Prompt, + update_prompt, + ) + + mock_user_auth = UserAPIKeyAuth( + api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN + ) + request = Prompt( + prompt_id="agent-prompt", + litellm_params=PromptLiteLLMParams( + prompt_id="agent-prompt", + prompt_integration="dotprompt", + prompt_data={"json_prompt": {"content": "AHOY", "metadata": {}}}, + ), + ) + + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()): # test-quality-ok: proxy_server module global is the endpoint's only injection point + with pytest.raises(HTTPException) as exc_info: + await update_prompt( + prompt_id="agent-prompt", + request=request, + user_api_key_dict=mock_user_auth, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == AMBIGUOUS_PROMPT_DATA_ERROR + + +def test_create_versioned_prompt_spec_populates_version(): + from litellm.proxy.prompts.prompt_endpoints import create_versioned_prompt_spec + + db_prompt = MagicMock() + db_prompt.model_dump.return_value = { + "prompt_id": "agent-prompt", + "version": 3, + "environment": "development", + "created_by": "user-1", + "litellm_params": { + "prompt_id": "agent-prompt", + "prompt_integration": "dotprompt", + }, + "prompt_info": {"prompt_type": "db"}, + "created_at": None, + "updated_at": None, + } + + prompt_spec = create_versioned_prompt_spec(db_prompt=db_prompt) + + assert prompt_spec.prompt_id == "agent-prompt.v3" + assert prompt_spec.version == 3 + + +def test_initialize_prompt_keeps_version_and_created_by(): + import litellm + from litellm.proxy.prompts.prompt_registry import InMemoryPromptRegistry + + registry = InMemoryPromptRegistry() + prompt_spec = PromptSpec( + prompt_id="agent-prompt.v3", + litellm_params=PromptLiteLLMParams( + prompt_id="agent-prompt", + prompt_integration="dotprompt", + prompt_data={"content": "AHOY", "metadata": {}}, + ), + prompt_info=PromptInfo(prompt_type="db"), + version=3, + environment="development", + created_by="user-1", + ) + + with patch.object(litellm.logging_callback_manager, "add_litellm_callback"): # test-quality-ok: keeps initialize_prompt from registering a global callback that would leak across tests + initialized_prompt = registry.initialize_prompt(prompt=prompt_spec) + + assert initialized_prompt is not None + assert initialized_prompt.version == 3 + assert initialized_prompt.created_by == "user-1" + assert initialized_prompt.environment == "development" diff --git a/tests/test_litellm/proxy/test_health_check_max_tokens.py b/tests/test_litellm/proxy/test_health_check_max_tokens.py index 642e4bb8e11..97e308d7c3c 100644 --- a/tests/test_litellm/proxy/test_health_check_max_tokens.py +++ b/tests/test_litellm/proxy/test_health_check_max_tokens.py @@ -684,3 +684,326 @@ async def test_run_model_health_check_skips_complexity_router_deployment(): fake_ahealth_check.assert_not_called() assert result == {} + + +def _router_health_fixture(): + """A real Router whose SIMPLE tier, default and classifier can each be pointed at a dead + group. That group has two replicas, so a verdict reached on only one of them is visible.""" + return litellm.Router( + model_list=[ + { + "model_name": "live-group", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-x"}, + "model_info": {"id": "live-1"}, + }, + { + "model_name": "dead-group", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-x"}, + "model_info": {"id": "dead-1"}, + }, + { + "model_name": "dead-group", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-x"}, + "model_info": {"id": "dead-2"}, + }, + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": {"SIMPLE": "dead-group", "MEDIUM": "live-group"}}, + "complexity_router_default_model": "live-group", + }, + "model_info": {"id": "router-1"}, + }, + ], + ignore_invalid_deployments=True, + ) + + +def _marker_deployment(router): + return next(d for d in router.model_list if d["model_info"]["id"] == "router-1") + + +def test_strategy_router_reds_when_a_tier_group_has_no_healthy_deployment(): + """LIT-6073: the marker is filed healthy by the {} placeholder; the verdict must override it.""" + router = _router_health_fixture() + healthy = [{"model_id": "router-1"}, {"model_id": "live-1"}] + unhealthy = [{"model_id": "dead-1", "error": "boom"}, {"model_id": "dead-2", "error": "boom"}] + + new_healthy, new_unhealthy = hc_module._finalize_strategy_router_endpoints( + healthy, unhealthy, router.model_list, router, () + ) + + assert [e["model_id"] for e in new_healthy] == ["live-1"] + moved = next(e for e in new_unhealthy if e["model_id"] == "router-1") + assert moved["error"] == "tier model 'dead-group' has no healthy deployment" + + +def test_strategy_router_stays_green_when_every_dependency_has_a_healthy_deployment(): + """The negative class: same router, same code path, nothing unhealthy behind it.""" + router = _router_health_fixture() + healthy = [{"model_id": "router-1"}, {"model_id": "live-1"}, {"model_id": "dead-1"}, {"model_id": "dead-2"}] + + new_healthy, new_unhealthy = hc_module._finalize_strategy_router_endpoints( + healthy, [], router.model_list, router, () + ) + + assert {e["model_id"] for e in new_healthy} == {"router-1", "live-1", "dead-1", "dead-2"} + assert new_unhealthy == () + + +def test_strategy_router_reds_when_a_dependency_name_matches_no_deployment(): + """An unresolvable tier name is a different fault from an unhealthy one, and says so.""" + router = _router_health_fixture() + marker = _marker_deployment(router) + marker["litellm_params"]["complexity_router_config"]["tiers"]["SIMPLE"] = "typo-group" + + _, new_unhealthy = hc_module._finalize_strategy_router_endpoints( + [{"model_id": "router-1"}], [], router.model_list, router, () + ) + + assert new_unhealthy[0]["error"] == "tier model 'typo-group' matches no deployment on this proxy" + + +@pytest.mark.parametrize("judged", [("router-1", "live-1"), ("router-1", "live-1", "dead-1")]) +def test_strategy_router_verdict_is_silent_when_part_of_a_group_went_unjudged(judged): + """Absent information never reds a router, whether the whole group went unjudged (hidden + from the caller) or only a replica did (opted out of health checks). The replica this run + never contacted can still serve every request the dead one drops.""" + router = _router_health_fixture() + scope = [d for d in router.model_list if d["model_info"]["id"] in judged] + + new_healthy, new_unhealthy = hc_module._finalize_strategy_router_endpoints( + [{"model_id": "router-1"}], [{"model_id": "dead-1", "error": "boom"}], scope, router, () + ) + + assert [e["model_id"] for e in new_healthy] == ["router-1"] + assert new_unhealthy == ({"model_id": "dead-1", "error": "boom"},) + + +def test_dependency_probe_expansion_is_a_no_op_when_every_dependency_is_already_checked(): + """The full-list run must gain no extra probe, or /health doubles its provider spend.""" + router = _router_health_fixture() + + assert hc_module._dependency_deployments_to_probe(router.model_list, router.model_list, router) == () + + +def test_dependency_probe_expansion_adds_dependencies_for_a_targeted_router_check(): + """GET /health?model_id= narrows to the marker, so the deps must be pulled back in.""" + router = _router_health_fixture() + marker_only = [_marker_deployment(router)] + + probes = hc_module._dependency_deployments_to_probe(marker_only, router.model_list, router) + + assert {d["model_info"]["id"] for d in probes} == {"dead-1", "dead-2", "live-1"} + + +def test_dependency_probes_carry_one_row_per_id(): + """An alias can put the same deployment in the list twice, which is what + filter_deployments_by_id exists for. Probing it twice doubles the provider spend, and two + results for one id can disagree, reding the router on whichever landed in the loser.""" + router = _router_health_fixture() + duplicated = tuple(router.model_list) + tuple(d for d in router.model_list if d["model_info"]["id"] == "dead-1") + + probes = hc_module._dependency_deployments_to_probe([_marker_deployment(router)], duplicated, router) + + assert [d["model_info"]["id"] for d in probes].count("dead-1") == 1 + + +def test_a_dependency_alias_whose_target_is_gone_reds_the_router(): + """An alias resolving to nothing fails a request exactly like an unknown name, so the + health check must not read the empty resolution as "no information" and stay green.""" + router = litellm.Router( + model_list=[ + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": {"SIMPLE": "broken-alias"}}, + "complexity_router_default_model": "broken-alias", + }, + "model_info": {"id": "router-1"}, + }, + ], + model_group_alias={"broken-alias": "target-that-no-longer-exists"}, + ignore_invalid_deployments=True, + ) + + _, new_unhealthy = hc_module._finalize_strategy_router_endpoints( + [{"model_id": "router-1"}], [], router.model_list, router, () + ) + + assert new_unhealthy[0]["error"] == "tier model 'broken-alias' matches no deployment on this proxy" + + +def test_a_dependency_that_opted_out_of_health_checks_is_never_probed(): + """skip-disabled is an operator opt-out. A router depending on that deployment must not + pull it back in and spend the proxy's provider credentials probing it.""" + disabled_dep = { + "model_name": "dead-group", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-x"}, + "model_info": {"id": "dead-1", "disable_background_health_check": True}, + } + router = litellm.Router( + model_list=[ + disabled_dep, + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": {"SIMPLE": "dead-group"}}, + "complexity_router_default_model": "dead-group", + }, + "model_info": {"id": "router-1"}, + }, + ], + ignore_invalid_deployments=True, + ) + marker = [d for d in router.model_list if d["model_info"]["id"] == "router-1"] + + eligible = hc_module._health_check_eligible(router.model_list, skip_disabled=True) + probes = hc_module._dependency_deployments_to_probe(marker, eligible, router) + + assert probes == () + assert [d["model_info"]["id"] for d in eligible] == ["router-1"] + + +def test_narrowing_by_an_id_that_matches_nothing_keeps_the_whole_list(): + """Pinned because the disabled-dependency fix moved this filter into its own helper.""" + deployments = [{"model_name": "a", "litellm_params": {"model": "openai/a"}, "model_info": {"id": "a-1"}}] + + assert hc_module._narrow_to_target(deployments, None, "no-such-id") == tuple(deployments) + assert hc_module._narrow_to_target(deployments, None, "a-1") == tuple(deployments) + assert hc_module._narrow_to_target(deployments, "a", None) == tuple(deployments) + + +def _nested_router_fixture(parent_tier: str): + return litellm.Router( + model_list=[ + { + "model_name": "dead-group", + "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-x"}, + "model_info": {"id": "dead-1"}, + }, + { + "model_name": "child", + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": {"SIMPLE": "dead-group"}}, + "complexity_router_default_model": "dead-group", + }, + "model_info": {"id": "child-1"}, + }, + { + "model_name": "parent", + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": {"SIMPLE": parent_tier}}, + "complexity_router_default_model": parent_tier, + }, + "model_info": {"id": "parent-1"}, + }, + ], + ignore_invalid_deployments=True, + ) + + +def test_a_router_routing_to_a_red_router_is_itself_red(): + """A marker never fails a probe of its own, so a single pass sees only probe failures and + leaves the parent of a dead child green while every request through it fails.""" + router = _nested_router_fixture("child") + + new_healthy, new_unhealthy = hc_module._finalize_strategy_router_endpoints( + [{"model_id": "parent-1"}, {"model_id": "child-1"}], + [{"model_id": "dead-1", "error": "boom"}], + router.model_list, + router, + (), + ) + + errors = {e["model_id"]: e["error"] for e in new_unhealthy if e["model_id"] != "dead-1"} + assert errors["child-1"] == "tier model 'dead-group' has no healthy deployment" + assert errors["parent-1"] == "tier model 'child' has no healthy deployment" + assert new_healthy == () + + +def test_a_router_routing_to_a_healthy_router_stays_green(): + """The negative class for nested propagation: the child serves, so the parent must not + inherit a red merely for depending on another router.""" + router = _nested_router_fixture("child") + child = next(d for d in router.model_list if d["model_info"]["id"] == "child-1") + child["litellm_params"]["complexity_router_config"]["tiers"]["SIMPLE"] = "dead-group" + + new_healthy, new_unhealthy = hc_module._finalize_strategy_router_endpoints( + [{"model_id": "parent-1"}, {"model_id": "child-1"}, {"model_id": "dead-1"}], + [], + router.model_list, + router, + (), + ) + + assert {e["model_id"] for e in new_healthy} == {"parent-1", "child-1", "dead-1"} + assert new_unhealthy == () + + +def test_two_routers_pointing_at_each_other_terminate_instead_of_recursing(): + """The round bound is what makes a cycle finish. Neither has a failing dependency, so + neither reds, and the walk must not recurse forever proving it.""" + router = litellm.Router( + model_list=[ + { + "model_name": name, + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": {"SIMPLE": other}}, + "complexity_router_default_model": other, + }, + "model_info": {"id": f"{name}-1"}, + } + for name, other in (("a", "b"), ("b", "a")) + ], + ignore_invalid_deployments=True, + ) + + new_healthy, new_unhealthy = hc_module._finalize_strategy_router_endpoints( + [{"model_id": "a-1"}, {"model_id": "b-1"}], [], router.model_list, router, () + ) + + assert {e["model_id"] for e in new_healthy} == {"a-1", "b-1"} + assert new_unhealthy == () + + +def test_a_targeted_check_on_a_nested_router_probes_the_grandchild_models(): + """One hop is not enough. GET /health?model_id= narrows to the parent, and pulling + in only the child marker leaves the child's own models unprobed, so nothing ever fails and + both settle green on the exact path the Admin UI uses.""" + router = _nested_router_fixture("child") + parent_only = [d for d in router.model_list if d["model_info"]["id"] == "parent-1"] + + probes = hc_module._dependency_deployments_to_probe(parent_only, router.model_list, router) + + assert {d["model_info"]["id"] for d in probes} == {"child-1", "dead-1"} + + +def test_transitive_probe_expansion_terminates_on_a_router_cycle(): + """Expansion follows routers through routers, so a cycle must stop rather than recurse.""" + router = litellm.Router( + model_list=[ + { + "model_name": name, + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": {"SIMPLE": other}}, + "complexity_router_default_model": other, + }, + "model_info": {"id": f"{name}-1"}, + } + for name, other in (("a", "b"), ("b", "a")) + ], + ignore_invalid_deployments=True, + ) + a_only = [d for d in router.model_list if d["model_info"]["id"] == "a-1"] + + probes = hc_module._dependency_deployments_to_probe(a_only, router.model_list, router) + + assert {d["model_info"]["id"] for d in probes} == {"b-1"} diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index afc42e8db45..f6aebc9546d 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -4741,6 +4741,90 @@ async def test_add_router_settings_from_db_config_merge_logic(): assert combined_settings["nested_config"] == expected_nested +@pytest.mark.asyncio +async def test_add_router_settings_from_db_config_empty_db_lists_do_not_clobber_config_fallbacks(): + """ + Regression test for DB router_settings rows carrying explicit empty lists + (e.g. {"fallbacks": []} written by the dashboard's delete-last-fallback flow): + empty lists are "no value" and must not clobber config.yaml fallbacks, + matching _deep_merge_dicts semantics. Non-empty DB lists still win. + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + mock_router = MagicMock() + mock_router.update_settings = MagicMock() + + config_data = { + "router_settings": { + "fallbacks": [{"gpt-oss-120b": ["granite-4-h-small"]}], + "context_window_fallbacks": [{"gpt-oss-120b": ["granite-4-h-small"]}], + "content_policy_fallbacks": [{"gpt-oss-120b": ["granite-4-h-small"]}], + } + } + + mock_db_config = MagicMock() + mock_db_config.param_value = { + "fallbacks": [], + "context_window_fallbacks": [], + "content_policy_fallbacks": [{"gpt-oss-120b": ["other-model"]}], + "model_group_alias": {}, + "num_retries": 3, + } + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=mock_db_config) + + await proxy_config._add_router_settings_from_db_config( + config_data=config_data, + llm_router=mock_router, + prisma_client=mock_prisma_client, + ) + + combined_settings = mock_router.update_settings.call_args.kwargs + assert combined_settings["fallbacks"] == [{"gpt-oss-120b": ["granite-4-h-small"]}] + assert combined_settings["context_window_fallbacks"] == [{"gpt-oss-120b": ["granite-4-h-small"]}] + assert combined_settings["content_policy_fallbacks"] == [{"gpt-oss-120b": ["other-model"]}] + assert combined_settings["num_retries"] == 3 + + +@pytest.mark.asyncio +async def test_add_router_settings_from_db_config_empty_db_list_still_clears_unconfigured_key(): + """ + An empty DB list only yields to config.yaml where the yaml configures that key. + When the yaml router_settings has no fallbacks, a DB {"fallbacks": []} (the + dashboard's delete-last-fallback write) must still reach the router so the + running pods drop the deleted fallback without a restart. + """ + from unittest.mock import AsyncMock, MagicMock + + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + mock_router = MagicMock() + mock_router.update_settings = MagicMock() + + config_data = {"router_settings": {"num_retries": 1}} + + mock_db_config = MagicMock() + mock_db_config.param_value = {"fallbacks": [], "model_group_alias": {}} + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_config.find_first = AsyncMock(return_value=mock_db_config) + + await proxy_config._add_router_settings_from_db_config( + config_data=config_data, + llm_router=mock_router, + prisma_client=mock_prisma_client, + ) + + combined_settings = mock_router.update_settings.call_args.kwargs + assert combined_settings["fallbacks"] == [] + assert combined_settings["num_retries"] == 1 + + @pytest.mark.asyncio async def test_add_router_settings_from_db_config_edge_cases(): """ @@ -9068,6 +9152,78 @@ class TestLazyFeatureMiddleware: ) +class TestInjectLazyStubs: + """Stub injection keys off the app-tracked loaded set, never sys.modules: + proxy boot imports several feature modules (mcp_management, cloudzero, + vantage, config_overrides) without mounting their routers, and their + /openapi.json entries must survive that (LIT-6275).""" + + def test_imported_but_unregistered_module_still_gets_stub(self): + import sys + + from litellm.proxy._lazy_features import LazyFeature, inject_lazy_stubs + + feat = LazyFeature( + name="dummy_lazy_test", + module_path="json", + path_prefixes=("/dummy-lazy-test",), + ) + assert feat.module_path in sys.modules + + schema = inject_lazy_stubs({"paths": {}}, loaded_modules=frozenset(), features=(feat,)) + assert "/dummy-lazy-test" in schema["paths"] + + def test_registered_module_gets_no_stub(self): + from litellm.proxy._lazy_features import LazyFeature, inject_lazy_stubs + + feat = LazyFeature( + name="dummy_lazy_test", + module_path="json", + path_prefixes=("/dummy-lazy-test",), + ) + schema = inject_lazy_stubs({"paths": {}}, loaded_modules=frozenset({"json"}), features=(feat,)) + assert "/dummy-lazy-test" not in schema["paths"] + + def test_snapshot_fragments_injected_for_boot_imported_features(self): + from litellm.proxy._lazy_features import LAZY_FEATURES, inject_lazy_stubs + from litellm.proxy._lazy_openapi_snapshot import load_snapshot + + snapshot = load_snapshot() + assert snapshot + boot_imported = tuple( + f for f in LAZY_FEATURES if f.name in ("mcp_management", "cloudzero", "vantage", "config_overrides") + ) + assert len(boot_imported) == 4 + + schema = inject_lazy_stubs({"paths": {}}, loaded_modules=frozenset(), features=boot_imported) + for feat in boot_imported: + missing = [p for p in snapshot[feat.name]["paths"] if p not in schema["paths"]] + assert not missing, f"{feat.name} snapshot paths missing from /openapi.json: {missing}" + + def test_persistent_stub_survives_load(self): + from litellm.proxy._lazy_features import LazyFeature, inject_lazy_stubs + + feat = LazyFeature( + name="dummy_lazy_test", + module_path="json", + path_prefixes=("/dummy-lazy-test",), + persistent_swagger_stub=True, + ) + schema = inject_lazy_stubs({"paths": {}}, loaded_modules=frozenset({"json"}), features=(feat,)) + assert "/dummy-lazy-test" in schema["paths"] + + def test_loaded_lazy_modules_reads_app_state(self): + from fastapi import FastAPI + + from litellm.proxy._lazy_features import loaded_lazy_modules + + app = FastAPI() + assert loaded_lazy_modules(app) == frozenset() + + app.state.lazy_loaded = {"litellm.proxy.spend_tracking.cloudzero_endpoints"} + assert loaded_lazy_modules(app) == frozenset({"litellm.proxy.spend_tracking.cloudzero_endpoints"}) + + @pytest.mark.asyncio async def test_get_current_spend_redis_clean_miss_skips_stale_in_memory(): """When Redis is reachable and cleanly returns None (TTL expired, @@ -11304,9 +11460,7 @@ class TestRouterModelNameOnStreamingChunks: with patch.object(ProxyLogging, "_fire_deferred_stream_logging"): return [ data - async for data in async_data_generator( - mock_response, MagicMock(spec=UserAPIKeyAuth), request_data - ) + async for data in async_data_generator(mock_response, MagicMock(spec=UserAPIKeyAuth), request_data) ] @staticmethod diff --git a/tests/test_litellm/proxy/test_shared_health_check.py b/tests/test_litellm/proxy/test_shared_health_check.py index 9f4078880e8..100425a8c9f 100644 --- a/tests/test_litellm/proxy/test_shared_health_check.py +++ b/tests/test_litellm/proxy/test_shared_health_check.py @@ -314,6 +314,7 @@ class TestSharedHealthCheckManager: details=True, max_concurrency=None, health_check_skip_disabled_background_models=False, + router=None, ) assert healthy == expected_healthy assert unhealthy == expected_unhealthy @@ -404,6 +405,7 @@ class TestSharedHealthCheckManager: details=True, max_concurrency=None, health_check_skip_disabled_background_models=False, + router=None, ) assert healthy == expected_healthy assert unhealthy == expected_unhealthy @@ -447,6 +449,7 @@ class TestSharedHealthCheckManager: details=True, max_concurrency=None, health_check_skip_disabled_background_models=False, + router=None, ) assert healthy == expected_healthy assert unhealthy == expected_unhealthy @@ -519,6 +522,7 @@ class TestSharedHealthCheckManager: details=True, max_concurrency=None, health_check_skip_disabled_background_models=False, + router=None, ) assert healthy == expected_healthy assert unhealthy == expected_unhealthy diff --git a/tests/test_litellm/realtime_api/test_main.py b/tests/test_litellm/realtime_api/test_main.py index a0c0d849e3e..a3dd5688ad1 100644 --- a/tests/test_litellm/realtime_api/test_main.py +++ b/tests/test_litellm/realtime_api/test_main.py @@ -1,10 +1,12 @@ import asyncio import time -from unittest.mock import MagicMock +from types import TracebackType +from unittest.mock import MagicMock, patch import pytest +import litellm from litellm.realtime_api import main as realtime_main from litellm.realtime_api.main import _with_resolved_session_model @@ -190,3 +192,105 @@ def test_client_secret_forwards_nested_transcription_model_untouched(monkeypatch session = captured["request_data"]["session"] assert session["model"] == "gpt-4o-realtime-preview" assert session["input_audio_transcription"]["model"] == "whisper-1" + + +class _CapturingConnect: + def __init__(self) -> None: + self.url: str | None = None + + def __call__(self, url: str, **kwargs: object) -> "_CapturingConnect": + self.url = url + return self + + async def __aenter__(self) -> MagicMock: + return MagicMock() + + async def __aexit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> None: + return None + + +@pytest.mark.asyncio +async def test_azure_health_check_probes_ga_transcription_url_for_transcription_model(local_model_cost_map): + """Regression for LIT-6240: transcription-only models (mode audio_transcription + in the cost map) are GA-only and 400 on the beta path, so the health probe + must hit /openai/v1/realtime?intent=transcription like real calls do.""" + connect = _CapturingConnect() + with patch("websockets.connect", connect): + assert await realtime_main._realtime_health_check( + model="gpt-realtime-whisper", + custom_llm_provider="azure", + api_key="fake-key", + api_base="https://my-endpoint.openai.azure.com", + api_version="2025-04-01-preview", + ) + assert connect.url == "wss://my-endpoint.openai.azure.com/openai/v1/realtime?intent=transcription" + + +@pytest.mark.asyncio +async def test_azure_health_check_stays_on_ga_when_deployment_registration_overwrites_mode( + local_model_cost_map, monkeypatch +): + """In a live proxy, Router._register_deployment_in_model_cost writes the + operator's deployment model_info (mode: realtime) over the catalog entry for + azure/gpt-realtime-whisper, so mode alone misreads the model as speech-capable + and the probe regresses to the beta path. supported_endpoints survives that + registration and must keep the probe on the GA transcription path.""" + polluted = {**litellm.model_cost["azure/gpt-realtime-whisper"], "mode": "realtime"} + monkeypatch.setitem(litellm.model_cost, "azure/gpt-realtime-whisper", polluted) + connect = _CapturingConnect() + with patch("websockets.connect", connect): + assert await realtime_main._realtime_health_check( + model="gpt-realtime-whisper", + custom_llm_provider="azure", + api_key="fake-key", + api_base="https://my-endpoint.openai.azure.com", + api_version="2025-04-01-preview", + ) + assert connect.url == "wss://my-endpoint.openai.azure.com/openai/v1/realtime?intent=transcription" + + +def test_transcription_only_detection_falls_back_to_mode(local_model_cost_map): + """azure/whisper-1 declares mode audio_transcription but no supported_endpoints, + so only the mode signal can classify it as transcription-only.""" + assert realtime_main._is_transcription_only_realtime_model("whisper-1", "azure") is True + + +def test_transcription_only_detection_rejects_speech_model(local_model_cost_map): + assert realtime_main._is_transcription_only_realtime_model("gpt-realtime-mini", "azure") is False + + +@pytest.mark.asyncio +async def test_azure_health_check_keeps_beta_path_for_speech_model(): + connect = _CapturingConnect() + with patch("websockets.connect", connect): + assert await realtime_main._realtime_health_check( + model="gpt-4o-realtime-preview", + custom_llm_provider="azure", + api_key="fake-key", + api_base="https://my-endpoint.openai.azure.com", + api_version="2024-10-01-preview", + ) + assert connect.url == ( + "wss://my-endpoint.openai.azure.com/openai/realtime" + "?api-version=2024-10-01-preview&deployment=gpt-4o-realtime-preview" + ) + + +@pytest.mark.asyncio +async def test_azure_health_check_honors_deployment_realtime_protocol(): + connect = _CapturingConnect() + with patch("websockets.connect", connect): + assert await realtime_main._realtime_health_check( + model="gpt-4o-realtime-preview", + custom_llm_provider="azure", + api_key="fake-key", + api_base="https://my-endpoint.openai.azure.com", + api_version="2024-10-01-preview", + model_params={"realtime_protocol": "GA"}, + ) + assert connect.url == "wss://my-endpoint.openai.azure.com/openai/v1/realtime?model=gpt-4o-realtime-preview" diff --git a/tests/test_litellm/responses/test_responses_api_request_body.py b/tests/test_litellm/responses/test_responses_api_request_body.py index 14eb9ab6e12..5fd53fda01b 100644 --- a/tests/test_litellm/responses/test_responses_api_request_body.py +++ b/tests/test_litellm/responses/test_responses_api_request_body.py @@ -41,9 +41,7 @@ def _minimal_responses_api_payload(response_id: str, model: str) -> dict: "id": "msg_1", "status": "completed", "role": "assistant", - "content": [ - {"type": "output_text", "text": "Done.", "annotations": []} - ], + "content": [{"type": "output_text", "text": "Done.", "annotations": []}], } ], "parallel_tool_calls": True, @@ -83,9 +81,9 @@ class MockResponse: def _assert_request_body_matches(request_body: dict, expected_body: dict) -> None: for key, expected_value in expected_body.items(): assert key in request_body, f"Missing key in request body: {key}" - assert ( - request_body[key] == expected_value - ), f"Mismatch for key {key}: got {request_body[key]!r}, expected {expected_value!r}" + assert request_body[key] == expected_value, ( + f"Mismatch for key {key}: got {request_body[key]!r}, expected {expected_value!r}" + ) @pytest.mark.asyncio @@ -100,9 +98,7 @@ async def test_aresponses_context_management_and_shell_request_body_matches_expe "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post", new_callable=AsyncMock, ) as mock_post: - mock_post.return_value = MockResponse( - _minimal_responses_api_payload("resp_ctx_shell_test", "gpt-4o"), 200 - ) + mock_post.return_value = MockResponse(_minimal_responses_api_payload("resp_ctx_shell_test", "gpt-4o"), 200) await litellm.aresponses( model="openai/gpt-4o", @@ -426,7 +422,18 @@ async def test_aresponses_websocket_strips_responses_routing_prefix_from_openai_ _INJECTION_POINT_INPUT = [{"role": "system", "content": "You are terse."}, {"role": "user", "content": "hi"}] -_SYSTEM_INJECTION_POINT = [{"location": "message", "role": "system"}] +_SYSTEM_POINT = {"location": "message", "role": "system"} +_USER_POINT = {"location": "message", "role": "user"} +_SYSTEM_INJECTION_POINT = [_SYSTEM_POINT] +_ANTHROPIC_MESSAGES_PAYLOAD = { + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-5", + "content": [{"type": "text", "text": "Done."}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 10, "output_tokens": 5}, +} def _sent_body(mock_post) -> dict: @@ -598,3 +605,178 @@ def test_responses_custom_api_base_sends_no_openai_markers(): body = _sent_body(mock_post) assert body["input"] == _INJECTION_POINT_INPUT assert "prompt_cache_options" not in body + + +@pytest.mark.asyncio +async def test_injection_points_still_reach_a_native_responses_provider(): + """Providers that serve Responses natively never reach the chat-completions bridge, + so this layer is their only chance to inject and must keep doing so.""" + injected_client = AsyncHTTPHandler() + mock_post = AsyncMock(return_value=MockResponse(_minimal_responses_api_payload("resp_native", "gpt-5.6"), 200)) + injected_client.post = mock_post + + await litellm.aresponses( + model="openai/gpt-5.6", + api_key="fake-api-key", + input=copy.deepcopy(_INJECTION_POINT_INPUT), + cache_control_injection_points=copy.deepcopy(_SYSTEM_INJECTION_POINT), + client=injected_client, + ) + + body = _sent_body(mock_post) + assert body["input"][0]["content"][0]["prompt_cache_breakpoint"] == {"mode": "explicit"} + assert "cache_control_injection_points" not in body + + +async def _bridged_body(mock_post, *, points, input, instructions="You are a documentation assistant."): + injected_client = AsyncHTTPHandler() + injected_client.post = mock_post + + await litellm.aresponses( + model="anthropic/claude-sonnet-4-5", + api_key="fake-api-key", + instructions=instructions, + input=copy.deepcopy(input), + cache_control_injection_points=copy.deepcopy(points), + client=injected_client, + ) + return _sent_body(mock_post) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "content", + [ + pytest.param("hi", id="string-content"), + pytest.param([{"type": "input_text", "text": "hi there friend"}], id="list-content"), + ], +) +@pytest.mark.parametrize( + "points", + [ + pytest.param([_SYSTEM_POINT], id="system-only"), + pytest.param([_USER_POINT, _SYSTEM_POINT], id="mixed-user-and-system"), + ], +) +async def test_instructions_are_marked_when_the_bridge_builds_the_system_message(points, content): + """The system prompt lives in `instructions`, which is not a message until the bridge + builds one, so the point targeting it matches nothing at the Responses layer. + + Carrying it forward is what marks it at all. Carrying it *stamped* is what keeps a + second point that did match from stranding it: without the stamp the next pass reads + litellm's own marks as client breakpoints and stands the whole configuration down. + """ + mock_post = AsyncMock(return_value=MockResponse(_ANTHROPIC_MESSAGES_PAYLOAD, 200)) + body = await _bridged_body(mock_post, points=points, input=[{"role": "user", "content": content}]) + + assert body["system"][0]["cache_control"] == {"type": "ephemeral"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("instructions", [None, "You are a documentation assistant."]) +async def test_positional_points_address_the_input_item_the_caller_indexed(instructions): + """`index` counts the caller's `input` items, and the Responses layer is where that + list still is, so a matched positional point must be spent there and never re-resolved + against the bridge's list, where the system message shifts every ordinal by one.""" + mock_post = AsyncMock(return_value=MockResponse(_ANTHROPIC_MESSAGES_PAYLOAD, 200)) + body = await _bridged_body( + mock_post, + points=[{"location": "message", "index": 0}], + input=[{"role": "user", "content": [{"type": "input_text", "text": "hi there friend"}]}], + instructions=instructions, + ) + + assert body["messages"][0]["content"][0]["cache_control"] == {"type": "ephemeral"} + if instructions: + assert "cache_control" not in json.dumps(body["system"]) + + +@pytest.mark.asyncio +async def test_out_of_bounds_positional_points_are_not_revived_by_a_longer_list(): + """An ordinal addresses the list in front of the pass that reads it. + + Carrying one forward would re-resolve it against the bridge's longer list, where an + index that named nothing in the caller's `input` can land on a real message -- the + system prompt included. Positional points are resolved where they were written or not + at all. + """ + mock_post = AsyncMock(return_value=MockResponse(_ANTHROPIC_MESSAGES_PAYLOAD, 200)) + body = await _bridged_body( + mock_post, + points=[{"location": "message", "index": 1}], + input=[{"role": "user", "content": [{"type": "input_text", "text": "only item"}]}], + ) + + assert "cache_control" not in json.dumps(body["system"]) + assert "cache_control" not in json.dumps(body["messages"]) + + +def _four_user_turns() -> list: + return [ + item + for i in range(4) + for item in ( + {"role": "user", "content": [{"type": "input_text", "text": f"msg{i}"}]}, + {"role": "assistant", "content": [{"type": "output_text", "text": f"reply{i}", "annotations": []}]}, + ) + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "points,instructions,system_marked,marked_messages", + [ + pytest.param([_SYSTEM_POINT, _USER_POINT], "You are terse.", True, [0, 2, 4], id="earlier-point-wins"), + pytest.param([_USER_POINT, _SYSTEM_POINT], "You are terse.", False, [0, 2, 4, 6], id="reversed-order-reverses"), + pytest.param([_USER_POINT, _SYSTEM_POINT], None, False, [0, 2, 4, 6], id="target-never-built-costs-nothing"), + ], +) +async def test_config_order_decides_who_wins_the_shared_breakpoint_budget( + points, instructions, system_marked, marked_messages +): + """Injection points are honoured in config order, earlier ones winning scarce slots. + + A role-targeted point is placed a pass later than a positional one, so the four + breakpoints it competes for are shared across both passes. Every role point being + settled in the pass that holds the final list -- rather than the earlier pass holding + a slot for one it cannot place -- is what keeps that competition ordered in both + directions, and what stops a point whose target is never built from costing anything. + """ + mock_post = AsyncMock(return_value=MockResponse(_ANTHROPIC_MESSAGES_PAYLOAD, 200)) + body = await _bridged_body(mock_post, points=points, input=_four_user_turns(), instructions=instructions) + + assert ("cache_control" in json.dumps(body.get("system", []))) is system_marked + assert [i for i, msg in enumerate(body["messages"]) if "cache_control" in json.dumps(msg)] == marked_messages + + +@pytest.mark.asyncio +async def test_a_native_responses_provider_places_every_point_itself(): + """A provider serving Responses natively gets no second pass. + + This layer is the last one that can place anything, so handing a point forward here + drops it -- and an unmatchable point must not cost a matching one its slot either. + The request has to be known to be bridged before anything is deferred. + """ + input_items = _four_user_turns() + + async def _marked_indices(points): + injected_client = AsyncHTTPHandler() + mock_post = AsyncMock(return_value=MockResponse(_minimal_responses_api_payload("resp_native", "gpt-5.6"), 200)) + injected_client.post = mock_post + await litellm.aresponses( + model="openai/gpt-5.6", + api_key="fake-api-key", + input=copy.deepcopy(input_items), + cache_control_injection_points=copy.deepcopy(points), + client=injected_client, + ) + body = _sent_body(mock_post) + return [i for i, item in enumerate(body["input"]) if "prompt_cache_breakpoint" in json.dumps(item)] + + user_only = await _marked_indices([_USER_POINT]) + # The system point can never match here: nothing turns `instructions` into a message + # on the native path, so it must not cost the user point a slot. + with_unmatchable_system = await _marked_indices([_SYSTEM_POINT, _USER_POINT]) + + assert user_only == [0, 2, 4, 6] + assert with_unmatchable_system == user_only diff --git a/tests/test_litellm/responses/test_streaming_iterator.py b/tests/test_litellm/responses/test_streaming_iterator.py index 5b0f40fdf27..38407c94fe7 100644 --- a/tests/test_litellm/responses/test_streaming_iterator.py +++ b/tests/test_litellm/responses/test_streaming_iterator.py @@ -235,3 +235,73 @@ def test_sync_transport_error_before_completed_event_raises(): with pytest.raises(httpx.ReadError): for _ in iterator: pass + + +def test_stream_cache_write_completes_when_asyncio_run_closes_the_loop(monkeypatch): + """ + Regression test for LIT-6184 on the /v1/responses streaming surface: the + completed-stream cache write was dispatched as a bare fire-and-forget task, + so asyncio.run cancelled it at loop close before the write landed. The + write must survive loop shutdown just like the chat-completions one. + """ + import asyncio + from types import SimpleNamespace + + import litellm + from litellm.types.utils import CallTypes + + writes = [] + + class _SlowWriteCache: + async def async_add_cache(self, result, dynamic_cache_object=None, **kwargs): + await asyncio.sleep(0.2) + writes.append(result) + + def add_cache(self, *args, **kwargs): + raise AssertionError("sync write must not run on the async path") + + caching_handler = SimpleNamespace( + request_kwargs={ + "model": "test-model", + "input": "hello", + "stream": True, + "caching": True, + "metadata": None, + "custom_llm_provider": "openai", + }, + preset_cache_key="responses-stream-cache-key", + original_function=litellm.aresponses, + dual_cache=None, + _should_store_result_in_cache=lambda original_function, kwargs: True, + ) + logging_obj = SimpleNamespace( + model_call_details={"litellm_params": {}}, + _llm_caching_handler=caching_handler, + ) + iterator = ResponsesAPIStreamingIterator( + response=httpx.Response(200), + model="test-model", + responses_api_provider_config=Mock(spec=BaseResponsesAPIConfig), + logging_obj=logging_obj, + request_data=caching_handler.request_kwargs, + call_type=CallTypes.aresponses.value, + ) + iterator.completed_response = ResponseCompletedEvent( + type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, + response=ResponsesAPIResponse( + id="resp_lit6184", + created_at=int(datetime.now().timestamp()), + status="completed", + model="test-model", + object="response", + output=[], + ), + ) + monkeypatch.setattr(litellm, "cache", _SlowWriteCache()) + + async def _short_lived_script(): + iterator._persist_completed_response_to_cache(is_async=True) + + asyncio.run(_short_lived_script()) + + assert len(writes) == 1 diff --git a/tests/test_litellm/router_utils/test_auto_router_model_naming.py b/tests/test_litellm/router_utils/test_auto_router_model_naming.py index 258ef99c6fb..571cb90cedb 100644 --- a/tests/test_litellm/router_utils/test_auto_router_model_naming.py +++ b/tests/test_litellm/router_utils/test_auto_router_model_naming.py @@ -2,6 +2,7 @@ import pytest from litellm.router_utils.auto_router_model_naming import ( classify_strategy_router_model, + strategy_router_dependencies, validate_complexity_router_config_write, validate_strategy_router_model_write, ) @@ -179,3 +180,123 @@ def test_config_check_ignores_the_model_entirely(): ) is not None ) + + +@pytest.mark.parametrize( + "litellm_params, expected", + [ + ({"model": "openai/gpt-4o"}, ()), + ( + { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": {"SIMPLE": "a", "MEDIUM": ["b", "c"]}}, + "complexity_router_default_model": "d", + }, + (("a", "tier"), ("b", "tier"), ("c", "tier"), ("d", "default")), + ), + ( + { + "model": "auto_router/complexity_router", + "complexity_router_config": { + "tiers": {"SIMPLE": "a"}, + "classifier_type": "llm", + "classifier_llm_config": {"model": "clf"}, + }, + }, + (("a", "tier"), ("clf", "classifier")), + ), + ( + { + "model": "auto_router/complexity_router", + "complexity_router_config": { + "tiers": {"SIMPLE": "a"}, + "classifier_llm_config": {"model": "clf"}, + }, + }, + (("a", "tier"),), + ), + ( + {"model": "auto_router/my_router", "auto_router_default_model": "d", "auto_router_embedding_model": "e"}, + (("d", "default"), ("e", "embedding")), + ), + ( + {"model": "auto_router/adaptive_router", "adaptive_router_config": {"available_models": ["m1", "m2"]}}, + (("m1", "tier"), ("m2", "tier")), + ), + ( + { + "model": "auto_router/quality_router", + "quality_router_config": {"available_models": ["q1"], "default_model": "qd"}, + }, + (("q1", "tier"), ("qd", "default")), + ), + ], +) +def test_strategy_router_dependencies(litellm_params, expected): + found = strategy_router_dependencies(litellm_params) + assert tuple((d.model_name, d.role) for d in found) == expected + + +def test_complexity_default_model_param_wins_over_the_config_field(): + """ComplexityRouter overwrites config.default_model with the litellm_params one, so the + config field is dead whenever the param is set and must not be able to red the router.""" + found = strategy_router_dependencies( + { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": {}, "default_model": "shadowed"}, + "complexity_router_default_model": "winner", + } + ) + + assert tuple(d.model_name for d in found) == ("winner",) + + +def test_complexity_ignores_its_config_default_model_and_quality_does_not(): + """Router init derives a complexity default from the tiers (fallback_tier, MEDIUM, SIMPLE) + and overwrites config.default_model, so that field names a model complexity never calls. + Quality init really does fall back to it, so the two must not be treated alike.""" + complexity = strategy_router_dependencies( + { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": {"MEDIUM": "derived"}, "default_model": "never-called"}, + } + ) + quality = strategy_router_dependencies( + { + "model": "auto_router/quality_router", + "quality_router_config": {"available_models": ["q1"], "default_model": "really-used"}, + } + ) + + assert tuple(d.model_name for d in complexity) == ("derived",) + assert tuple(d.model_name for d in quality) == ("q1", "really-used") + + +@pytest.mark.parametrize( + "config", + ["not-a-dict", None, {"tiers": "not-a-dict"}, {"tiers": {"SIMPLE": 7}}, {"tiers": {"SIMPLE": [None, ""]}}], +) +def test_strategy_router_dependencies_never_raises_on_a_malformed_config(config): + """A config the router itself would refuse must not take the whole /health response down.""" + assert strategy_router_dependencies({"model": "auto_router/complexity_router", "complexity_router_config": config}) == () + + +@pytest.mark.parametrize( + "semantic_on, expected", + [(False, ("t",)), (True, ("t", "emb"))], +) +def test_complexity_embedding_model_is_a_dependency_only_when_semantic_matching_is_on(semantic_on, expected): + """The runtime reads embedding_model only under semantic_keyword_matching, so listing it + unconditionally would red a router that never calls it.""" + found = strategy_router_dependencies( + { + "model": "auto_router/complexity_router", + "complexity_router_config": { + "tiers": {"SIMPLE": "t"}, + "embedding_model": "emb", + "semantic_keyword_matching": semantic_on, + }, + } + ) + + assert tuple(d.model_name for d in found) == expected diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 55e3406cb3a..4117b2fce6d 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -1727,6 +1727,73 @@ def test_azure_ai_cache_cost_calculation(_local_model_cost_map): ), f"Output cost mismatch: got {output_cost}, expected {expected_output_cost}" + +AZURE_GPT_5_6_MAP_KEYS = ( + "azure/gpt-5.6", + "azure/gpt-5.6-sol", + "azure/gpt-5.6-terra", + "azure/gpt-5.6-luna", + "azure/us/gpt-5.6", + "azure/us/gpt-5.6-sol", + "azure/us/gpt-5.6-terra", + "azure/us/gpt-5.6-luna", + "azure/eu/gpt-5.6", + "azure/eu/gpt-5.6-sol", + "azure/eu/gpt-5.6-terra", + "azure/eu/gpt-5.6-luna", +) + + +def test_azure_gpt_5_6_cache_write_tokens_are_billed(_local_model_cost_map): + """ + Azure bills gpt-5.6 prompt cache writes at 1.25x the input rate on every + tier, but the azure entries carried no ``cache_creation_input_token_cost``, + so cache-write tokens were billed at the plain input rate instead. + """ + from litellm.litellm_core_utils.llm_cost_calc.utils import generic_cost_per_token + from litellm.types.utils import PromptTokensDetailsWrapper, Usage + + usage = Usage( + completion_tokens=100, + prompt_tokens=2000, + total_tokens=2100, + prompt_tokens_details=PromptTokensDetailsWrapper(cached_tokens=0, text_tokens=687), + cache_creation_input_tokens=1313, + ) + + input_cost, output_cost = generic_cost_per_token( + model="azure/gpt-5.6-luna", usage=usage, custom_llm_provider="azure" + ) + + assert input_cost == pytest.approx(687 * 2e-07 + 1313 * 2.5e-07) + assert output_cost == pytest.approx(100 * 1.2e-06) + + +@pytest.mark.parametrize("model", AZURE_GPT_5_6_MAP_KEYS) +def test_azure_gpt_5_6_rates_match_azure_price_page(_local_model_cost_map, model): + """ + Per the Azure OpenAI price page (rendered 2026-08-26): cache writes cost + 1.25x input on every gpt-5.6 tier, and Data Zone costs 1.1x Global for + standard and priority alike (us/eu priority rates previously sat at 1.25x). + """ + entry = litellm.model_cost[model] + input_keys = [key for key in entry if key.startswith("input_cost_per_token")] + assert input_keys + for key in input_keys: + suffix = key[len("input_cost_per_token") :] + assert entry["cache_creation_input_token_cost" + suffix] == pytest.approx(entry[key] * 1.25) + + zone = model.split("/")[1] + if zone in ("us", "eu"): + global_entry = litellm.model_cost["azure/" + model.split("/", 2)[2]] + prefixes = ("input_cost_per_token", "output_cost_per_token", "cache_read", "cache_creation") + token_cost_keys = [key for key in entry if key.startswith(prefixes)] + global_token_cost_keys = [key for key in global_entry if key.startswith(prefixes)] + assert len(token_cost_keys) >= 9 + assert sorted(token_cost_keys) == sorted(global_token_cost_keys) + for key in token_cost_keys: + assert entry[key] == pytest.approx(global_entry[key] * 1.1), key + def test_vertex_regional_deployment_costs_uplift_over_global(monkeypatch): """ Regression for https://github.com/BerriAI/litellm/issues/34393: two Vertex @@ -2552,6 +2619,49 @@ def test_completion_cost_anthropic_auto_tier_uses_served_priority_rate(_local_mo assert cost == pytest.approx(expected_priority) +def test_completion_cost_vertex_ai_gemini_flex_traffic_type(_local_model_cost_map): + """ + Vertex AI flex-tier billing regression for issue #37647. + + Vertex Gemini 3.x models route through ``cost_per_character`` (the + ``cost_router`` token-path gate only matches "gemini-2"), and its token + fallbacks dropped ``service_tier``. A response served with + ``trafficType=ON_DEMAND_FLEX`` must be billed at the flex rate, not the + standard rate. + """ + from litellm import completion_cost + + model = "gemini-3-test-flex-tier-cost-model" + litellm.register_model( + model_cost={ + model: { + "input_cost_per_token": 1.5e-6, + "output_cost_per_token": 9e-6, + "input_cost_per_token_flex": 7.5e-7, + "output_cost_per_token_flex": 4.5e-6, + "litellm_provider": "vertex_ai", + "max_tokens": 8192, + } + } + ) + + def _cost_for_traffic_type(traffic_type): + usage = Usage(prompt_tokens=1000, completion_tokens=500, total_tokens=1500) + response = ModelResponse(usage=usage, model=model) + response._hidden_params["provider_specific_fields"] = {"traffic_type": traffic_type} + return completion_cost( + completion_response=response, + model=model, + custom_llm_provider="vertex_ai", + ) + + standard_cost = _cost_for_traffic_type("ON_DEMAND") + flex_cost = _cost_for_traffic_type("ON_DEMAND_FLEX") + + assert standard_cost == pytest.approx(1000 * 1.5e-6 + 500 * 9e-6) + assert flex_cost == pytest.approx(1000 * 7.5e-7 + 500 * 4.5e-6) + + def test_completion_cost_non_string_service_tier_defers_to_served_tier(_local_model_cost_map): """ Regression: a non-string request-level ``service_tier`` (reachable via @@ -2701,12 +2811,10 @@ def test_anthropic_cost_per_token_prices_cache_at_served_tier_with_multiplier(_l """ Regression for the cache/tier interaction in the Anthropic geo/speed path. - When a request is served at "priority" and also carries a geo/speed - multiplier (here ``speed="fast"``), the cache portion is held out of the - multiplier so it is not scaled. That held-out cache cost must use the - served tier's cache rate; pricing it at the standard rate while the cache - embedded in ``prompt_cost`` is priced at the priority rate leaves a - ``(cache_priority - cache_standard)(multiplier - 1)`` billing error. + When a request is served at "priority" and also carries the ``fast`` speed + multiplier, the cache portion must be priced at the served tier's cache + rate and, per Anthropic's fast-mode pricing, scaled by the multiplier like + every other token type. """ from litellm.llms.anthropic.cost_calculation import ( cost_per_token as anthropic_cost_per_token, @@ -2743,10 +2851,7 @@ def test_anthropic_cost_per_token_prices_cache_at_served_tier_with_multiplier(_l model=model, usage=usage, service_tier="priority" ) - # non-cache input priced at the priority rate and scaled by the fast - # multiplier; the 200 cache-hit tokens priced at the priority cache rate - # and held out of the multiplier - expected_prompt = (1000 - 200) * 6e-6 * 2 + 200 * 0.6e-6 + expected_prompt = ((1000 - 200) * 6e-6 + 200 * 0.6e-6) * 2 expected_completion = 500 * 30e-6 * 2 assert prompt_cost == pytest.approx(expected_prompt) assert completion_cost == pytest.approx(expected_completion) @@ -2814,10 +2919,9 @@ def test_anthropic_geo_multiplier_applies_to_cache_tokens(_local_model_cost_map, def test_anthropic_geo_and_fast_multipliers_compose(_local_model_cost_map, monkeypatch): """ - The ``fast`` speed multiplier stays cache-exclusive (the old explicit - ``fast/`` entries kept base cache rates) while the geo multiplier scales the - whole cost, so a fast + regional row prices as - ``((non_cache * fast) + cache) * geo``. + Anthropic's fast-mode pricing doubles every token type, cache reads and + writes included, and the regional uplift stacks on top, so a fast + + regional row prices as ``(non_cache + cache) * fast * geo``. """ from litellm.llms.anthropic.cost_calculation import ( cost_per_token as anthropic_cost_per_token, @@ -2845,10 +2949,32 @@ def test_anthropic_geo_and_fast_multipliers_compose(_local_model_cost_map, monke cache_cost = 2_000 * 0.5e-6 + 6_000 * 6.25e-6 non_cache_cost = 2_000 * 5e-6 - assert prompt_cost == pytest.approx((non_cache_cost * 2.0 + cache_cost) * 1.1) + assert prompt_cost == pytest.approx((non_cache_cost + cache_cost) * 2.0 * 1.1) assert completion_cost == pytest.approx(500 * 25e-6 * 2.0 * 1.1) +@pytest.mark.parametrize( + "model,expected_fast", + [ + ("claude-opus-5", 2.0), + ("claude-opus-4-8", 2.0), + ("claude-opus-4-6", None), + ("claude-opus-4-6-20260205", None), + ("claude-opus-4-7", None), + ("claude-opus-4-7-20260416", None), + ], +) +def test_anthropic_fast_multiplier_only_on_models_with_fast_mode(_local_model_cost_map, model, expected_fast): + """ + Anthropic serves fast mode on Opus 5 and Opus 4.8 only, at 2x. Opus 4.6 and + 4.7 accept the ``speed`` request param but are always served standard, so a + ``fast`` multiplier on their map entries overbills every request that asked + for fast and was served standard. + """ + entry = litellm.model_cost[model] + assert entry["provider_specific_entry"].get("fast") == expected_fast + + @pytest.mark.parametrize( "model", ["claude-sonnet-4-6", "claude-mythos-5", "claude-mythos-preview"], @@ -3826,6 +3952,167 @@ def test_completion_cost_prices_anthropic_shaped_cache_read_tokens(_local_model_ assert cost == pytest.approx(3 * 4e-6 + 4014 * 4e-7 + 5 * 2e-5, rel=1e-9) +def test_select_model_name_strips_unregistered_alias_prefix(_local_model_cost_map): + """A router-facing model_name alias containing "/" whose leading segment is NOT a + registered provider must not be double-prefixed into a non-existent cost key. + + Regression test for #38069: alias "vertex/claude-opus-5" (real deployment + "vertex_ai/claude-opus-5") was re-prefixed into "vertex_ai/vertex/claude-opus-5", + silently pricing every streamed request at $0. + """ + + from litellm.cost_calculator import _select_model_name_for_cost_calc + + response = litellm.ModelResponse( + id="x", + choices=[ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + model="vertex/claude-opus-5", + ) + response._hidden_params = {} + + selected = _select_model_name_for_cost_calc( + model=None, + completion_response=response, + custom_llm_provider="vertex_ai", + ) + + assert selected == "vertex_ai/claude-opus-5" + + +def test_select_model_name_strips_duplicated_region_segment(_local_model_cost_map): + """A "region/model" alias whose leading segment repeats the request's region must + resolve to the region-priced cost key instead of keeping the region segment twice.""" + + from litellm.cost_calculator import _select_model_name_for_cost_calc + + response = litellm.ModelResponse( + id="x", + choices=[ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + model="us-east-1/anthropic.claude-v2:1", + ) + response._hidden_params = {"region_name": "us-east-1"} + + selected = _select_model_name_for_cost_calc( + model=None, + completion_response=response, + custom_llm_provider="bedrock", + ) + + assert selected == "bedrock/us-east-1/anthropic.claude-v2:1" + + +def test_completion_cost_nonzero_for_slash_alias_model_name(_local_model_cost_map): + """End-to-end cost through a "/"-containing alias must price above zero (#38069).""" + + response = litellm.ModelResponse( + id="x", + choices=[ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + model="vertex/claude-opus-5", + ) + response._hidden_params = {"custom_llm_provider": "vertex_ai"} + response.usage = litellm.Usage(prompt_tokens=100, completion_tokens=50) + + cost = litellm.completion_cost( + completion_response=response, + custom_llm_provider="vertex_ai", + ) + + assert cost == pytest.approx(100 * 5e-6 + 50 * 2.5e-5, rel=1e-9) + + +def test_select_model_name_unresolvable_alias_unchanged(_local_model_cost_map): + """An alias that resolves to no known cost key keeps the legacy double-prefixed name.""" + + from litellm.cost_calculator import _select_model_name_for_cost_calc + + response = litellm.ModelResponse( + id="x", + choices=[ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + model="team/nonsense-model", + ) + response._hidden_params = {} + + selected = _select_model_name_for_cost_calc( + model=None, + completion_response=response, + custom_llm_provider="vertex_ai", + ) + + assert selected == "vertex_ai/team/nonsense-model" + + +def test_completion_cost_keeps_custom_priced_slash_router_id(_local_model_cost_map): + """A custom-priced router id containing "/" keeps its custom pricing instead of being + rewritten to the built-in key its suffix happens to match.""" + + from litellm.cost_calculator import _select_model_name_for_cost_calc + + litellm.register_model( + model_cost={ + "vertex/claude-opus-5": { + "input_cost_per_token": 7e-6, + "output_cost_per_token": 8e-6, + "litellm_provider": "vertex_ai", + } + } + ) + + selected = _select_model_name_for_cost_calc( + model="vertex_ai/claude-opus-5", + completion_response=None, + custom_pricing=True, + custom_llm_provider="vertex_ai", + router_model_id="vertex/claude-opus-5", + ) + assert selected == "vertex_ai/vertex/claude-opus-5" + + response = litellm.ModelResponse( + id="x", + choices=[ + { + "index": 0, + "message": {"role": "assistant", "content": "hi"}, + "finish_reason": "stop", + } + ], + model="vertex/claude-opus-5", + ) + response._hidden_params = {"custom_llm_provider": "vertex_ai"} + response.usage = litellm.Usage(prompt_tokens=100, completion_tokens=50) + + cost = litellm.completion_cost( + completion_response=response, + custom_llm_provider="vertex_ai", + custom_pricing=True, + router_model_id="vertex/claude-opus-5", + ) + assert cost == pytest.approx(100 * 7e-6 + 50 * 8e-6, rel=1e-9) + + @pytest.mark.parametrize( ("model", "expected_1hr_rate"), [("claude-3-haiku-20240307", 5e-07), ("claude-3-opus-20240229", 3e-05)], diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 8f2b06be4b3..3eea47bcd5a 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -1,7 +1,12 @@ +import asyncio +import base64 import contextlib import copy import json import os +from collections.abc import Mapping +from dataclasses import dataclass +from typing import Final import httpx import pytest @@ -14,6 +19,9 @@ from unittest.mock import MagicMock, patch import litellm from litellm import main as litellm_main +from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.core_helpers import get_litellm_metadata_from_kwargs +from litellm.types.utils import Usage async def _async_fake_bedrock_image_details(image_url): @@ -2957,3 +2965,109 @@ async def test_acompletion_resolves_provider_from_api_base(): ) assert response.choices[0].message.content == "resolved" + + +@dataclass(frozen=True, slots=True) +class _RecordedSpeechSuccess: + call_type: str | None + spend_metadata: Mapping[str, object] + response_cost: float | None + logged_response_cost: float | None + + +def _record_speech_success(payload: dict[str, object]) -> _RecordedSpeechSuccess: + call_type: Final = payload.get("call_type") + response_cost: Final = payload.get("response_cost") + logging_payload: Final = payload.get("standard_logging_object") + logged_cost: Final = logging_payload.get("response_cost") if isinstance(logging_payload, dict) else None + return _RecordedSpeechSuccess( + call_type=call_type if isinstance(call_type, str) else None, + spend_metadata=get_litellm_metadata_from_kwargs(payload), + response_cost=response_cost if isinstance(response_cost, float) else None, + logged_response_cost=logged_cost if isinstance(logged_cost, float) else None, + ) + + +class _SuccessEventRecorder(CustomLogger): + def __init__(self) -> None: + super().__init__() + self.events: list[_RecordedSpeechSuccess] = [] # mutable-ok: test recorder of success-callback events + + async def async_log_success_event( + self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object + ) -> None: + self.events.append(_record_speech_success(kwargs)) + + +async def _wait_for_success_event(recorder: _SuccessEventRecorder, call_type: str) -> _RecordedSpeechSuccess: + for _ in range(100): + if (event := next((e for e in recorder.events if e.call_type == call_type), None)) is not None: + return event + await asyncio.sleep(0.05) + pytest.fail(f"no {call_type} success event; got {[e.call_type for e in recorder.events]}") + + +def _gemini_tts_generate_content_response() -> dict[str, object]: + return { + "candidates": [ + { + "content": { + "parts": [ + { + "inlineData": { + "mimeType": "audio/L16;codec=pcm;rate=24000", + "data": base64.b64encode(b"pcm-audio-bytes").decode(), + } + } + ], + "role": "model", + }, + "finishReason": "STOP", + "index": 0, + } + ], + "usageMetadata": { + "promptTokenCount": 5, + "candidatesTokenCount": 60, + "totalTokenCount": 65, + "promptTokensDetails": [{"modality": "TEXT", "tokenCount": 5}], + "candidatesTokensDetails": [{"modality": "AUDIO", "tokenCount": 60}], + }, + "modelVersion": "gemini-2.5-flash-preview-tts", + } + + +@pytest.mark.asyncio +async def test_aspeech_gemini_bridge_keeps_proxy_metadata_for_spend_tracking( + respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + monkeypatch.delenv("GEMINI_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + recorder: Final = _SuccessEventRecorder() + monkeypatch.setattr(litellm, "callbacks", [recorder]) + mock_route: Final = respx_mock.post( + url__regex=r"https://generativelanguage\.googleapis\.com/v1beta/models/gemini-2\.5-flash-preview-tts:generateContent.*" + ).mock(return_value=httpx.Response(200, json=_gemini_tts_generate_content_response())) + + await litellm.aspeech( + model="gemini/gemini-2.5-flash-preview-tts", + input="spend tracking check", + voice="Kore", + api_key="fake-gemini-key", + metadata={"user_api_key": "hashed-virtual-key", "user_api_key_user_id": "user-1"}, + ) + + assert mock_route.called + assert mock_route.calls.last.request.headers["x-goog-api-key"] == "fake-gemini-key" + speech_event: Final = await _wait_for_success_event(recorder, call_type="aspeech") + assert speech_event.spend_metadata["user_api_key"] == "hashed-virtual-key" + assert speech_event.spend_metadata["user_api_key_user_id"] == "user-1" + expected_prompt_cost, expected_completion_cost = litellm.cost_per_token( + model="gemini/gemini-2.5-flash-preview-tts", + usage_object=Usage(prompt_tokens=5, completion_tokens=60, total_tokens=65), + ) + expected_cost: Final = expected_prompt_cost + expected_completion_cost + assert expected_cost > 0 + assert speech_event.response_cost == pytest.approx(expected_cost) + assert speech_event.logged_response_cost == pytest.approx(expected_cost) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index cceb034a20b..2e81d5f9f46 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -8855,6 +8855,119 @@ class TestAutoRoutedRequestMarker: assert AUTO_ROUTED_REQUEST_METADATA_KEY not in request_kwargs["metadata"] +class TestModelGroupAliasReachesPreRoutingStrategies: + """A `model_group_alias` whose target is a strategy router must dispatch exactly like the + router's own model_name. The four strategy registries are keyed by the marker deployment's + model_name, so the alias has to be resolved before the pre-routing hook looks anything up, + and a group that resolves only to markers is not callable at all (LIT-4664).""" + + MARKER_TIMEOUT = 42.0 + REGISTRY_NAMES = ("auto_routers", "complexity_routers", "adaptive_routers", "quality_routers") + + class _RewriteStrategy: + async def async_pre_routing_hook( + self, model, request_kwargs, messages=None, input=None, specific_deployment=False + ): + from litellm.types.router import PreRoutingHookResponse + + return PreRoutingHookResponse(model="gemini-flash", messages=messages) + + @classmethod + def _router(cls, registry_name: str | None) -> "litellm.Router": + from litellm.types.router import TaggedPreRoutingStrategy + + tiers = dict.fromkeys(("SIMPLE", "MEDIUM", "COMPLEX", "REASONING"), "gemini-flash") + router = litellm.Router( + model_list=[ + { + "model_name": "smart-route", + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": tiers}, + "complexity_router_default_model": "gemini-flash", + "timeout": cls.MARKER_TIMEOUT, + }, + }, + { + "model_name": "gemini-flash", + "litellm_params": {"model": "gemini/gemini-3.6-flash", "mock_response": "routed by the tier"}, + }, + ], + model_group_alias={"smart-alias": "smart-route"}, + ) + for name in cls.REGISTRY_NAMES: + setattr(router, name, {}) + if registry_name is not None: + setattr( + router, + registry_name, + {"smart-route": [TaggedPreRoutingStrategy(tags=(), strategy=cls._RewriteStrategy())]}, + ) + return router + + @staticmethod + def _messages() -> list[dict[str, str]]: + return [{"role": "user", "content": "What is the capital of France?"}] + + @pytest.mark.parametrize("registry_name", REGISTRY_NAMES) + @pytest.mark.asyncio + async def test_alias_dispatches_to_the_strategy_registered_under_the_target(self, registry_name): + from litellm.constants import AUTO_ROUTED_REQUEST_METADATA_KEY + + router = self._router(registry_name) + request_kwargs = {"metadata": {}} + + response = await router.async_pre_routing_hook( + model="smart-alias", request_kwargs=request_kwargs, messages=self._messages() + ) + + assert response is not None + assert response.model == "gemini-flash" + assert request_kwargs["metadata"][AUTO_ROUTED_REQUEST_METADATA_KEY] is True + + @pytest.mark.asyncio + async def test_alias_call_still_forwards_the_marker_own_params_to_the_routed_tier(self): + router = self._router("auto_routers") + request_kwargs = {"metadata": {}} + + await router.async_pre_routing_hook( + model="smart-alias", request_kwargs=request_kwargs, messages=self._messages() + ) + + assert request_kwargs["timeout"] == self.MARKER_TIMEOUT + + @pytest.mark.asyncio + async def test_alias_deployment_selection_lands_on_the_tier_never_the_marker(self): + router = self._router("auto_routers") + + deployment = await router.async_get_available_deployment( + model="smart-alias", request_kwargs={"metadata": {}}, messages=self._messages() + ) + + assert deployment["litellm_params"]["model"] == "gemini/gemini-3.6-flash" + + @pytest.mark.asyncio + async def test_alias_call_completes_and_still_bills_the_name_the_caller_sent(self): + router = self._router("auto_routers") + metadata: dict = {} + + response = await router.acompletion( + model="smart-alias", messages=self._messages(), metadata=metadata + ) + + assert response.choices[0].message.content == "routed by the tier" + assert metadata["model_group"] == "smart-alias" + assert metadata["model_group_alias"] == "smart-alias" + + def test_a_group_of_only_markers_is_not_a_callable_model(self): + router = self._router(None) + + with pytest.raises(litellm.BadRequestError, match="strategy router marker"): + router.get_available_deployment( + model="smart-route", messages=self._messages(), request_kwargs={"metadata": {}} + ) + + @pytest.mark.usefixtures("local_model_cost_map") class TestAzureBaseModelFallbackLogging: """When an azure deployment has no base_model but its model name is a known diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index e1ea6edb2d8..20e67b902b8 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -120,6 +120,15 @@ def test_get_model_info_surfaces_supports_adaptive_thinking(local_model_cost_map assert generalized["supports_adaptive_thinking"] is True +def test_get_model_info_surfaces_supported_endpoints(local_model_cost_map): + """supported_endpoints ships in the cost map and is declared on ModelInfoBase, + but the constructor never copied it, so get_model_info always returned None. + The realtime health check reads it to spot GA-only transcription models + (LIT-6240).""" + info = litellm.get_model_info(model="gpt-realtime-whisper", custom_llm_provider="azure") + assert info["supported_endpoints"] == ["/v1/realtime", "/v1/realtime/transcription_sessions"] + + def test_potential_model_names_keeps_provider_prefixed_candidate(): """A provider whose own model ids repeat the litellm provider name (Perplexity's Agent API serves `perplexity/glm-5.2`, mapped as `perplexity/perplexity/glm-5.2`) @@ -845,6 +854,9 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "cache_creation_input_token_cost_above_272k_tokens_flex": { "type": "number" }, + "cache_creation_input_token_cost_above_272k_tokens_priority": { + "type": "number" + }, "cache_creation_input_token_cost_flex": {"type": "number"}, "cache_creation_input_token_cost_priority": {"type": "number"}, "cache_read_input_token_cost": {"type": "number"}, @@ -862,6 +874,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "deprecation_date": {"type": "string"}, "input_cost_per_audio_per_second": {"type": "number"}, "input_cost_per_audio_per_second_above_128k_tokens": {"type": "number"}, + "google_maps_grounding_cost_per_query": {"type": "number"}, "input_cost_per_audio_token": {"type": "number"}, "input_cost_per_image_token": {"type": "number"}, "input_cost_per_character": {"type": "number"}, @@ -4440,14 +4453,101 @@ def test_get_prompt_cache_min_tokens_resolves_per_model( assert get_prompt_cache_min_tokens(model=model) == expected_min_tokens -def test_get_prompt_cache_min_tokens_differs_per_platform_for_same_model(local_model_cost_map: None) -> None: - """The same model can carry a different minimum per platform, so the threshold must come from - the platform's own cost-map entry rather than being derived from the model family name.""" - assert get_prompt_cache_min_tokens(model="claude-fable-5") == 512 - assert get_prompt_cache_min_tokens(model="anthropic.claude-fable-5") == 1024 - assert get_prompt_cache_min_tokens(model="claude-fable-5") != get_prompt_cache_min_tokens( - model="anthropic.claude-fable-5" - ) +def test_get_prompt_cache_min_tokens_uniform_for_fable_5_across_platforms(local_model_cost_map: None) -> None: + """Anthropic removed the Amazon Bedrock override for Claude Fable 5, so its 512-token minimum + now applies on every platform. The Bedrock entries carried the old 1024 and the re-export + entries carried nothing, so the router judged 512-1023-token prefixes uncacheable and skipped + prompt-cache-affinity routing for prompts the provider demonstrably caches (issue #35011).""" + wrong: Final = { + model: get_prompt_cache_min_tokens(model=model) + for model, info in litellm.model_cost.items() + if "fable-5" in model + and info.get("supports_prompt_caching") + and get_prompt_cache_min_tokens(model=model) != 512 + } + assert not wrong, f"every Claude Fable 5 entry must carry prompt_cache_min_tokens 512: {wrong}" + + +ANTHROPIC_REEXPORT_CACHE_MIN: Final = { + "azure_ai/claude-fable-5": 512, + "azure_ai/claude-haiku-4-5": 4096, + "azure_ai/claude-opus-4-1": 1024, + "azure_ai/claude-opus-4-5": 4096, + "azure_ai/claude-opus-4-6": 4096, + "azure_ai/claude-opus-4-7": 2048, + "azure_ai/claude-opus-4-8": 1024, + "azure_ai/claude-sonnet-4-5": 1024, + "azure_ai/claude-sonnet-4-6": 1024, + "azure_ai/claude-sonnet-5": 1024, + "databricks/databricks-claude-haiku-4-5": 4096, + "databricks/databricks-claude-opus-4": 1024, + "databricks/databricks-claude-opus-4-1": 1024, + "databricks/databricks-claude-opus-4-5": 4096, + "databricks/databricks-claude-opus-4-6": 4096, + "databricks/databricks-claude-sonnet-4": 1024, + "databricks/databricks-claude-sonnet-4-5": 1024, + "databricks/databricks-claude-sonnet-4-6": 1024, + "openrouter/anthropic/claude-haiku-4.5": 4096, + "openrouter/anthropic/claude-opus-4": 1024, + "openrouter/anthropic/claude-opus-4.1": 1024, + "openrouter/anthropic/claude-opus-4.5": 4096, + "openrouter/anthropic/claude-opus-4.6": 4096, + "openrouter/anthropic/claude-opus-4.7": 2048, + "openrouter/anthropic/claude-sonnet-4": 1024, + "openrouter/anthropic/claude-sonnet-4.5": 1024, + "openrouter/anthropic/claude-sonnet-4.6": 1024, + "replicate/anthropic/claude-4-sonnet": 1024, + "replicate/anthropic/claude-4.5-haiku": 4096, + "replicate/anthropic/claude-4.5-sonnet": 1024, + "snowflake/claude-4-opus": 1024, + "snowflake/claude-4-sonnet": 1024, + "snowflake/claude-haiku-4-5": 4096, + "snowflake/claude-sonnet-4-5": 1024, + "snowflake/claude-sonnet-4-6": 1024, + "vercel_ai_gateway/anthropic/claude-haiku-4.5": 4096, + "vercel_ai_gateway/anthropic/claude-opus-4": 1024, + "vercel_ai_gateway/anthropic/claude-opus-4.1": 1024, + "vercel_ai_gateway/anthropic/claude-opus-4.5": 4096, + "vercel_ai_gateway/anthropic/claude-opus-4.6": 4096, + "vercel_ai_gateway/anthropic/claude-sonnet-4": 1024, + "vercel_ai_gateway/anthropic/claude-sonnet-4.5": 1024, + "vertex_ai/claude-fable-5": 512, + "vertex_ai/claude-fable-5@default": 512, +} + + +def test_anthropic_reexport_entries_carry_explicit_prompt_cache_min_tokens(local_model_cost_map: None) -> None: + """Regression for issue #35011: these re-export entries carried no prompt_cache_min_tokens, so + they silently inherited the 1024 default. That skipped cache-affinity routing for Fable 5's + 512-1023-token prefixes and reported 1024-4095-token prompts as cacheable on the 2048/4096 + models. The entry must be explicit so a default change can never re-break them, which is why + this asserts the cost-map value itself and not just the resolver's answer.""" + wrong: Final = { + model: (litellm.model_cost[model].get("prompt_cache_min_tokens"), get_prompt_cache_min_tokens(model=model)) + for model, expected in ANTHROPIC_REEXPORT_CACHE_MIN.items() + if litellm.model_cost[model].get("prompt_cache_min_tokens") != expected + or get_prompt_cache_min_tokens(model=model) != expected + } + assert not wrong, f"(cost-map value, resolved value) diverge from Anthropic's published minimums: {wrong}" + + +def test_anthropic_reexport_cache_minimums_present_in_root_cost_map() -> None: + """The root map ships to the CDN independently of the bundled backup, so both must carry the + minimum or proxies reading one of them regress to the 1024 default.""" + root_map_path: Final = os.path.join(os.path.dirname(__file__), "..", "..", "model_prices_and_context_window.json") + with open(root_map_path) as f: + root_map: Final = json.load(f) + wrong: Final = { + model: root_map[model].get("prompt_cache_min_tokens") + for model, expected in ANTHROPIC_REEXPORT_CACHE_MIN.items() + if root_map[model].get("prompt_cache_min_tokens") != expected + } + fable_5_wrong: Final = { + model: info.get("prompt_cache_min_tokens") + for model, info in root_map.items() + if "fable-5" in model and info.get("supports_prompt_caching") and info.get("prompt_cache_min_tokens") != 512 + } + assert not wrong and not fable_5_wrong, f"root cost map diverges: {wrong | fable_5_wrong}" GEMINI_4096_CACHE_MIN_MODELS: Final = tuple( diff --git a/tests/test_litellm/types/llms/test_types_llms_openai.py b/tests/test_litellm/types/llms/test_types_llms_openai.py index 3966677e928..42719ce838b 100644 --- a/tests/test_litellm/types/llms/test_types_llms_openai.py +++ b/tests/test_litellm/types/llms/test_types_llms_openai.py @@ -7,6 +7,7 @@ import pytest import json import litellm +from litellm.types.llms.openai import HttpxBinaryResponseContent def test_generic_event(): @@ -522,3 +523,34 @@ class TestOpenAIFileObjectBatchGuardrailSerialization: page = FileListPage(object="list", data=[self._file_object()], has_more=False) assert "litellm_batch_guardrail" not in page.model_dump(mode="json")["data"][0] + + +def _binary_content(payload: bytes) -> HttpxBinaryResponseContent: + import httpx + + return HttpxBinaryResponseContent(httpx.Response(200, content=payload)) + + +def test_httpx_binary_response_content_hidden_params_are_per_instance(): + first = _binary_content(b"first") + second = _binary_content(b"second") + + first._hidden_params["response_cost"] = 0.5 + + assert second._hidden_params == {} + + +def test_set_response_cost_none_leaves_hidden_params_empty(): + binary_response = _binary_content(b"audio") + + binary_response.set_response_cost(None) + + assert "response_cost" not in binary_response._hidden_params + + binary_response.set_response_cost(0.25) + + assert binary_response._hidden_params["response_cost"] == 0.25 + + binary_response.set_response_cost(None) + + assert "response_cost" not in binary_response._hidden_params diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 4465580657b..6fd7828906c 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -3,7 +3,7 @@ "limit": 22733 }, "LIT002": { - "limit": 26864 + "limit": 26863 }, "LIT003": { "limit": 269 @@ -15,7 +15,7 @@ "limit": 0 }, "LIT006": { - "limit": 1066 + "limit": 1065 }, "LIT007": { "limit": 0 @@ -27,10 +27,10 @@ "limit": 0 }, "LIT010": { - "limit": 16621 + "limit": 16619 }, "LIT011": { - "limit": 5585 + "limit": 5583 }, "LIT012": { "limit": 4510 diff --git a/ui/litellm-dashboard/eslint.config.mjs b/ui/litellm-dashboard/eslint.config.mjs index 6d7ca2ad071..23cc5096bb1 100644 --- a/ui/litellm-dashboard/eslint.config.mjs +++ b/ui/litellm-dashboard/eslint.config.mjs @@ -82,9 +82,22 @@ const eslintConfig = [ "no-restricted-syntax": "off", }, }, + { + files: ["src/**/*.{ts,tsx}", "tests/**/*.{ts,tsx}"], + rules: { "local/no-ad-hoc-z-index": "error" }, + }, + { + files: [ + "src/components/ui/**/*.{ts,tsx}", + "src/components/shared/DataTable/**/*.{ts,tsx}", + "src/**/*.test.{ts,tsx}", + "tests/**/*.{ts,tsx}", + ], + rules: { "local/no-ad-hoc-z-index": ["error", { allowPopupLayer: true }] }, + }, { files: ["tests/eslint-rules/**/*.{ts,tsx}"], - rules: { "local/no-noop-hover-variant": "off" }, + rules: { "local/no-noop-hover-variant": "off", "local/no-ad-hoc-z-index": "off" }, }, { files: ["src/**/*.test.{ts,tsx}", "tests/**/*.{ts,tsx}"], diff --git a/ui/litellm-dashboard/scripts/eslint-rules/index.mjs b/ui/litellm-dashboard/scripts/eslint-rules/index.mjs index 983399ae4a3..750b8df4e27 100644 --- a/ui/litellm-dashboard/scripts/eslint-rules/index.mjs +++ b/ui/litellm-dashboard/scripts/eslint-rules/index.mjs @@ -3,6 +3,7 @@ import noLongConditionChain from "./no-long-condition-chain.mjs"; import noComplexJsxArrow from "./no-complex-jsx-arrow.mjs"; import filenamePascalCase from "./filename-pascal-case.mjs"; import noNoopHoverVariant from "./no-noop-hover-variant.mjs"; +import noAdHocZIndex from "./no-ad-hoc-z-index.mjs"; const plugin = { rules: { @@ -11,6 +12,7 @@ const plugin = { "no-complex-jsx-arrow": noComplexJsxArrow, "filename-pascal-case": filenamePascalCase, "no-noop-hover-variant": noNoopHoverVariant, + "no-ad-hoc-z-index": noAdHocZIndex, }, }; diff --git a/ui/litellm-dashboard/scripts/eslint-rules/no-ad-hoc-z-index.mjs b/ui/litellm-dashboard/scripts/eslint-rules/no-ad-hoc-z-index.mjs new file mode 100644 index 00000000000..6af86d2c501 --- /dev/null +++ b/ui/litellm-dashboard/scripts/eslint-rules/no-ad-hoc-z-index.mjs @@ -0,0 +1,92 @@ +const AD_HOC_Z = /^-?z-(?:\d+|\[[^\]]*\]|\([^)]*\))$/; + +const OPENERS = { "[": "]", "(": ")" }; + +const utilityOf = (token) => { + const closers = []; + const lastTopLevelColon = [...token].reduce((found, ch, i) => { + if (closers.length > 0 && ch === closers[closers.length - 1]) { + closers.pop(); + return found; + } + if (ch in OPENERS) { + closers.push(OPENERS[ch]); + return found; + } + return ch === ":" && closers.length === 0 ? i : found; + }, -1); + return token + .slice(lastTopLevelColon + 1) + .replace(/^!/, "") + .replace(/!$/, ""); +}; + +const classify = (token, allowPopupLayer) => { + const utility = utilityOf(token); + if (AD_HOC_Z.test(utility)) return "adHoc"; + if (!allowPopupLayer && utility === "z-popup") return "popupReserved"; + return null; +}; + +const offendingTokens = (value, allowPopupLayer) => + value + .split(/\s+/) + .filter(Boolean) + .map((token) => ({ token, messageId: classify(token, allowPopupLayer) })) + .filter(({ messageId }) => messageId !== null); + +const propertyName = (key) => { + if (key.type === "Identifier") return key.name; + if (key.type === "Literal" && typeof key.value === "string") return key.value; + return null; +}; + +const rule = { + meta: { + type: "problem", + docs: { + description: + "Disallow hand-picked z-index values (numeric or arbitrary z-* classes, inline zIndex styles). Use the named scale defined in src/app/globals.css so nothing can stack above the portalled popup layer.", + }, + schema: [ + { + type: "object", + properties: { allowPopupLayer: { type: "boolean" } }, + additionalProperties: false, + }, + ], + messages: { + adHoc: + "`{{token}}` is a hand-picked z-index. Use the scale from globals.css: z-raised, z-chrome, z-sticky, z-sticky-pinned, z-floating, z-overlay (z-popup is reserved for portalled primitives).", + popupReserved: + "`{{token}}` is reserved for the portalled primitives in src/components/ui. Page content must stay below the popup layer; use z-overlay or lower.", + inlineZIndex: + "Inline `zIndex` styles bypass the z-index scale. Use a class from globals.css (z-raised, z-chrome, z-sticky, z-sticky-pinned, z-floating, z-overlay) instead.", + }, + }, + create(context) { + const allowPopupLayer = context.options[0]?.allowPopupLayer ?? false; + const check = (node, value) => { + if (typeof value !== "string" || !value.includes("z-")) return; + for (const { token, messageId } of offendingTokens(value, allowPopupLayer)) { + context.report({ node, messageId, data: { token } }); + } + }; + return { + Literal(node) { + check(node, node.value); + }, + TemplateElement(node) { + check(node, node.value.cooked); + }, + Property(node) { + const name = propertyName(node.key); + if (name === "zIndex" || name === "z-index") { + context.report({ node, messageId: "inlineZIndex" }); + } + }, + }; + }, +}; + +export default rule; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx index 55ce5061af7..63ff0f4100f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx @@ -99,6 +99,7 @@ describe("AccessGroupsPage", () => { renderWithProviders(); expect(screen.getByRole("heading", { name: "Access Groups" })).toBeInTheDocument(); expect(screen.getByText("Manage resource permissions for your organization")).toBeInTheDocument(); + expect(document.querySelector(".lucide-boxes")).not.toBeNull(); }); it("shows the Create Access Group button for an admin", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx index 4fc51910161..2e82fe3c418 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.tsx @@ -1,9 +1,9 @@ import { AccessGroupResponse, useAccessGroups } from "@/app/(dashboard)/hooks/accessGroups/useAccessGroups"; import { useDeleteAccessGroup } from "@/app/(dashboard)/hooks/accessGroups/useDeleteAccessGroup"; -import { Plus, SearchIcon, X } from "lucide-react"; +import { Boxes, Plus, SearchIcon, X } from "lucide-react"; import { useMemo, useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; -import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader"; +import { PageHeader } from "@/components/shared/PageHeader"; import { Button } from "@/components/ui/button"; import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; import { AccessGroupDetail } from "./AccessGroupsDetailsPage"; @@ -59,23 +59,22 @@ export function AccessGroupsPage() { } return ( -
-
- setIsCreateModalVisible(true)}> - - Create Access Group - - ) : undefined - } - /> -
+
+ } + title="Access Groups" + subtitle="Manage resource permissions for your organization" + primaryAction={ + canModify ? ( + + ) : undefined + } + /> -
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx index 60e886754ce..f2608c1221f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.test.tsx @@ -85,6 +85,14 @@ describe("Budget Panel", () => { respondWith(DEFAULT_ROWS, 1); }); + it("renders the standard page header with the sidebar's Budgets icon", async () => { + const { container } = renderPanel(); + + expect(await screen.findByRole("heading", { level: 1, name: "Budgets" })).toBeInTheDocument(); + expect(screen.getByText("Spend, TPM and RPM limits you can assign to customers.")).toBeInTheDocument(); + expect(container.querySelector(".lucide-wallet")).not.toBeNull(); + }); + it("loads the first page of budgets, newest first", async () => { renderPanel(); await waitFor(() => expect(getMock).toHaveBeenCalled()); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx index 18b8e774aae..25344c52847 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/_components/budget_panel.tsx @@ -9,8 +9,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { prism } from "react-syntax-highlighter/dist/esm/styles/prism"; import { useSyntaxTheme } from "@/hooks/useSyntaxTheme"; -import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader"; -import { ToolbarSeparator } from "@/components/shared/ToolbarSeparator"; +import { PageHeader } from "@/components/shared/PageHeader"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; @@ -79,34 +78,37 @@ const BudgetPanel: React.FC = ({ accessToken }) => { }; return ( -
- } - title="Budgets" - subtitle="Spend, TPM and RPM limits you can assign to customers." - /> - -
- {canModify && ( - <> +
+ + } + title="Budgets" + subtitle="Spend, TPM and RPM limits you can assign to customers." + primaryAction={ + canModify ? ( - - + ) : undefined + } + tabs={({ leadingControls }) => ( + + {leadingControls} + + Budgets + + + Examples + + )} - - - Budgets - - - Examples - - -
+ /> -
+
{selectedBudget && ( = ({ accessToken }) => {
-
+ ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx index 384c6cdbc8f..d5df5aa75da 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx @@ -44,6 +44,14 @@ describe("CostOptimizationView", () => { useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "Admin" }); }); + it("renders the standard page header with the sidebar's Cost Optimization icon", () => { + const { container, getByRole, getByText } = renderView(); + + expect(getByRole("heading", { level: 1, name: "Cost Optimization" })).toBeInTheDocument(); + expect(getByText(/Track and configure the mechanisms that save you money/)).toBeInTheDocument(); + expect(container.querySelector(".lucide-piggy-bank")).not.toBeNull(); + }); + it("renders the four cost-optimization tabs", () => { const { getByText } = renderView(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 165c63ea969..8094fa2e8b6 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -6,6 +6,7 @@ import { Info, PiggyBank } from "lucide-react"; import useCan from "@/app/(dashboard)/hooks/useCan"; import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { PageHeader } from "@/components/shared/PageHeader"; import UsageTab from "./UsageTab"; import PromptCompressionTab from "./PromptCompressionTab"; import PromptCachingTab from "./PromptCachingTab"; @@ -32,63 +33,63 @@ const CostOptimizationView: React.FC = ({ accessToken }; return ( -
-
-
- -

Cost Optimization

-
-

- Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers - live under Models + Endpoints, on the Auto-Routers tab -

-
- -
-
- - - - - - Overall - - {canViewProxyWideCostData && ( - <> - - Prompt Compression +
+ + } + title="Cost Optimization" + subtitle="Track and configure the mechanisms that save you money: prompt compression and prompt caching. Auto routers live under Models + Endpoints, on the Auto-Routers tab" + tabs={({ leadingControls }) => ( + + {leadingControls} + + Overall - - Prompt Caching - - - Auto-Router - - + {canViewProxyWideCostData && ( + <> + + Prompt Compression + + + Prompt Caching + + + Auto-Router + + + )} + )} - + /> +
+
+ + @@ -106,7 +107,7 @@ const CostOptimizationView: React.FC = ({ accessToken )}
-
+ ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx index 3b5cc156242..a9acf3e6377 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx @@ -41,27 +41,32 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails setView({ type: "overview" }); }; + const dateRangeControl = ( + + ); + return ( -
-
- -
+
{view.type === "overview" ? ( ) : ( - + <> +
{dateRangeControl}
+ + )} -
+ ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx index b616982d69b..c62505cc74f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx @@ -110,6 +110,7 @@ describe("GuardrailsOverview", () => { expect(await screen.findByRole("heading", { name: "Guardrails Monitor", level: 1 })).toBeInTheDocument(); expect(screen.getByText("Monitor guardrail performance across all requests")).toBeInTheDocument(); + expect(document.querySelector(".lucide-heart-pulse")).not.toBeNull(); expect(screen.getByRole("button", { name: /Export Data/i })).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx index 42de8e2707b..5bc9eb16cee 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx @@ -1,11 +1,12 @@ import { useQuery } from "@tanstack/react-query"; import type { ColumnDef, OnChangeFn, SortingState } from "@tanstack/react-table"; -import { Download, Settings, Shield, TrendingUp, TriangleAlert } from "lucide-react"; +import { Download, HeartPulse, Settings, TrendingUp, TriangleAlert } from "lucide-react"; import React, { useMemo, useState } from "react"; import { DataTable, DataTableSortHeader } from "@/components/shared/DataTable"; import { getGuardrailsUsageOverview } from "@/components/networking"; import { type PerformanceRow } from "@/components/GuardrailsMonitor/mockData"; import { Button } from "@/components/ui/button"; +import { PageHeader } from "@/components/shared/PageHeader"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { EvaluationSettingsModal } from "./EvaluationSettingsModal"; import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; @@ -16,6 +17,7 @@ interface GuardrailsOverviewProps { startDate: string; endDate: string; onSelectGuardrail: (id: string) => void; + dateRangeControl?: React.ReactNode; } type SortKey = "failRate" | "requestsEvaluated" | "avgLatency" | "falsePositiveRate" | "falseNegativeRate"; @@ -43,6 +45,7 @@ export function GuardrailsOverview({ startDate, endDate, onSelectGuardrail, + dateRangeControl, }: GuardrailsOverviewProps) { const [sortBy, setSortBy] = useState("failRate"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); @@ -197,23 +200,22 @@ export function GuardrailsOverview({ return (
-
-
-
- -

Guardrails Monitor

-
-

Monitor guardrail performance across all requests

-
-
- -
-
+ } + title="Guardrails Monitor" + subtitle="Monitor guardrail performance across all requests" + utilities={ + <> + {dateRangeControl} + + + } + /> -
+
+
= ({ }) => { return ( !open && onCancel()}> - + Add custom regex pattern diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx index b87df6d8996..2d8819ad876 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.test.tsx @@ -88,4 +88,13 @@ describe("KeywordModal", () => { expect(screen.queryByText("Add blocked keyword")).not.toBeInTheDocument(); }); + + it("should not raise the dialog above the portalled popup layer its Action select renders into", async () => { + renderModal(); + await screen.findByText("Add blocked keyword"); + + const content = document.querySelector('[data-slot="dialog-content"]'); + expect(content).not.toBeNull(); + expect(Array.from(content!.classList).filter((cls) => cls.startsWith("z-"))).toEqual(["z-popup"]); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx index 177c0d2fac6..504f35973fd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/KeywordModal.tsx @@ -5,7 +5,6 @@ import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { ACTION_ITEMS } from "./action_options"; -import { NESTED_DIALOG_LAYER } from "./dialog_layering"; interface KeywordModalProps { visible: boolean; @@ -32,7 +31,7 @@ const KeywordModal: React.FC = ({ }) => { return ( !open && onCancel()}> - + Add blocked keyword diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx index 5302ac3b7f7..46af4263eab 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.test.tsx @@ -142,4 +142,13 @@ describe("PatternModal", () => { expect(screen.queryByText("Add prebuilt pattern")).not.toBeInTheDocument(); }); + + it("should not raise the dialog above the portalled popup layer its pattern combobox renders into", async () => { + renderModal(); + await screen.findByText("Add prebuilt pattern"); + + const content = document.querySelector('[data-slot="dialog-content"]'); + expect(content).not.toBeNull(); + expect(Array.from(content!.classList).filter((cls) => cls.startsWith("z-"))).toEqual(["z-popup"]); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx index e703711a03a..4caa47217fe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/PatternModal.tsx @@ -14,7 +14,6 @@ import { import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ACTION_ITEMS } from "./action_options"; -import { NESTED_DIALOG_LAYER } from "./dialog_layering"; interface PrebuiltPattern { name: string; @@ -66,7 +65,7 @@ const PatternModal: React.FC = ({ return ( !open && onCancel()}> - + Add prebuilt pattern diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/dialog_layering.ts b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/dialog_layering.ts deleted file mode 100644 index 0e29ffb6250..00000000000 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/content_filter/dialog_layering.ts +++ /dev/null @@ -1 +0,0 @@ -export const NESTED_DIALOG_LAYER = "z-[1100]"; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx index 226b7b6064d..aaa656d15bb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx @@ -522,7 +522,7 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, variant="ghost" size="icon-xs" onClick={() => copyToClipboard(guardrailData.guardrail_id, "guardrail-id")} - className={`left-2 z-10 transition-all duration-200 ${ + className={`left-2 z-raised transition-all duration-200 ${ copiedStates["guardrail-id"] ? "text-success bg-success/10 border-success/20" : "text-muted-foreground hover:text-foreground hover:bg-muted" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx index fe097387dd2..7062a6f0a5f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPSubmissionsTab.tsx @@ -79,7 +79,7 @@ function ConfirmDialog({ action, serverName, isCurrentlyActive, onConfirm, onCan ? "This server is currently live. Rejecting it will immediately remove it from the proxy runtime." : "This will mark the submission as rejected."; return ( -
+
= ({ currentServerAccessGroups = [] variant="ghost" size="icon-xs" onClick={() => copyToClipboard(code, copyKey)} - className={`absolute top-2 right-2 z-10 transition-all duration-200 ${ + className={`absolute top-2 right-2 z-raised transition-all duration-200 ${ copiedStates[copyKey] ? "text-success bg-success/10 border-success/20" : "text-muted-foreground hover:text-foreground hover:bg-accent" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx index 37a4ddcb0a0..1299ec7a325 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/compareUI/components/ComparisonPanel.tsx @@ -100,7 +100,7 @@ export function ComparisonPanel({ {/* Close button in top right */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx index 005ec035ca3..54ea3702cb4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/complianceUI/ComplianceUI.tsx @@ -757,7 +757,7 @@ export default function ComplianceUI({ {showGuardrailDropdown && ( -
+
{guardrailOptions.length === 0 ? (
No guardrails available. Create guardrails in the Guardrails page. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.test.tsx index 5940dc4049e..72dc744c5f4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.test.tsx @@ -198,6 +198,12 @@ describe("AddPolicyForm", () => { renderWithProviders(); await user.click(await screen.findByText("Flow Builder")); + + expect( + screen.getByText("You'll be taken to the Flow Builder to design your policy logic visually."), + ).toBeInTheDocument(); + expect(screen.queryByText(/full-screen/i)).not.toBeInTheDocument(); + await user.click(screen.getByRole("button", { name: "Continue to Builder" })); expect(onOpenFlowBuilder).toHaveBeenCalledTimes(1); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx index 097a6389721..2830b0fda12 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/add_policy_form.tsx @@ -342,9 +342,7 @@ const AddPolicyForm: React.FC = ({ {selectedMode === "flow_builder" && ( - - You'll be redirected to the full-screen Flow Builder to design your policy logic visually. - + You'll be taken to the Flow Builder to design your policy logic visually. )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.test.tsx index 1d4c58a05ee..b6e18bc1413 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.test.tsx @@ -2,7 +2,7 @@ import React from "react"; import { screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "@/../tests/test-utils"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import PoliciesPanel from "./index"; /** @@ -52,7 +52,11 @@ vi.mock("./policy_templates", () => ({ })); vi.mock("./pipeline_flow_builder", () => ({ - FlowBuilderPage: () => null, + FlowBuilderPage: ({ onBack }: { onBack: () => void }) => ( + + ), })); vi.mock("./policy_info", () => ({ @@ -159,3 +163,48 @@ describe("PoliciesPanel attachment delete", () => { }); }); }); + +describe("PoliciesPanel flow builder", () => { + const POLICY_ID = "pol-11111111-2222-3333-4444-555555555555"; + + beforeEach(() => { + vi.clearAllMocks(); + networkingMocks.getPoliciesList.mockResolvedValue({ + policies: [ + { + policy_id: POLICY_ID, + policy_name: "pii-policy", + inherit: null, + description: null, + guardrails_add: [], + guardrails_remove: [], + condition: null, + definition_location: "db", + }, + ], + }); + }); + + afterEach(() => { + networkingMocks.getPoliciesList.mockResolvedValue({ policies: [] }); + }); + + it("replaces the tabs and policy table with the flow builder while editing, then restores them on back", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("tab", { name: /^policies$/i })); + await user.click(await screen.findByTestId(`policy-actions-${POLICY_ID}`)); + await user.click(await screen.findByTestId("policy-action-edit")); + + expect(await screen.findByRole("button", { name: "Back to policies" })).toBeInTheDocument(); + expect(screen.queryByRole("tab", { name: /^policies$/i })).not.toBeInTheDocument(); + expect(screen.queryByText("pii-policy")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Back to policies" })); + + expect(await screen.findByText("pii-policy")).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /^policies$/i })).toHaveAttribute("aria-selected", "true"); + expect(screen.queryByRole("button", { name: "Back to policies" })).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.tsx index 08ee2213413..16469728f39 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/index.tsx @@ -406,6 +406,37 @@ const PoliciesPanel: React.FC = ({ accessToken, userRole }) setTemplateQueueProgress(null); }; + if (showFlowBuilder) { + return ( + { + setShowFlowBuilder(false); + setEditingPolicy(null); + }} + onSuccess={() => { + fetchPolicies(); + setEditingPolicy(null); + }} + accessToken={accessToken} + editingPolicy={editingPolicy} + availableGuardrails={guardrailsList} + createPolicy={createPolicyCall} + updatePolicy={updatePolicyCall} + onVersionCreated={(newPolicy) => { + setEditingPolicy(newPolicy); + fetchPolicies(); + }} + onSelectVersion={(policy) => { + setEditingPolicy(policy); + }} + onVersionStatusUpdated={(updatedPolicy) => { + setEditingPolicy(updatedPolicy); + fetchPolicies(); + }} + /> + ); + } + return (
@@ -628,35 +659,6 @@ const PoliciesPanel: React.FC = ({ accessToken, userRole }) accessToken={accessToken} allTemplates={loadedTemplates} /> - - {showFlowBuilder && ( - { - setShowFlowBuilder(false); - setEditingPolicy(null); - }} - onSuccess={() => { - fetchPolicies(); - setEditingPolicy(null); - }} - accessToken={accessToken} - editingPolicy={editingPolicy} - availableGuardrails={guardrailsList} - createPolicy={createPolicyCall} - updatePolicy={updatePolicyCall} - onVersionCreated={(newPolicy) => { - setEditingPolicy(newPolicy); - fetchPolicies(); - }} - onSelectVersion={(policy) => { - setEditingPolicy(policy); - }} - onVersionStatusUpdated={(updatedPolicy) => { - setEditingPolicy(updatedPolicy); - fetchPolicies(); - }} - /> - )}
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx index 3af5b31c41f..4e7c3d23cf9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx @@ -169,8 +169,7 @@ describe("PipelineFlowBuilder", () => { }); describe("FlowBuilderPage", () => { - it("stacks its full-screen shell below the portalled popup layer", () => { - const portalLayerZIndex = 50; + it("renders its shell in flow with no stacking level, so it can never cover the portalled popup layer", () => { const { container } = renderWithProviders( { ); const shell = container.firstElementChild as HTMLElement; + const shellClasses = shell.className.split(/\s+/); - expect(shell).toHaveStyle({ position: "fixed" }); - expect(Number(shell.style.zIndex)).toBeLessThan(portalLayerZIndex); + expect(shell).toContainElement(screen.getByPlaceholderText("Policy name...")); + expect(shell).not.toHaveStyle({ position: "fixed" }); + expect(shellClasses).not.toContain("fixed"); + expect(window.getComputedStyle(shell).zIndex).not.toMatch(/\d/); + expect(shellClasses.filter((cls) => /^-?z-/.test(cls))).toEqual([]); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx index c649bb4844b..8651be39f0d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx @@ -227,7 +227,7 @@ const Connector: React.FC = ({ onInsert }) => (
- } - /> -
+
+ } + title="Projects" + subtitle="Manage projects within your teams" + primaryAction={ + + } + /> -
+
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.tsx index a9dc88a952b..64bc7755694 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/prompt_editor_view/VersionHistorySidePanel.tsx @@ -86,7 +86,7 @@ const VersionHistorySidePanel: React.FC = ({ role="dialog" aria-modal={false} aria-labelledby="version-history-title" - className="fixed inset-y-0 right-0 z-50 flex w-[400px] max-w-full flex-col gap-4 border-l border-border bg-popover text-popover-foreground shadow-lg" + className="fixed inset-y-0 right-0 z-overlay flex w-[400px] max-w-full flex-col gap-4 border-l border-border bg-popover text-popover-foreground shadow-lg" > {showTooltip && (
{content}
@@ -178,7 +178,7 @@ export const DocsMenu: React.FC = ({ items, children = "Docs", cl {isOpen && ( -
+
{items.map((item, index) => ( = ({ accessToken, /> {isDeleteModalOpen && ( -
+
{/* Visual Connection */} -
+
IF FAILS, TRY... diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index eed449d6676..df1a51d8e38 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -221,7 +221,7 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals customTooltip={(props) => { const item = props.payload?.[0]?.payload; return ( -
+
Key Alias: @@ -246,7 +246,10 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals )} {isModalOpen && selectedKey && keyData && ( -
+
{/* Close button */} } />); const heading = screen.getByRole("heading", { name: "Virtual Keys" }); + expect(screen.getByText("Every key that authenticates requests to the gateway.")).toBeInTheDocument(); + expect(document.querySelector(".lucide-key-round")).not.toBeNull(); const ctas = screen.getAllByRole("button", { name: "Create New Key" }); expect(ctas).toHaveLength(1); const cta = ctas[0]; diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index fbdcf2d8d83..7dd25ca1fd0 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -12,7 +12,7 @@ import { DataTableToolbar, } from "@/components/shared/DataTable"; import { SearchSelect } from "@/components/shared/SearchSelect"; -import { LegacyPageHeader } from "@/components/shared/LegacyPageHeader"; +import { PageHeader } from "@/components/shared/PageHeader"; import { Input } from "@/components/ui/input"; import { useDebouncedValue } from "@tanstack/react-pacer/debouncer"; import { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; @@ -182,13 +182,13 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { } return ( -
- } +
+ } title="Virtual Keys" subtitle="Every key that authenticates requests to the gateway." + primaryAction={headerActions} /> - {headerActions} tierLabels?.[tier]?.trim() || TIER_DESCRIPTIONS[tier].label; -/** Tiers the plan-mode floor may name: the backend rejects a floor whose tier has no models. */ -export const planModeEligibleTiers = (tiers: ComplexityTiers): Array => - TIER_KEYS.filter((tier) => (tiers[tier] ?? []).length > 0); - const ComplexityRouterConfig: React.FC = ({ modelInfo, value, @@ -246,12 +241,12 @@ const ComplexityRouterConfig: React.FC = ({ onEscalationKeywordsChange, showValidationErrors = false, }) => { - const planModeTiers = planModeEligibleTiers(value.tiers); - const planModeTierOptions = tierOptions(value.tier_labels).filter((option) => - (planModeTiers as string[]).includes(option.value), - ); - const derivedDefaultModel = resolveComplexityDefaultModel(value.tiers); - const defaultModel = resolveComplexityDefaultModel(value.tiers, value.default_model); + const tierRows = activeTierRows(value); + const planModeTierOptions = tierRows + .filter((row) => row.models.length > 0) + .map((row) => ({ value: row.id, label: effectiveTierLabel(row.id as keyof ComplexityTiers, value.tier_labels) })); + const derivedDefaultModel = resolveComplexityDefaultModel(value); + const defaultModel = resolveComplexityDefaultModel(value, value.default_model); // An absent list means the proxy does not send the field yet, so every level is offered as before. // An empty list is the group's own answer that its deployments share no level, and is left empty. @@ -325,12 +320,13 @@ const ComplexityRouterConfig: React.FC = ({ - {TIER_KEYS.map((tier, index) => { + {tierRows.map((row: TierRow, index) => { + const tier = row.id as keyof ComplexityTiers; const tierInfo = TIER_DESCRIPTIONS[tier]; const label = effectiveTierLabel(tier, value.tier_labels); - const tierMissing = showValidationErrors && value.tiers[tier].length === 0; + const tierMissing = showValidationErrors && row.models.length === 0; return ( -
+
{index > 0 && }
@@ -339,7 +335,7 @@ const ComplexityRouterConfig: React.FC = ({ - Tier {index + 1} of {TIER_KEYS.length} · {tier} + Tier {index + 1} of {tierRows.length} · {row.id}
Examples: {tierInfo.examples} @@ -364,7 +360,7 @@ const ComplexityRouterConfig: React.FC = ({ handleTierChange(tier, models)} placeholder={`Select model(s) for ${label.toLowerCase()} queries`} emptyText="No models found" @@ -372,12 +368,12 @@ const ComplexityRouterConfig: React.FC = ({ /> handleTierModelEffortChange(tier, model, effort)} /> - {value.tiers[tier].length > 1 && ( + {row.models.length > 1 && ( Multiple models selected — the router randomly picks among them per request (or Thompson-samples within the pool when adaptive routing is on). @@ -483,9 +479,12 @@ const ComplexityRouterConfig: React.FC = ({
- onChange({ ...value, plan_mode_min_tier: enabled ? planModeTiers.at(-1) : undefined }) + onChange({ + ...value, + plan_mode_min_tier: enabled ? planModeTierOptions.at(-1)?.value : undefined, + }) } aria-label="Route plan-mode requests to a minimum tier" /> @@ -494,7 +493,7 @@ const ComplexityRouterConfig: React.FC = ({ Requests from coding agents in plan mode (Claude Code, GitHub Copilot) route to at least this tier. The classifier still wins when it picks higher, and the override only lasts while plan mode is active. - {planModeTiers.length === 0 && " Add models to a tier to enable this."} + {planModeTierOptions.length === 0 && " Add models to a tier to enable this."} {value.plan_mode_min_tier !== undefined && (
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index 98ee2b7ae7c..87c4754f56d 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -22,7 +22,6 @@ import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; import { autoRouterListKey, fetchAllModelDeployments } from "@/app/(dashboard)/hooks/models/useModels"; import ComplexityRouterConfig, { ComplexityRouterConfigValue, - ComplexityTiers, DEFAULT_ADAPTIVE_WEIGHTS, DEFAULT_SESSION_AFFINITY, DEFAULT_DEPLOYMENT_AFFINITY, @@ -40,7 +39,9 @@ import { getSemanticConfigError, getTierLabelsError, } from "./build_complexity_router_config"; -import { resolveComplexityDefaultModel } from "./complexity_router_tiers"; +import { activeTierName, activeTierRows, resolveComplexityDefaultModel } from "./tier_rows"; +import { DEFAULT_TIER_LABELS } from "./complexity_router_tiers"; +import type { ComplexityTier } from "./KeywordTierRules"; import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets"; import AutoRouterConnectionTest from "./auto_router_connection_test"; import AutoRouterRoutingTest from "./AutoRouterRoutingTest"; @@ -104,17 +105,10 @@ const presets = getAllPresets(); // A one-line summary of what's configured, shown when the detailed section is collapsed so a // caller can see the shape of the config without opening it. -const tierConfigSummary = (tiers: ComplexityTiers): string => { - const parts = ( - [ - ["Simple", tiers.SIMPLE], - ["Medium", tiers.MEDIUM], - ["Complex", tiers.COMPLEX], - ["Reasoning", tiers.REASONING], - ] as const - ) - .filter(([, models]) => models.length > 0) - .map(([label, models]) => `${label}: ${models.join(", ")}`); +const tierConfigSummary = (config: ComplexityRouterConfigValue): string => { + const parts = activeTierRows(config) + .filter((row) => row.models.length > 0) + .map((row) => `${DEFAULT_TIER_LABELS[row.id as ComplexityTier] ?? activeTierName(row)}: ${row.models.join(", ")}`); return parts.length > 0 ? parts.join(" · ") : "No tiers configured yet"; }; @@ -128,9 +122,9 @@ const getSubmitBlockedReason = ( referencedModelsParams: Parameters[0], availability: ModelAvailability, ): string | null => - getMissingTiersError(config.tiers) ?? + getMissingTiersError(activeTierRows(config)) ?? getTierLabelsError(config.tier_labels) ?? - getPlanModeTierError(config.plan_mode_min_tier, config.tiers) ?? + getPlanModeTierError(config.plan_mode_min_tier, activeTierRows(config)) ?? getKeywordTierRulesError(keywordTierRules) ?? getReferencedModelsError(referencedModelsParams, availability); @@ -378,7 +372,7 @@ const AddAutoRouterTab: React.FC = ({ const submitRecommendedRouter = async (name: string) => { const { tiers, tierLabels, classifierType, classifierLlmConfig } = complexityRouterConfigParams; - const missingTiersError = getMissingTiersError(tiers); + const missingTiersError = getMissingTiersError(activeTierRows(complexityRouterConfig)); if (missingTiersError) { setShowValidationErrors(true); toast.fromError(missingTiersError); @@ -423,7 +417,7 @@ const AddAutoRouterTab: React.FC = ({ return; } - const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model); + const defaultModel = resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model); const validatedFields = requiresTeamScope ? (["auto_router_name", "team_id"] as const) : (["auto_router_name"] as const); @@ -463,10 +457,12 @@ const AddAutoRouterTab: React.FC = ({ const handleTestConnection = () => { const testTargetParams = { - tiers: complexityRouterConfig.tiers, + tiers: activeTierRows(complexityRouterConfig).map( + (row) => [activeTierName(row), row.models] as [string, string[]], + ), semanticMatchingEnabled, embeddingModel, - defaultModel: resolveComplexityDefaultModel(complexityRouterConfig.tiers, complexityRouterConfig.default_model), + defaultModel: resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model), }; const targets = buildAutoRouterTestTargets(testTargetParams); @@ -581,7 +577,7 @@ const AddAutoRouterTab: React.FC = ({ {!detailsExpanded && ( - {tierConfigSummary(complexityRouterConfig.tiers)} + {tierConfigSummary(complexityRouterConfig)} )} @@ -694,10 +690,7 @@ const AddAutoRouterTab: React.FC = ({ @@ -707,7 +700,6 @@ const AddAutoRouterTab: React.FC = ({ - , ]
@@ -744,7 +736,6 @@ const AddAutoRouterTab: React.FC = ({ > Close - , ]
diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts index 85fd846ddbc..7c29ea53060 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.test.ts @@ -1,11 +1,18 @@ import { buildAutoRouterTestTargets } from "./build_auto_router_test_targets"; -const tiers = { - SIMPLE: ["gpt-4o-mini"], - MEDIUM: ["claude-sonnet-4"], - COMPLEX: ["claude-sonnet-4"], - REASONING: ["o3"], -}; +const tierEntries = ( + SIMPLE: string[], + MEDIUM: string[] = [], + COMPLEX: string[] = [], + REASONING: string[] = [], +): [string, string[]][] => [ + ["SIMPLE", SIMPLE], + ["MEDIUM", MEDIUM], + ["COMPLEX", COMPLEX], + ["REASONING", REASONING], +]; + +const tiers = tierEntries(["gpt-4o-mini"], ["claude-sonnet-4"], ["claude-sonnet-4"], ["o3"]); describe("buildAutoRouterTestTargets", () => { it("dedups tiers that share a model group into one chat target carrying both labels", () => { @@ -19,7 +26,7 @@ describe("buildAutoRouterTestTargets", () => { it("emits a target per model when a tier has more than one, and dedups across tiers", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini", "claude-sonnet-4"], MEDIUM: ["claude-sonnet-4"], COMPLEX: [], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini", "claude-sonnet-4"], ["claude-sonnet-4"]), semanticMatchingEnabled: false, embeddingModel: undefined, }); @@ -31,7 +38,7 @@ describe("buildAutoRouterTestTargets", () => { it("drops empty/whitespace tiers", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [" "], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini"], [], [" "]), semanticMatchingEnabled: false, embeddingModel: undefined, }); @@ -41,7 +48,7 @@ describe("buildAutoRouterTestTargets", () => { it("returns [] when no tier is configured", () => { expect( buildAutoRouterTestTargets({ - tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }, + tiers: tierEntries([]), semanticMatchingEnabled: false, embeddingModel: undefined, }), @@ -50,7 +57,7 @@ describe("buildAutoRouterTestTargets", () => { it("appends an embedding target only when semantic matching is on and a model is set", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini"]), semanticMatchingEnabled: true, embeddingModel: "voyage-3-5", }); @@ -62,7 +69,7 @@ describe("buildAutoRouterTestTargets", () => { it("omits the embedding target when semantic matching is on but no model is chosen", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini"]), semanticMatchingEnabled: true, embeddingModel: undefined, }); @@ -71,7 +78,7 @@ describe("buildAutoRouterTestTargets", () => { it("omits the embedding target when a model is set but semantic matching is off", () => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini"]), semanticMatchingEnabled: false, embeddingModel: "voyage-3-5", }); @@ -112,7 +119,7 @@ describe("buildAutoRouterTestTargets", () => { it.each([[undefined], [""], [" "]])("adds no default target for %o", (defaultModel) => { const targets = buildAutoRouterTestTargets({ - tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: [], COMPLEX: [], REASONING: [] }, + tiers: tierEntries(["gpt-4o-mini"]), semanticMatchingEnabled: false, embeddingModel: undefined, defaultModel, diff --git a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts index 471552fc84f..70b92dbf8cc 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_auto_router_test_targets.ts @@ -1,5 +1,3 @@ -import { ComplexityTiers } from "./ComplexityRouterConfig"; - export type AutoRouterTestMode = "chat" | "embedding"; export interface AutoRouterTestTarget { @@ -9,7 +7,8 @@ export interface AutoRouterTestTarget { } export interface BuildAutoRouterTestTargetsParams { - tiers: ComplexityTiers; + /** Ordered [tier name, model groups] entries of the active tier set. */ + tiers: readonly (readonly [string, string[]])[]; semanticMatchingEnabled: boolean; embeddingModel: string | undefined; /** The resolved default model - see resolveComplexityDefaultModel. A live fallback destination, @@ -17,23 +16,14 @@ export interface BuildAutoRouterTestTargetsParams { defaultModel?: string; } -// Keys drive iteration order; `satisfies Record` makes it a -// compile error to add a tier to ComplexityTiers without listing it here (and vice versa). -const TIER_ORDER = Object.keys({ - SIMPLE: null, - MEDIUM: null, - COMPLEX: null, - REASONING: null, -} satisfies Record) as (keyof ComplexityTiers)[]; - export const buildAutoRouterTestTargets = ({ tiers, semanticMatchingEnabled, embeddingModel, defaultModel, }: BuildAutoRouterTestTargetsParams): AutoRouterTestTarget[] => { - const tieredByModel = TIER_ORDER.reduce>((acc, tier) => { - return (tiers[tier] ?? []).reduce((tierAcc, rawModel) => { + const tieredByModel = tiers.reduce>((acc, [tier, models]) => { + return models.reduce((tierAcc, rawModel) => { const modelGroup = rawModel?.trim(); if (!modelGroup) return tierAcc; return { ...tierAcc, [modelGroup]: [...(tierAcc[modelGroup] ?? []), tier] }; diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts index 33f0fb8a539..63545f5b7de 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts @@ -9,6 +9,7 @@ import { hydrateTierLabels, BuildComplexityRouterConfigParams, } from "./build_complexity_router_config"; +import { activeTierRows } from "./tier_rows"; const tiers = { SIMPLE: ["gpt-4o-mini"], @@ -275,30 +276,30 @@ describe("buildComplexityRouterConfig", () => { describe("getMissingTiersError", () => { it("returns null when all four tiers have a model", () => { - expect(getMissingTiersError(tiers)).toBeNull(); + expect(getMissingTiersError(activeTierRows({ tiers: tiers }))).toBeNull(); }); it("names the specific missing tier when only one is blank", () => { - expect(getMissingTiersError({ ...tiers, REASONING: [] })).toBe( + expect(getMissingTiersError(activeTierRows({ tiers: { ...tiers, REASONING: [] } }))).toBe( "Select a model for the following tier(s): REASONING", ); }); it("names multiple missing tiers in SIMPLE/MEDIUM/COMPLEX/REASONING order", () => { - expect(getMissingTiersError({ ...tiers, SIMPLE: [], REASONING: [] })).toBe( + expect(getMissingTiersError(activeTierRows({ tiers: { ...tiers, SIMPLE: [], REASONING: [] } }))).toBe( "Select a model for the following tier(s): SIMPLE, REASONING", ); }); it("names all four tiers when none are filled", () => { const noTiers = { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }; - expect(getMissingTiersError(noTiers)).toBe( + expect(getMissingTiersError(activeTierRows({ tiers: noTiers }))).toBe( "Select a model for the following tier(s): SIMPLE, MEDIUM, COMPLEX, REASONING", ); }); it("treats a tier with more than one model as filled", () => { - expect(getMissingTiersError({ ...tiers, SIMPLE: ["gpt-4o-mini", "gpt-4o"] })).toBeNull(); + expect(getMissingTiersError(activeTierRows({ tiers: { ...tiers, SIMPLE: ["gpt-4o-mini", "gpt-4o"] } }))).toBeNull(); }); }); @@ -645,15 +646,15 @@ describe("getPlanModeTierError", () => { const tiersWithEmptyComplex = { SIMPLE: ["m1"], MEDIUM: ["m1"], COMPLEX: [], REASONING: [] }; it("passes when the override is off", () => { - expect(getPlanModeTierError(undefined, tiersWithEmptyComplex)).toBeNull(); + expect(getPlanModeTierError(undefined, activeTierRows({ tiers: tiersWithEmptyComplex }))).toBeNull(); }); it("passes when the named tier has models", () => { - expect(getPlanModeTierError("MEDIUM", tiersWithEmptyComplex)).toBeNull(); + expect(getPlanModeTierError("MEDIUM", activeTierRows({ tiers: tiersWithEmptyComplex }))).toBeNull(); }); it("blocks a tier whose models were removed, which the backend would reject with a 400", () => { - expect(getPlanModeTierError("COMPLEX", tiersWithEmptyComplex)).toContain("COMPLEX"); + expect(getPlanModeTierError("COMPLEX", activeTierRows({ tiers: tiersWithEmptyComplex }))).toContain("COMPLEX"); }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts index bd95bea226a..241cae25705 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts @@ -1,4 +1,5 @@ import { KeywordTierRule } from "./KeywordTierRules"; +import { type TierRow, activeTierName, tierRowById } from "./tier_rows"; import { emptyKeywordTierRuleIndexes, serializeKeywordTierRules } from "./complexity_router_keywords"; import { TierModelParams, TierModelParamsByTier, serializeTierModelConfigs } from "./complexity_router_tiers"; import { @@ -10,6 +11,7 @@ import { ComplexityTierLabels, ComplexityTiers, DimensionWeights, + TIER_KEYS, TIER_DESCRIPTIONS, TierBoundaries, TokenThresholds, @@ -135,8 +137,6 @@ export interface ComplexityRouterConfigPayload { tier_model_configs?: Record; } -const TIER_KEYS: Array = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; - export const serializeTierLabels = (tierLabels: ComplexityTierLabels | undefined): ComplexityTierLabels | undefined => { const renamed = TIER_KEYS.map((tier) => [tier, tierLabels?.[tier]?.trim() ?? ""] as const).filter( ([tier, label]) => label !== "" && label !== TIER_DESCRIPTIONS[tier].label, @@ -171,26 +171,20 @@ export const getTierLabelsError = (tierLabels: ComplexityTierLabels | undefined) return null; }; -// Requires all 4 tiers non-empty, so the create form can never reach the -// resolveComplexityDefaultModel(tiers, ...) === undefined case — MEDIUM (or SIMPLE) is always -// populated. The edit modal has no equivalent of this check (it allows saving with only some -// tiers filled), which is why it needs its own explicit `!defaultModel` guard after deriving — -// see edit_auto_router_modal.tsx's save handler. A future contributor copying this form's submit -// handler elsewhere should not assume the same guarantee holds without this check. -export const getMissingTiersError = (tiers: ComplexityTiers): string | null => { - const missing = TIER_KEYS.filter((tier) => tiers[tier].length === 0); +// Requires every active tier non-empty, so the create form can never reach the +// resolveComplexityDefaultModel === undefined case. The edit modal allows a partially filled +// set, which is why it keeps its own !defaultModel guard after deriving. +export const getMissingTiersError = (rows: readonly TierRow[]): string | null => { + const missing = rows.filter((row) => row.models.length === 0).map(activeTierName); if (missing.length === 0) return null; return `Select a model for the following tier(s): ${missing.join(", ")}`; }; -// The backend rejects a plan-mode floor naming a tier with no models. The create form's -// getMissingTiersError makes this unreachable there; the edit modal allows partially filled -// tiers, so both gates call this to keep the two forms symmetric. -export const getPlanModeTierError = (planModeMinTier: string | undefined, tiers: ComplexityTiers): string | null => { +export const getPlanModeTierError = (planModeMinTier: string | undefined, rows: readonly TierRow[]): string | null => { if (!planModeMinTier) return null; - const models = tiers[planModeMinTier as keyof ComplexityTiers] ?? []; - if (models.length > 0) return null; - return `The plan-mode minimum tier (${planModeMinTier}) has no models. Add one or turn the override off.`; + const floor = tierRowById(rows, planModeMinTier); + if (floor && floor.models.length > 0) return null; + return `The plan-mode minimum tier (${floor ? activeTierName(floor) : planModeMinTier}) has no models. Add one or turn the override off.`; }; export const getKeywordTierRulesError = (keywordTierRules: KeywordTierRule[]): string | null => { diff --git a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts index 4dffbbd2ac7..be48ff4958d 100644 --- a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.test.ts @@ -4,10 +4,10 @@ import { hydrateTierModelParams, normalizeTierModels, pruneTierModelParams, - resolveComplexityDefaultModel, serializeTierModelConfigs, setTierModelReasoningEffort, } from "./complexity_router_tiers"; +import { resolveComplexityDefaultModel } from "./tier_rows"; import type { ComplexityTiers } from "./ComplexityRouterConfig"; @@ -50,31 +50,31 @@ describe("resolveComplexityDefaultModel", () => { const noTiers: ComplexityTiers = { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }; it("derives from MEDIUM first when nothing is pinned", () => { - expect(resolveComplexityDefaultModel(tiers)).toBe("medium-model"); + expect(resolveComplexityDefaultModel({ tiers: tiers })).toBe("medium-model"); }); it("falls back to SIMPLE when MEDIUM is empty", () => { - expect(resolveComplexityDefaultModel({ ...tiers, MEDIUM: [] })).toBe("simple-model"); + expect(resolveComplexityDefaultModel({ tiers: { ...tiers, MEDIUM: [] } })).toBe("simple-model"); }); it("derives nothing from COMPLEX or REASONING, which the backend never falls through to", () => { - expect(resolveComplexityDefaultModel({ ...tiers, MEDIUM: [], SIMPLE: [] })).toBeUndefined(); + expect(resolveComplexityDefaultModel({ tiers: { ...tiers, MEDIUM: [], SIMPLE: [] } })).toBeUndefined(); }); it("lets a pin beat the tiers rather than merely filling in for them", () => { - expect(resolveComplexityDefaultModel(tiers, "pinned-model")).toBe("pinned-model"); + expect(resolveComplexityDefaultModel({ tiers: tiers }, "pinned-model")).toBe("pinned-model"); }); it("stands alone as the default when no tier holds a model", () => { - expect(resolveComplexityDefaultModel(noTiers, "pinned-model")).toBe("pinned-model"); + expect(resolveComplexityDefaultModel({ tiers: noTiers }, "pinned-model")).toBe("pinned-model"); }); it.each([[""], [" "], [undefined]])("reads %o as no pin and goes back to the tiers", (pinned) => { - expect(resolveComplexityDefaultModel(tiers, pinned)).toBe("medium-model"); + expect(resolveComplexityDefaultModel({ tiers: tiers }, pinned)).toBe("medium-model"); }); it("resolves to nothing when neither a pin nor a tier offers a model", () => { - expect(resolveComplexityDefaultModel(noTiers)).toBeUndefined(); + expect(resolveComplexityDefaultModel({ tiers: noTiers })).toBeUndefined(); }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts index 2ea1915ca03..ea0d34f6581 100644 --- a/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts +++ b/ui/litellm-dashboard/src/components/add_model/complexity_router_tiers.ts @@ -1,5 +1,5 @@ -import type { ComplexityTiers } from "./ComplexityRouterConfig"; import type { ComplexityTier } from "./KeywordTierRules"; +import { TIER_ORDER } from "./tier_rows"; export type TierModelParams = Record; @@ -80,13 +80,13 @@ export const hydrateTierModelParams = ( * tiers this editor does not render pass through rather than being dropped now the key is managed. */ export const serializeTierModelConfigs = ( - tiers: ComplexityTiers, + tiers: Record, tierModelParams: TierModelParamsByTier | undefined, ): Record | undefined => { if (tierModelParams === undefined) return undefined; const serialized = Object.entries(tierModelParams) .map(([tier, byModel]) => { - const selected = (TIER_ORDER as string[]).includes(tier) ? new Set(tiers[tier as ComplexityTier]) : undefined; + const selected = tier in tiers ? new Set(tiers[tier]) : undefined; const entries = Object.entries(byModel) .filter(([model, params]) => (selected === undefined || selected.has(model)) && Object.keys(params).length > 0) .map(([model_name, litellm_params]) => ({ model_name, litellm_params })); @@ -126,14 +126,6 @@ export const pruneTierModelParams = ( return Object.keys(next).length > 0 ? next : undefined; }; -/** - * Mirrors `init_complexity_router_deployment` (litellm/router.py): an explicit pin wins, otherwise - * the default is `MEDIUM or SIMPLE`. Deriving past SIMPLE would name a model the backend never - * picks, and it raises rather than falling through to COMPLEX/REASONING. - */ -export const resolveComplexityDefaultModel = (tiers: ComplexityTiers, pinned?: string): string | undefined => - pinned?.trim() || tiers.MEDIUM[0] || tiers.SIMPLE[0]; - export const DEFAULT_TIER_LABELS: Record = { SIMPLE: "Simple", MEDIUM: "Medium", @@ -141,8 +133,6 @@ export const DEFAULT_TIER_LABELS: Record = { REASONING: "Reasoning", }; -export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; - export const tierOptions = ( tierLabels: Partial> | undefined, ): { value: ComplexityTier; label: string }[] => diff --git a/ui/litellm-dashboard/src/components/add_model/tier_rows.test.ts b/ui/litellm-dashboard/src/components/add_model/tier_rows.test.ts new file mode 100644 index 00000000000..54d9e4f3f0a --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/tier_rows.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; + +import { + activeTierName, + activeTierRows, + isBuiltInTierName, + resolveComplexityDefaultModel, + sameTierIdentity, + tierRowById, + tierRowByName, +} from "./tier_rows"; + +const tiers = { SIMPLE: ["a"], MEDIUM: ["b"], COMPLEX: ["c"], REASONING: ["d"] }; + +describe("activeTierRows", () => { + it("reads the tier set as rows whose id is the canonical tier key, in severity order", () => { + expect(activeTierRows({ tiers })).toEqual([ + { id: "SIMPLE", name: "SIMPLE", models: ["a"] }, + { id: "MEDIUM", name: "MEDIUM", models: ["b"] }, + { id: "COMPLEX", name: "COMPLEX", models: ["c"] }, + { id: "REASONING", name: "REASONING", models: ["d"] }, + ]); + }); + + it("gives a tier with no models an empty pool rather than dropping the row", () => { + expect(activeTierRows({ tiers: { ...tiers, COMPLEX: [] } })[2]).toEqual({ + id: "COMPLEX", + name: "COMPLEX", + models: [], + }); + }); + + it("finds a row by id and by name", () => { + const rows = activeTierRows({ tiers }); + expect(tierRowById(rows, "MEDIUM")?.models).toEqual(["b"]); + expect(tierRowById(rows, undefined)).toBeUndefined(); + expect(tierRowByName(rows, " medium ")?.id).toBe("MEDIUM"); + }); +}); + +describe("sameTierIdentity", () => { + it.each([ + ["AUDIT", "audit", true], + ["AUDIT", " audit ", true], + ["AUDIT", "AUDITS", false], + ])("compares %s and %s casefold, matching the backend's uniqueness rule", (left, right, expected) => { + expect(sameTierIdentity(left, right)).toBe(expected); + }); + + it("recognises the four built-in names regardless of case", () => { + expect(["SIMPLE", "medium", "Complex", "REASONING"].every(isBuiltInTierName)).toBe(true); + expect(isBuiltInTierName("SECURITY_REVIEW")).toBe(false); + }); + + it("trims a row name, since the backend matches fallback_tier and keyword rules exactly", () => { + expect(activeTierName({ id: "1", name: " AUDIT ", models: [] })).toBe("AUDIT"); + }); +}); + +describe("resolveComplexityDefaultModel", () => { + it("mirrors init_complexity_router_deployment: a pin wins, then MEDIUM, then SIMPLE", () => { + expect(resolveComplexityDefaultModel({ tiers }, "pinned")).toBe("pinned"); + expect(resolveComplexityDefaultModel({ tiers })).toBe("b"); + expect(resolveComplexityDefaultModel({ tiers: { ...tiers, MEDIUM: [] } })).toBe("a"); + }); + + it("resolves to nothing rather than falling through to COMPLEX, which the backend never picks", () => { + expect(resolveComplexityDefaultModel({ tiers: { ...tiers, SIMPLE: [], MEDIUM: [] } })).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/tier_rows.ts b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts new file mode 100644 index 00000000000..c320980916a --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts @@ -0,0 +1,40 @@ +import type { ComplexityTiers } from "./ComplexityRouterConfig"; +import type { ComplexityTier } from "./KeywordTierRules"; + +export const TIER_ORDER: ComplexityTier[] = ["SIMPLE", "MEDIUM", "COMPLEX", "REASONING"]; + +export interface TierRow { + id: string; + name: string; + models: string[]; +} + +export interface ActiveTierSet { + tiers: ComplexityTiers; +} + +export const activeTierName = (row: TierRow): string => row.name.trim(); + +export const sameTierIdentity = (left: string, right: string): boolean => + left.trim().toLowerCase() === right.trim().toLowerCase(); + +export const isBuiltInTierName = (name: string): boolean => TIER_ORDER.some((tier) => sameTierIdentity(tier, name)); + +// The only reader of the tier set. A row's id is the canonical tier key, so anything pointing into +// the set (the plan-mode floor, per-model params) points at a row rather than at a position. +export const activeTierRows = (value: ActiveTierSet): TierRow[] => + TIER_ORDER.map((tier) => ({ id: tier, name: tier, models: value.tiers[tier] ?? [] })); + +export const tierRowById = (rows: readonly TierRow[], id: string | undefined): TierRow | undefined => + id === undefined ? undefined : rows.find((row) => row.id === id); + +export const tierRowByName = (rows: readonly TierRow[], name: string): TierRow | undefined => + rows.find((row) => sameTierIdentity(row.name, name)); + +// Mirrors init_complexity_router_deployment (litellm/router.py): a pin wins, then MEDIUM or SIMPLE +// looked up by exact name. +export const resolveComplexityDefaultModel = (value: ActiveTierSet, pinned?: string): string | undefined => { + const rows = activeTierRows(value); + const named = (name: string) => rows.find((row) => activeTierName(row) === name)?.models[0]; + return pinned?.trim() || named("MEDIUM") || named("SIMPLE"); +}; diff --git a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx index 3906aa744f7..4b6f6233fe8 100644 --- a/ui/litellm-dashboard/src/components/callback_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/callback_info_helpers.tsx @@ -109,6 +109,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [ langfuse_public_key: "text", langfuse_secret_key: "password", langfuse_host: "text", + langfuse_environment: "text", }, description: "Langfuse v2 Logging Integration", }, @@ -121,6 +122,7 @@ export const CALLBACK_CONFIGS: CallbackConfig[] = [ langfuse_public_key: "text", langfuse_secret_key: "password", langfuse_host: "text", + langfuse_environment: "text", }, description: "Langfuse v3 OTEL Logging Integration", }, diff --git a/ui/litellm-dashboard/src/components/chat_ui/MCPEventsDisplay.tsx b/ui/litellm-dashboard/src/components/chat_ui/MCPEventsDisplay.tsx index 632553e6d1b..6d7437a388d 100644 --- a/ui/litellm-dashboard/src/components/chat_ui/MCPEventsDisplay.tsx +++ b/ui/litellm-dashboard/src/components/chat_ui/MCPEventsDisplay.tsx @@ -93,7 +93,7 @@ function MCPEventsPanels({ toolsEvent, mcpCallEvents, defaultOpenKeys }: MCPEven {toolsEvent.item?.tools?.map((tool, index) => (
{tool.name}
@@ -113,7 +113,7 @@ function MCPEventsPanels({ toolsEvent, mcpCallEvents, defaultOpenKeys }: MCPEven onOpenChange={(open) => toggleKey(key, open)} >
-
+
Request
{callEvent.item?.arguments && ( @@ -124,7 +124,7 @@ function MCPEventsPanels({ toolsEvent, mcpCallEvents, defaultOpenKeys }: MCPEven
-
+
{callEvent.item?.output && ( -
+
Response
{callEvent.item.output} diff --git a/ui/litellm-dashboard/src/components/common_components/DurationSelect.test.tsx b/ui/litellm-dashboard/src/components/common_components/DurationSelect.test.tsx deleted file mode 100644 index 49a3ae8a001..00000000000 --- a/ui/litellm-dashboard/src/components/common_components/DurationSelect.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { useState } from "react"; -import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; -import { describe, it, expect, vi } from "vitest"; -import DurationSelect from "./DurationSelect"; - -describe("DurationSelect", () => { - it("should render", () => { - render(); - expect(screen.getByRole("combobox")).toBeInTheDocument(); - }); - - it("should render all three duration options", async () => { - const user = userEvent.setup(); - render(); - - const select = screen.getByRole("combobox"); - await user.click(select); - - expect(screen.getByText("Daily")).toBeInTheDocument(); - expect(screen.getByText("Weekly")).toBeInTheDocument(); - expect(screen.getByText("Monthly")).toBeInTheDocument(); - const dailyLabel = screen.getByText("Daily"); - const dailyOption = dailyLabel.closest('[role="option"]') ?? dailyLabel; - await user.click(dailyOption); - }); - - it("should apply className prop", () => { - render(); - const select = screen.getByRole("combobox"); - expect(select.closest(".test-class")).toBeInTheDocument(); - }); - - it("should call onChange when an option is selected", async () => { - const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); - const onChange = vi.fn(); - render(); - - const select = screen.getByRole("combobox"); - await user.click(select); - - const dailyLabel = screen.getByText("Daily"); - const dailyOption = dailyLabel.closest('[role="option"]') ?? dailyLabel; - await user.click(dailyOption); - - expect(onChange).toHaveBeenCalledWith("24h", expect.any(Object)); - }); - - it("should accept and pass value prop to Select", () => { - render(); - const select = screen.getByRole("combobox"); - expect(select).toBeInTheDocument(); - }); - - it.each([ - ["24h", "Daily"], - ["7d", "Weekly"], - ["30d", "Monthly"], - ])("shows the human label on the trigger for %s", (value, label) => { - render(); - - expect(screen.getByRole("combobox")).toHaveTextContent(label); - }); - - it("shows the human label on the trigger after the user picks an option", async () => { - const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); - const Harness = () => { - const [value, setValue] = useState("24h"); - return ; - }; - render(); - - await user.click(screen.getByRole("combobox")); - const monthly = screen.getByText("Monthly"); - await user.click(monthly.closest('[role="option"]') ?? monthly); - - expect(screen.getByRole("combobox")).toHaveTextContent("Monthly"); - }); -}); diff --git a/ui/litellm-dashboard/src/components/common_components/DurationSelect.tsx b/ui/litellm-dashboard/src/components/common_components/DurationSelect.tsx deleted file mode 100644 index 55e8aafbae7..00000000000 --- a/ui/litellm-dashboard/src/components/common_components/DurationSelect.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; - -interface DurationSelectProps { - className?: string; - value?: string; - onChange?: (value: string, option: { value: string; label: string }) => void; -} - -const DURATION_OPTIONS = [ - { value: "24h", label: "Daily" }, - { value: "7d", label: "Weekly" }, - { value: "30d", label: "Monthly" }, -]; - -export default function DurationSelect({ className, value, onChange }: DurationSelectProps) { - return ( - - ); -} diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index bce96ec76f5..8f4b06d80fb 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -14,25 +14,21 @@ import ModelChoiceCombobox, { type ModelChoice } from "../add_model/ModelChoiceC import { modelAvailableCall, modelPatchUpdateCall } from "../networking"; import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models"; import RouterConfigBuilder from "../add_model/RouterConfigBuilder"; -import { - hydrateTierModelParams, - normalizeTierModels, - resolveComplexityDefaultModel, - serializeTierModelConfigs, -} from "../add_model/complexity_router_tiers"; +import { hydrateTierModelParams, normalizeTierModels } from "../add_model/complexity_router_tiers"; +import { type ActiveTierSet, activeTierRows, resolveComplexityDefaultModel } from "../add_model/tier_rows"; import { isComplexityRouter } from "../add_model/auto_router_strategies"; import { + type BuildComplexityRouterConfigParams, + buildComplexityRouterConfig, getKeywordTierRulesError, getSemanticConfigError, getPlanModeTierError, getTierLabelsError, hydrateTierLabels, - normalizeClassifierLlmConfig, - serializeTierLabels, } from "../add_model/build_complexity_router_config"; import { KeywordTierRule } from "../add_model/KeywordTierRules"; import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching"; -import { hydrateKeywordTierRules, serializeKeywordTierRules } from "../add_model/complexity_router_keywords"; +import { hydrateKeywordTierRules } from "../add_model/complexity_router_keywords"; import { hydrateDimensionWeights, hydrateReasoningOverrideMinScore, @@ -46,7 +42,6 @@ import ComplexityRouterConfig, { DEFAULT_SESSION_AFFINITY, DEFAULT_DEPLOYMENT_AFFINITY, DEFAULT_TIER_DISTANCE_PENALTY, - heuristicScoringRole, } from "../add_model/ComplexityRouterConfig"; import { Dialog, @@ -119,12 +114,12 @@ const toRecord = (value: unknown): Record => { export const hydratePinnedDefaultModel = ( storedConfigDefaultModel: unknown, litellmParamsDefaultModel: string | null | undefined, - tiers: ComplexityTiers, + activeTiers: ActiveTierSet, ): string | undefined => { if (typeof storedConfigDefaultModel === "string" && storedConfigDefaultModel.trim()) { return storedConfigDefaultModel; } - const tierDerived = resolveComplexityDefaultModel(tiers); + const tierDerived = resolveComplexityDefaultModel(activeTiers); const externalOverride = litellmParamsDefaultModel?.trim(); return externalOverride && externalOverride !== tierDerived ? externalOverride : undefined; }; @@ -148,73 +143,48 @@ export const buildUpdatedComplexityRouterConfig = ( if (keywordMatching !== undefined && KEYWORD_MATCHING_KEYS.has(key)) return true; return customTechnicalKeywords !== undefined && key === "custom_technical_keywords"; }; - const preservedConfig = Object.fromEntries(Object.entries(toRecord(storedConfig)).filter(([key]) => !isManaged(key))); - const adaptiveEligible = value.adaptive_eligible ?? "all"; - const storedKeywordRules = keywordMatching ? serializeKeywordTierRules(keywordMatching.keywordTierRules) : []; - const serializedTierLabels = serializeTierLabels(value.tier_labels); - const scorerRuns = heuristicScoringRole(value) !== "never"; - const serializedTierModelConfigs = serializeTierModelConfigs(value.tiers, value.tier_model_params); + const builderParams: BuildComplexityRouterConfigParams = { + tiers: value.tiers, + defaultModel: value.default_model, + planModeMinTier: value.plan_mode_min_tier, + tierLabels: value.tier_labels, + classifierType: value.classifier_type, + classifierLlmConfig: value.classifier_llm_config, + classifierContextWindowSize: value.classifier_context_window_size, + classifierContextBudgetChars: value.classifier_context_budget_chars, + classifierContextIncludeAssistantTurns: value.classifier_context_include_assistant_turns, + classifierFallback: value.classifier_fallback, + sessionAffinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY, + deploymentAffinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY, + customTechnicalKeywords: customTechnicalKeywords ?? [], + keywordTierRules: keywordMatching?.keywordTierRules ?? [], + semanticMatchingEnabled: keywordMatching?.semanticMatchingEnabled ?? false, + embeddingModel: keywordMatching?.embeddingModel, + matchThreshold: keywordMatching?.matchThreshold ?? DEFAULT_MATCH_THRESHOLD, + escalationKeywords: keywordMatching?.escalationKeywords ?? [], + adaptive: value.adaptive ?? false, + adaptiveWeights: value.adaptive_weights ?? DEFAULT_ADAPTIVE_WEIGHTS, + tierDistancePenalty: value.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY, + adaptiveEligible: value.adaptive_eligible ?? "all", + returnRawModelName: value.return_raw_model_name ?? false, + tierBoundaries: value.tier_boundaries, + tokenThresholds: value.token_thresholds, + dimensionWeights: value.dimension_weights, + reasoningOverrideMinScore: value.reasoning_override_min_score, + tierModelParams: value.tier_model_params, + }; + const built = buildComplexityRouterConfig(builderParams); + // Keys this call does not own stay as the stored config left them. + const unowned: readonly string[] = [ + ...(keywordMatching === undefined ? KEYWORD_MATCHING_KEYS : []), + ...(customTechnicalKeywords === undefined ? ["custom_technical_keywords"] : []), + ]; return { ...preservedConfig, - tiers: value.tiers, - ...(serializedTierModelConfigs && { tier_model_configs: serializedTierModelConfigs }), - ...(value.default_model?.trim() && { default_model: value.default_model }), - ...(value.plan_mode_min_tier?.trim() && { plan_mode_min_tier: value.plan_mode_min_tier }), - ...(serializedTierLabels && { tier_labels: serializedTierLabels }), - classifier_type: value.classifier_type, - ...(value.classifier_type === "llm" && value.classifier_llm_config - ? { classifier_llm_config: normalizeClassifierLlmConfig(value.classifier_llm_config) } - : {}), - ...(value.classifier_type === "llm" && - value.classifier_fallback !== undefined && { classifier_fallback: value.classifier_fallback }), - ...(value.classifier_type === "llm" && - value.classifier_context_window_size !== undefined && { - classifier_context_window_size: value.classifier_context_window_size, - }), - ...(value.classifier_type === "llm" && - value.classifier_context_budget_chars !== undefined && { - classifier_context_budget_chars: value.classifier_context_budget_chars, - }), - ...(value.classifier_type === "llm" && - value.classifier_context_include_assistant_turns !== undefined && { - classifier_context_include_assistant_turns: value.classifier_context_include_assistant_turns, - }), - session_affinity: value.session_affinity ?? DEFAULT_SESSION_AFFINITY, - deployment_affinity: value.deployment_affinity ?? DEFAULT_DEPLOYMENT_AFFINITY, - ...(customTechnicalKeywords && - customTechnicalKeywords.length > 0 && { - custom_technical_keywords: customTechnicalKeywords, - }), - ...(value.adaptive && { - adaptive: true, - adaptive_weights: value.adaptive_weights ?? DEFAULT_ADAPTIVE_WEIGHTS, - ...(adaptiveEligible === "all" && { - tier_distance_penalty: value.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY, - }), - adaptive_eligible: adaptiveEligible, - }), - ...(value.return_raw_model_name && { return_raw_model_name: true }), - ...(keywordMatching && { - // Mirrors buildComplexityRouterConfig: the key only when there is a rule to write, - // escalation keywords always, semantic trio only when on. - ...(storedKeywordRules.length > 0 && { keyword_tier_rules: storedKeywordRules }), - escalation_keywords: keywordMatching.escalationKeywords.map((k) => k.trim()).filter(Boolean), - ...(keywordMatching.semanticMatchingEnabled && { - semantic_keyword_matching: true, - embedding_model: keywordMatching.embeddingModel, - match_threshold: keywordMatching.matchThreshold, - }), - }), - ...(scorerRuns && value.tier_boundaries !== undefined && { tier_boundaries: value.tier_boundaries }), - ...(scorerRuns && value.token_thresholds !== undefined && { token_thresholds: value.token_thresholds }), - ...(scorerRuns && value.dimension_weights !== undefined && { dimension_weights: value.dimension_weights }), - ...(scorerRuns && - value.reasoning_override_min_score !== undefined && { - reasoning_override_min_score: value.reasoning_override_min_score, - }), + ...Object.fromEntries(Object.entries(built).filter(([key]) => !unowned.includes(key))), }; }; @@ -297,7 +267,7 @@ const EditAutoRouterModal: React.FC = ({ ? "Please select at least one model for a complexity tier" : null) ?? getTierLabelsError(complexityRouterConfig.tier_labels) ?? - getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, complexityRouterConfig.tiers) ?? + getPlanModeTierError(complexityRouterConfig.plan_mode_min_tier, activeTierRows(complexityRouterConfig)) ?? getKeywordTierRulesError(keywordTierRules); useEffect(() => { @@ -355,7 +325,7 @@ const EditAutoRouterModal: React.FC = ({ default_model: hydratePinnedDefaultModel( parsedConfig.default_model, modelData.litellm_params?.complexity_router_default_model, - hydratedTiers, + { tiers: hydratedTiers }, ), plan_mode_min_tier: typeof parsedConfig.plan_mode_min_tier === "string" && parsedConfig.plan_mode_min_tier.trim() !== "" @@ -486,7 +456,7 @@ const EditAutoRouterModal: React.FC = ({ // build_complexity_router_config.ts for why create never can). init_complexity_router_deployment // raises in that case (litellm/router.py), so block it rather than saving a router that // fails at init. - const defaultModel = resolveComplexityDefaultModel(tiers, complexityRouterConfig.default_model); + const defaultModel = resolveComplexityDefaultModel(complexityRouterConfig, complexityRouterConfig.default_model); if (!defaultModel) { setShowValidationErrors(true); toast.fromError( diff --git a/ui/litellm-dashboard/src/components/model_info_view.tsx b/ui/litellm-dashboard/src/components/model_info_view.tsx index 5d4596e6747..f21e98e084c 100644 --- a/ui/litellm-dashboard/src/components/model_info_view.tsx +++ b/ui/litellm-dashboard/src/components/model_info_view.tsx @@ -16,7 +16,7 @@ import { stripMaskedSecrets } from "../utils/maskedSecretUtils"; import { truncateString } from "../utils/textUtils"; import AutoRouterConnectionTest from "./add_model/auto_router_connection_test"; import { AutoRouterTestTarget, buildAutoRouterTestTargets } from "./add_model/build_auto_router_test_targets"; -import { normalizeTierModels, resolveComplexityDefaultModel } from "./add_model/complexity_router_tiers"; +import { normalizeTierModels } from "./add_model/complexity_router_tiers"; import { hasAutoRouterEditor, isAutoRouterDeployment, @@ -91,12 +91,10 @@ const buildComplexityRouterTestTargets = ( config = rawConfig; } - const tiers = { - SIMPLE: normalizeTierModels(config.tiers?.SIMPLE), - MEDIUM: normalizeTierModels(config.tiers?.MEDIUM), - COMPLEX: normalizeTierModels(config.tiers?.COMPLEX), - REASONING: normalizeTierModels(config.tiers?.REASONING), - }; + const tiers: [string, string[]][] = + config.tiers && typeof config.tiers === "object" + ? Object.entries(config.tiers).map(([tier, models]) => [tier, normalizeTierModels(models)]) + : []; // Mirrors init_complexity_router_deployment (litellm/router.py): litellm_params wins, otherwise // pure tier-derivation. complexity_router_config.default_model is a UI-only marker the backend @@ -108,7 +106,7 @@ const buildComplexityRouterTestTargets = ( tiers, semanticMatchingEnabled: Boolean(config.semantic_keyword_matching), embeddingModel: config.embedding_model, - defaultModel: resolveComplexityDefaultModel(tiers, effectiveDefaultModel), + defaultModel: effectiveDefaultModel, }; return buildAutoRouterTestTargets(testTargetParams); }; @@ -604,7 +602,7 @@ export default function ModelInfoView({ size="icon-xs" aria-label="Copy model ID" onClick={() => copyToClipboard(modelData.model_info.id, "model-id")} - className={`left-2 z-10 transition-all duration-200 ${ + className={`left-2 z-raised transition-all duration-200 ${ copiedStates["model-id"] ? "text-success bg-success/10 border-success/20" : "text-muted-foreground hover:text-foreground hover:bg-muted" diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 0cd3a0d44e5..e81c67cc958 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -66,7 +66,7 @@ const Navbar: React.FC = ({ }; return ( -