Merge branch 'litellm_internal_staging' into litellm_/search-tools-sync-issue-e522a2

This commit is contained in:
yuneng-jiang 2026-08-26 17:04:01 -07:00 committed by GitHub
commit cbefebbd9f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
206 changed files with 7235 additions and 1387 deletions

View file

@ -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

View file

@ -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
}
}

View file

@ -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.",

View file

@ -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,

View file

@ -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

View file

@ -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))

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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."""

View file

@ -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)

View file

@ -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

View file

@ -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,

View file

@ -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),

View file

@ -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",

View file

@ -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

View file

@ -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,
)

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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 = (

View file

@ -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:

View file

@ -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:
"""

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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,

View file

@ -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)

View file

@ -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,
)

View file

@ -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:

View file

@ -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(

File diff suppressed because it is too large Load diff

View file

@ -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)

View file

@ -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 <your_api_key>\" \\\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 <your_api_key>\" \\\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": {

View file

@ -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

View file

@ -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(

View file

@ -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:

View file

@ -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=<router>` 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(

View file

@ -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:

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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.

View file

@ -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
)

View file

@ -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={"<template_id>": {"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)

View file

@ -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

View file

@ -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<name>[^']+)'")
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):

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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 ()
)

View file

@ -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.

View file

@ -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):

View file

@ -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

View file

@ -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):

View file

@ -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:

View file

@ -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):

View file

@ -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

View file

@ -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),

File diff suppressed because it is too large Load diff

View file

@ -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).",

View file

@ -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

View file

@ -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",
]

View file

@ -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,

View file

@ -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

View file

@ -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(

View file

@ -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}}"

View file

@ -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

View file

@ -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"}

View file

@ -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

View file

@ -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",

View file

@ -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)

View file

@ -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."""

View file

@ -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."""

View file

@ -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 = [
{

View file

@ -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

View file

@ -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(

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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():

View file

@ -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"

View file

@ -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
)

View file

@ -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(
{

View file

@ -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"

View file

@ -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()

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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)

Some files were not shown because too many files have changed in this diff Show more