Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_together_glm53_flash

This commit is contained in:
mateo-berri 2026-08-27 15:12:35 -07:00
commit ae89f9cf74
180 changed files with 22558 additions and 2334 deletions

View file

@ -83,6 +83,24 @@ jobs:
if: steps.changes.outputs.relevant == 'true'
run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma
- name: Regenerate the lazy OpenAPI snapshot
if: steps.changes.outputs.relevant == 'true'
run: uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot
- name: Fail if the lazy OpenAPI snapshot is stale
if: steps.changes.outputs.relevant == 'true'
run: |
if ! git diff --exit-code -- litellm/proxy/_lazy_openapi_snapshot.json; then
echo "::error file=litellm/proxy/_lazy_openapi_snapshot.json::The lazy OpenAPI snapshot is out of sync with the lazily loaded routes."
echo ""
echo "A lazily loaded route or model changed without regenerating the snapshot that /openapi.json serves for unloaded features."
echo "To fix, run from the repo root:"
echo " uv run python -m litellm.proxy._lazy_openapi_snapshot"
echo "then run npm run gen:api from ui/litellm-dashboard and commit both files."
exit 1
fi
echo "_lazy_openapi_snapshot.json is in sync with the lazily loaded routes."
- name: Set up Node.js
if: steps.changes.outputs.relevant == 'true'
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0

View file

@ -10,6 +10,11 @@
-- partitioned, so existing installs are unaffected until you run this.
--
-- IMPORTANT
-- * After partitioning, `prisma db push` (including the proxy's
-- --use_prisma_db_push startup mode) is NOT supported: it tries to rewrite
-- the primary key back to ("request_id"), which Postgres rejects on a
-- partitioned table. The proxy detects this and exits with guidance.
-- Use the default startup path (`prisma migrate deploy`) instead.
-- * Test on a staging copy first and take a backup.
-- * Postgres cannot convert a populated table to partitioned in place, so this
-- renames the old table aside and creates a fresh partitioned table.

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-enterprise"
version = "0.1.60"
version = "0.1.61"
description = "Package for LiteLLM Enterprise features"
readme = "README.md"
requires-python = ">=3.9"
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
version = "0.1.60"
version = "0.1.61"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-enterprise==",

View file

@ -40,6 +40,65 @@ def _get_prisma_env() -> dict:
_MIGRATION_TS_RE = re.compile(r"^(\d{14})_")
_SPEND_LOGS_ALTER_RE = re.compile(r'^ALTER\s+TABLE\s+"LiteLLM_SpendLogs"\s', re.IGNORECASE)
_SPEND_LOGS_ARTIFACT_DROP_RE = re.compile(
r'^DROP\s+TABLE\s+"LiteLLM_SpendLogs_[^"]*"', re.IGNORECASE
)
_SPEND_LOGS_PK_CLAUSE_RE = re.compile(
r'^(?:DROP\s+CONSTRAINT\s+"[^"]*_pkey"'
r'|ADD\s+(?:CONSTRAINT\s+"[^"]*"\s+)?PRIMARY\s+KEY\s*\([^)]*\))$',
re.IGNORECASE,
)
PARTITIONED_SPEND_LOGS_PUSH_ERROR = (
"LiteLLM_SpendLogs is a partitioned table (see db_scripts/partition_spend_logs.sql), "
"so its primary key must include the partition key (\"startTime\"). `prisma db push` "
"reconciles the database against schema.prisma, which declares the unpartitioned "
"primary key (\"request_id\"), and Postgres rejects that rewrite with: unique "
"constraint on partitioned table must include all partitioning columns. Start the "
"proxy without --use_prisma_db_push so it uses `prisma migrate deploy`, which only "
"applies shipped migrations and leaves the partitioned primary key alone."
)
def _without_sql_comments(statement: str) -> str:
return "\n".join(
line
for line in statement.splitlines()
if line.strip() and not line.strip().startswith("--")
).strip()
def _without_spend_logs_pk_clauses(statement: str) -> Optional[str]:
prefix_match = _SPEND_LOGS_ALTER_RE.match(statement)
if not prefix_match:
return statement
kept = tuple(
clause.strip()
for clause in statement[prefix_match.end():].split(",\n")
if not _SPEND_LOGS_PK_CLAUSE_RE.match(clause.strip())
)
if not kept:
return None
return statement[: prefix_match.end()] + ",\n".join(kept)
def filter_partitioned_spend_logs_diff(diff_sql: str) -> str:
"""Drop statements from a `prisma migrate diff` script that fight the
SpendLogs partitioning runbook (db_scripts/partition_spend_logs.sql): the
primary-key rewrite on "LiteLLM_SpendLogs", which Postgres rejects on a
partitioned table, and drops of runbook artifacts such as
"LiteLLM_SpendLogs_legacy"."""
kept = tuple(
filtered
for statement in diff_sql.split(";")
for bare in (_without_sql_comments(statement),)
if bare and not _SPEND_LOGS_ARTIFACT_DROP_RE.match(bare)
for filtered in (_without_spend_logs_pk_clauses(bare),)
if filtered is not None
)
return "".join(f"{statement};\n\n" for statement in kept)
def _migration_timestamp(name: str) -> int:
"""Extract the leading `YYYYMMDDHHMMSS` timestamp from a migration name.
@ -355,7 +414,24 @@ class ProxyExtrasDBManager:
return
logger.info(f"Migration diff created at {diff_sql_path}")
if ProxyExtrasDBManager.spend_logs_is_partitioned():
filtered_sql = filter_partitioned_spend_logs_diff(
diff_sql_path.read_text()
)
diff_sql_path.write_text(filtered_sql)
logger.info(
"LiteLLM_SpendLogs is partitioned; removed its primary-key "
"rewrite and partitioning artifacts from the drift script"
)
if not filtered_sql.strip():
logger.info("Drift script is empty after filtering; nothing to apply")
if not mark_all_applied:
return
ProxyExtrasDBManager._mark_migrations_applied(migrations_dir)
return
# 2. Run prisma db execute to apply the migration
applied_ok = False
try:
logger.info("Running prisma db execute to apply the migration diff...")
result = subprocess.run(
@ -376,6 +452,7 @@ class ProxyExtrasDBManager:
)
logger.info(f"prisma db execute stdout: {result.stdout}")
logger.info("✅ Migration diff applied successfully")
applied_ok = True
except subprocess.CalledProcessError as e:
logger.warning(f"Failed to apply migration diff: {e.stderr}")
except subprocess.TimeoutExpired:
@ -384,6 +461,16 @@ class ProxyExtrasDBManager:
# 3. Mark all migrations as applied
if not mark_all_applied:
return
if not applied_ok:
logger.warning(
"Drift script failed to apply; NOT marking migrations as "
"applied so a later migration run can retry them"
)
return
ProxyExtrasDBManager._mark_migrations_applied(migrations_dir)
@staticmethod
def _mark_migrations_applied(migrations_dir: str):
migration_names = ProxyExtrasDBManager._get_migration_names(migrations_dir)
logger.info(f"Resolving {len(migration_names)} migrations")
for migration_name in migration_names:
@ -410,6 +497,55 @@ class ProxyExtrasDBManager:
f"Failed to resolve migration {migration_name}: {e.stderr}"
)
@staticmethod
def spend_logs_is_partitioned() -> bool:
"""True when the connected database's LiteLLM_SpendLogs is a
partitioned table in Prisma's target schema (the `schema` URL param,
falling back to Prisma's default target, public), i.e. the operator
ran db_scripts/partition_spend_logs.sql. Returns False when psycopg is
unavailable or the database cannot be reached, preserving the
pre-existing behavior in those cases."""
database_url = os.getenv("DATABASE_URL")
if not database_url:
return False
try:
import psycopg
except ImportError:
return False
cleaned_url = ProxyExtrasDBManager._strip_prisma_query_params(database_url)
try:
with psycopg.connect(
cleaned_url, connect_timeout=10, autocommit=True
) as conn:
row = conn.execute(
"SELECT 1 "
"FROM pg_partitioned_table pt "
"JOIN pg_class c ON c.oid = pt.partrelid "
"JOIN pg_namespace n ON n.oid = c.relnamespace "
"WHERE c.relname = 'LiteLLM_SpendLogs' "
" AND n.nspname = %s",
(
ProxyExtrasDBManager._prisma_schema_param(database_url)
or "public",
),
).fetchone()
except (psycopg.OperationalError, psycopg.DatabaseError):
return False
return row is not None
@staticmethod
def _prisma_schema_param(url: str) -> Optional[str]:
"""The `schema` query param Prisma uses to pick its target schema,
or None when the URL does not set one."""
from urllib.parse import urlparse, parse_qsl
return next(
(v for k, v in parse_qsl(urlparse(url).query) if k == "schema"),
None,
)
@staticmethod
def _strip_prisma_query_params(url: str) -> str:
"""Remove Prisma-specific query params (connection_limit, pool_timeout,
@ -528,7 +664,8 @@ class ProxyExtrasDBManager:
migrations_dir = ProxyExtrasDBManager._get_prisma_dir()
if not use_migrate:
# Preserve `prisma db push` path unchanged.
if ProxyExtrasDBManager.spend_logs_is_partitioned():
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
original_dir = os.getcwd()
os.chdir(migrations_dir)
try:
@ -972,6 +1109,8 @@ class ProxyExtrasDBManager:
)
raise
else:
if ProxyExtrasDBManager.spend_logs_is_partitioned():
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
# Use prisma db push with increased timeout
subprocess.run(
[_get_prisma_command(), "db", "push", "--accept-data-loss"],

View file

@ -1,6 +1,6 @@
[project]
name = "litellm-proxy-extras"
version = "0.4.89"
version = "0.4.90"
description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package."
readme = "README.md"
requires-python = ">=3.9"
@ -26,7 +26,7 @@ required-version = ">=0.10.9"
module-root = ""
[tool.commitizen]
version = "0.4.89"
version = "0.4.90"
version_files = [
"pyproject.toml:^version",
"../pyproject.toml:litellm-proxy-extras==",

View file

@ -445,6 +445,7 @@ max_ui_session_budget: Optional[float] = (
1.0 # USD budget for each dashboard login session (playground, test connection)
)
internal_user_budget_duration: Optional[str] = None
budget_rollover: bool = False # carry spend beyond max_budget into the next window instead of zeroing it
tag_budget_config: Optional[Dict[str, "BudgetConfig"]] = None
max_end_user_budget: Optional[float] = None
max_end_user_budget_id: Optional[str] = None

View file

@ -1473,6 +1473,12 @@ LITELLM_PROXY_MASTER_KEY_ALIAS: Final = "litellm_proxy_master_key"
# ``ProxyLogging._handle_logging_proxy_only_error``.
LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL: Final = "litellm_no_upstream_llm_call"
# Key/team metadata fields naming the OTel Resource ``service.name``, highest
# precedence first. Shared between the OTel v2 tenant router (which reads them
# out of ``user_api_key_auth_metadata``) and proxy request setup (which re-applies
# the key's values after the team metadata merge so a key outranks its team).
OTEL_SERVICE_NAME_METADATA_KEYS: Final = ("otel_service_name_override", "otel_service_name")
# Key Rotation Constants
LITELLM_KEY_ROTATION_ENABLED: Final = os.getenv("LITELLM_KEY_ROTATION_ENABLED", "false")
LITELLM_KEY_ROTATION_CHECK_INTERVAL_SECONDS: Final = int(
@ -1646,6 +1652,7 @@ LITELLM_SETTINGS_SAFE_DB_OVERRIDES: Final = [
"enable_anthropic_prompt_caching",
"anthropic_prompt_caching_ttl",
"max_ui_session_budget",
"budget_rollover",
]
SPECIAL_LITELLM_AUTH_TOKEN: Final = ["ui-token"]
DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL = int(os.getenv("DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL", 60))

View file

@ -557,9 +557,10 @@ def cost_per_token(
)
elif call_type == "atranscription" or call_type == "transcription":
if _transcription_usage_has_token_details(usage_block):
return openai_cost_per_token(
return generic_cost_per_token(
model=model_without_prefix,
usage=usage_block,
custom_llm_provider=custom_llm_provider,
service_tier=service_tier,
data_residency=data_residency,
)

View file

@ -56,6 +56,9 @@ from litellm.types.mcp import (
MCPStdioConfig,
MCPTransport,
MCPTransportType,
credential_redirect_hook,
has_header,
without_header,
)
@ -273,6 +276,7 @@ class MCPClient:
transport_type: MCPTransportType = MCPTransport.http,
auth_type: MCPAuthType = None,
auth_value: str | dict[str, str] | None = None,
auth_header_name: str | None = None,
timeout: float | None = None,
stdio_config: MCPStdioConfig | None = None,
extra_headers: dict[str, str] | None = None,
@ -288,6 +292,11 @@ class MCPClient:
self.auth_type: MCPAuthType = auth_type
self.timeout: float = timeout if timeout is not None else MCP_CLIENT_TIMEOUT
self._mcp_auth_value: str | dict[str, str] | None = None
# The one place this client decides which header its credential occupies: the operator's
# configured slot on the v1 path, or the slot the v2 resolver's auth object already owns.
# Every consumer reads this rather than re-deriving it, since each re-derivation so far
# picked up a different bug.
self._credential_slot: str | None = auth_header_name or getattr(resolved_auth, "header_name", None)
self.stdio_config: MCPStdioConfig | None = stdio_config
self.extra_headers: dict[str, str] | None = extra_headers
self.ssl_verify: VerifyTypes | None = ssl_verify
@ -501,26 +510,33 @@ class MCPClient:
else:
self._mcp_auth_value = mcp_auth_value
def _header_slot(self, default: str) -> str:
return self._credential_slot or default
def _get_auth_headers(self) -> dict:
"""Generate authentication headers based on auth type."""
headers: Final = {}
if self._mcp_auth_value:
if isinstance(self._mcp_auth_value, str):
if self.auth_type == MCPAuth.bearer_token:
headers["Authorization"] = f"Bearer {strip_auth_scheme(self._mcp_auth_value, 'Bearer')}"
static_bearer: Final = strip_auth_scheme(self._mcp_auth_value, "Bearer")
headers[self._header_slot("Authorization")] = f"Bearer {static_bearer}"
elif self.auth_type == MCPAuth.basic:
headers["Authorization"] = f"Basic {self._mcp_auth_value}"
headers[self._header_slot("Authorization")] = f"Basic {self._mcp_auth_value}"
elif self.auth_type == MCPAuth.api_key:
headers["X-API-Key"] = self._mcp_auth_value
headers[self._header_slot("X-API-Key")] = self._mcp_auth_value
elif self.auth_type == MCPAuth.authorization:
# This auth type means the caller owns the whole header value.
headers["Authorization"] = self._mcp_auth_value
headers[self._header_slot("Authorization")] = self._mcp_auth_value
elif self.auth_type == MCPAuth.oauth2:
headers["Authorization"] = f"Bearer {strip_auth_scheme(self._mcp_auth_value, 'Bearer')}"
oauth2_bearer: Final = strip_auth_scheme(self._mcp_auth_value, "Bearer")
headers[self._header_slot("Authorization")] = f"Bearer {oauth2_bearer}"
elif self.auth_type == MCPAuth.token:
headers["Authorization"] = f"token {strip_auth_scheme(self._mcp_auth_value, 'token')}"
scheme_token: Final = strip_auth_scheme(self._mcp_auth_value, "token")
headers[self._header_slot("Authorization")] = f"token {scheme_token}"
elif self.auth_type == MCPAuth.oauth2_token_exchange:
headers["Authorization"] = f"Bearer {strip_auth_scheme(self._mcp_auth_value, 'Bearer')}"
exchanged_bearer: Final = strip_auth_scheme(self._mcp_auth_value, "Bearer")
headers[self._header_slot("Authorization")] = f"Bearer {exchanged_bearer}"
elif isinstance(self._mcp_auth_value, dict):
headers.update(self._mcp_auth_value)
# Note: aws_sigv4 auth is not handled here — SigV4 requires per-request
@ -528,7 +544,14 @@ class MCPClient:
# of static headers. See MCPSigV4Auth and _create_httpx_client_factory().
# update the headers with the extra headers
if self.extra_headers:
headers.update(self.extra_headers)
# Mirrors _resolve_v2_auth: when the operator named a slot for the credential the
# gateway resolved, no injected header may shadow it, case-insensitively, since HTTP
# header names are. Without a configured slot the old precedence stands unchanged.
slot: Final = self._credential_slot
injected: Final = (
without_header(self.extra_headers, slot) if slot and has_header(headers, slot) else self.extra_headers
)
headers.update(injected or {})
return _strip_header_whitespace(headers)
def _create_httpx_client_factory(self) -> Callable[..., httpx.AsyncClient]:
@ -556,12 +579,14 @@ class MCPClient:
# SigV4 aws_auth. Both are None for the common case — no behavior change.
fallback_auth: Final = self._resolved_auth if self._resolved_auth is not None else self._aws_auth
effective_auth: Final = auth if auth is not None else fallback_auth
guard: Final = credential_redirect_hook(self.server_url, self._credential_slot)
return httpx.AsyncClient(
headers=headers,
timeout=timeout,
auth=effective_auth,
verify=ssl_config,
follow_redirects=True,
event_hooks={"request": [guard]} if guard else {},
)
return factory

View file

@ -149,7 +149,6 @@ class PromptManager:
)
self.prompts[template_id] = template
except Exception:
# Optional: print(f"Error loading prompt from JSON: {template_id}")
pass
def _load_prompt_file(self, file_path: str | Path, prompt_id: str) -> PromptTemplate:

View file

@ -146,7 +146,7 @@ class SpanEmitter:
For callers that own and manage their own span lifecycle. ``tracer``
overrides the bound tracer for this span only, used for per-request
multi-tenant credential routing. ``links`` records related-but-not-parent
spans (e.g. the transport span of an MCP message, per MCP semconv).
spans (e.g. the trace context an MCP client propagated in ``params._meta``).
"""
return (tracer or self._tracer).start_span(
name,
@ -196,8 +196,8 @@ class SpanEmitter:
Return the span, or ``None`` if it was deduplicated away. ``tracer``
overrides the bound tracer for this span, used for per-request routing.
``links`` records related-but-not-parent spans (the transport span of an
MCP message).
``links`` records related-but-not-parent spans (e.g. the trace context an
MCP client propagated in ``params._meta``).
"""
# LLM-call and MCP tool-call spans carry a dedup key (their request's
# call id), so a sync+async double-firing coalesces. ``isinstance`` narrows

View file

@ -390,10 +390,10 @@ class OpenTelemetryV2(CustomLogger):
MCP tool calls reach the success/failure callbacks like any other request
(with ``call_type`` ``call_mcp_tool``), but they are not LLM calls and have
no ``pre_call`` carrier so they get their own CLIENT span here. Per the MCP
semconv it parents to the trace context the client propagated in
``params._meta`` (or starts a new root) and links the transport span, rather
than nesting under the HTTP/session span. Returns whether it handled the
no ``pre_call`` carrier so they get their own CLIENT span here. It nests
under the transport span of the request carrying this message, and trace
context the client propagated in ``params._meta`` is recorded as a span
link (see ``resolve_mcp_span_context``). Returns whether it handled the
event, so the caller skips the LLM-call path. The whole span is emitted at
once (there is no boundary to open it at), deduped on the call id.
"""
@ -436,9 +436,9 @@ class OpenTelemetryV2(CustomLogger):
Like a tool call, listing reaches the success/failure callbacks (here with
``call_type`` ``list_mcp_tools``) with no ``pre_call`` carrier, so it gets its
own CLIENT span. Per the MCP semconv it parents to the ``params._meta`` trace
context (or starts a new root) and links the transport span, rather than
nesting under the HTTP/session span. Returns whether it handled the event so
own CLIENT span, nested under the transport span of the request carrying
this message with any ``params._meta`` trace context recorded as a span
link (see ``resolve_mcp_span_context``). Returns whether it handled the event so
the caller skips the LLM-call path.
"""
raw_payload: Final = kwargs.get("standard_logging_object")

View file

@ -10,6 +10,8 @@ Canonical hierarchy::
DB_CALL (CLIENT) # its key/user/team lookups nest here
GUARDRAIL (INTERNAL) # request-lifecycle hook, sibling of LLM_CALL
LLM_CALL (CLIENT)
MCP_TOOL_CALL (CLIENT) # nests under the POST carrying the message
MCP_LIST_TOOLS (CLIENT) # (client-propagated context is a span link)
DB_CALL (CLIENT) # e.g. the spend-log write
Guardrails parent to PROXY_REQUEST, not LLM_CALL: pre/during/post-call guardrail
@ -18,14 +20,14 @@ before the LLM call even starts), so a guardrail is a sibling of the LLM call,
not a child of it. The emitter parents every span to the ambient OTel context
(the active server span), which matches this.
MCP spans (``MCP_TOOL_CALL``, ``MCP_LIST_TOOLS``) have two shapes, chosen at emit
time by :func:`resolve_mcp_span_context`. When the client propagates trace context
in ``params._meta`` MCP and the HTTP transport are independent contexts per the
OTel GenAI MCP semconv, so the span parents to that propagated context and records
the ``PROXY_REQUEST`` transport span as a span *link*, never a parent the shape
this registry's ``parent=None, links=PROXY_REQUEST`` entry encodes. When nothing is
propagated (the common case) the span nests under the transport span of the request
carrying that message, so the tool call stays in one trace.
MCP spans (``MCP_TOOL_CALL``, ``MCP_LIST_TOOLS``) are parented at emit time by
:func:`resolve_mcp_span_context`: they nest under the ``PROXY_REQUEST`` transport
span of the request carrying that message, so the tool call stays in one trace.
Trace context the client propagated in ``params._meta`` (SEP-414) is recorded as
a span *link*, never the parent a remote parent would root the span in a trace
whose root never reaches the gateway's tracing backend. Links always target that
remote client context, never a registry role, so ``SpanSpec`` declares no link
field; the concrete transport parent is resolved per message at emit time.
Not every service call becomes a span :func:`span_role_for_service` decides:
@ -85,25 +87,19 @@ class SpanSpec:
role: SpanRole
kind: LiteLLMSpanKind
parent: SpanRole | None
links: SpanRole | None = None
SPAN_REGISTRY: Final[dict[SpanRole, SpanSpec]] = {
SpanRole.PROXY_REQUEST: SpanSpec(SpanRole.PROXY_REQUEST, LiteLLMSpanKind.SERVER, parent=None),
SpanRole.LLM_CALL: SpanSpec(SpanRole.LLM_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
# The proxy is an MCP client to the upstream server, so MCP spans are CLIENT
# spans. With trace context propagated in ``params._meta``, MCP and the HTTP
# transport are independent contexts (OTel GenAI MCP semconv): the span parents
# to the propagated context and records the PROXY_REQUEST transport span as a
# span *link*, never a parent — the shape ``parent=None, links=PROXY_REQUEST``
# encodes. With nothing propagated, ``resolve_mcp_span_context`` nests the span
# under that message's transport span instead, keeping the call in one trace.
SpanRole.MCP_TOOL_CALL: SpanSpec(
SpanRole.MCP_TOOL_CALL, LiteLLMSpanKind.CLIENT, parent=None, links=SpanRole.PROXY_REQUEST
),
SpanRole.MCP_LIST_TOOLS: SpanSpec(
SpanRole.MCP_LIST_TOOLS, LiteLLMSpanKind.CLIENT, parent=None, links=SpanRole.PROXY_REQUEST
),
# spans. ``resolve_mcp_span_context`` nests them under the PROXY_REQUEST
# transport span of the request carrying that message (resolved per message at
# emit time), keeping the call in one trace. Trace context the client
# propagated in ``params._meta`` becomes a span *link* to that remote context,
# which is not a registry role, so ``SpanSpec`` has no link field.
SpanRole.MCP_TOOL_CALL: SpanSpec(SpanRole.MCP_TOOL_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
SpanRole.MCP_LIST_TOOLS: SpanSpec(SpanRole.MCP_LIST_TOOLS, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
SpanRole.GUARDRAIL: SpanSpec(SpanRole.GUARDRAIL, LiteLLMSpanKind.INTERNAL, parent=SpanRole.PROXY_REQUEST),
SpanRole.DB_CALL: SpanSpec(SpanRole.DB_CALL, LiteLLMSpanKind.CLIENT, parent=SpanRole.PROXY_REQUEST),
SpanRole.SERVICE: SpanSpec(SpanRole.SERVICE, LiteLLMSpanKind.INTERNAL, parent=SpanRole.PROXY_REQUEST),
@ -209,8 +205,8 @@ def service_span_name(data: "ServiceSpanData") -> str:
def root_roles() -> list[SpanRole]:
"""Roles with no in-process parent. They start a new trace unless they adopt a
remote parent (e.g. an MCP span joining the client's propagated context)."""
"""Roles with no in-process parent, i.e. they start a new trace (only the
instrumentor-owned ``PROXY_REQUEST`` server span today)."""
return [role for role, spec in SPAN_REGISTRY.items() if spec.parent is None]
@ -227,8 +223,6 @@ def validate_registry(
raise ValueError(f"SPAN_REGISTRY[{role}] has mismatched role {spec.role}")
if spec.parent is not None and spec.parent not in reg:
raise ValueError(f"span role {role} declares unknown parent {spec.parent}")
if spec.links is not None and spec.links not in reg:
raise ValueError(f"span role {role} declares unknown link target {spec.links}")
missing: Final = [role for role in SpanRole if role not in reg]
if missing:
raise ValueError(f"SPAN_REGISTRY is missing roles: {missing}")

View file

@ -57,8 +57,8 @@ def request_root_span() -> "Span | None":
# The W3C trace-context carrier (``traceparent``/``tracestate``/``baggage``) the
# MCP client propagated in the current request's ``params._meta``. The MCP gateway
# sets it per message so the MCP span can parent to the client's span rather than
# to the transport. A ``ContextVar`` because, like the root-span anchor, it must
# sets it per message so the MCP span can record the client's span as a span
# link. A ``ContextVar`` because, like the root-span anchor, it must
# ride the request task and be readable by the inline success-logging callback.
_mcp_message_trace_carrier: Final["ContextVar[Mapping[str, str] | None]"] = ContextVar(
"litellm_otel_mcp_message_trace_carrier", default=None
@ -148,10 +148,10 @@ def _mcp_transport_span_context() -> "SpanContext | None":
Prefers the transport the gateway published for this specific message; falls
back to the ambient request anchor for paths that emit an MCP span on the
request task itself (the REST MCP endpoints, the SDK). Parenting and linking
only need the immutable context, and unlike ``mcp_message_transport_span`` they
stay correct against a transport that has already finished, so this does not
require the span to still be recording.
request task itself (the REST MCP endpoints). Parenting needs only the
immutable context, and unlike ``mcp_message_transport_span`` it stays correct
against a transport that has already finished, so this does not require the
span to still be recording.
"""
published: Final = _mcp_message_transport_span.get()
if published is not None:
@ -222,25 +222,31 @@ def resolve_mcp_span_context(
) -> "tuple[Context, tuple[Link, ...]]":
"""Parent context + links for an MCP message span.
The span always nests under the transport span of the request carrying this
message, so a tool call and the ``POST`` that carried it stay in one trace.
The transport comes from :func:`_mcp_transport_span_context`, which is the
*current message's* POST rather than whatever request happened to open the
session, so a long-lived session does not glue every message under its first
request.
When the client propagates W3C trace context in the request's ``params._meta``
(SEP-414), MCP and the underlying transport are independent lifecycles one
streamable-HTTP session multiplexes many messages, and the client's own span is
the truthful parent. So, per the OTel GenAI MCP semconv:
(SEP-414), that remote context is recorded as a span *link*, never the parent.
The OTel GenAI MCP semconv prefers the inverse (remote parent, transport link),
but the gateway's tracing backend only ever receives the gateway's half of such
a trace: parenting into the client's trace id roots the span in a trace whose
root span never reaches the backend, so the span is unreachable from the trace
view and the transport transaction shows a dangling link (observed with
clients that propagate synthetic trace ids). Anchoring to the gateway's own
request and linking the client's context keeps every trace renderable while
preserving the client-side correlation.
* parent to the trace context the client propagated (a *remote* parent), and
* record the transport span as a *link*, never the parent.
Almost no client implements SEP-414 yet, so in practice nothing is propagated.
Rooting the span there splits a single tool call into two disconnected traces
joined only by a link, which is how it surfaces in APM: the ``POST`` transaction
and the ``tools/call`` span share no trace. With no remote parent to honor,
parent to the transport span of the request carrying this message instead, so
the call stays in one trace; no link is added since the transport is now the
real parent. The transport comes from :func:`_mcp_transport_span_context`, which
is the *current message's* POST rather than whatever request happened to open
the session, so a long-lived session does not glue every message under its
first request. With neither a remote parent nor a transport the returned context
carries no span and the span legitimately starts its own root trace.
With no transport at all the span starts its own root trace, still carrying
the link the client context is only ever a link, so this event keeps one
shape everywhere. Both returned contexts are built on an explicitly empty
base, so ambient (stale session) state can never leak in, and the span
inherits the transport's sampling decision exactly like every other
request-level span a client's sampled flag neither forces nor suppresses
recording.
Only trace context (``traceparent``/``tracestate``) is extracted, never the
client's W3C Baggage: ``params._meta`` is caller-controlled, and the otel
@ -251,13 +257,12 @@ def resolve_mcp_span_context(
never fall through to the ambient (stale session) span.
"""
source: Final = carrier if carrier is not None else _mcp_message_trace_carrier.get()
parent: Final = _PROPAGATOR.extract(dict(source or {}), context=Context())
propagated: Final = get_current_span(_PROPAGATOR.extract(dict(source or {}), context=Context()))
links: Final = (Link(propagated.get_span_context()),) if is_recordable_span(propagated) else ()
transport: Final = _mcp_transport_span_context()
if is_recordable_span(get_current_span(parent)):
return parent, (Link(transport),) if transport is not None else ()
if transport is not None:
return context_from_span(NonRecordingSpan(transport)), ()
return parent, ()
if transport is None:
return Context(), links
return context_from_span(NonRecordingSpan(transport), context=Context()), links
def is_recordable_span(obj: object) -> bool:

View file

@ -2,12 +2,13 @@
When a request carries team/key vendor credentials in
``standard_callback_dynamic_params``, or the key/team config resolved at auth
names a destination project, its spans must export through a
``TracerProvider`` whose OTLP headers carry those credentials / that project.
``TenantTracerCache`` builds and caches one provider per distinct
(credentials, project) pair, and otherwise hands back the logger's default
tracer. This lets a single logger fan requests out to many tenants without
needing a logger per tenant.
names a destination project or a service name, its spans must export through a
``TracerProvider`` whose OTLP headers carry those credentials / that project,
or whose Resource carries that ``service.name``. ``TenantTracerCache`` builds
and caches one provider per distinct (credentials, project, service name)
tuple, and otherwise hands back the logger's default tracer. This lets a
single logger fan requests out to many tenants without needing a logger per
tenant.
"""
import threading
@ -22,6 +23,7 @@ from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.trace import Tracer
from litellm._logging import verbose_logger
from litellm.constants import OTEL_SERVICE_NAME_METADATA_KEYS
from litellm.integrations.otel.model.config import ExporterSpec, OpenTelemetryV2Config
from litellm.integrations.otel.plumbing.providers import (
build_tracer_provider,
@ -65,8 +67,30 @@ _MAX_RETIRED_PROVIDERS: Final = 64
_HeaderItems: TypeAlias = tuple[tuple[str, str], ...]
_RouteKey: TypeAlias = tuple[_HeaderItems, _HeaderItems, str | None, str | None]
_NO_HEADERS: Final[Mapping[str, str]] = MappingProxyType({})
#: Key/team config fields naming the Resource ``service.name``, highest
#: precedence first. Read only from ``user_api_key_auth_metadata`` (the config
#: the proxy resolved at auth), never from client-supplied request metadata:
#: the service name picks the dataset/service traces land in (Honeycomb routes
#: datasets by it), so a caller must not be able to choose one.
_SERVICE_NAME_KEYS: Final = OTEL_SERVICE_NAME_METADATA_KEYS
def tenant_service_name(auth_metadata: Mapping[str, str] | None) -> str | None:
"""The per-request ``service.name`` override for this key/team, if any.
``None`` keeps the env-configured default (``OTEL_SERVICE_NAME``).
"""
if not auth_metadata:
return None
return next(
(stripped for key in _SERVICE_NAME_KEYS if (stripped := (auth_metadata.get(key) or "").strip())),
None,
)
def _shutdown_provider(provider: TracerProvider) -> None:
"""Flush + stop an evicted provider's processors (reclaims their threads).
@ -116,7 +140,7 @@ class TenantRoute:
class TenantTracerCache:
"""Credential/project-scoped ``TracerProvider`` cache keyed by the routing headers."""
"""Tenant-scoped ``TracerProvider`` cache keyed by routing headers and service name."""
def __init__(
self,
@ -131,7 +155,7 @@ class TenantTracerCache:
# thread-pool workers concurrently with the event loop, so cache
# updates, span counts, and retirement must be atomic.
self._lock: Final = threading.Lock()
self._providers: OrderedDict[tuple[_HeaderItems, _HeaderItems, str | None], TracerProvider] = (
self._providers: OrderedDict[_RouteKey, TracerProvider] = (
OrderedDict() # mutable-ok: bounded LRU; eviction needs in-place ordered mutation
)
self._open_span_counts: dict[TracerProvider, int] = {} # mutable-ok: live refcount state
@ -172,10 +196,11 @@ class TenantTracerCache:
) -> TenantRoute:
"""Return the tracer (and trace-detachment flag) for this request.
Use ``default`` unless the request's dynamic credentials or its key/team
project require a scoped tracer, in which case build (or reuse) one. The
cache is a bounded LRU: the least-recently-used provider is flushed and
shut down on overflow so its exporter threads don't accumulate.
Use ``default`` unless the request's dynamic credentials, its key/team
project, or its key/team service name require a scoped tracer, in
which case build (or reuse) one. The cache is a bounded LRU: the
least-recently-used provider is flushed and shut down on overflow so
its exporter threads don't accumulate.
A routed provider is returned already held its open-span count is
incremented in the same critical section as the cache update so a
@ -184,7 +209,8 @@ class TenantTracerCache:
"""
credential_headers: Final = dynamic_otlp_headers(self._callback_name, dynamic_params) or _NO_HEADERS
project_headers: Final = self._project_headers(auth_metadata)
if not credential_headers and not project_headers:
service_name: Final = tenant_service_name(auth_metadata)
if not credential_headers and not project_headers and service_name is None:
return TenantRoute(tracer=default, detached=False)
# A fixed per-integration region endpoint (New Relic us/eu), never a
# caller-supplied host; ``None`` keeps the preset's own endpoint.
@ -193,9 +219,12 @@ class TenantTracerCache:
tuple(sorted(credential_headers.items())),
tuple(sorted(project_headers.items())),
endpoint,
service_name,
)
with self._lock:
provider: Final = self._cached_provider_locked(cache_key, credential_headers, project_headers, endpoint)
provider: Final = self._cached_provider_locked(
cache_key, credential_headers, project_headers, endpoint, service_name
)
self._open_span_counts[provider] = self._open_span_counts.get(provider, 0) + 1
evicted: Final = self._evicted_on_overflow_locked()
if evicted is not None:
@ -208,16 +237,19 @@ class TenantTracerCache:
def _cached_provider_locked(
self,
cache_key: tuple[_HeaderItems, _HeaderItems, str | None],
cache_key: _RouteKey,
credential_headers: Mapping[str, str],
project_headers: Mapping[str, str],
endpoint: str | None,
service_name: str | None,
) -> TracerProvider:
cached: Final = self._providers.get(cache_key)
if cached is not None:
self._providers.move_to_end(cache_key)
return cached
built: Final = build_tracer_provider(self._routed_config(credential_headers, project_headers, endpoint))
built: Final = build_tracer_provider(
self._routed_config(credential_headers, project_headers, endpoint, service_name)
)
self._providers[cache_key] = built
return built
@ -267,6 +299,7 @@ class TenantTracerCache:
credential_headers: Mapping[str, str],
project_headers: Mapping[str, str],
endpoint: str | None = None,
service_name: str | None = None,
) -> OpenTelemetryV2Config:
"""Clone the config, rewriting headers on the callback's own exporter.
@ -285,7 +318,10 @@ class TenantTracerCache:
self._routed_exporter(spec, credential_headers, project_headers, endpoint)
for spec in self._config.exporters
]
return self._config.model_copy(update={"exporters": exporters})
update: Final = (
{"exporters": exporters} if service_name is None else {"exporters": exporters, "service_name": service_name}
)
return self._config.model_copy(update=update)
def _routed_exporter(
self,

View file

@ -2341,6 +2341,7 @@ def exception_type(
litellm_response_headers: Final = _get_response_headers(original_exception=original_exception)
try:
error_str = redact_string(str(original_exception)) if _ENABLE_SECRET_REDACTION else str(original_exception)
extra_information = ""
if model or custom_llm_provider:
if hasattr(original_exception, "message"):
error_str = (
@ -2357,7 +2358,6 @@ def exception_type(
# Common Extra information needed for all providers
# We pass num retries, api_base, vertex_deployment etc to the exception here
################################################################################
extra_information = ""
try:
_api_base: Final = litellm.get_api_base(model=model, optional_params=extra_kwargs)
messages: Final = litellm.get_first_chars_messages(kwargs=completion_kwargs)

View file

@ -7,7 +7,9 @@ from typing import Any, Final, Literal
import litellm
from litellm.constants import OPENAI_FILE_SEARCH_COST_PER_1K_CALLS
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
from litellm.litellm_core_utils.llm_cost_calc.utils import (
get_web_search_requests_from_usage,
)
from litellm.types.llms.openai import (
FileSearchTool,
ResponsesAPIResponse,
@ -368,7 +370,7 @@ class StandardBuiltInToolCostTracking:
get_anthropic_web_search_requests_from_response,
)
if usage is not None and (get_web_search_requests(getattr(usage, "server_tool_use", None)) is not None):
if usage is not None and (get_web_search_requests_from_usage(usage) is not None):
return usage
web_search_requests: Final = get_anthropic_web_search_requests_from_response(response_object)
if web_search_requests is None:
@ -416,7 +418,7 @@ class StandardBuiltInToolCostTracking:
# Anthropic Claude (direct API and Vertex AI) uses server_tool_use.web_search_requests.
# Without this check, Claude ModelResponse always falls through to return False
# and _handle_web_search_cost() is never called.
if hasattr(usage, "server_tool_use") and get_web_search_requests(usage.server_tool_use) is not None:
if get_web_search_requests_from_usage(usage) is not None:
return True
# xAI reports usage.server_side_tool_usage_details.web_search_calls; a searched
# answer with no url_citation annotations has no other chat-path signal
@ -429,16 +431,12 @@ class StandardBuiltInToolCostTracking:
response_object=response_object, output_type="web_search_call"
)
elif usage is not None:
if (
hasattr(usage, "server_tool_use")
and get_web_search_requests(usage.server_tool_use) is not None
or (
hasattr(usage, "prompt_tokens_details")
and usage.prompt_tokens_details is not None
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
and hasattr(usage.prompt_tokens_details, "web_search_requests")
and usage.prompt_tokens_details.web_search_requests is not None
)
if get_web_search_requests_from_usage(usage) is not None or (
hasattr(usage, "prompt_tokens_details")
and usage.prompt_tokens_details is not None
and isinstance(usage.prompt_tokens_details, PromptTokensDetailsWrapper)
and hasattr(usage.prompt_tokens_details, "web_search_requests")
and usage.prompt_tokens_details.web_search_requests is not None
):
return True
if _usage_reports_server_side_web_search_calls(usage):

View file

@ -92,6 +92,16 @@ def get_web_search_requests(server_tool_use: Any) -> int | None:
return getattr(server_tool_use, "web_search_requests", None)
def get_web_search_requests_from_usage(usage: Usage) -> int | None:
"""Read ``web_search_requests`` from a ``Usage``'s ``server_tool_use``.
``Usage`` deletes unset optional fields from ``__dict__`` (see
``SafeAttributeModel``), so direct attribute access can raise
``AttributeError``; ``getattr`` with a default is required here.
"""
return get_web_search_requests(getattr(usage, "server_tool_use", None))
def _is_above_128k(tokens: float) -> bool:
if tokens > 128000:
return True

View file

@ -330,6 +330,24 @@ class RealTimeStreaming:
except (AttributeError, TypeError):
pass
def _flush_unbilled_transcription_usage(self) -> None:
if self.provider_config is None:
return
usage: Final = self.provider_config.unbilled_usage_on_session_close(self.model)
if usage is None:
return
flush_event: Final = (
cast( # cast-ok: usage-only partial event, the same shape _capture_transcription_usage logs
OpenAIRealtimeEvents,
{
"type": "conversation.item.input_audio_transcription.completed",
"usage": usage,
},
)
)
self.store_message(flush_event)
self._capture_transcription_usage(flush_event)
def _collect_tool_calls_from_response_done(self, event_obj: dict | OpenAIRealtimeEvents) -> None:
"""Extract function_call items from response.done events for spend logging."""
try:
@ -955,6 +973,7 @@ class RealTimeStreaming:
transcript = event.get("transcript", "")
self._collect_user_input_from_backend_event(cast(dict, event))
self.store_message(event_str)
self._capture_transcription_usage(event)
await self._send_event_to_client(event, event_str)
blocked = await self.run_realtime_guardrails(
cast(str, transcript),
@ -1068,6 +1087,7 @@ class RealTimeStreaming:
except Exception as e:
verbose_logger.exception("Error in backend to client send messages: %s", e)
finally:
self._flush_unbilled_transcription_usage()
await self.log_messages()
@staticmethod

View file

@ -10,7 +10,7 @@ from pydantic import BaseModel, ValidationError
from litellm.litellm_core_utils.llm_cost_calc.utils import (
generic_cost_per_token,
get_provider_specific_geo_multiplier,
get_web_search_requests,
get_web_search_requests_from_usage,
)
if TYPE_CHECKING:
@ -104,7 +104,7 @@ def get_cost_for_anthropic_web_search(
if usage is None:
return 0.0
web_search_requests: Final = get_web_search_requests(getattr(usage, "server_tool_use", None))
web_search_requests: Final = get_web_search_requests_from_usage(usage)
if web_search_requests is None:
return 0.0

View file

@ -1358,12 +1358,10 @@ class LiteLLMAnthropicMessagesAdapter:
@classmethod
def _get_web_search_request_count(cls, usage: Usage) -> int:
from litellm.litellm_core_utils.llm_cost_calc.utils import (
get_web_search_requests,
get_web_search_requests_from_usage,
)
from_server_tool_use: Final = cls._positive_int(
get_web_search_requests(getattr(usage, "server_tool_use", None))
)
from_server_tool_use: Final = cls._positive_int(get_web_search_requests_from_usage(usage))
if from_server_tool_use > 0:
return from_server_tool_use
return cls._first_positive_prompt_tokens_detail_value(usage, ("web_search_requests",))

View file

@ -5,6 +5,7 @@ import httpx
from litellm.types.llms.openai import OpenAIRealtimeStreamSessionEvents
from litellm.types.realtime import (
RealtimeInputAudioTranscriptionUsage,
RealtimeResponseTransformInput,
RealtimeResponseTypedDict,
)
@ -70,6 +71,9 @@ class BaseRealtimeConfig(ABC):
def session_configuration_request(self, model: str) -> str | None: # message sent to setup the realtime session
return None
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
return None
def transform_session_created_event(
self,
model: str,

View file

@ -1434,9 +1434,12 @@ class BaseAWSLLM:
data: str | bytes,
headers: dict,
api_key: str | None = None,
supports_bearer_token: bool = True,
) -> AWSPreparedRequest:
if api_key is not None:
aws_bearer_token: str | None = api_key
if not supports_bearer_token:
aws_bearer_token: str | None = None
elif api_key is not None:
aws_bearer_token = api_key
else:
aws_bearer_token = get_secret_str("AWS_BEARER_TOKEN_BEDROCK")

View file

@ -138,11 +138,6 @@ class BedrockRerankHandler(BaseAWSLLM):
data: dict,
optional_params: dict,
) -> BedrockPreparedRequest:
try:
from botocore.auth import SigV4Auth
from botocore.awsrequest import AWSRequest
except ImportError:
raise ImportError("Missing boto3 to call bedrock. Run 'pip install boto3'.")
boto3_credentials_info: Final = self._get_boto_credentials_from_optional_params(optional_params, model)
### SET RUNTIME ENDPOINT ###
@ -153,24 +148,21 @@ class BedrockRerankHandler(BaseAWSLLM):
)
proxy_endpoint_url = proxy_endpoint_url.replace("bedrock-runtime", "bedrock-agent-runtime")
proxy_endpoint_url = f"{proxy_endpoint_url}/rerank"
sigv4: Final = SigV4Auth(
boto3_credentials_info.credentials,
"bedrock",
boto3_credentials_info.aws_region_name,
)
# Make POST Request
body: Final = json.dumps(data).encode("utf-8")
body: Final = json.dumps(data).encode("utf-8")
headers = {"Content-Type": "application/json"}
if extra_headers is not None:
headers = {"Content-Type": "application/json", **extra_headers}
request: Final = AWSRequest(method="POST", url=proxy_endpoint_url, data=body, headers=headers)
sigv4.add_auth(request)
if (
extra_headers is not None and "Authorization" in extra_headers
): # prevent sigv4 from overwriting the auth header
request.headers["Authorization"] = extra_headers["Authorization"]
prepped: Final = request.prepare()
prepped: Final = self.get_request_headers(
credentials=boto3_credentials_info.credentials,
aws_region_name=boto3_credentials_info.aws_region_name,
extra_headers=extra_headers,
endpoint_url=proxy_endpoint_url,
data=body,
headers=headers,
supports_bearer_token=False,
)
return BedrockPreparedRequest(
endpoint_url=proxy_endpoint_url,

View file

@ -0,0 +1,250 @@
import base64
from collections.abc import Mapping, Sequence
from typing import Final
from httpx import Headers, Response
from litellm.litellm_core_utils.audio_utils.utils import (
normalize_transcription_language_to_bcp47,
process_audio_file,
)
from litellm.llms.base_llm.audio_transcription.transformation import (
AudioTranscriptionRequestData,
BaseAudioTranscriptionConfig,
)
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.llms.gemini.common_utils import GeminiError, GeminiModelInfo
from litellm.types.llms.gemini_audio_transcription import (
GeminiTranscriptionAudioInput,
GeminiTranscriptionConfig,
GeminiTranscriptionInteractionRequest,
GeminiTranscriptionInteractionResponse,
GeminiTranscriptionWordAnnotation,
)
from litellm.types.llms.openai import (
AllMessageValues,
OpenAIAudioTranscriptionOptionalParams,
)
from litellm.types.utils import (
FileTypes,
TranscriptionResponse,
TranscriptionUsageInputTokenDetailsObject,
TranscriptionUsageTokensObject,
)
INTERACTIONS_API_REVISION: Final = "2026-05-20"
WORD_INFO_ANNOTATION_TYPE: Final = "word_info"
class GeminiAudioTranscriptionConfig(BaseAudioTranscriptionConfig):
"""
Maps OpenAI /v1/audio/transcriptions onto the Gemini Interactions API
(POST /v1beta/interactions) for transcription models like
gemini-3.5-transcribe. https://ai.google.dev/gemini-api/docs/transcribe
"""
def get_supported_openai_params(
self, model: str
) -> list[OpenAIAudioTranscriptionOptionalParams]: # mutable-ok: BaseAudioTranscriptionConfig signature
return ["language", "response_format", "timestamp_granularities"] # mutable-ok: base contract returns a list
def map_openai_params(
self,
non_default_params: Mapping[str, object],
optional_params: Mapping[str, object],
model: str,
drop_params: bool,
) -> dict: # mutable-ok: BaseAudioTranscriptionConfig signature
supported_params: Final = frozenset(self.get_supported_openai_params(model))
accepted: Final = tuple((k, v) for k, v in non_default_params.items() if k in supported_params)
return dict((*optional_params.items(), *accepted)) # mutable-ok: base contract returns a plain dict
def get_error_class(
self,
error_message: str,
status_code: int,
headers: dict | Headers, # mutable-ok: base signature and BaseLLMException take dict | Headers
) -> BaseLLMException:
return GeminiError(status_code=status_code, message=error_message, headers=headers)
def validate_environment(
self,
headers: Mapping[str, str],
model: str,
messages: Sequence[AllMessageValues],
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
api_key: str | None = None,
api_base: str | None = None,
) -> dict: # mutable-ok: BaseAudioTranscriptionConfig signature
resolved_api_key: Final = GeminiModelInfo.get_api_key(api_key)
if not resolved_api_key:
raise GeminiError(
status_code=401,
message="Google API key is required. Set GOOGLE_API_KEY or GEMINI_API_KEY environment variable.",
)
return { # mutable-ok: the http handler passes these headers straight to httpx
**headers,
"Content-Type": "application/json",
"x-goog-api-key": resolved_api_key,
"Api-Revision": INTERACTIONS_API_REVISION,
}
def get_complete_url(
self,
api_base: str | None,
api_key: str | None,
model: str,
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
stream: bool | None = None,
) -> str:
resolved_api_base: Final = GeminiModelInfo.get_api_base(api_base)
return f"{resolved_api_base}/v1beta/interactions"
def transform_audio_transcription_request(
self,
model: str,
audio_file: FileTypes,
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
) -> AudioTranscriptionRequestData:
processed_audio: Final = process_audio_file(audio_file)
audio_input: Final = GeminiTranscriptionAudioInput(
type="audio",
data=base64.b64encode(processed_audio.file_content).decode("utf-8"),
mime_type=processed_audio.content_type,
)
request: Final = _build_interaction_request(
model=model,
audio_input=audio_input,
transcription_config=_build_transcription_config(optional_params),
)
return AudioTranscriptionRequestData(data=dict(request)) # mutable-ok: AudioTranscriptionRequestData wants dict
def transform_audio_transcription_response(
self,
raw_response: Response,
) -> TranscriptionResponse:
try:
response_json: Final = raw_response.json()
except ValueError:
raise GeminiError(
status_code=raw_response.status_code,
message=f"Received non-JSON response from Gemini Interactions API: {raw_response.text}",
)
parsed: Final = GeminiTranscriptionInteractionResponse.model_validate(response_json)
if parsed.status != "completed":
raise GeminiError(
status_code=raw_response.status_code,
message=f"Gemini transcription interaction did not complete (status={parsed.status}): {raw_response.text}",
)
text_contents: Final = tuple(
content
for step in parsed.steps
for content in step.content
if content.type == "text" and content.text is not None
)
response: Final = TranscriptionResponse(text=" ".join(content.text or "" for content in text_contents))
response["task"] = "transcribe"
words: Final = tuple(
word
for content in text_contents
for annotation in content.annotations
if (word := _annotation_to_word(annotation)) is not None
)
if words:
response["words"] = list(words) # mutable-ok: verbose_json words is a JSON array
last_word_end: Final = words[-1].get("end")
if last_word_end is not None:
response["duration"] = last_word_end
if parsed.usage is not None:
audio_tokens: Final = sum(
by_modality.tokens
for by_modality in parsed.usage.input_tokens_by_modality
if by_modality.modality == "audio"
)
response.usage = TranscriptionUsageTokensObject(
type="tokens",
input_tokens=parsed.usage.total_input_tokens,
output_tokens=parsed.usage.total_output_tokens,
total_tokens=parsed.usage.total_tokens,
input_token_details=TranscriptionUsageInputTokenDetailsObject(
audio_tokens=audio_tokens,
text_tokens=parsed.usage.total_input_tokens - audio_tokens,
),
)
return response
_EMPTY_TRANSCRIPTION_CONFIG: Final[GeminiTranscriptionConfig] = {}
_WORD_TIMESTAMP_CONFIG: Final[GeminiTranscriptionConfig] = {
"mode": {
"type": "verbatim",
"timestamp_granularities": ("word",),
"diarization_mode": "speaker",
},
}
def _build_interaction_request(
model: str,
audio_input: GeminiTranscriptionAudioInput,
transcription_config: GeminiTranscriptionConfig,
) -> GeminiTranscriptionInteractionRequest:
if not transcription_config:
bare_request: Final[GeminiTranscriptionInteractionRequest] = {
"model": model.removeprefix("gemini/"),
"input": (audio_input,),
}
return bare_request
configured_request: Final[GeminiTranscriptionInteractionRequest] = {
"model": model.removeprefix("gemini/"),
"input": (audio_input,),
"generation_config": {"transcription_config": transcription_config},
}
return configured_request
def _language_config(language: object) -> GeminiTranscriptionConfig:
if not isinstance(language, str) or not language:
return _EMPTY_TRANSCRIPTION_CONFIG
language_config: Final[GeminiTranscriptionConfig] = {
"language_codes": (normalize_transcription_language_to_bcp47(language),),
}
return language_config
def _timestamp_config(timestamp_granularities: object) -> GeminiTranscriptionConfig:
if isinstance(timestamp_granularities, list) and "word" in timestamp_granularities:
return _WORD_TIMESTAMP_CONFIG
return _EMPTY_TRANSCRIPTION_CONFIG
def _build_transcription_config(optional_params: Mapping[str, object]) -> GeminiTranscriptionConfig:
transcription_config: Final[GeminiTranscriptionConfig] = {
**_language_config(optional_params.get("language")),
**_timestamp_config(optional_params.get("timestamp_granularities")),
}
return transcription_config
def _annotation_to_word(annotation: GeminiTranscriptionWordAnnotation) -> Mapping[str, str | float] | None:
if annotation.type != WORD_INFO_ANNOTATION_TYPE or annotation.text is None:
return None
entries: Final = (
("word", annotation.text),
("start", _parse_offset_seconds(annotation.start_offset)),
("end", _parse_offset_seconds(annotation.end_offset)),
("speaker", annotation.speaker),
)
return {key: value for key, value in entries if value is not None} # mutable-ok: word entries serialize to JSON
def _parse_offset_seconds(offset: str | None) -> float | None:
if offset is None or not offset.endswith("s"):
return None
try:
return float(offset[:-1])
except ValueError:
return None

View file

@ -39,7 +39,9 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa
``model_info`` when available, falling back to $0.035 for models not
yet updated in the pricing JSON.
"""
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
from litellm.litellm_core_utils.llm_cost_calc.utils import (
get_web_search_requests_from_usage,
)
from litellm.types.utils import PromptTokensDetailsWrapper
_DEFAULT_COST: Final = 35e-3
@ -57,7 +59,7 @@ def cost_per_web_search_request(usage: "Usage", model_info: "ModelInfo") -> floa
)
else None
)
requests_from_server_tool_use: Final = get_web_search_requests(getattr(usage, "server_tool_use", None))
requests_from_server_tool_use: Final = get_web_search_requests_from_usage(usage)
number_of_web_search_requests: Final = requests_from_prompt_details or requests_from_server_tool_use or 0
billing_mode: Final = model_info.get("web_search_billing_unit") or "per_prompt"

View file

@ -4,7 +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 collections.abc import Mapping, Sequence
from typing import Any, Final, cast
import litellm
@ -53,6 +53,7 @@ from litellm.types.llms.vertex_ai import (
)
from litellm.types.realtime import (
ALL_DELTA_TYPES,
RealtimeInputAudioTranscriptionUsage,
RealtimeModalityResponseTransformOutput,
RealtimeResponseTransformInput,
RealtimeResponseTypedDict,
@ -95,6 +96,18 @@ def _gemini_live_speech_config(voice: object) -> Mapping[str, object] | None:
return VertexGeminiConfig()._map_audio_params({"voice": voice})
# Google bills Live transcription at an estimated 25 audio tokens/sec of input and
# 175 text tokens/min of output (ai.google.dev/gemini-api/docs/pricing).
GEMINI_LIVE_TRANSCRIBE_AUDIO_TOKENS_PER_SECOND: Final = 25
GEMINI_LIVE_TRANSCRIBE_OUTPUT_TEXT_TOKENS_PER_MINUTE: Final = 175
PCM16_INPUT_AUDIO_BYTES_PER_SECOND: Final = 48000
def _base64_decoded_byte_count(data: str) -> int:
padding: Final = 2 if data.endswith("==") else 1 if data.endswith("=") else 0
return max(len(data) * 3 // 4 - padding, 0)
class GeminiRealtimeConfig(BaseRealtimeConfig):
_TOOL_CALL_ID_TO_NAME_MAX = 256 # LRU cap for call_id→name mapping
@ -104,6 +117,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
# Gemini Live sometimes emits usageMetadata in a standalone frame between
# turns; buffer it here so the next response.done carries the token counts.
self._pending_usage_metadata: dict | None = None
self._unbilled_input_audio_bytes: int = 0
def is_setup_message(self, msg_obj: dict) -> bool:
return "setup" in msg_obj
@ -384,17 +398,25 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
return bool(entry.get("gemini_native_audio") or entry.get("gemini_audio_only_live"))
@staticmethod
def _coerce_response_modalities(model: str, modalities: list[Any]) -> list[str]:
"""Map unsupported TEXT responseModalities to AUDIO for audio-only Live models."""
normalized: Final = [
def _is_text_only_live_model(model: str) -> bool:
return GeminiRealtimeConfig._model_cost_entry(model).get("mode") == "audio_transcription"
@staticmethod
def _default_response_modality(model: str) -> GeminiResponseModalities:
return "TEXT" if GeminiRealtimeConfig._is_text_only_live_model(model) else "AUDIO"
@staticmethod
def _coerce_response_modalities(model: str, modalities: Sequence[Any]) -> tuple[str, ...]:
"""Swap responseModalities a Live model cannot produce: TEXT to AUDIO for
audio-only models, AUDIO to TEXT for text-only ones (e.g. transcribe-live)."""
normalized: Final = tuple(
modality.upper() if isinstance(modality, str) else str(modality).upper() for modality in modalities
]
if not GeminiRealtimeConfig._is_audio_only_live_model(model):
return normalized
if "TEXT" not in normalized:
return normalized
without_text: Final = [modality for modality in normalized if modality != "TEXT"]
return without_text if without_text else ["AUDIO"]
)
if GeminiRealtimeConfig._is_audio_only_live_model(model) and "TEXT" in normalized:
return tuple(modality for modality in normalized if modality != "TEXT") or ("AUDIO",)
if GeminiRealtimeConfig._is_text_only_live_model(model) and "AUDIO" in normalized:
return tuple(modality for modality in normalized if modality != "AUDIO") or ("TEXT",)
return normalized
@staticmethod
def _finalize_gemini_live_setup(model: str, setup: dict[str, Any]) -> dict[str, Any]:
@ -436,7 +458,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
if session_configuration_request is None:
generation_config: Final = new_overrides.setdefault("generationConfig", {})
generation_config.setdefault("responseModalities", ["AUDIO"])
generation_config.setdefault("responseModalities", [GeminiRealtimeConfig._default_response_modality(model)])
new_overrides.setdefault("inputAudioTranscription", {})
new_overrides["model"] = f"models/{model}"
verbose_logger.debug("Gemini Realtime: Sending initial setup with tools to backend")
@ -558,9 +580,10 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
return self._handle_conversation_item(json_message)
if msg_type == "input_audio_buffer.append":
realtime_input_dict["audio"] = HttpxBlobType(
mimeType=self.get_audio_mime_type(), data=json_message["audio"]
)
audio_b64: Final = json_message["audio"]
if isinstance(audio_b64, str):
self._unbilled_input_audio_bytes += _base64_decoded_byte_count(audio_b64)
realtime_input_dict["audio"] = HttpxBlobType(mimeType=self.get_audio_mime_type(), data=audio_b64)
realtime_input_dict = cast(
BidiGenerateContentRealtimeInput,
@ -1151,6 +1174,26 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
raise ValueError(f"Unknown openai event: {key}, value: {value}")
return openai_event
def _consume_input_transcription_usage_estimate(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
"""Gemini Live sends no usageMetadata for transcribe sessions; estimate billing from streamed audio duration."""
if self._unbilled_input_audio_bytes <= 0 or not self._is_text_only_live_model(model):
return None
audio_seconds: Final = self._unbilled_input_audio_bytes / PCM16_INPUT_AUDIO_BYTES_PER_SECOND
self._unbilled_input_audio_bytes = 0
audio_tokens: Final = round(audio_seconds * GEMINI_LIVE_TRANSCRIBE_AUDIO_TOKENS_PER_SECOND)
output_tokens: Final = round(audio_seconds * GEMINI_LIVE_TRANSCRIBE_OUTPUT_TEXT_TOKENS_PER_MINUTE / 60)
usage: Final[RealtimeInputAudioTranscriptionUsage] = {
"type": "tokens",
"input_tokens": audio_tokens,
"output_tokens": output_tokens,
"total_tokens": audio_tokens + output_tokens,
"input_token_details": {"text_tokens": 0, "audio_tokens": audio_tokens},
}
return usage
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
return self._consume_input_transcription_usage_estimate(model)
def transform_realtime_response(
self,
message: str | bytes,
@ -1190,6 +1233,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
if isinstance(server_content, dict):
input_tx: Final = server_content.get("inputTranscription")
if isinstance(input_tx, dict) and input_tx.get("text"):
transcription_usage: Final = self._consume_input_transcription_usage_estimate(model)
returned_message.append(
cast(
OpenAIRealtimeEvents,
@ -1199,6 +1243,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
"transcript": input_tx["text"],
"item_id": f"item_{uuid.uuid4()}",
"content_index": 0,
**({} if transcription_usage is None else {"usage": transcription_usage}),
},
)
)
@ -1235,6 +1280,12 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
)
)
# Transcription-only models emit generationComplete with no prior
# modelTurn delta; there is no started OpenAI response to close, so
# drop it and let siblings (turnComplete, usageMetadata) process.
if current_delta_type is None and "modelTurn" not in server_content:
server_content.pop("generationComplete", None)
# Mark transcription-only serverContent as handled so the main loop
# skips it; sibling keys like toolCall are still processed below.
_model_content_keys: Final = {
@ -1583,7 +1634,9 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
```
"""
response_modalities: Final[list[GeminiResponseModalities]] = ["AUDIO"]
response_modalities: Final[list[GeminiResponseModalities]] = [
GeminiRealtimeConfig._default_response_modality(model)
]
output_audio_transcription: Final = False
# if "audio" in model: ## UNCOMMENT THIS WHEN AUDIO IS SUPPORTED
# output_audio_transcription = True

View file

@ -4,7 +4,8 @@ Translates from OpenAI's `/v1/chat/completions` to Together AI's `/v1/chat/compl
Docs: https://docs.together.ai/docs/chat-overview
"""
from collections.abc import Callable, Container, Coroutine
from collections.abc import Callable, Container, Coroutine, Mapping
from types import MappingProxyType
from typing import (
Final,
Literal,
@ -12,11 +13,13 @@ from typing import (
overload,
)
from typing_extensions import ReadOnly, TypedDict
import litellm
from litellm._logging import verbose_logger
from litellm.exceptions import UnsupportedParamsError
from litellm.types.llms.openai import AllMessageValues
from litellm.utils import supports_function_calling, supports_response_schema
from litellm.utils import supports_function_calling, supports_reasoning, supports_response_schema
from ...openai.chat.gpt_transformation import OpenAIGPTConfig
@ -38,6 +41,34 @@ def _registry_verdict(model: str, flag: str, check: Callable[[str], bool]) -> bo
return None
ADJUSTABLE_EFFORT_REASONING_MODELS: Final = frozenset(
{
"openai/gpt-oss-120b",
"openai/gpt-oss-20b",
}
)
HYBRID_REASONING_MODELS: Final = frozenset(
{
"MiniMaxAI/MiniMax-M3",
"Qwen/Qwen3.5-9B",
"Qwen/Qwen3.6-Plus",
"deepseek-ai/DeepSeek-V4-Pro",
"moonshotai/Kimi-K3",
"nvidia/nemotron-3-ultra-550b-a55b",
"zai-org/GLM-5.2",
}
)
HIGH_MAX_EFFORT_MODEL_PREFIX: Final = "deepseek-ai/DeepSeek-V4-Pro"
EFFORT_TRANSLATION: Final = MappingProxyType({"minimal": "low", "xhigh": "high", "max": "high"})
HIGH_MAX_EFFORT_TRANSLATION: Final = MappingProxyType(
{"minimal": "high", "low": "high", "medium": "high", "xhigh": "max"}
)
class TogetherReasoningToggle(TypedDict):
enabled: ReadOnly[bool]
def _function_calling_verdict(model: str) -> bool | None:
return _registry_verdict(
model,
@ -83,6 +114,36 @@ def _tool_params_to_drop(passed_params: Container[str], model: str, drop_params:
)
def _supports_together_reasoning(model: str) -> bool:
if model in ADJUSTABLE_EFFORT_REASONING_MODELS or model in HYBRID_REASONING_MODELS:
return True
if model.startswith(HIGH_MAX_EFFORT_MODEL_PREFIX):
return True
return supports_reasoning(model, custom_llm_provider="together_ai")
def _adjustable_effort(effort: str, model: str) -> str:
if effort == "none":
verbose_logger.debug(
"together_ai model %s cannot disable reasoning; mapping reasoning_effort=none to low", model
)
return "low"
return EFFORT_TRANSLATION.get(effort, effort)
def _reasoning_effort_payload(effort: str, model: str) -> Mapping[str, object]:
if effort == "default":
return MappingProxyType({})
if model in ADJUSTABLE_EFFORT_REASONING_MODELS:
return MappingProxyType({"reasoning_effort": _adjustable_effort(effort, model)})
if effort == "none":
disable_reasoning: Final[TogetherReasoningToggle] = {"enabled": False}
return MappingProxyType({"reasoning": disable_reasoning})
if model.startswith(HIGH_MAX_EFFORT_MODEL_PREFIX):
return MappingProxyType({"reasoning_effort": HIGH_MAX_EFFORT_TRANSLATION.get(effort, effort)})
return MappingProxyType({"reasoning_effort": EFFORT_TRANSLATION.get(effort, effort)})
def _drop_response_format(passed_params: Container[str], model: str, drop_params: bool) -> bool:
if "response_format" not in passed_params:
return False
@ -153,6 +214,15 @@ class TogetherAIChatConfig(OpenAIGPTConfig):
return super()._transform_messages(stripped, model, is_async=True)
return super()._transform_messages(stripped, model, is_async=False)
def get_supported_openai_params(self, model: str) -> list: # mutable-ok: inherited contract
supported_params: Final = super().get_supported_openai_params(model)
if not _supports_together_reasoning(model):
return supported_params
return [ # mutable-ok: the inherited contract returns a plain list; building fresh avoids mutating the base class's value
*supported_params,
"reasoning_effort",
]
def map_openai_params(
self,
non_default_params: dict,
@ -165,4 +235,10 @@ class TogetherAIChatConfig(OpenAIGPTConfig):
mapped_openai_params.pop(param)
if _drop_response_format(mapped_openai_params, model, drop_params):
mapped_openai_params.pop("response_format")
effort: Final = mapped_openai_params.get("reasoning_effort")
if not isinstance(effort, str):
return mapped_openai_params
mapped_openai_params.pop("reasoning_effort")
for key, value in _reasoning_effort_payload(effort, model).items():
mapped_openai_params.setdefault(key, value)
return mapped_openai_params

View file

@ -1219,6 +1219,7 @@ def _register_custom_pricing_for_request(
shared_key: CustomPricingLiteLLMParams.strip_custom_pricing_fields(entry),
},
persist_across_reloads=False,
warning_display_name=shared_key,
)

File diff suppressed because it is too large Load diff

View file

@ -34,6 +34,7 @@ from mcp.types import (
)
from mcp.types import Tool as MCPTool
from pydantic import AnyUrl, BaseModel
from typing_extensions import ReadOnly
import litellm
from litellm._logging import verbose_logger
@ -72,6 +73,7 @@ from litellm.proxy._experimental.mcp_server.oauth2_token_cache import (
MCPPerUserTokenCache,
mcp_per_user_token_cache,
resolve_mcp_auth,
resolved_token_header,
)
from litellm.proxy._experimental.mcp_server.oauth_utils import (
_redact_mcp_resource_url,
@ -99,6 +101,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchange_
build_token_exchanger,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
DEFAULT_CREDENTIAL_HEADER,
AuthorizationCodeConfig,
ClientCredentialsConfig,
CredError,
@ -153,6 +156,8 @@ from litellm.types.mcp import (
MCPAuth,
MCPStdioConfig,
MCPTokenEndpointAuthMethod,
has_header,
without_header,
)
from litellm.types.mcp_server.mcp_server_manager import (
MCPInfo,
@ -349,6 +354,7 @@ class MCPServerConfig(TypedDict, total=False):
audience: str
subject_token_type: str
upstream_resource: str
upstream_token_header: ReadOnly[str]
id_jag_resource_token_endpoint: str
id_jag_resource: str
client_private_key: str
@ -828,18 +834,6 @@ def _should_strip_caller_authorization(
)
def _without_authorization(
headers: dict[str, str] | None,
) -> dict[str, str] | None:
"""A copy of ``headers`` with any ``Authorization`` key removed (case-insensitive), or
None if nothing remains. Drops only the credential, keeping other forwarded headers.
"""
if not headers:
return None
filtered: Final = {k: v for k, v in headers.items() if k.lower() != "authorization"}
return filtered or None
def _format_byok_openapi_auth_header(mcp_server: MCPServer, mcp_auth_header: str) -> str:
"""Format a raw BYOK credential for OpenAPI tool ``Authorization`` injection.
@ -914,7 +908,9 @@ def _resolve_openapi_tool_auth(
if isinstance(per_server, dict):
authorization: Final = next((v for k, v in per_server.items() if k.lower() == "authorization"), None)
merged: Final = merge_mcp_headers(extra_headers=forwarded, static_headers=_without_authorization(per_server))
merged: Final = merge_mcp_headers(
extra_headers=forwarded, static_headers=without_header(per_server, DEFAULT_CREDENTIAL_HEADER)
)
if authorization is None:
byok: Final = _format_byok_openapi_auth_header(mcp_server, mcp_auth_header) if mcp_auth_header else None
return byok, merged, mcp_auth_header
@ -981,7 +977,7 @@ def _client_forwarded_authorization_headers(
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
):
return _without_authorization(extra_headers)
return without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER)
return extra_headers
@ -994,7 +990,7 @@ def _take_forwarded_authorization(
if not headers:
return None, headers
value: Final = next((v for k, v in headers.items() if k.lower() == "authorization"), None)
return value, _without_authorization(headers)
return value, without_header(headers, DEFAULT_CREDENTIAL_HEADER)
def _passthrough_token_from_mcp_auth_header(
@ -2166,6 +2162,7 @@ class MCPServerManager:
DEFAULT_SUBJECT_TOKEN_TYPE,
),
upstream_resource=server_config.get("upstream_resource", None),
upstream_token_header=server_config.get("upstream_token_header", None),
# ID-JAG fields
id_jag_resource_token_endpoint=server_config.get("id_jag_resource_token_endpoint", None),
id_jag_resource=server_config.get("id_jag_resource", None),
@ -2698,6 +2695,7 @@ class MCPServerManager:
or (credentials_dict.get("subject_token_type") if credentials_dict else None)
or DEFAULT_SUBJECT_TOKEN_TYPE,
upstream_resource=(credentials_dict.get("upstream_resource") if credentials_dict else None),
upstream_token_header=(credentials_dict.get("upstream_token_header") if credentials_dict else None),
# ID-JAG fields — read from credentials JSON blob
id_jag_resource_token_endpoint=(
credentials_dict.get("id_jag_resource_token_endpoint") if credentials_dict else None
@ -3525,10 +3523,9 @@ class MCPServerManager:
case Ok(auth):
# NoOpAuth has no header_name and so never conflicts.
header_name: Final[str | None] = getattr(auth, "header_name", None)
conflicts: Final = bool(
header_name and extra_headers and any(key.lower() == header_name.lower() for key in extra_headers)
)
if not conflicts:
if header_name is None or not extra_headers:
return auth, extra_headers
if not has_header(extra_headers, header_name):
return auth, extra_headers
if isinstance(
spec.config,
@ -3540,9 +3537,10 @@ class MCPServerManager:
# guardrail such as MCPJWTSigner, static_headers, or any other injected
# Authorization must NOT shadow it (otherwise the upstream gets e.g. the
# signer's JWT instead of the minted token and rejects it, and for M2M the
# one-shot 401 refetch is lost with it). Drop the conflicting header so the
# resolved token reaches upstream.
return auth, _without_authorization(extra_headers)
# one-shot 401 refetch is lost with it). Drop only the header the resolved
# credential is about to occupy, so a static credential the operator aimed at a
# DIFFERENT header still reaches upstream.
return auth, without_header(extra_headers, header_name)
# Other modes: an Authorization already supplied via extra_headers (a forwarded caller
# header or static_headers) is intentional and wins; v1 applies those last.
return None, extra_headers
@ -3650,6 +3648,7 @@ class MCPServerManager:
):
spec = None
auth_value: Final = await resolve_mcp_auth(resolved_server, mcp_auth_header) if spec is None else None
auth_header_name: Final = resolved_token_header(resolved_server, mcp_auth_header) if spec is None else None
# Create sampling and elicitation callbacks for this client
sampling_cb = (
@ -3758,6 +3757,7 @@ class MCPServerManager:
transport_type=transport,
auth_type=resolved_server.auth_type,
auth_value=auth_value,
auth_header_name=auth_header_name,
timeout=(resolved_server.timeout if resolved_server.timeout is not None else MCP_CLIENT_TIMEOUT),
extra_headers=extra_headers,
aws_auth=aws_auth,
@ -5256,7 +5256,9 @@ class MCPServerManager:
proxy_logging_obj: Optional ProxyLogging object for hook integration
host_progress_callback: Optional callback for progress updates
hook_extra_headers: Optional headers injected by pre_mcp_call guardrail
hooks. Merged last (highest priority) into outbound request headers.
hooks. Merged last into outbound request headers, except a hook
Authorization header is dropped when an upstream credential already
occupies the Authorization slot.
Returns:
CallToolResult from the MCP server
@ -5304,7 +5306,7 @@ class MCPServerManager:
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
):
extra_headers = _without_authorization(extra_headers)
extra_headers = without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER)
elif mcp_server.is_client_forwarded_token:
extra_headers = _client_forwarded_authorization_headers(
mcp_server=mcp_server,
@ -5347,27 +5349,26 @@ class MCPServerManager:
if hook_extra_headers:
if extra_headers is None:
extra_headers = {}
if "Authorization" in hook_extra_headers:
if "Authorization" in extra_headers:
verbose_logger.warning(
"MCPServerManager: hook_extra_headers 'Authorization' will overwrite "
"the existing Authorization header from static_headers. "
"The hook JWT will take precedence."
)
elif server_auth_header is not None:
# server_auth_header is passed separately to _create_mcp_client as
# auth_value. Both will reach the upstream server — warn so admins
# know two Authorization credentials are being sent.
verbose_logger.warning(
"MCPServerManager: hook_extra_headers injects 'Authorization' while "
"server '%s' already has a configured authentication_token. "
"Both credentials will be sent; the hook header is in extra_headers "
"and the server token is in auth_value — the upstream server decides "
"which one wins. Consider unsetting authentication_token if you want "
"the hook JWT to be the sole credential.",
mcp_server.server_name or mcp_server.name,
)
extra_headers.update(hook_extra_headers)
hook_has_authorization: Final = any(k.lower() == "authorization" for k in hook_extra_headers)
existing_has_authorization: Final = any(k.lower() == "authorization" for k in extra_headers)
server_auth_occupies_authorization: Final = (
any(k.lower() == "authorization" for k in server_auth_header)
if isinstance(server_auth_header, dict)
else server_auth_header is not None and mcp_server.auth_type != MCPAuth.api_key
)
if hook_has_authorization and (existing_has_authorization or server_auth_occupies_authorization):
# Mirror the tools/list signer guard: an upstream credential (user OAuth,
# static header, or configured authentication_token) already occupies the
# Authorization slot, so the hook must not replace it.
verbose_logger.warning(
"MCPServerManager: dropping hook-injected 'Authorization' header for "
"server '%s' because an upstream credential already occupies the "
"Authorization slot; the existing credential is kept.",
mcp_server.server_name or mcp_server.name,
)
extra_headers.update({k: v for k, v in hook_extra_headers.items() if k.lower() != "authorization"})
else:
extra_headers.update(hook_extra_headers)
# Reset to None if no headers were actually added
if extra_headers is not None and len(extra_headers) == 0:

View file

@ -7,6 +7,7 @@ with ``client_id``, ``client_secret``, and ``token_url``.
import asyncio
import hashlib
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final
import httpx
@ -313,9 +314,26 @@ async def resolve_mcp_auth(
1. ``mcp_auth_header`` per-request/per-user override
2. OAuth2 client_credentials token auto-fetched and cached
3. ``server.authentication_token`` static token from config/DB
``resolved_token_header`` answers, for the same two inputs, which header the value belongs in.
"""
if mcp_auth_header:
return mcp_auth_header
if server.has_client_credentials:
return await mcp_oauth2_token_cache.async_get_token(server)
return server.authentication_token
def resolved_token_header(
server: "MCPServer",
mcp_auth_header: str | Mapping[str, str] | None = None,
) -> str | None:
"""Which upstream header the value ``resolve_mcp_auth`` just returned belongs in.
``None`` means keep the auth_type default. A caller-supplied ``mcp_auth_header`` is the caller's
own credential aimed at the slot the upstream normally uses, so it never moves; only the values
the gateway resolved from its own config (the minted M2M token, the static token) follow
``upstream_token_header``. Same inputs and same branch order as ``resolve_mcp_auth``, so the two
cannot disagree about which case they are in.
"""
return None if mcp_auth_header else server.upstream_token_header

View file

@ -47,12 +47,14 @@ def sanitize_openapi_tool_name(raw_name: str) -> str:
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.url_utils import async_safe_get
from litellm.llms.custom_httpx.http_handler import (
AsyncHTTPHandler,
get_async_httpx_client,
httpxSpecialProvider,
)
from litellm.proxy._experimental.mcp_server.tool_registry import (
global_mcp_tool_registry,
)
from litellm.types.mcp import credential_redirect_hook, custom_credential_slot
class _OpenAPIJSONSchema(TypedDict, total=False):
@ -119,6 +121,10 @@ _request_resolved_auth_headers: Final[contextvars.ContextVar[dict[str, str] | No
"_request_resolved_auth_headers", default=None
)
_request_upstream_url: Final[contextvars.ContextVar[str | None]] = contextvars.ContextVar(
"_request_upstream_url", default=None
)
def _sanitize_path_parameter_value(param_value: object, param_name: str) -> str:
"""Ensure path params cannot introduce directory traversal."""
@ -349,6 +355,35 @@ def build_input_schema(operation: _OpenAPIOperation) -> dict[str, object]:
}
async def _drop_credential_across_origin(request: httpx.Request) -> None:
"""Apply this request's cross-origin credential guard, if it needs one.
Reads the per-request context rather than closing over it so the hook is one stable object, which
keeps the guarded client cacheable. A closure would key a new entry per call, and the handler it
built would never be closed.
"""
guard: Final = credential_redirect_hook(
_request_upstream_url.get() or "", custom_credential_slot(_request_resolved_auth_headers.get())
)
if guard is not None:
await guard(request)
def _upstream_client() -> AsyncHTTPHandler:
"""The HTTP client for one upstream call, guarded when a credential rides a custom slot.
A resolved credential outside ``Authorization`` is not stripped across origins by the client
itself, so this arm installs the same hook the MCP client uses. Both variants come from the
shared cache, so a guarded call reuses its connection pool like any other.
"""
if custom_credential_slot(_request_resolved_auth_headers.get()) is None:
return get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
return get_async_httpx_client(
llm_provider=httpxSpecialProvider.MCP,
params={"event_hooks": {"request": [_drop_credential_across_origin]}},
)
def _merge_openapi_tool_request_headers(
static_headers: dict[str, str],
) -> dict[str, str]:
@ -510,8 +545,9 @@ def create_tool_function(
except (json.JSONDecodeError, TypeError):
json_body = {"data": body_value}
client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP)
client: Final = _upstream_client()
upstream: Final = server_label or f"{original_method.upper()} {path}"
url_token: Final = _request_upstream_url.set(url)
try:
if original_method == "get":
@ -529,6 +565,8 @@ def create_tool_function(
except MaskedHTTPStatusError as e:
_raise_for_upstream_failure(e.response, upstream, relays_upstream_auth)
raise
finally:
_request_upstream_url.reset(url_token)
_raise_for_upstream_failure(response, upstream, relays_upstream_auth)
return response.text

View file

@ -21,6 +21,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
Result,
)
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
DEFAULT_CREDENTIAL_HEADER,
Ambient,
ApiKeyConfig,
ApiKeySource,
@ -35,6 +36,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
ClientCredentialsConfig,
ClientSecretAuth,
CredError,
HeaderCarrier,
IdJagConfig,
NoneConfig,
PassthroughConfig,
@ -45,9 +47,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
Subject,
TokenExchangeConfig,
parse_auth_spec_kind,
validate_header_name,
)
__all__ = [
"DEFAULT_CREDENTIAL_HEADER",
"Ambient",
"ApiKeyConfig",
"ApiKeySource",
@ -63,6 +67,7 @@ __all__ = [
"ClientSecretAuth",
"CredError",
"Error",
"HeaderCarrier",
"IdJagConfig",
"NoOpAuth",
"NoneConfig",
@ -78,4 +83,5 @@ __all__ = [
"TokenExchangeConfig",
"UpstreamCredentialProvider",
"parse_auth_spec_kind",
"validate_header_name",
]

View file

@ -20,6 +20,7 @@ from typing_extensions import assert_never
from litellm.proxy._experimental.mcp_server.oauth_utils import resolve_upstream_resource
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
DEFAULT_CREDENTIAL_HEADER,
ApiKeyConfig,
AuthorizationCodeConfig,
ClientAuth,
@ -45,6 +46,15 @@ _TOKEN_EXCHANGE_SUBJECT_TOKEN_DEFAULT: Final = "urn:ietf:params:oauth:token-type
_ID_JAG_SUBJECT_TOKEN_DEFAULT: Final = "urn:ietf:params:oauth:token-type:id_token"
def token_header(server: MCPServer) -> str:
"""The upstream header this server's resolved credential occupies.
One owner for every arm, so no spec builder spells the default itself and a server can never
hand two arms different answers.
"""
return server.upstream_token_header or DEFAULT_CREDENTIAL_HEADER
def to_subject(user_api_key_auth: UserAPIKeyAuth | None, subject_token: str | None) -> Subject:
"""Map v1's authenticated principal onto the resolver's Subject.
@ -122,7 +132,7 @@ def _oauth2_spec(server: MCPServer, resource: str) -> ServerSpec | None:
return ServerSpec(
server_id=server.server_id,
resource=resource,
config=AuthorizationCodeConfig(),
config=AuthorizationCodeConfig(header_name=token_header(server)),
)
return None
@ -140,6 +150,7 @@ def _client_credentials_spec(server: MCPServer, resource: str) -> ServerSpec:
server_id=server.server_id,
resource=resource,
config=ClientCredentialsConfig(
header_name=token_header(server),
client_id=server.client_id,
client_secret=SecretStr(server.client_secret) if server.client_secret else None,
token_url=server.effective_token_url,
@ -173,6 +184,7 @@ def _token_exchange_spec(server: MCPServer, resource: str) -> ServerSpec | None:
server_id=server.server_id,
resource=resource,
config=TokenExchangeConfig(
header_name=token_header(server),
profile=profile,
subject_token_type=server.subject_token_type or DEFAULT_SUBJECT_TOKEN_TYPE,
token_exchange_endpoint=endpoint,
@ -206,7 +218,7 @@ def _shared_key_spec(
server_id=server.server_id,
resource=resource,
config=ApiKeyConfig(
header_name=header_name,
header_name=server.upstream_token_header or header_name,
value_prefix=value_prefix,
key_source=SharedKey(value=SecretStr(value)),
),
@ -231,6 +243,7 @@ def _id_jag_spec(server: MCPServer, resource: str) -> ServerSpec | None:
server_id=server.server_id,
resource=resource,
config=IdJagConfig(
header_name=token_header(server),
org_token_endpoint=org_token_endpoint,
resource_token_endpoint=resource_token_endpoint,
client_id=client_id,

View file

@ -239,5 +239,6 @@ def resolve_bridge_envelope(
if opened.identity.server_id != expected_server_id:
return BridgeEnvelopeInvalid()
grant: Final = opened.grant
upstream_authorization: Final = f"{grant.token_type} {grant.access_token.get_secret_value()}"
authorization_scheme: Final = "Bearer" if grant.token_type.lower() == "bearer" else grant.token_type
upstream_authorization: Final = f"{authorization_scheme} {grant.access_token.get_secret_value()}"
return BridgeEnvelopeAdmitted(identity=opened.identity, upstream_authorization=SecretStr(upstream_authorization))

View file

@ -50,6 +50,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
from litellm.proxy._experimental.mcp_server.outbound_credentials.types import (
ClientCredentialsConfig,
CredError,
HeaderCarrier,
)
@ -328,14 +329,21 @@ class ClientCredentialsBearerAuth(httpx.Auth):
refetch fails, or the retried request 401s again, the upstream's response stands.
"""
def __init__(self, access_token: str, refetch: Callable[[str], Awaitable[str | None]]) -> None:
self.header_name = "Authorization"
def __init__(
self,
access_token: str,
refetch: Callable[[str], Awaitable[str | None]],
carrier: HeaderCarrier,
) -> None:
self._carrier = carrier
self.header_name = carrier.header_name
self._access_token = SecretStr(access_token)
self._refetch = refetch
async def async_auth_flow(self, request: httpx.Request) -> AsyncGenerator[httpx.Request, httpx.Response]:
token: Final = self._access_token.get_secret_value()
request.headers[self.header_name] = f"Bearer {token}"
name, value = self._carrier.header(token)
request.headers[name] = value
response: Final = yield request
if response.status_code != 401:
return
@ -343,7 +351,8 @@ class ClientCredentialsBearerAuth(httpx.Auth):
if fresh is None:
return
self._access_token = SecretStr(fresh)
request.headers[self.header_name] = f"Bearer {fresh}"
fresh_name, fresh_value = self._carrier.header(fresh)
request.headers[fresh_name] = fresh_value
yield request
def sync_auth_flow(self, request: httpx.Request) -> Generator[httpx.Request, httpx.Response, None]:

View file

@ -145,8 +145,8 @@ class UpstreamCredentialProvider:
return await self._token_exchange(subject, server, config)
case IdJagConfig() as config:
return await self._id_jag(subject, server, config)
case AuthorizationCodeConfig():
return await self._authorization_code(subject, server)
case AuthorizationCodeConfig() as config:
return await self._authorization_code(subject, server, config)
case AwsSigV4Config():
return _not_implemented(AuthSpecKind.aws_sigv4)
assert_never(server.config)
@ -284,15 +284,19 @@ class UpstreamCredentialProvider:
match await self._exchanged_tokens.get_or_compute(slot, _exchange, fingerprint=fingerprint):
case Ok(access_token):
return Ok(StaticHeaderAuth(f"Bearer {access_token}"))
header_name, header_value = config.header(access_token)
return Ok(StaticHeaderAuth(header_value, header_name=header_name))
case Error(err):
return Error(err)
async def _authorization_code(self, subject: Subject, server: ServerSpec) -> Result[StaticHeaderAuth, CredError]:
async def _authorization_code(
self, subject: Subject, server: ServerSpec, config: AuthorizationCodeConfig
) -> Result[StaticHeaderAuth, CredError]:
token: Final = await self._authz_token(subject, server)
if token is None:
return Error(CredError.of_unauthorized("Authorization required: complete the OAuth flow for this server."))
return Ok(StaticHeaderAuth(f"Bearer {token.access_token}", header_name="Authorization"))
header_name, header_value = config.header(token.access_token)
return Ok(StaticHeaderAuth(header_value, header_name=header_name))
async def _client_credentials(
self, server_id: str, config: ClientCredentialsConfig
@ -307,7 +311,7 @@ class UpstreamCredentialProvider:
match await self._client_credentials_source.get(server_id, config):
case Ok(token):
refetch: Final = partial(self._client_credentials_source.refetch, server_id, config)
return Ok(ClientCredentialsBearerAuth(token.access_token, refetch))
return Ok(ClientCredentialsBearerAuth(token.access_token, refetch, config))
case Error(err):
return Error(err)
@ -332,7 +336,8 @@ class UpstreamCredentialProvider:
inbound.get_secret_value(), server, config, tenant_id=subject.tenant_id
):
case Ok(token):
return Ok(StaticHeaderAuth(f"Bearer {token.access_token}", header_name="Authorization"))
header_name, header_value = config.header(token.access_token)
return Ok(StaticHeaderAuth(header_value, header_name=header_name))
case Error(err):
return Error(err)

View file

@ -31,7 +31,7 @@ from enum import Enum
from typing import Annotated, Final, Literal
from expression import case, tag, tagged_union
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from pydantic import BaseModel, ConfigDict, Field, SecretStr, field_validator
from typing_extensions import assert_never
from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
@ -39,7 +39,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import (
Ok,
Result,
)
from litellm.types.mcp import DEFAULT_SUBJECT_TOKEN_TYPE
from litellm.types.mcp import (
DEFAULT_CREDENTIAL_HEADER,
DEFAULT_SUBJECT_TOKEN_TYPE,
normalize_upstream_header_name,
)
class AuthSpecKind(str, Enum):
@ -161,7 +165,52 @@ class CredError:
assert_never(self.tag)
class AuthorizationCodeConfig(BaseModel):
def validate_header_name(raw: str) -> Result[str, CredError]:
"""``normalize_upstream_header_name`` with this package's error-as-value policy.
The grammar itself lives in ``litellm.types.mcp`` so the v1 model, the management endpoint and
this vocabulary all judge a header name the same way while each keeps its own failure shape.
"""
normalized: Final = normalize_upstream_header_name(raw)
if normalized is None:
return Error(CredError.of_misconfigured(f"invalid upstream header name: {raw!r}"))
return Ok(normalized)
class HeaderCarrier(BaseModel):
"""Where a resolved credential is written upstream, and how its value is formatted.
``Authorization: Bearer`` is only OAuth's *default* conveyance (RFC 6750 section 2.1), not its
only one: an ESB or API gateway commonly terminates its own credential in a private header while
a second credential passes through to the origin, so a credential has to be able to say which
slot it owns. Modeled like OpenAPI's apiKey scheme, so any upstream convention is expressible
(Authorization + Bearer, a raw value on X-API-Key, Ocp-Apim-Subscription-Key, esb-oauth, ...).
Every config whose credential the gateway mints or holds inherits this, so no resolver arm names
a header itself and the conflict rule in ``_resolve_v2_auth`` can always ask the auth object
which slot it is about to occupy. ``passthrough`` deliberately does not: it forwards the
caller's own credential into the slot the caller used, and mints nothing to place.
"""
model_config = ConfigDict(frozen=True)
header_name: str = DEFAULT_CREDENTIAL_HEADER
value_prefix: str = "Bearer"
@field_validator("header_name")
@classmethod
def _check_header_name(cls, value: str) -> str:
match validate_header_name(value):
case Ok(name):
return name
case Error(err):
raise ValueError(err.summary)
def header(self, value: str) -> tuple[str, str]:
formatted: Final = f"{self.value_prefix} {value}" if self.value_prefix else value
return self.header_name, formatted
class AuthorizationCodeConfig(HeaderCarrier):
"""Per-user 3LO; the gateway is the OAuth client and stores the user's token.
Endpoints are discovered (RFC 9728 -> RFC 8414) and the client is registered via DCR
@ -179,7 +228,7 @@ class AuthorizationCodeConfig(BaseModel):
token_url: str | None = None
class ClientCredentialsConfig(BaseModel):
class ClientCredentialsConfig(HeaderCarrier):
"""M2M service account; one upstream identity for every user.
Fields are optional so the config can be built incomplete: a value may be supplied at
@ -203,7 +252,7 @@ class ClientCredentialsConfig(BaseModel):
token_endpoint_auth_method: Literal["client_secret_post", "client_secret_basic"] | None = None
class TokenExchangeConfig(BaseModel):
class TokenExchangeConfig(HeaderCarrier):
"""OBO: swap the caller's live inbound token for a token bound to the upstream's audience. The
gateway authenticates to the exchange endpoint as an OAuth client (`client_id`/`client_secret`);
the inbound token is sent only to that endpoint, never to the upstream.
@ -255,7 +304,7 @@ class ClientSecretAuth(BaseModel):
ClientAuth = Annotated[PrivateKeyJwtAuth | ClientSecretAuth, Field(discriminator="source")]
class IdJagConfig(BaseModel):
class IdJagConfig(HeaderCarrier):
"""draft-ietf-oauth-identity-assertion-authz-grant (Okta "AI agent token exchange").
Two legs: leg 1 is an RFC 8693 token exchange at the IdP org AS (`org_token_endpoint`) that
@ -297,23 +346,16 @@ class Byok(BaseModel):
ApiKeySource = Annotated[SharedKey | Byok, Field(discriminator="source")]
class ApiKeyConfig(BaseModel):
class ApiKeyConfig(HeaderCarrier):
"""A fixed credential injected as a header. The value is shared (in config) or seeded
per-user (pulled from the store); `header_name` and `value_prefix` say where and how it is
written, modeled like OpenAPI's apiKey scheme so any upstream convention is expressible
(Authorization + Bearer, a raw value on X-API-Key, Ocp-Apim-Subscription-Key, etc.).
per-user (pulled from the store); the inherited `header_name` and `value_prefix` say where
and how it is written.
"""
model_config = ConfigDict(frozen=True)
kind: Literal[AuthSpecKind.api_key] = AuthSpecKind.api_key
header_name: str = "Authorization"
value_prefix: str = "Bearer"
key_source: ApiKeySource
def header(self, value: str) -> tuple[str, str]:
formatted: Final = f"{self.value_prefix} {value}" if self.value_prefix else value
return self.header_name, formatted
class PassthroughConfig(BaseModel):
"""Client-driven upstream OAuth; the gateway forwards the client's upstream token."""

View file

@ -246,11 +246,12 @@ def _mcp_meta_trace_carrier(req_ctx: object) -> dict[str, str] | None:
"""The W3C trace context (``traceparent``/``tracestate``) the MCP client
propagated in the request's ``params._meta`` (SEP-414), or ``None``.
When present, per the OTel MCP semconv the MCP span parents to this propagated
context rather than to the HTTP transport (which is recorded as a link instead).
When absent, the span nests under the transport span of the request carrying
this specific message, so a streamable-HTTP session that multiplexes many
messages still does not glue every message under the session's first request;
When present, the MCP span records this propagated context as a span *link*,
never the parent a remote parent would root the span in a trace whose root
never reaches the gateway's tracing backend. The span itself nests under the
transport span of the request carrying this specific message, so a
streamable-HTTP session that multiplexes many messages still does not glue
every message under the session's first request;
see ``resolve_mcp_span_context``. The client's W3C Baggage is
deliberately excluded: it is caller-controlled, and the otel baggage processor
stamps allowlisted baggage keys (``litellm.team.id``, ``litellm.metadata.*``,
@ -432,7 +433,6 @@ if MCP_AVAILABLE:
_client_forwarded_authorization_headers,
_resolve_openapi_tool_auth,
_should_strip_caller_authorization,
_without_authorization,
global_mcp_server_manager,
)
from litellm.proxy._experimental.mcp_server.openapi_to_mcp_generator import (
@ -451,6 +451,7 @@ if MCP_AVAILABLE:
split_server_prefix_from_name,
strip_known_server_prefix,
)
from litellm.types.mcp import DEFAULT_CREDENTIAL_HEADER, without_header
######################################################
############ MCP Tools List REST API Response Object #
@ -1732,7 +1733,7 @@ if MCP_AVAILABLE:
raw_headers=raw_headers,
user_api_key_auth=user_api_key_auth,
):
extra_headers = _without_authorization(extra_headers)
extra_headers = without_header(extra_headers, DEFAULT_CREDENTIAL_HEADER)
elif is_client_forwarded_mode:
if not withhold_forwarded_authorization:
extra_headers = _client_forwarded_authorization_headers(

File diff suppressed because it is too large Load diff

View file

@ -3,18 +3,27 @@ Per-feature OpenAPI snapshot for lazy-loaded routers.
The committed JSON is generated by `python -m litellm.proxy._lazy_openapi_snapshot`
and consumed at runtime so /openapi.json can show full route info for unloaded
features without importing them. No CI job regenerates this file; drift surfaces
only indirectly through check-ui-api-types.yml, which rebuilds schema.d.ts from
app.openapi() with the committed snapshot injected. After changing any lazily
loaded route or this generator, rerun the module and commit the JSON, then run
`npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts.
features without importing them. check-ui-api-types.yml (mirrored locally by
`make check`) regenerates this file and fails when the committed copy differs,
then rebuilds schema.d.ts from app.openapi() with the snapshot injected. After
changing any lazily loaded route or this generator, rerun the module and commit
the JSON, then run `npm run gen:api` in ui/litellm-dashboard and commit schema.d.ts.
"""
import json
import re
import sys
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from pathlib import Path
from typing import Final
from typing import TYPE_CHECKING, Final
from typing_extensions import ReadOnly, TypedDict
if TYPE_CHECKING:
from fastapi import FastAPI
from litellm.proxy._lazy_features import LazyFeature
SNAPSHOT_FILE: Final = Path(__file__).parent / "_lazy_openapi_snapshot.json"
HTTP_METHOD_SUFFIXES: Final = {
@ -83,51 +92,84 @@ def _normalize_operation_ids(paths: dict[str, dict]) -> None:
break
def generate_snapshot() -> dict[str, dict]:
class SnapshotFragment(TypedDict):
paths: ReadOnly[Mapping[str, Mapping[str, object]]]
components: ReadOnly[Mapping[str, Mapping[str, object]]]
@dataclass(frozen=True, slots=True)
class SnapshotResult:
fragments: Mapping[str, SnapshotFragment]
skipped: tuple[str, ...]
def _register_feature(app: "FastAPI", feat: "LazyFeature") -> str | None:
import importlib
try:
feat.register_fn(app, importlib.import_module(feat.module_path))
except Exception as exc:
sys.stderr.write(f"warning: skip {feat.name}: {exc}\n")
return feat.name
return None
def _feature_fragment(app: "FastAPI", feat: "LazyFeature", used_operation_ids: set[str]) -> SnapshotFragment | None:
from fastapi.openapi.utils import get_openapi
from litellm.proxy.proxy_server import ensure_unique_openapi_operation_ids
feat_routes: Final = [r for r in app.routes if feat.matches(getattr(r, "path", ""))]
if not feat_routes:
return None
_stabilize_multi_method_route_ids(feat_routes)
full: Final = get_openapi(title=app.title, version=app.version, routes=feat_routes)
paths: Final = full.get("paths", {})
_normalize_operation_ids(paths)
# Group all of a feature's routes under one tag.
for path_ops in paths.values():
for method, op in path_ops.items():
if isinstance(op, dict):
operation_id = op.get("operationId")
if isinstance(operation_id, str):
for suffix in HTTP_METHOD_SUFFIXES:
if operation_id.endswith(f"_{suffix}"):
op["operationId"] = operation_id[: -len(suffix)] + method
break
op["tags"] = [feat.name]
unique: Final = ensure_unique_openapi_operation_ids(full, used_operation_ids)
return {
"paths": paths,
"components": {"schemas": unique.get("components", {}).get("schemas", {})},
}
def generate_snapshot() -> SnapshotResult:
from litellm.proxy._lazy_features import LAZY_FEATURES
from litellm.proxy.proxy_server import app, ensure_unique_openapi_operation_ids
from litellm.proxy.proxy_server import app
for feat in LAZY_FEATURES:
try:
module = importlib.import_module(feat.module_path)
feat.register_fn(app, module)
except Exception as exc:
sys.stderr.write(f"warning: skip {feat.name}: {exc}\n")
fragments: Final[dict[str, dict]] = {}
skipped: Final = tuple(name for feat in LAZY_FEATURES if (name := _register_feature(app, feat)) is not None)
used_operation_ids: Final[set[str]] = set()
for feat in LAZY_FEATURES:
feat_routes = [r for r in app.routes if feat.matches(getattr(r, "path", ""))]
if not feat_routes:
continue
_stabilize_multi_method_route_ids(feat_routes)
full = get_openapi(title=app.title, version=app.version, routes=feat_routes)
paths = full.get("paths", {})
_normalize_operation_ids(paths)
# Group all of a feature's routes under one tag.
for path_ops in full.get("paths", {}).values():
for method, op in path_ops.items():
if isinstance(op, dict):
operation_id = op.get("operationId")
if isinstance(operation_id, str):
for suffix in HTTP_METHOD_SUFFIXES:
if operation_id.endswith(f"_{suffix}"):
op["operationId"] = operation_id[: -len(suffix)] + method
break
op["tags"] = [feat.name]
full = ensure_unique_openapi_operation_ids(full, used_operation_ids)
fragments[feat.name] = {
"paths": paths,
"components": {"schemas": full.get("components", {}).get("schemas", {})},
}
return fragments
fragments: Final = {
feat.name: fragment
for feat in LAZY_FEATURES
if (fragment := _feature_fragment(app, feat, used_operation_ids)) is not None
}
return SnapshotResult(fragments=fragments, skipped=skipped)
def main(snapshot_file: Path = SNAPSHOT_FILE, generate: Callable[[], SnapshotResult] = generate_snapshot) -> int:
result: Final = generate()
if result.skipped:
sys.stderr.write(
f"error: {len(result.skipped)} feature(s) failed to import, so their fragments would vanish from the "
f"snapshot: {', '.join(result.skipped)}\n"
)
return 1
snapshot_file.write_text(json.dumps(result.fragments, indent=2, sort_keys=True) + "\n")
sys.stdout.write(f"wrote {len(result.fragments)} feature fragments to {snapshot_file}\n")
return 0
if __name__ == "__main__":
fragments: Final = generate_snapshot()
SNAPSHOT_FILE.write_text(json.dumps(fragments, indent=2, sort_keys=True) + "\n")
sys.stdout.write(f"wrote {len(fragments)} feature fragments to {SNAPSHOT_FILE}\n")
sys.exit(main())

View file

@ -2510,6 +2510,18 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
"are skipped for on-demand GET /health as well as the background health loop."
),
)
background_health_check_model_groups: tuple[str, ...] | None = Field(
None,
description=(
"Opt-in allowlist of model group names for background health checks and "
"health-check routing. When set, the background loop probes only deployments "
"whose model_name is listed, and enable_health_check_routing filters unhealthy "
"deployments only within the listed groups; every other group, including newly "
"added deployments, is skipped and keeps its configured routing strategy. "
"When unset, all deployments participate (opt out per deployment via "
"model_info.disable_background_health_check)."
),
)
model_list_healthy_only: bool | None = Field(
None,
description=(

View file

@ -1769,7 +1769,12 @@ async def _user_api_key_auth_builder(
return valid_token
if valid_token is not None and isinstance(valid_token, UserAPIKeyAuth) and valid_token.team_id is not None:
if (
valid_token is not None
and isinstance(valid_token, UserAPIKeyAuth)
and valid_token.team_id is not None
and valid_token.team_id != UI_TEAM_ID
):
## UPDATE TEAM VALUES BASED ON CACHED TEAM OBJECT - allows `/team/update` values to work for cached token
try:
team_obj: Final[LiteLLM_TeamTableCachedObj] = await get_team_object(
@ -2149,6 +2154,8 @@ async def _user_api_key_auth_builder(
# Check 6: Additional Common Checks across jwt + key auth
if valid_token.team_id is not None:
try:
if valid_token.team_id == UI_TEAM_ID:
raise TeamNotFoundError(team_id=UI_TEAM_ID)
with tracer.trace("litellm.proxy.auth.get_team_object"):
_team_obj = await get_team_object(
team_id=valid_token.team_id,
@ -2443,7 +2450,7 @@ async def _run_centralized_common_checks(
)
fetch_coros: Final = []
if user_api_key_auth_obj.team_id is not None:
if user_api_key_auth_obj.team_id is not None and user_api_key_auth_obj.team_id != UI_TEAM_ID:
fetch_coros.append(
_safe_fetch(
"team",
@ -2567,7 +2574,9 @@ async def _run_centralized_common_checks(
else:
raise team_result
else:
team_object = team_result
team_object = (
_team_obj_from_token(user_api_key_auth_obj) if user_api_key_auth_obj.team_id == UI_TEAM_ID else team_result
)
user_object: LiteLLM_UserTable | None = None if isinstance(user_result, BaseException) else user_result
project_object: Final[LiteLLM_ProjectTableCachedObj | None] = (

View file

@ -1,5 +1,6 @@
import asyncio
import json
import math
import time
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from dataclasses import dataclass
@ -45,6 +46,7 @@ from litellm.repositories.table_repositories import (
)
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.unit_of_work import (
LinkedSpendResetWrites,
budget_cascade_unit_of_work,
spend_reset_unit_of_work,
)
@ -59,7 +61,15 @@ _LINKED_KEYS_WHERE: Final[Mapping[str, object]] = MappingProxyType({"budget_dura
_SPENT_ROWS_WHERE: Final[Mapping[str, object]] = MappingProxyType({"spend": {"gt": 0}})
class _TeamMembershipRow(Protocol):
class _BudgetLinkedRow(Protocol):
@property
def spend(self) -> float | None: ...
@property
def budget_id(self) -> str | None: ...
class _TeamMembershipRow(_BudgetLinkedRow, Protocol):
@property
def user_id(self) -> str: ...
@ -67,26 +77,48 @@ class _TeamMembershipRow(Protocol):
def team_id(self) -> str: ...
class _KeyRow(Protocol):
class _KeyRow(_BudgetLinkedRow, Protocol):
@property
def token(self) -> str: ...
class _OrgRow(Protocol):
class _OrgRow(_BudgetLinkedRow, Protocol):
@property
def organization_id(self) -> str: ...
class _TagRow(Protocol):
class _TagRow(_BudgetLinkedRow, Protocol):
@property
def tag_name(self) -> str: ...
class _EndUserRow(Protocol):
class _EndUserRow(_BudgetLinkedRow, Protocol):
@property
def user_id(self) -> str: ...
def _rollover_enabled() -> bool:
return litellm.budget_rollover is True
def _rollover_cap(max_budget: float | None) -> float | None:
if max_budget is None or not math.isfinite(max_budget):
return None
return max_budget
def _carried_spend(spend: float | None, cap: float | None) -> float:
if cap is None:
return 0.0
return max(0.0, (spend or 0.0) - cap)
def _row_carried_spend(row: _BudgetLinkedRow, caps: Mapping[str, float]) -> float:
if not caps:
return 0.0
return _carried_spend(row.spend, caps.get(row.budget_id) if row.budget_id is not None else None)
def _team_membership_counter_key(row: _TeamMembershipRow) -> str:
return f"spend:team_member:{row.user_id}:{row.team_id}"
@ -129,6 +161,59 @@ def _budget_link_where(
return {"budget_id": {"in": list(budget_ids)}, **extra}
def _queue_budget_linked_resets(
writes: LinkedSpendResetWrites,
cascade: "_BudgetCascade",
extra: Mapping[str, object] = MappingProxyType({}),
) -> None:
"""Reset one linked table's spend for every expiring tier: tiers with a
rollover cap keep spend beyond the cap (decrement preserves writes racing
the reset), everything else is zeroed as before. Zero the under-cap rows
BEFORE decrementing the over-cap ones: the statements run sequentially in
one transaction, so the reverse order lets the zero re-match a row the
decrement just moved into the (0, cap] range and erase its carried spend."""
for budget_id, cap in cascade.rollover_caps.items():
writes.queue_spend_zero(
where={"budget_id": budget_id, **extra, "spend": {"gt": 0, "lte": cap}}
) # mutable-ok: prisma where filter must be a dict
writes.queue_spend_decrement(
where={"budget_id": budget_id, **extra, "spend": {"gt": cap}}, amount=cap
) # mutable-ok: prisma where filter must be a dict
plain_ids: Final = tuple(bid for bid in cascade.budget_ids if bid not in cascade.rollover_caps)
if plain_ids:
writes.queue_spend_zero(where=_budget_link_where(plain_ids, extra))
def _queue_enduser_resets(writes: LinkedSpendResetWrites, cascade: "_BudgetCascade") -> None:
"""End users are matched by id rather than budget link: rows with no
budget_id ride the default budget tier (litellm.max_end_user_budget_id).
Zero-before-decrement ordering matters here too (see
_queue_budget_linked_resets)."""
if not cascade.rollover_caps:
if cascade.endusers:
writes.queue_spend_zero(
where={"user_id": {"in": [row.user_id for row in cascade.endusers]}}
) # mutable-ok: prisma where filter must be a dict
return
tiered: Final = tuple((row.budget_id or litellm.max_end_user_budget_id, row.user_id) for row in cascade.endusers)
for budget_id, cap in cascade.rollover_caps.items():
if not (
user_ids := [uid for bid, uid in tiered if bid == budget_id]
): # mutable-ok: prisma "in" filter takes a list
continue
writes.queue_spend_zero(
where={"user_id": {"in": user_ids}, "spend": {"lte": cap}}
) # mutable-ok: prisma where filter must be a dict
writes.queue_spend_decrement(
where={"user_id": {"in": user_ids}, "spend": {"gt": cap}}, amount=cap
) # mutable-ok: prisma where filter must be a dict
plain: Final = [
uid for bid, uid in tiered if bid is None or bid not in cascade.rollover_caps
] # mutable-ok: prisma "in" filter takes a list
if plain:
writes.queue_spend_zero(where={"user_id": {"in": plain}}) # mutable-ok: prisma where filter must be a dict
@dataclass(frozen=True, slots=True)
class _BudgetCascade:
"""Everything one budget-tier reset touches, resolved before any write."""
@ -137,8 +222,9 @@ class _BudgetCascade:
budget_ids: tuple[str, ...] = ()
budget_resets: tuple[tuple[str, datetime], ...] = ()
endusers: tuple[_EndUserRow, ...] = ()
counter_keys: tuple[str, ...] = ()
counter_resets: tuple[tuple[str, float], ...] = ()
cache_keys: tuple[str, ...] = ()
rollover_caps: Mapping[str, float] = MappingProxyType({})
@dataclass(frozen=True, slots=True)
@ -404,8 +490,10 @@ class ResetBudgetJob:
)
@staticmethod
async def _invalidate_spend_counter(counter_key: str) -> None:
"""Zero a spend counter so a DB-row reset takes effect immediately.
async def _invalidate_spend_counter(counter_key: str, new_spend: float = 0.0) -> None:
"""Overwrite a spend counter with the post-reset value (0, or the carried
overage when budget rollover is enabled) so a DB-row reset takes effect
immediately.
Call AFTER the DB write commits. Clearing Redis before the DB
commit opens a window where get_current_spend reads 0 from Redis
@ -414,10 +502,10 @@ class ResetBudgetJob:
try:
from litellm.proxy.proxy_server import spend_counter_cache
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.0, ttl=60)
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=new_spend, ttl=60)
if spend_counter_cache.redis_cache is not None:
try:
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=0.0, ttl=60)
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=new_spend, ttl=60)
except Exception as redis_err:
verbose_proxy_logger.warning(
"Failed to reset spend counter %s in Redis: %s. "
@ -522,6 +610,15 @@ class ResetBudgetJob:
where=_budget_link_where(budget_ids, _SPENT_ROWS_WHERE),
log_subject="tags",
)
rollover_caps: Final[Mapping[str, float]] = MappingProxyType(
{ # mutable-ok: MappingProxyType wraps a one-shot dict comprehension
b.budget_id: cap
for b in budgets_to_reset
if b.budget_id is not None and (cap := _rollover_cap(b.max_budget)) is not None
}
if _rollover_enabled()
else {} # mutable-ok: empty sentinel immediately frozen by MappingProxyType
)
return _BudgetCascade(
budgets=tuple(budgets_to_reset),
budget_ids=budget_ids,
@ -534,12 +631,16 @@ class ResetBudgetJob:
if b.budget_id is not None and b.budget_duration is not None
),
endusers=await self._collect_endusers_to_reset(budget_ids),
counter_keys=(
*(_team_membership_counter_key(row) for row in team_memberships),
*(_key_counter_key(row) for row in keys),
*(_org_counter_key(row) for row in orgs),
*(_tag_counter_key(row) for row in tags),
counter_resets=(
*(
(_team_membership_counter_key(row), _row_carried_spend(row, rollover_caps))
for row in team_memberships
),
*((_key_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in keys),
*((_org_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in orgs),
*((_tag_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in tags),
),
rollover_caps=rollover_caps,
cache_keys=(
*(key for row in team_memberships for key in _team_membership_cache_keys(row)),
*(key for row in keys for key in _key_cache_keys(row)),
@ -565,20 +666,18 @@ class ResetBudgetJob:
)
async def _commit_budget_cascade_once(self, cascade: _BudgetCascade) -> None:
enduser_ids: Final = tuple(row.user_id for row in cascade.endusers)
async with budget_cascade_unit_of_work(self.prisma_client.db.batch_) as uow:
uow.team_memberships.queue_spend_zero(where=_budget_link_where(cascade.budget_ids))
uow.keys.queue_spend_zero(where=_budget_link_where(cascade.budget_ids, _LINKED_KEYS_WHERE))
uow.organizations.queue_spend_zero(where=_budget_link_where(cascade.budget_ids, _SPENT_ROWS_WHERE))
uow.tags.queue_spend_zero(where=_budget_link_where(cascade.budget_ids, _SPENT_ROWS_WHERE))
if enduser_ids:
uow.endusers.queue_spend_zero(where={"user_id": {"in": list(enduser_ids)}})
_queue_budget_linked_resets(uow.team_memberships, cascade)
_queue_budget_linked_resets(uow.keys, cascade, extra=_LINKED_KEYS_WHERE)
_queue_budget_linked_resets(uow.organizations, cascade, extra=_SPENT_ROWS_WHERE)
_queue_budget_linked_resets(uow.tags, cascade, extra=_SPENT_ROWS_WHERE)
_queue_enduser_resets(uow.endusers, cascade)
for budget_id, budget_reset_at in cascade.budget_resets:
uow.budgets.queue_window_advance(budget_id=budget_id, budget_reset_at=budget_reset_at)
async def _invalidate_budget_cascade_caches(self, cascade: _BudgetCascade) -> None:
for counter_key in cascade.counter_keys:
await self._invalidate_spend_counter(counter_key)
for counter_key, new_spend in cascade.counter_resets:
await self._invalidate_spend_counter(counter_key, new_spend=new_spend)
for cache_key in cascade.cache_keys:
await self._invalidate_user_api_key_cache_entry(cache_key)
@ -708,7 +807,11 @@ class ResetBudgetJob:
for k in updated_keys:
if k.token is None:
continue
uow.keys.queue_spend_reset(token=k.token, budget_reset_at=k.budget_reset_at)
uow.keys.queue_spend_reset(
token=k.token,
budget_reset_at=k.budget_reset_at,
spend_decrement=k.max_budget if (k.spend or 0.0) > 0.0 else None,
)
async def _write_user_reset_updates(self, updated_users: list[LiteLLM_UserTable]) -> None:
"""
@ -726,7 +829,11 @@ class ResetBudgetJob:
async def _write_user_reset_updates_once(self, updated_users: list[LiteLLM_UserTable]) -> None:
async with spend_reset_unit_of_work(self.prisma_client.db.batch_) as uow:
for u in updated_users:
uow.users.queue_spend_reset(user_id=u.user_id, budget_reset_at=u.budget_reset_at)
uow.users.queue_spend_reset(
user_id=u.user_id,
budget_reset_at=u.budget_reset_at,
spend_decrement=u.max_budget if (u.spend or 0.0) > 0.0 else None,
)
async def _write_team_reset_updates(self, updated_teams: list[LiteLLM_TeamTable]) -> None:
"""
@ -744,7 +851,11 @@ class ResetBudgetJob:
async def _write_team_reset_updates_once(self, updated_teams: list[LiteLLM_TeamTable]) -> None:
async with spend_reset_unit_of_work(self.prisma_client.db.batch_) as uow:
for t in updated_teams:
uow.teams.queue_spend_reset(team_id=t.team_id, budget_reset_at=t.budget_reset_at)
uow.teams.queue_spend_reset(
team_id=t.team_id,
budget_reset_at=t.budget_reset_at,
spend_decrement=t.max_budget if (t.spend or 0.0) > 0.0 else None,
)
def _emit_phase_failure(
self,
@ -820,7 +931,7 @@ class ResetBudgetJob:
for k in updated_keys:
token = getattr(k, "token", None)
if token:
await self._invalidate_spend_counter(f"spend:key:{token}")
await self._invalidate_spend_counter(f"spend:key:{token}", new_spend=k.spend or 0.0)
end_time = time.time()
outcome: Final = _ChunkOutcome(
@ -925,7 +1036,7 @@ class ResetBudgetJob:
for u in updated_users:
user_id = getattr(u, "user_id", None)
if user_id:
await self._invalidate_spend_counter(f"spend:user:{user_id}")
await self._invalidate_spend_counter(f"spend:user:{user_id}", new_spend=u.spend or 0.0)
if user_id == LITELLM_PROXY_BUDGET_NAME:
await self._invalidate_global_proxy_spend_cache()
@ -1034,7 +1145,7 @@ class ResetBudgetJob:
for t in updated_teams:
team_id = getattr(t, "team_id", None)
if team_id:
await self._invalidate_spend_counter(f"spend:team:{team_id}")
await self._invalidate_spend_counter(f"spend:team:{team_id}", new_spend=t.spend or 0.0)
end_time = time.time()
outcome: Final = _ChunkOutcome(
@ -1107,10 +1218,11 @@ class ResetBudgetJob:
reset_at: Final = datetime.fromisoformat(reset_at_str.replace("Z", "+00:00")).replace(tzinfo=None)
if reset_at > now:
return False
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=0.0)
new_value: Final = await ResetBudgetJob._window_carried_spend(window, counter_key, spend_counter_cache)
spend_counter_cache.in_memory_cache.set_cache(key=counter_key, value=new_value)
if spend_counter_cache.redis_cache is not None:
try:
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=0.0)
await spend_counter_cache.redis_cache.async_set_cache(key=counter_key, value=new_value)
except Exception as redis_err:
verbose_proxy_logger.warning("Failed to reset Redis counter %s: %s", counter_key, redis_err)
window["reset_at"] = compute_budget_reset_at(
@ -1118,6 +1230,27 @@ class ResetBudgetJob:
).isoformat()
return True
@staticmethod
async def _window_carried_spend(
window: Mapping[str, object], counter_key: str, spend_counter_cache: DualCache
) -> float:
"""Per-window spend lives only in the counter, so the carried overage is
read from it before the reset overwrites it."""
if not _rollover_enabled():
return 0.0
window_max: Final = window.get("max_budget")
cap: Final = _rollover_cap(window_max) if isinstance(window_max, (int, float)) else None
if cap is None:
return 0.0
try:
current: Final = await spend_counter_cache.async_get_cache(key=counter_key)
except Exception as e: # noqa: BLE001 # an unreadable counter falls back to a plain zero reset
verbose_proxy_logger.warning("Failed to read spend counter %s for rollover: %s", counter_key, e)
return 0.0
if not isinstance(current, (int, float)):
return 0.0
return _carried_spend(float(current), cap)
async def reset_budget_windows(self) -> None:
"""
For keys and teams with budget_limits, reset any individual windows where
@ -1222,7 +1355,7 @@ class ResetBudgetJob:
still holds the pre-reset value, admitting requests past the cap.
"""
try:
item.spend = 0.0
item.spend = _carried_spend(item.spend, _rollover_cap(item.max_budget)) if _rollover_enabled() else 0.0
if hasattr(item, "budget_duration") and item.budget_duration is not None:
item.budget_reset_at = compute_budget_reset_at(
budget_duration=item.budget_duration, settings=reset_settings

View file

@ -887,6 +887,22 @@ class PrismaManager:
return
ProxyExtrasDBManager.apply_replica_identity_full_if_requested()
@staticmethod
def _raise_if_partitioned_spend_logs() -> None:
"""`prisma db push` rewrites a doc-partitioned LiteLLM_SpendLogs
primary key back to ("request_id"), which Postgres rejects. Fail fast
with guidance instead of retrying into that raw error. No-op when
litellm-proxy-extras is absent."""
try:
from litellm_proxy_extras.utils import (
PARTITIONED_SPEND_LOGS_PUSH_ERROR,
ProxyExtrasDBManager,
)
except ImportError:
return
if ProxyExtrasDBManager.spend_logs_is_partitioned():
raise RuntimeError(PARTITIONED_SPEND_LOGS_PUSH_ERROR)
@staticmethod
def setup_database(use_migrate: bool = False, use_v2_resolver: bool = False) -> bool:
"""
@ -921,6 +937,7 @@ class PrismaManager:
use_v2_resolver=use_v2_resolver,
)
else:
PrismaManager._raise_if_partitioned_spend_logs()
# Use prisma db push with increased timeout
subprocess.run(
[

View file

@ -7,8 +7,11 @@ import sys
import threading
import time
from collections.abc import Mapping, Sequence
from collections.abc import Set as AbstractSet
from types import MappingProxyType
from typing import TYPE_CHECKING, Final
from typing import TYPE_CHECKING, Final, TypeVar
from pydantic import TypeAdapter, ValidationError
import litellm
@ -16,6 +19,7 @@ if TYPE_CHECKING:
from litellm.router import Router
logger: Final = logging.getLogger(__name__)
_DeploymentT: Final = TypeVar("_DeploymentT", bound=Mapping[str, object])
from litellm.constants import (
BACKGROUND_HEALTH_CHECK_MAX_TOKENS,
BACKGROUND_HEALTH_CHECK_MAX_TOKENS_REASONING,
@ -167,6 +171,38 @@ def health_check_filter_kwargs_from_general_settings(
}
def parse_background_health_check_model_groups(
general_settings: Mapping[str, object] | None,
) -> frozenset[str] | None:
"""
Read ``general_settings.background_health_check_model_groups``.
``None`` means the allowlist is unset and every deployment participates
(legacy behavior). A list scopes background health checks and health-check
routing to deployments whose ``model_name`` is listed. A malformed value
raises so the proxy fails at startup instead of silently probing everything.
"""
raw: Final = (general_settings or {}).get("background_health_check_model_groups")
if raw is None:
return None
try:
return frozenset(TypeAdapter(list[str]).validate_python(raw))
except ValidationError as e:
raise ValueError(
"general_settings.background_health_check_model_groups must be a list of model group names"
) from e
def filter_deployments_to_model_groups(
model_list: Sequence[_DeploymentT],
model_groups: AbstractSet[str] | None,
) -> tuple[_DeploymentT, ...]:
"""Deployments whose ``model_name`` is in ``model_groups``; all of them when unset."""
if model_groups is None:
return tuple(model_list)
return tuple(x for x in model_list if x.get("model_name") in model_groups)
def filter_deployments_by_id(
model_list: Sequence[Mapping[str, object]],
) -> list:

View file

@ -19,6 +19,7 @@ from litellm.constants import (
CONSUMED_REQUEST_TAGS_METADATA_KEY,
INTERNAL_CALL_ORIGIN_METADATA_KEY,
LITELLM_PROXY_MASTER_KEY_ALIAS,
OTEL_SERVICE_NAME_METADATA_KEYS,
PRE_CALL_EXECUTED_GUARDRAILS_KEY,
SESSION_DEPLOYMENT_AFFINITY_TTL_METADATA_KEY,
)
@ -2003,6 +2004,19 @@ async def add_litellm_data_to_request(
_metadata_variable_name=_metadata_variable_name,
)
# A key's OTel service name outranks its team's, so the key's values are
# re-applied after the last-writer-wins team metadata merge above
_key_otel_service_names: Final = {
field: value
for field, value in (key_metadata or {}).items()
if field in OTEL_SERVICE_NAME_METADATA_KEYS and isinstance(value, str) and value.strip()
}
data = LiteLLMProxyRequestSetup.add_management_endpoint_metadata_to_request_metadata(
data=data,
management_endpoint_metadata=_key_otel_service_names,
_metadata_variable_name=_metadata_variable_name,
)
# Team spend, budget - used by prometheus.py
data[_metadata_variable_name]["user_api_key_team_max_budget"] = user_api_key_dict.team_max_budget
data[_metadata_variable_name]["user_api_key_team_spend"] = user_api_key_dict.team_spend

View file

@ -2190,7 +2190,7 @@ async def _get_and_validate_existing_key(
existing_key_row: Final[LiteLLM_VerificationToken | None] = await _prisma_table(
VerificationTokenRepository(prisma_client)
).find_unique(where={"token": hashed_token})
).find_unique(where={"token": hashed_token}, include={"object_permission": True})
if existing_key_row is None:
raise ProxyException(
@ -2442,11 +2442,13 @@ async def _validate_mcp_servers_for_key_update(
check_db_only=True,
)
object_permission_dict: Final = _object_permission_to_dict(data.object_permission)
team_unchanged: Final = data.team_id is None or data.team_id == existing_key_row.team_id
normalized_object_permission: Final = await validate_key_mcp_servers_against_team(
object_permission=object_permission_dict,
team_obj=effective_team_obj,
prisma_client=prisma_client,
is_proxy_admin=is_proxy_admin,
existing_key_object_permission=existing_key_row.object_permission if team_unchanged else None,
)
await validate_key_search_tools_against_team(
object_permission=object_permission_dict,

View file

@ -204,6 +204,7 @@ if MCP_AVAILABLE:
MCP_ADMIN_CONFIG_CREDENTIAL_KEYS,
MCPAuth,
MCPCredentials,
normalize_upstream_header_name,
)
from litellm.types.mcp_server.mcp_server_manager import MCPServer
@ -239,9 +240,26 @@ if MCP_AVAILABLE:
detail={"error": error_messages_text},
)
def _validate_upstream_token_header(payload: McpServerPayloadLike) -> None:
credentials: Final = getattr(payload, "credentials", None)
raw: Final = credentials.get("upstream_token_header") if isinstance(credentials, dict) else None
if not isinstance(raw, str) or raw == "":
return
if normalize_upstream_header_name(raw) is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"error": (
f"Invalid upstream_token_header {raw!r}: must be a valid HTTP header name "
"(RFC 7230 token, e.g. 'esb-oauth')"
)
},
)
def validate_and_normalize_mcp_server_payload(payload: McpServerPayloadLike) -> None:
_base_validate_and_normalize_mcp_server_payload(payload)
_validate_mcp_server_name_fields(payload)
_validate_upstream_token_header(payload)
def stamp_omitted_oauth2_flow(payload: NewMCPServerRequest) -> None:
"""Fallback only: fill in oauth2_flow when an oauth2 create omits it.
@ -739,6 +757,7 @@ if MCP_AVAILABLE:
("aws_region_name", "aws_region_name"),
("aws_service_name", "aws_service_name"),
("upstream_resource", "upstream_resource"),
("upstream_token_header", "upstream_token_header"),
)
def _has_non_admin_config_credentials(credentials: "MCPCredentials | None") -> bool:

View file

@ -808,6 +808,15 @@ def normalize_email(email: str | None) -> str | None:
return email.lower() if isinstance(email, str) else email
# Ordered highest to lowest privilege
LITELLM_USER_ROLE_HIERARCHY: Final = (
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
)
def determine_role_from_groups(
user_groups: list[str],
role_mappings: "RoleMappings",
@ -832,19 +841,11 @@ def determine_role_from_groups(
# No role mappings configured, return default_role
return role_mappings.default_role
# Role hierarchy (highest to lowest)
role_hierarchy: Final = [
LitellmUserRoles.PROXY_ADMIN,
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY,
LitellmUserRoles.INTERNAL_USER,
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
]
# Convert user_groups to a set for efficient lookup
user_groups_set: Final = set(user_groups) if isinstance(user_groups, list) else set()
# Find the highest privilege role the user belongs to
for role in role_hierarchy:
for role in LITELLM_USER_ROLE_HIERARCHY:
if role in role_mappings.roles:
role_groups = role_mappings.roles[role]
if isinstance(role_groups, list) and user_groups_set.intersection(set(role_groups)):
@ -4236,15 +4237,7 @@ class MicrosoftSSOHandler:
verbose_proxy_logger.debug("Extracted app roles from id_token: %s", app_roles)
# Combine groups and app roles
user_role: LitellmUserRoles | None = None
if app_roles:
# Check if any app role is a valid LitellmUserRoles
for role_str in app_roles:
role = get_litellm_user_role(role_str)
if role is not None:
user_role = role
verbose_proxy_logger.debug("Found valid LitellmUserRoles '%s' in app_roles", role.value)
break
user_role: Final = MicrosoftSSOHandler.get_user_role_from_app_roles(app_roles)
verbose_proxy_logger.debug("Combined team_ids (groups + app roles): %s", user_team_ids)
@ -4282,6 +4275,27 @@ class MicrosoftSSOHandler:
verbose_proxy_logger.debug("Microsoft SSO OpenID Response: %s", openid_response)
return openid_response
@staticmethod
def get_user_role_from_app_roles(
app_roles: Sequence[str] | None,
) -> LitellmUserRoles | None:
"""
Resolve the one role LiteLLM stores for a user from their Entra app roles.
Entra does not guarantee `roles` claim ordering, so a user holding several app
roles resolves to the highest privilege one rather than whichever the claim
listed first. Roles the hierarchy does not rank (org_admin, team, customer)
resolve by name to stay deterministic
"""
resolved: Final = frozenset(
role for role in (get_litellm_user_role(role_str) for role_str in app_roles or ()) if role is not None
)
if not resolved:
return None
ranked: Final = next((role for role in LITELLM_USER_ROLE_HIERARCHY if role in resolved), None)
return ranked if ranked is not None else min(resolved, key=lambda role: role.value)
@staticmethod
def get_app_roles_from_id_token(id_token: str | None) -> list[str]:
"""

View file

@ -447,6 +447,36 @@ async def enforce_all_proxy_mcp_servers_grant_is_admin_only(
)
async def _get_grandfathered_key_mcp_server_ids(
existing_object_permission: Optional["LiteLLM_ObjectPermissionTable"],
prisma_client: PrismaClient | None,
) -> frozenset[str]:
"""
Resolve the canonical MCP server IDs a key's stored object_permission already
grants. Updates that keep or shrink those grants stay valid even when the
team allowlist has since changed; sentinels are excluded so they cannot
grandfather anything.
"""
if existing_object_permission is None or prisma_client is None:
return frozenset()
raw_tool_perms: Final = existing_object_permission.mcp_tool_permissions or {}
tool_perm_keys: Final[frozenset[str]] = frozenset(
json.loads(raw_tool_perms).keys() if isinstance(raw_tool_perms, str) else raw_tool_perms.keys()
)
identifiers: Final = (frozenset(existing_object_permission.mcp_servers or []) | tool_perm_keys) - {
SpecialMCPServerNames.no_mcp_servers.value,
SpecialMCPServerName.all_proxy_servers.value,
}
return frozenset(
_flatten_resolved_mcp_server_ids(
await _resolve_mcp_server_identifiers_to_ids(
identifiers=set(identifiers),
prisma_client=prisma_client,
)
)
)
async def _get_team_allowed_mcp_servers(
team_obj: Optional["LiteLLM_TeamTableCachedObj"],
prisma_client: PrismaClient | None = None,
@ -527,10 +557,16 @@ async def validate_key_mcp_servers_against_team(
team_obj: Optional["LiteLLM_TeamTableCachedObj"],
prisma_client: PrismaClient | None = None,
is_proxy_admin: bool = False,
existing_key_object_permission: Optional["LiteLLM_ObjectPermissionTable"] = None,
) -> ObjectPermissionDict | None:
"""
Validate that MCP servers requested on a key are within the allowed scope.
When ``existing_key_object_permission`` is provided (key updates), servers
the key already holds are grandfathered: keeping or removing them stays valid
even if the team allowlist has since shrunk, while adding new servers outside
the allowlist is still rejected.
Rules:
- If key is in a team: key's mcp_servers must be a subset of
(team's allowed servers + allow_all_keys servers)
@ -589,7 +625,11 @@ async def validate_key_mcp_servers_against_team(
if teamless_admin_assignment:
allowed_servers = all_allowed_servers | active_requested_servers
disallowed_servers: Final = active_requested_servers - allowed_servers
grandfathered_servers: Final = await _get_grandfathered_key_mcp_server_ids(
existing_object_permission=existing_key_object_permission,
prisma_client=prisma_client,
)
disallowed_servers: Final = active_requested_servers - allowed_servers - grandfathered_servers
if disallowed_servers:
if team_obj is not None:
team_id = team_obj.team_id

View file

@ -1321,10 +1321,10 @@ def run_server(
use_v2_resolver=use_v2_migration_resolver,
)
except RuntimeError as e:
# v2 resolver raises on unrecoverable migration errors
# (e.g. non-idempotent failures, permission issues).
# v1 never raises here, so this only fires when the
# operator opted into v2.
# Raised on unrecoverable migration errors: the v2
# resolver's non-idempotent failures and permission
# issues, and any `prisma db push` against a
# partitioned LiteLLM_SpendLogs.
print(
f"\033[1;31mLiteLLM Proxy: Database migration cannot proceed. {e}\033[0m",
file=sys.stderr,

View file

@ -411,7 +411,9 @@ from litellm.proxy.guardrails.init_guardrails import (
initialize_guardrails,
)
from litellm.proxy.health_check import (
filter_deployments_to_model_groups,
health_check_filter_kwargs_from_general_settings,
parse_background_health_check_model_groups,
perform_health_check,
)
from litellm.proxy.health_endpoints._health_endpoints import router as health_router
@ -3660,6 +3662,13 @@ async def _run_background_health_check():
_llm_model_list = [
m for m in _llm_model_list if not m.get("model_info", {}).get("disable_background_health_check", False)
]
scoped_model_groups = llm_router.background_health_check_model_groups if llm_router is not None else None
_llm_model_list = list(filter_deployments_to_model_groups(_llm_model_list, scoped_model_groups))
if scoped_model_groups is not None and not _llm_model_list:
verbose_proxy_logger.warning(
"background_health_check_model_groups matched no deployments; groups=%s",
sorted(scoped_model_groups),
)
model_count_enabled = len(_llm_model_list)
expected_peak_in_flight = model_count_enabled
if isinstance(health_check_concurrency, int) and health_check_concurrency > 0 and model_count_enabled > 0:
@ -5239,6 +5248,7 @@ class ProxyConfig:
general_settings = config.get("general_settings", {})
if general_settings is None:
general_settings = {}
_bg_hc_model_groups: Final = parse_background_health_check_model_groups(general_settings)
_enable_hc_routing = False
_hc_staleness = None
_hc_ignore_transient = False
@ -5434,13 +5444,14 @@ class ProxyConfig:
_hc_staleness = general_settings.get("health_check_staleness_threshold", None)
_hc_ignore_transient = general_settings.get("health_check_ignore_transient_errors", False)
verbose_proxy_logger.info(
"background_health_check_config enabled=%s shared=%s interval_seconds=%s max_concurrency=%s details=%s health_check_routing=%s",
"background_health_check_config enabled=%s shared=%s interval_seconds=%s max_concurrency=%s details=%s health_check_routing=%s model_groups=%s",
use_background_health_checks,
use_shared_health_check,
health_check_interval,
health_check_concurrency,
health_check_details,
_enable_hc_routing,
sorted(_bg_hc_model_groups) if _bg_hc_model_groups is not None else None,
)
### RBAC ###
@ -5472,6 +5483,8 @@ class ProxyConfig:
router_params["health_check_staleness_threshold"] = _hc_staleness
if _hc_ignore_transient:
router_params["health_check_ignore_transient_errors"] = True
if _bg_hc_model_groups is not None:
router_params["background_health_check_model_groups"] = sorted(_bg_hc_model_groups)
## MODEL LIST
model_list: Final = config.get("model_list", None)
if model_list:
@ -9240,6 +9253,7 @@ class ProxyStartupEvent:
prisma_client,
pod_lock_manager=proxy_logging_obj.db_spend_update_writer.pod_lock_manager,
alert=_alert_ptu_rollup_failure,
router=llm_router,
)
scheduler.add_job(
@ -16402,6 +16416,13 @@ _GENERAL_SETTINGS_UI_LITELLM_FIELDS: Final[dict[str, GeneralSettingsUILiteLLMFie
"tab": "prompt_caching",
"description": "Empty uses Anthropic's 5m default. 1h suits long sessions but doubles the cache write cost.",
},
"budget_rollover": { # mutable-ok: registry literal, frozen with its siblings below
"type": "Boolean",
"description": (
"Carry spend beyond max_budget into the next window when budgets reset, instead of "
"forgiving it. Applies to key, user, team, team member, org, tag and end-user budgets."
),
},
"max_ui_session_budget": {
"type": "Dollar",
"default": 1.0,

View file

@ -14,7 +14,6 @@ and share the existing unique constraint.
import asyncio
import json
import sys
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass
from datetime import date, datetime, time, timedelta, timezone
@ -326,16 +325,6 @@ class _LoadedDeployments:
scanned_ids: frozenset[str]
def _running_router() -> object | None:
"""The proxy's router, or None outside a running proxy.
Read out of ``sys.modules`` rather than imported, so a rollup driven from a test or a
script does not pull the whole proxy server in behind it.
"""
proxy_server: Final = sys.modules.get("litellm.proxy.proxy_server")
return getattr(proxy_server, "llm_router", None) if proxy_server is not None else None
def _config_deployments(router: object | None, *, owned_by_db: frozenset[str]) -> tuple[_PTUDeployment, ...]:
"""Deployments the router holds that no ``LiteLLM_ProxyModelTable`` row owns.
@ -356,15 +345,17 @@ def _config_deployments(router: object | None, *, owned_by_db: frozenset[str]) -
)
async def _load_ptu_models(prisma_client: "PrismaClient") -> _LoadedDeployments:
async def _load_ptu_models(prisma_client: "PrismaClient", *, router: object | None) -> _LoadedDeployments:
"""Every deployment carrying valid manual PTU config, and every id the scan saw.
Reserved capacity is billed by the provider whichever file declared it, so a
deployment the proxy only knows from config.yaml accrues alongside the stored ones.
The router is handed in rather than read off the proxy module, so a run prices exactly
the deployments its caller declares and nothing a co-resident process left behind.
"""
rows: Final = await prisma_client.db.litellm_proxymodeltable.find_many()
db_ids: Final = frozenset(model_id for row in rows if (model_id := str(getattr(row, "model_id", "") or "")))
config_records: Final = _config_deployments(_running_router(), owned_by_db=db_ids)
config_records: Final = _config_deployments(router, owned_by_db=db_ids)
models: Final = tuple(
parsed for parsed in (_parse_ptu_model(row) for row in (*rows, *config_records)) if parsed is not None
)
@ -380,6 +371,7 @@ async def run_ptu_flat_cost_rollup(
prisma_client: "PrismaClient",
target_date: date | None = None,
may_prune: bool = True,
router: object | None = None,
) -> RollupResult:
"""Rollup one UTC day of flat PTU cost across all PTU-configured model deployments.
@ -406,7 +398,7 @@ async def run_ptu_flat_cost_rollup(
date_str: Final = day.isoformat()
run_started: Final = datetime.now(timezone.utc)
loaded: Final = await _load_ptu_models(prisma_client)
loaded: Final = await _load_ptu_models(prisma_client, router=router)
ptu_models: Final = loaded.models
charges: Final = _aggregate_charges(ptu_models, day)
@ -527,6 +519,7 @@ async def _existing_sentinel_keys(
async def run_ptu_flat_cost_backfill(
prisma_client: "PrismaClient",
today: date | None = None,
router: object | None = None,
) -> BackfillResult:
"""Price the elapsed days of every PTU window that carry no sentinel row yet.
@ -546,7 +539,7 @@ async def run_ptu_flat_cost_backfill(
verbose_proxy_logger.warning("PTU backfill: prisma_client is None, skipping")
return BackfillResult(start=end, end=end, days_scanned=0, rows_written=0)
ptu_models: Final = (await _load_ptu_models(prisma_client)).models
ptu_models: Final = (await _load_ptu_models(prisma_client, router=router)).models
days: Final = _backfill_window(ptu_models, end)
if not days:
@ -591,6 +584,7 @@ async def run_scheduled_ptu_rollup(
pod_lock_manager: "PodLockManager | None" = None,
target_date: date | None = None,
alert: Callable[[str], Awaitable[None]] | None = None,
router: object | None = None,
) -> RollupResult | None:
"""Run the daily rollup under a cross-pod lock so only one proxy reconciles a day.
@ -615,7 +609,7 @@ async def run_scheduled_ptu_rollup(
return None
if pod_lock_manager is None or pod_lock_manager.redis_cache is None:
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=False)
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=False, router=router)
if not await pod_lock_manager.acquire_lock(cronjob_id=PTU_ROLLUP_JOB_ID, ttl=PTU_ROLLUP_LOCK_TTL_SECONDS):
if await _lock_is_held(pod_lock_manager):
@ -629,10 +623,10 @@ async def run_scheduled_ptu_rollup(
"PTU rollup: could not take the rollup lock and no other pod holds it, "
"running unguarded rather than skipping the day"
)
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=False)
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=False, router=router)
try:
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=True)
return await _run_and_alert(prisma_client, target_date=target_date, alert=alert, may_prune=True, router=router)
finally:
await pod_lock_manager.release_lock(cronjob_id=PTU_ROLLUP_JOB_ID)
@ -657,6 +651,7 @@ async def _run_and_alert(
target_date: date | None,
alert: "Callable[[str], Awaitable[None]] | None",
may_prune: bool = True,
router: object | None = None,
) -> RollupResult:
"""Reconcile the day, catch up any days left unpriced, and alert on charges that did not land.
@ -669,7 +664,9 @@ async def _run_and_alert(
explicit date means reconcile exactly that day, so it stays a single-day operation.
Its failure is contained: the day's own result is returned either way.
"""
result: Final = await run_ptu_flat_cost_rollup(prisma_client, target_date=target_date, may_prune=may_prune)
result: Final = await run_ptu_flat_cost_rollup(
prisma_client, target_date=target_date, may_prune=may_prune, router=router
)
if result.rows_failed:
await _deliver_alert(
alert,
@ -686,7 +683,7 @@ async def _run_and_alert(
"by the provider with nothing attributing it here. Extend the window, or retire the deployment.",
)
if target_date is None:
await _backfill_and_alert(prisma_client, alert=alert)
await _backfill_and_alert(prisma_client, alert=alert, router=router)
return result
@ -694,6 +691,7 @@ async def _backfill_and_alert(
prisma_client: "PrismaClient",
*,
alert: "Callable[[str], Awaitable[None]] | None",
router: object | None = None,
) -> None:
"""Catch up unpriced PTU days, alerting on charges that did not land.
@ -701,7 +699,7 @@ async def _backfill_and_alert(
caller whatever the catch-up pass does.
"""
try:
backfill: Final = await run_ptu_flat_cost_backfill(prisma_client)
backfill: Final = await run_ptu_flat_cost_backfill(prisma_client, router=router)
except Exception as exc: # noqa: BLE001 # the catch-up pass must not fail the day's rollup
verbose_proxy_logger.error("PTU backfill: catch-up pass failed, the day's rollup still stands: %s", exc)
return

View file

@ -23,6 +23,7 @@ from typing_extensions import ReadOnly
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.constants import LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME
from litellm.proxy._types import *
from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
@ -54,6 +55,11 @@ router: Final = APIRouter()
SPEND_LOGS_PAGINATION_COUNT_CAP: Final = 10000
_INTERNAL_HEALTH_CHECK_API_KEYS: Final = (
LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME,
hash_token(token=LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME),
)
_RowT = TypeVar("_RowT")
@ -2248,6 +2254,10 @@ async def ui_view_spend_logs(
status_filter: str | None = fastapi.Query(
default=None, description="Filter logs by status (e.g., success, failure)"
),
cache_hit_filter: str | None = fastapi.Query(
default=None,
description="Filter logs by cache state: 'hit' or 'miss'. Miss includes legacy rows with a null/unknown cache state",
),
model: str | None = fastapi.Query(default=None, description="Filter logs by model"),
model_id: str | None = fastapi.Query(
default=None,
@ -2268,6 +2278,10 @@ async def ui_view_spend_logs(
default="desc",
description="Sort order: asc or desc",
),
exclude_internal_health_checks: bool = fastapi.Query(
default=False,
description="Exclude LiteLLM internal health check requests from results",
),
):
"""
View spend logs with pagination support.
@ -2320,6 +2334,13 @@ async def ui_view_spend_logs(
param="sort_order",
code=status.HTTP_400_BAD_REQUEST,
)
if isinstance(cache_hit_filter, str) and cache_hit_filter not in {"hit", "miss"}:
raise ProxyException(
message=f"Invalid cache_hit_filter: {cache_hit_filter}. Must be one of: hit, miss",
type="bad_request",
param="cache_hit_filter",
code=status.HTTP_400_BAD_REQUEST,
)
try:
is_admin_view: Final = _is_admin_view_safe(user_api_key_dict=user_api_key_dict)
@ -2560,6 +2581,16 @@ async def ui_view_spend_logs(
sql_params.append(status_filter)
p += 1
if cache_hit_filter == "hit":
sql_conditions.append("LOWER(cache_hit) = 'true'")
elif cache_hit_filter == "miss":
sql_conditions.append("(cache_hit IS NULL OR LOWER(cache_hit) != 'true')")
if exclude_internal_health_checks:
sql_conditions.append(f"api_key NOT IN (${p}, ${p + 1})")
sql_params.extend(_INTERNAL_HEALTH_CHECK_API_KEYS)
p += 2 # rebind-ok: advances the file's shared $N placeholder counter
# Spend range
if min_spend is not None:
sql_conditions.append(f"spend >= ${p}")

View file

@ -19,32 +19,57 @@ from collections.abc import AsyncGenerator, Callable, Mapping
from contextlib import asynccontextmanager
from dataclasses import dataclass
from datetime import datetime
from typing import Final
from litellm.repositories.prisma_protocols import BatchTable, PrismaBatch
def _spend_reset_data(budget_reset_at: datetime | None, spend_decrement: float | None) -> Mapping[str, object]:
spend: Final[object] = (
{"decrement": spend_decrement} # mutable-ok: prisma update payload must be a dict
if spend_decrement is not None
else 0
)
return {"spend": spend, "budget_reset_at": budget_reset_at} # mutable-ok: prisma update payload must be a dict
@dataclass(frozen=True, slots=True)
class KeySpendResetWrites:
table: BatchTable
def queue_spend_reset(self, token: str, budget_reset_at: datetime | None) -> None:
self.table.update(where={"token": token}, data={"spend": 0, "budget_reset_at": budget_reset_at})
def queue_spend_reset(
self, token: str, budget_reset_at: datetime | None, spend_decrement: float | None = None
) -> None:
self.table.update(
where={"token": token}, # mutable-ok: prisma where filter must be a dict
data=_spend_reset_data(budget_reset_at, spend_decrement),
)
@dataclass(frozen=True, slots=True)
class UserSpendResetWrites:
table: BatchTable
def queue_spend_reset(self, user_id: str, budget_reset_at: datetime | None) -> None:
self.table.update(where={"user_id": user_id}, data={"spend": 0, "budget_reset_at": budget_reset_at})
def queue_spend_reset(
self, user_id: str, budget_reset_at: datetime | None, spend_decrement: float | None = None
) -> None:
self.table.update(
where={"user_id": user_id}, # mutable-ok: prisma where filter must be a dict
data=_spend_reset_data(budget_reset_at, spend_decrement),
)
@dataclass(frozen=True, slots=True)
class TeamSpendResetWrites:
table: BatchTable
def queue_spend_reset(self, team_id: str, budget_reset_at: datetime | None) -> None:
self.table.update(where={"team_id": team_id}, data={"spend": 0, "budget_reset_at": budget_reset_at})
def queue_spend_reset(
self, team_id: str, budget_reset_at: datetime | None, spend_decrement: float | None = None
) -> None:
self.table.update(
where={"team_id": team_id}, # mutable-ok: prisma where filter must be a dict
data=_spend_reset_data(budget_reset_at, spend_decrement),
)
@dataclass(frozen=True, slots=True)
@ -54,6 +79,14 @@ class LinkedSpendResetWrites:
def queue_spend_zero(self, where: Mapping[str, object]) -> None:
self.table.update_many(where=where, data={"spend": 0})
def queue_spend_decrement(self, where: Mapping[str, object], amount: float) -> None:
"""``decrement`` rather than a read-then-set, so spend written between the
cascade's read and its commit survives the reset instead of being erased."""
self.table.update_many(
where=where,
data={"spend": {"decrement": amount}}, # mutable-ok: prisma update payload must be a dict
)
@dataclass(frozen=True, slots=True)
class BudgetWindowWrites:

View file

@ -602,6 +602,7 @@ class Router:
enable_health_check_routing: bool = False,
health_check_staleness_threshold: int | None = None,
health_check_ignore_transient_errors: bool = False,
background_health_check_model_groups: Sequence[str] | None = None,
enable_weighted_failover: bool = False,
) -> None:
"""
@ -811,6 +812,11 @@ class Router:
self.enable_health_check_routing = enable_health_check_routing
self.enable_weighted_failover = enable_weighted_failover
self.health_check_ignore_transient_errors = health_check_ignore_transient_errors
self.background_health_check_model_groups: frozenset[str] | None = (
frozenset(background_health_check_model_groups)
if background_health_check_model_groups is not None
else None
)
_staleness: Final = health_check_staleness_threshold or (
DEFAULT_HEALTH_CHECK_INTERVAL * DEFAULT_HEALTH_CHECK_STALENESS_MULTIPLIER
)
@ -9210,7 +9216,11 @@ class Router:
}
if model_id is not None:
litellm.register_model(model_cost={model_id: model_info}, persist_across_reloads=False)
litellm.register_model(
model_cost={model_id: model_info},
persist_across_reloads=False,
warning_display_name=model,
)
## OLD MODEL REGISTRATION ## Kept to prevent breaking changes
backend_keys: Final = Router._backend_cost_map_keys(model=model, custom_llm_provider=custom_llm_provider)
@ -12719,6 +12729,10 @@ class Router:
"""
Filter out deployments marked unhealthy by background health checks.
No-op when enable_health_check_routing is False.
When background_health_check_model_groups is set, only deployments in the
listed model groups are filtered; every other group keeps its configured
routing strategy untouched, and a router-level allowed_fails_policy no
longer disables the filter for the listed groups.
Returns all deployments if health state is unavailable, stale, or would
exclude every candidate (safety net).
"""
@ -12727,8 +12741,10 @@ class Router:
# When allowed_fails_policy is set, cooldown is the sole routing exclusion
# mechanism -- skip the binary health check filter so the policy threshold
# is respected before any deployment is excluded.
if self.allowed_fails_policy is not None:
# is respected before any deployment is excluded. With a model-group
# allowlist the filter is already scoped, so listed groups keep it.
scoped_groups: Final = self.background_health_check_model_groups
if self.allowed_fails_policy is not None and scoped_groups is None:
return healthy_deployments
unhealthy_ids: Final = await self.health_state_cache.async_get_unhealthy_deployment_ids(
@ -12737,7 +12753,12 @@ class Router:
if not unhealthy_ids:
return healthy_deployments
filtered: Final = [d for d in healthy_deployments if d["model_info"]["id"] not in unhealthy_ids]
filtered: Final = [
d
for d in healthy_deployments
if d["model_info"]["id"] not in unhealthy_ids
or (scoped_groups is not None and d["model_name"] not in scoped_groups)
]
if not filtered:
verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")
@ -12754,14 +12775,20 @@ class Router:
if not self.enable_health_check_routing:
return healthy_deployments
if self.allowed_fails_policy is not None:
scoped_groups: Final = self.background_health_check_model_groups
if self.allowed_fails_policy is not None and scoped_groups is None:
return healthy_deployments
unhealthy_ids: Final = self.health_state_cache.get_unhealthy_deployment_ids(parent_otel_span=parent_otel_span)
if not unhealthy_ids:
return healthy_deployments
filtered: Final = [d for d in healthy_deployments if d["model_info"]["id"] not in unhealthy_ids]
filtered: Final = [
d
for d in healthy_deployments
if d["model_info"]["id"] not in unhealthy_ids
or (scoped_groups is not None and d["model_name"] not in scoped_groups)
]
if not filtered:
verbose_router_logger.warning("All deployments marked unhealthy by health checks, bypassing health filter")

View file

@ -43,12 +43,33 @@ class DeploymentHealthCache:
self.staleness_threshold = staleness_threshold
def set_deployment_health_states(self, states: dict[str, DeploymentHealthStateValue]) -> None:
"""Bulk-write all deployment health states as a single cache entry."""
"""Merge the given states into the shared cache entry, pruning expired ones.
Merging instead of replacing lets writers probing different deployment
scopes (e.g. pods with different background health check allowlists)
coexist on the one shared entry without erasing each other's results.
The snapshot is read from Redis when available, since a pod-local read
would only ever see this writer's own previous merge. When the Redis
read comes back empty (a miss, or a swallowed connection error), the
pod-local copy of the last merge is used so peers are not erased.
"""
try:
redis_raw: Final = (
self.cache.redis_cache.get_cache(self.CACHE_KEY) if self.cache.redis_cache is not None else None
)
raw: Final = redis_raw if isinstance(redis_raw, dict) else self.cache.get_cache(key=self.CACHE_KEY)
existing: Final = raw if isinstance(raw, dict) else {}
expiry_seconds: Final = self.staleness_threshold * 1.5
now: Final = time.time()
merged: Final = {
model_id: state
for model_id, state in {**existing, **states}.items()
if isinstance(state, dict) and (now - state.get("timestamp", 0)) < expiry_seconds
}
self.cache.set_cache(
key=self.CACHE_KEY,
value=states,
ttl=int(self.staleness_threshold * 1.5),
value=merged,
ttl=int(expiry_seconds),
)
except Exception as e:
verbose_logger.error(

View file

@ -0,0 +1,81 @@
from typing import Literal, Required
from pydantic import BaseModel, ConfigDict
from typing_extensions import ReadOnly, TypedDict
class GeminiTranscriptionAudioInput(TypedDict):
type: ReadOnly[Literal["audio"]]
data: ReadOnly[str]
mime_type: ReadOnly[str]
class GeminiTranscriptionVerbatimMode(TypedDict, total=False):
type: ReadOnly[Required[Literal["verbatim"]]]
timestamp_granularities: ReadOnly[tuple[Literal["word"], ...]]
diarization_mode: ReadOnly[Literal["speaker"]]
class GeminiTranscriptionConfig(TypedDict, total=False):
language_codes: ReadOnly[tuple[str, ...]]
mode: ReadOnly[GeminiTranscriptionVerbatimMode]
class GeminiTranscriptionGenerationConfig(TypedDict):
transcription_config: ReadOnly[GeminiTranscriptionConfig]
class GeminiTranscriptionInteractionRequest(TypedDict, total=False):
model: ReadOnly[Required[str]]
input: ReadOnly[Required[tuple[GeminiTranscriptionAudioInput, ...]]]
generation_config: ReadOnly[GeminiTranscriptionGenerationConfig]
class GeminiTranscriptionWordAnnotation(BaseModel):
model_config = ConfigDict(extra="ignore")
type: str | None = None
text: str | None = None
speaker: str | None = None
start_offset: str | None = None
end_offset: str | None = None
class GeminiTranscriptionContent(BaseModel):
model_config = ConfigDict(extra="ignore")
type: str | None = None
text: str | None = None
annotations: tuple[GeminiTranscriptionWordAnnotation, ...] = ()
class GeminiTranscriptionStep(BaseModel):
model_config = ConfigDict(extra="ignore")
type: str | None = None
content: tuple[GeminiTranscriptionContent, ...] = ()
class GeminiTranscriptionModalityTokens(BaseModel):
model_config = ConfigDict(extra="ignore")
modality: str | None = None
tokens: int = 0
class GeminiTranscriptionUsage(BaseModel):
model_config = ConfigDict(extra="ignore")
total_tokens: int = 0
total_input_tokens: int = 0
total_output_tokens: int = 0
input_tokens_by_modality: tuple[GeminiTranscriptionModalityTokens, ...] = ()
class GeminiTranscriptionInteractionResponse(BaseModel):
model_config = ConfigDict(extra="ignore")
id: str | None = None
status: str | None = None
usage: GeminiTranscriptionUsage | None = None
steps: tuple[GeminiTranscriptionStep, ...] = ()

View file

@ -1,6 +1,11 @@
import enum
import re
from collections.abc import Awaitable, Callable, Mapping
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, Literal
from urllib.parse import urlsplit
import httpx
from pydantic import BaseModel
from typing_extensions import TypedDict
@ -181,6 +186,15 @@ class MCPCredentials(TypedDict, total=False):
``audience``, which is the RFC 8693 token-exchange parameter.
"""
upstream_token_header: str | None # writable-ok: pydantic warns it cannot honour ReadOnly here
"""
Which upstream header carries the credential LiteLLM resolves for this server. Omitted when
unset, which keeps RFC 6750's default of ``Authorization``. Set it when the upstream expects the
gateway's token somewhere else (an ESB terminating its own credential on e.g. ``esb-oauth``), so
a separate operator-configured ``Authorization`` reaches the origin untouched. Non-secret, so it
is stored in plaintext and returned on admin reads.
"""
client_private_key: str | None
"""
PEM private key used to sign the private-key-JWT client_assertion (RFC 7523)
@ -223,7 +237,92 @@ class MCPCredentials(TypedDict, total=False):
"""
MCP_ADMIN_CONFIG_CREDENTIAL_KEYS: Final[tuple[str, ...]] = ("upstream_resource",)
DEFAULT_CREDENTIAL_HEADER: Final = "Authorization"
_HEADER_NAME_TOKEN: Final = re.compile(r"^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$")
def normalize_upstream_header_name(raw: str) -> str | None:
"""The trimmed header name if it is a usable RFC 7230 ``token``, else None.
One owner for the grammar; each caller picks its own failure shape (a config-load raise, an
API 400, a typed CredError). An operator-supplied name reaches egress verbatim, so a value
carrying CR/LF, spaces or separators must never get that far.
"""
stripped: Final = raw.strip()
return stripped if stripped and _HEADER_NAME_TOKEN.match(stripped) else None
def same_header(name: str, other: str) -> bool:
"""Whether two HTTP header names are the same one. They are case-insensitive (RFC 7230 3.2)."""
return name.lower() == other.lower()
def has_header(headers: Mapping[str, str] | None, name: str) -> bool:
"""Whether ``headers`` carries ``name`` under any casing."""
return bool(headers) and any(same_header(key, name) for key in headers or {})
def without_header(headers: Mapping[str, str] | None, name: str) -> dict[str, str] | None:
"""A copy of ``headers`` with every casing of ``name`` removed, or None if nothing remains.
The one owner of "drop this credential's header". Both MCP stacks and the upstream-credential
resolver share it so a slot can never be dropped case-sensitively in one place and
case-insensitively in another, which is how an injected header came to shadow a resolved
credential on the v1 path.
"""
if not headers:
return None
filtered: Final = {key: value for key, value in headers.items() if not same_header(key, name)}
return filtered or None
_DEFAULT_PORTS: Final[Mapping[str, int]] = MappingProxyType({"http": 80, "https": 443})
def crosses_origin(configured: str, target: str) -> bool:
"""Whether ``target`` leaves ``configured``'s origin, by the rule HTTP clients use.
Origin is scheme, host and port, not host alone, so a same-host HTTPS downgrade or a port change
counts as crossing it. A plain http -> https upgrade of the same host is exempt, matching what
httpx exempts when it decides whether to keep ``Authorization`` across a redirect.
"""
a: Final = urlsplit(configured)
b: Final = urlsplit(target)
port_a: Final = a.port or _DEFAULT_PORTS.get(a.scheme)
port_b: Final = b.port or _DEFAULT_PORTS.get(b.scheme)
if a.scheme == b.scheme and a.hostname == b.hostname and port_a == port_b:
return False
return not (
a.hostname == b.hostname and a.scheme == "http" and port_a == 80 and b.scheme == "https" and port_b == 443
)
def custom_credential_slot(headers: Mapping[str, str] | None) -> str | None:
"""The first header carrying a credential somewhere other than ``Authorization``, if any."""
return next((name for name in headers or {} if not same_header(name, DEFAULT_CREDENTIAL_HEADER)), None)
def credential_redirect_hook(
configured_url: str, slot: str | None
) -> Callable[[httpx.Request], Awaitable[None]] | None:
"""An httpx request hook dropping ``slot`` once a redirect leaves ``configured_url``'s origin.
None when no guard is needed, so callers do not each repeat the exemption: HTTP clients already
strip ``Authorization`` across origins, but forward every other header, so only a credential an
operator moved to its own slot can be replayed to whatever host the upstream redirects to.
"""
if not configured_url or not slot or same_header(slot, DEFAULT_CREDENTIAL_HEADER):
return None
async def guard(request: httpx.Request) -> None:
if slot in request.headers and crosses_origin(configured_url, str(request.url)):
del request.headers[slot]
return guard
MCP_ADMIN_CONFIG_CREDENTIAL_KEYS: Final[tuple[str, ...]] = ("upstream_resource", "upstream_token_header")
"""Non-secret credential keys returned on read so the admin form can show and clear them. Mirrors
``ADMIN_CONFIG_CREDENTIAL_KEYS`` in ``ui/litellm-dashboard/src/components/mcp_tools/types.tsx``."""

View file

@ -1,7 +1,7 @@
from datetime import datetime
from typing import Any, Final, Literal
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, field_validator
from litellm.types.mcp import (
DEFAULT_SUBJECT_TOKEN_TYPE,
@ -9,6 +9,7 @@ from litellm.types.mcp import (
MCPAuthType,
MCPTokenEndpointAuthMethod,
MCPTransportType,
normalize_upstream_header_name,
)
# MCPInfo now allows arbitrary additional fields for custom metadata
@ -86,6 +87,22 @@ class MCPServer(BaseModel):
# today's behavior; "auto" derives the canonical URI from ``url``; any other value is sent
# verbatim. Resolved by ``oauth_utils.resolve_upstream_resource``.
upstream_resource: str | None = None
# Which upstream header carries the credential LiteLLM resolves for this server (the minted
# OAuth token, or the static key). None keeps RFC 6750's default, ``Authorization``. An ESB or
# API gateway that terminates its own credential in a private header needs this so a second,
# operator-configured ``Authorization`` can pass through to the origin untouched.
upstream_token_header: str | None = None
@field_validator("upstream_token_header")
@classmethod
def _check_upstream_token_header(cls, value: str | None) -> str | None:
if value is None or not value.strip():
return None
normalized: Final = normalize_upstream_header_name(value)
if normalized is None:
raise ValueError(f"upstream_token_header must be a valid HTTP header name (RFC 7230 token), got {value!r}")
return normalized
# AWS SigV4 fields
aws_access_key_id: str | None = None
aws_secret_access_key: str | None = None

View file

@ -162,3 +162,16 @@ class RealtimeErrorDetail(TypedDict):
class RealtimeErrorEvent(TypedDict):
type: ReadOnly[Literal["error"]]
error: ReadOnly[RealtimeErrorDetail]
class RealtimeInputAudioTranscriptionUsageInputTokenDetails(TypedDict):
text_tokens: ReadOnly[int]
audio_tokens: ReadOnly[int]
class RealtimeInputAudioTranscriptionUsage(TypedDict):
type: ReadOnly[Literal["tokens"]]
input_tokens: ReadOnly[int]
output_tokens: ReadOnly[int]
total_tokens: ReadOnly[int]
input_token_details: ReadOnly[RealtimeInputAudioTranscriptionUsageInputTokenDetails]

View file

@ -2948,7 +2948,12 @@ def reapply_runtime_model_cost_registrations() -> None:
register_model(model_cost=dict(_runtime_registered_model_cost)) # mutable-ok: snapshot, replay rewrites it
def register_model(model_cost: str | dict, *, persist_across_reloads: bool = True):
def register_model(
model_cost: str | dict,
*,
persist_across_reloads: bool = True,
warning_display_name: str | None = None,
):
"""
Register new / Override existing models (and their pricing) to specific providers.
Provide EITHER a model cost dictionary or a url to a hosted json blob
@ -2968,6 +2973,10 @@ def register_model(model_cost: str | dict, *, persist_across_reloads: bool = Tru
registering a model is declaring durable intent. Pass False for a
registration that only describes one request, so it is dropped rather than
re-asserted over every future catalog.
``warning_display_name`` names the model in the missing-cache-pricing
warning instead of the registered key, for callers that register under an
opaque key (e.g. the router's hashed deployment ids).
"""
loaded_model_cost = {}
@ -3014,10 +3023,14 @@ def register_model(model_cost: str | dict, *, persist_across_reloads: bool = Tru
elif (
value.get("cache_creation_input_token_cost") is None
and value.get("cache_read_input_token_cost") is None
and value.get("tiered_pricing") is None
and (
value.get("input_cost_per_token") is not None or value.get("output_cost_per_token") is not None
)
):
verbose_logger.warning(
"register_model: model=%s not in built-in cost map and no prefix/region variant matched; cache cost fields will default to 0. To track cache cost, add cache_creation_input_token_cost and cache_read_input_token_cost to model_info",
key,
"register_model: model=%s has custom pricing but not in built-in cost map and no prefix/region variant matched; cache_creation_input_token_cost and cache_read_input_token_cost will default to 0 for this model (input/output cost tracking is unaffected). To track cache cost, add them to model_info",
warning_display_name or key,
)
# ``get_model_info`` returns ``litellm_provider: None`` when the
# provider is unknown (e.g. custom deployments registered via
@ -8503,6 +8516,12 @@ class ProviderConfigManager:
)
return VertexAIAudioTranscriptionConfig()
elif litellm.LlmProviders.GEMINI == provider:
from litellm.llms.gemini.audio_transcription.transformation import (
GeminiAudioTranscriptionConfig,
)
return GeminiAudioTranscriptionConfig()
return None
@staticmethod

File diff suppressed because it is too large Load diff

View file

@ -67,8 +67,8 @@ proxy = [
"azure-identity>=1.25.2,<2.0",
"azure-storage-blob>=12.28.0,<13.0",
"mcp>=1.28.1,<2.0",
"litellm-proxy-extras==0.4.89",
"litellm-enterprise==0.1.60",
"litellm-proxy-extras==0.4.90",
"litellm-enterprise==0.1.61",
"RestrictedPython>=8.1,<9.0",
"rich>=13.9.4,<14.0",
"InquirerPy>=0.3.4,<1.0",

View file

@ -13,7 +13,7 @@
# - tests/e2e Python -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step)
# + raw HTTP client ban (test-code-quality.yml's check_e2e_no_raw_requests)
# - dashboard -> prettier + eslint + lint budgets (test-litellm-ui-build.yml's frontend-lint)
# - proxy/types -> regenerate dashboard API types and fail on drift (check-ui-api-types.yml)
# - proxy/types -> regenerate the lazy OpenAPI snapshot and dashboard API types, fail on drift (check-ui-api-types.yml)
#
# Each block is skipped when no matching files are in scope, so unrelated commits
# stay fast. This is intentionally not auto-installed as a git hook (see
@ -244,7 +244,7 @@ fi
genapi_checks() {
local status=0
echo "check: checking dashboard API types are in sync (npm run gen:api)"
echo "check: checking the lazy OpenAPI snapshot and dashboard API types are in sync (npm run gen:api)"
# gen-api-types.mjs imports litellm.proxy.proxy_server, which needs the proxy deps
# and an up-to-date Prisma client; check-ui-api-types.yml installs those and runs
# prisma generate before gen:api, so mirror that here or a stale client can mask
@ -260,7 +260,14 @@ genapi_checks() {
elif ! uv run --no-sync python scripts/prisma_generate_if_needed.py; then
echo "✗ Could not regenerate Prisma client (prisma generate failed)." >&2
status=1
elif ! uv run --no-sync python -m litellm.proxy._lazy_openapi_snapshot; then
echo "✗ Could not regenerate the lazy OpenAPI snapshot (python -m litellm.proxy._lazy_openapi_snapshot failed)." >&2
status=1
elif ( cd ui/litellm-dashboard && LITELLM_PYTHON="uv run --no-sync python" npm run gen:api ); then
if ! git diff --quiet -- litellm/proxy/_lazy_openapi_snapshot.json; then
echo "✗ The lazy OpenAPI snapshot is stale; regenerated litellm/proxy/_lazy_openapi_snapshot.json. Stage it and commit; re-run make check only if other checks failed too." >&2
status=1
fi
if ! git diff --quiet -- ui/litellm-dashboard/src/lib/http/schema.d.ts; then
echo "✗ Dashboard API types are stale; regenerated src/lib/http/schema.d.ts. Stage it and commit; re-run make check only if other checks failed too." >&2
status=1

View file

@ -5,8 +5,9 @@ import json
from pathlib import Path
import re
import sys
import time
import tomllib
from typing import Dict, List, Optional, Set, Tuple
from typing import Callable, Dict, Final, List, Optional, Protocol, Set, Tuple
from packaging.requirements import Requirement
import requests
@ -37,6 +38,13 @@ DEFAULT_TRANSITIVE_PIN_PACKAGES = (
# of the identifier, not an operator.
_SPDX_OPERATOR_SPLIT = re.compile(r"\s+(?:OR|AND)\s+")
_SPDX_WITH_SUFFIX = re.compile(r"\s+WITH\s+.*", re.DOTALL)
_PYPI_FETCH_ATTEMPTS: Final[int] = 3
_PYPI_FETCH_BACKOFF_SECONDS: Final[float] = 0.5
class _HttpGet(Protocol):
def __call__(self, url: str, *, timeout: float) -> requests.Response:
...
@dataclass
@ -50,7 +58,10 @@ class PackageLicense:
class LicenseChecker:
def __init__(
self, config_file: Path = Path("./tests/code_coverage_tests/liccheck.ini")
self,
config_file: Path = Path("./tests/code_coverage_tests/liccheck.ini"),
http_get: Optional[_HttpGet] = None,
sleep: Optional[Callable[[float], None]] = None,
):
if not config_file.exists():
print(f"Error: Config file {config_file} not found")
@ -79,6 +90,8 @@ class LicenseChecker:
# Track package results
self.package_results: List[PackageLicense] = []
self._http_get = http_get
self._sleep = sleep
@staticmethod
def _normalize_package_name(package_name: str) -> str:
@ -123,21 +136,38 @@ class LicenseChecker:
last resort derives the license from the ``License :: OSI Approved ::
...`` trove classifiers.
"""
try:
url = f"https://pypi.org/pypi/{package_name}/{version}/json"
response = requests.get(url, timeout=10)
response.raise_for_status()
info = response.json().get("info", {}) or {}
return (
info.get("license_expression")
or info.get("license")
or self._license_from_classifiers(info.get("classifiers") or [])
)
except Exception as e:
print(
f"Warning: Failed to fetch license for {package_name} {version}: {str(e)}"
)
return None
url = f"https://pypi.org/pypi/{package_name}/{version}/json"
http_get = self._http_get if self._http_get is not None else requests.get
sleep = self._sleep if self._sleep is not None else time.sleep
for attempt in range(_PYPI_FETCH_ATTEMPTS):
try:
response = http_get(url, timeout=10)
response.raise_for_status()
info = response.json().get("info", {}) or {}
return (
info.get("license_expression")
or info.get("license")
or self._license_from_classifiers(info.get("classifiers") or [])
)
except Exception as error:
if self._is_retryable_pypi_error(error) and attempt < _PYPI_FETCH_ATTEMPTS - 1:
sleep(_PYPI_FETCH_BACKOFF_SECONDS)
continue
print(
f"Warning: Failed to fetch license for {package_name} {version}: {str(error)}"
)
return None
return None
@staticmethod
def _is_retryable_pypi_error(error: Exception) -> bool:
if isinstance(error, (requests.ConnectionError, requests.Timeout)):
return True
if not isinstance(error, requests.HTTPError) or error.response is None:
return False
status_code = error.response.status_code
return status_code == 429 or status_code >= 500
@staticmethod
def _license_from_classifiers(classifiers: List[str]) -> Optional[str]:

View file

@ -210,16 +210,24 @@ def _deltas(result: StreamingResponse) -> list[_StreamDelta]:
]
def _single_weather_call(message: OutMessage) -> ToolCall:
assert message.tool_calls, f"Together dropped the tool call: {message}"
assert len(message.tool_calls) == 1, f"expected one tool call, got {message.tool_calls}"
call = message.tool_calls[0]
def _validated_weather_call_id(call: ToolCall) -> str:
assert call.id, f"tool call carries no id, so a tool result cannot answer it: {call}"
assert call.function.name == "get_weather", f"wrong tool called: {call}"
assert call.function.arguments, f"tool call carries no arguments: {call}"
args = _WeatherArgs.model_validate_json(call.function.arguments)
assert "paris" in args.location.lower(), f"tool arguments lost the location: {args}"
return call
return call.id
def _weather_call_ids(message: OutMessage) -> tuple[str, ...]:
"""The id of every tool call the model made, each one checked for the fields a
caller needs to answer it. The backend is whichever together_ai row is cheapest
with tools and reasoning, and those rows carry supports_parallel_function_calling,
so one weather prompt can legitimately come back as several get_weather calls.
What the gateway owes us is that each call survives translation intact; how many
the model chose to make is the model's business."""
assert message.tool_calls, f"Together dropped the tool call: {message}"
return tuple(_validated_weather_call_id(call) for call in message.tool_calls)
def _weather_call(client: PassthroughClient, key: str, model: str) -> OutMessage:
@ -289,7 +297,7 @@ class TestTogetherChatCompletions:
self, client: PassthroughClient, resources: ResourceManager, reasoning_tool_backend: str
) -> None:
model, key = _register(client, resources, reasoning_tool_backend)
_single_weather_call(_weather_call(client, key, model))
_ = _weather_call_ids(_weather_call(client, key, model))
@pytest.mark.covers("llm.chat_completions.together_ai.tool_use.stream.works")
def test_tool_call_is_streamed(
@ -328,8 +336,7 @@ class TestTogetherChatCompletions:
) -> None:
model, key = _register(client, resources, reasoning_tool_backend)
first = _weather_call(client, key, model)
call = _single_weather_call(first)
assert call.id is not None
call_ids = _weather_call_ids(first)
answer = _message(
unwrap(
@ -344,7 +351,10 @@ class TestTogetherChatCompletions:
reasoning_content=first.reasoning_content,
tool_calls=first.tool_calls,
),
ChatToolResultTurn(tool_call_id=call.id, content=WEATHER_REPORT),
*(
ChatToolResultTurn(tool_call_id=call_id, content=WEATHER_REPORT)
for call_id in call_ids
),
],
tools=[WEATHER_TOOL],
max_tokens=512,
@ -470,9 +480,21 @@ def _tool_use_blocks(content: list[AnthropicContentBlock] | None) -> list[Anthro
return [block for block in content if block.type == "tool_use"]
def _validated_tool_use_id(block: AnthropicContentBlock) -> str:
assert block.name == "get_weather", f"wrong tool called: {block}"
assert block.id, f"tool_use block carries no id, so a tool_result cannot answer it: {block}"
assert block.input is not None, f"tool_use block carries no input: {block}"
args = _WeatherArgs.model_validate(block.input)
assert "paris" in args.location.lower(), f"tool input lost the location: {args}"
return block.id
def _messages_weather_call(
client: PassthroughClient, key: str, model: str
) -> tuple[list[AnthropicContentBlock], AnthropicContentBlock]:
) -> tuple[list[AnthropicContentBlock], tuple[str, ...]]:
"""The blocks /v1/messages returned and the id of every tool_use among them. The
count is the model's choice (see _weather_call_ids); what this surface owes us is
that each tool_use arrives named and addressable."""
response = unwrap(
client.proxy.messages(
key,
@ -485,12 +507,9 @@ def _messages_weather_call(
)
)
tool_uses = _tool_use_blocks(response.content)
assert len(tool_uses) == 1, f"expected one tool_use block, got {response.content}"
block = tool_uses[0]
assert block.name == "get_weather", f"wrong tool called: {block}"
assert block.id, f"tool_use block carries no id, so a tool_result cannot answer it: {block}"
assert tool_uses, f"/v1/messages carried no tool_use block: {response.content}"
assert response.content is not None
return response.content, block
return response.content, tuple(_validated_tool_use_id(block) for block in tool_uses)
class TestTogetherMessages:
@ -506,8 +525,7 @@ class TestTogetherMessages:
self, client: PassthroughClient, resources: ResourceManager, reasoning_tool_backend: str
) -> None:
model, key = _register(client, resources, reasoning_tool_backend)
first_content, block = _messages_weather_call(client, key, model)
assert block.id is not None
first_content, tool_use_ids = _messages_weather_call(client, key, model)
response = unwrap(
client.proxy.messages(
@ -520,7 +538,10 @@ class TestTogetherMessages:
ChatMessage(role="user", content=WEATHER_PROMPT),
AnthropicAssistantTurn(content=first_content),
AnthropicToolResultTurn(
content=[AnthropicToolResultBlock(tool_use_id=block.id, content=WEATHER_REPORT)]
content=[
AnthropicToolResultBlock(tool_use_id=tool_use_id, content=WEATHER_REPORT)
for tool_use_id in tool_use_ids
]
),
],
),

View file

@ -9,8 +9,21 @@ from __future__ import annotations
import time
from dataclasses import dataclass
import jwt
from e2e_config import MASTER_KEY
from proxy_client import ProxyClient
from e2e_http import NoBody, ProbeResult, Result, StreamingResponse, Success, UnknownApiError, unwrap
from e2e_http import (
AuthHeaders,
NetworkError,
NoBody,
ProbeResult,
Result,
StreamingResponse,
Success,
UnknownApiError,
unwrap,
)
from models import (
ChatBody,
ChatMessage,
@ -50,6 +63,9 @@ from models import (
TeamNewBody,
TeamNewResponse,
TeamUpdateBody,
UiLoginBody,
UiLoginResponse,
UiSessionClaims,
UserDeleteBody,
UserDeleteResponse,
UserInfoParams,
@ -63,38 +79,73 @@ from models import (
MODEL_ACCESS_DENIED_MARKER = "key_model_access_denied"
ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route"
DASHBOARD_SESSION_TEAM_ID = "litellm-dashboard"
_TEAM_READY_ATTEMPTS = 15
_TEAM_READY_SLEEP_SECONDS = 0.4
_KEY_WRITE_ATTEMPTS = 5
_TRANSIENT_BACKEND_MARKERS = ("connecting to redis", "name resolution")
@dataclass(frozen=True, slots=True)
class DashboardSession:
"""What a dashboard sign-in hands the Admin UI: the session key it sends as
its bearer on every subsequent call, the claims it renders the signed-in user
from, and where it lands the browser."""
session_key: str
claims: UiSessionClaims
redirect_url: str
@dataclass(frozen=True, slots=True)
class ManagementClient:
proxy: ProxyClient
master_key: str
def llm_only_key(self) -> str:
return self.proxy.generate_key(KeyGenerateBody(models=[], allowed_routes=["llm_api_routes"]))
def update_key_models(self, key: str, models: list[str]) -> None:
last: Result[NoBody] | None = None
for attempt in range(5):
def generate_key(self, body: KeyGenerateBody, *, caller_key: str | None = None) -> Result[KeyGenerateResponse]:
"""POST /key/generate. `caller_key` is who is creating the key: the master
key by default, or a virtual key (an admin filling in Create New Key on the
dashboard creates it under the session key their sign-in minted). Returns
the outcome rather than unwrapping it, so a caller can poll a route that is
only transiently refusing."""
headers = self.proxy.transport.master if caller_key is None else self.proxy.transport.bearer(caller_key)
return self.proxy.transport.post(
"/key/generate",
headers=headers,
json=body,
response_type=KeyGenerateResponse,
)
def update_key(self, body: KeyUpdateBody, *, caller_key: str | None = None) -> Result[NoBody]:
"""POST /key/update. `caller_key` is who is editing: the master key by
default, or a virtual key (the dashboard edits under the session key its
sign-in minted, never the master key). Returns the outcome rather than
unwrapping it, so a caller can poll a route that is only transiently
refusing; `update_key_models` is the unwrapping shorthand."""
headers = self.proxy.transport.master if caller_key is None else self.proxy.transport.bearer(caller_key)
last: Result[NoBody] = NetworkError(message="/key/update was never attempted")
for attempt in range(_KEY_WRITE_ATTEMPTS):
last = self.proxy.transport.post(
"/key/update",
headers=self.proxy.transport.master,
json=KeyUpdateBody(key=key, models=models),
headers=headers,
json=body,
response_type=NoBody,
)
match last:
case Success():
return
case UnknownApiError(body=body) if (
"connecting to redis" in body.lower() or "name resolution" in body.lower()
case UnknownApiError(body=error_body) if any(
marker in error_body.lower() for marker in _TRANSIENT_BACKEND_MARKERS
):
time.sleep(0.5 * (attempt + 1))
continue
case _:
break
assert last is not None
raise AssertionError(last)
return last
def update_key_models(self, key: str, models: list[str]) -> None:
_ = unwrap(self.update_key(KeyUpdateBody(key=key, models=models)))
def delete_key_strict(self, key: str) -> None:
"""Strict delete for the act phase of a test: a failed delete is a hard
@ -150,15 +201,42 @@ class ManagementClient:
)
).key
def key_list(self, key_alias: str, *, caller_key: str | None = None) -> Result[KeyListResponse]:
"""GET /key/list, the Virtual Keys page's own inventory call. `caller_key` is
who is asking: the master key by default, or a virtual key."""
headers = self.proxy.transport.master if caller_key is None else self.proxy.transport.bearer(caller_key)
return self.proxy.transport.get(
"/key/list",
headers=headers,
params=KeyListParams(key_alias=key_alias),
response_type=KeyListResponse,
)
def key_alias_count(self, key_alias: str) -> int:
return unwrap(
self.proxy.transport.get(
"/key/list",
headers=self.proxy.transport.master,
params=KeyListParams(key_alias=key_alias),
response_type=KeyListResponse,
return unwrap(self.key_list(key_alias)).total_count
def dashboard_login(self, username: str, password: str) -> DashboardSession:
"""POST /v2/login, the call the Admin UI's sign-in form makes.
The proxy authenticates the credentials, mints a UI session key for the
signed-in user, and hands it back inside a JWT signed with the master key.
Decoding that JWT is the only way to reach the session key, and it is what
the dashboard itself does before it can call a single management route."""
response = unwrap(
self.proxy.transport.post(
"/v2/login",
headers=AuthHeaders(),
json=UiLoginBody(username=username, password=password),
response_type=UiLoginResponse,
)
).total_count
)
decoded: object = jwt.decode(response.token, self.master_key, algorithms=["HS256"])
claims = UiSessionClaims.model_validate(decoded)
return DashboardSession(
session_key=claims.key,
claims=claims,
redirect_url=response.redirect_url,
)
def create_team(self, body: TeamNewBody) -> str:
team_id = unwrap(
@ -465,4 +543,4 @@ class ManagementClient:
def build_client(proxy: ProxyClient) -> ManagementClient:
return ManagementClient(proxy=proxy)
return ManagementClient(proxy=proxy, master_key=MASTER_KEY)

View file

@ -15,15 +15,30 @@ from collections.abc import Callable
import pytest
from e2e_config import unique_marker
from e2e_http import StreamingResponse
from e2e_config import UI_PASSWORD, UI_USERNAME, unique_marker
from e2e_http import StreamingResponse, Success
from lifecycle import ResourceManager
from management_client import (
DASHBOARD_SESSION_TEAM_ID,
MODEL_ACCESS_DENIED_MARKER,
ROUTE_NOT_ALLOWED_MARKER,
ManagementClient,
)
from models import KeyGenerateBody, OrgInfoResponse, OrgNewBody, OrgUpdateBody, TagListEntry, TagNewBody, TeamNewBody, TeamUpdateBody, UserNewBody, UserUpdateBody, LiteLLMParamsBody, ModelInfoEntry
from models import (
KeyGenerateBody,
KeyUpdateBody,
LiteLLMParamsBody,
ModelInfoEntry,
OrgInfoResponse,
OrgNewBody,
OrgUpdateBody,
TagListEntry,
TagNewBody,
TeamNewBody,
TeamUpdateBody,
UserNewBody,
UserUpdateBody,
)
pytestmark = pytest.mark.e2e
@ -199,6 +214,132 @@ class TestKeyRoutes:
return True if client.proxy.key_info(key).blocked else None
_ = _poll(client, blocked, "/key/info never reported the key blocked after /key/block before the deadline")
class TestDashboardKeyRoutes:
"""The /key writes as the Admin UI makes them. Signing in mints the session key
the dashboard authenticates with, and every key an admin creates or edits in the
browser is written under that session key rather than the master key, so these
are the same routes the API-surface tests cover with a different caller."""
@pytest.mark.covers("mgmt.key.generate.happy_path")
def test_creating_a_key_from_the_dashboard_persists_and_works(
self, client: ManagementClient, resources: ResourceManager
) -> None:
session = client.dashboard_login(UI_USERNAME, UI_PASSWORD)
resources.defer(lambda: client.proxy.delete_key(session.session_key))
assert session.claims.login_method == "username_password", (
f"/v2/login reports login_method {session.claims.login_method!r} for a username/password sign-in"
)
assert session.claims.user_role == "proxy_admin", (
f"/v2/login reports user_role {session.claims.user_role!r} for the admin credentials, "
"expected 'proxy_admin'"
)
assert session.redirect_url.endswith("/ui?login=success"), (
f"/v2/login sends the browser to {session.redirect_url!r} instead of the dashboard"
)
session_info = client.proxy.key_info(session.session_key)
assert session_info.team_id == DASHBOARD_SESSION_TEAM_ID, (
f"the minted session key reports team_id {session_info.team_id!r}, expected the dashboard's "
f"{DASHBOARD_SESSION_TEAM_ID!r}"
)
alias = f"e2e-mgmt-uicreate-{unique_marker()}"
def dashboard_creates_the_key() -> str | None:
match client.generate_key(
KeyGenerateBody(models=["gemini-2.5-flash"], key_alias=alias, tpm_limit=100),
caller_key=session.session_key,
):
case Success(data=created):
return created.key
case _:
return None
created = _poll(
client,
dashboard_creates_the_key,
"the dashboard session key was never accepted on /key/generate before the deadline",
)
resources.defer(lambda: client.proxy.delete_key(created))
created_info = client.proxy.key_info(created)
assert created_info.key_alias == alias, (
f"/key/info reports key_alias {created_info.key_alias!r} for the key the dashboard created, "
f"expected {alias!r}"
)
assert created_info.models == ["gemini-2.5-flash"], (
f"/key/info reports models {created_info.models} for the key the dashboard created"
)
assert created_info.tpm_limit == 100, (
f"/key/info reports tpm_limit {created_info.tpm_limit} for the key the dashboard created, expected 100"
)
def dashboard_lists_the_key() -> bool | None:
match client.key_list(alias, caller_key=session.session_key):
case Success(data=listing) if listing.total_count == 1:
return True
case _:
return None
_ = _poll(
client,
dashboard_lists_the_key,
f"the session key never saw {alias!r} in /key/list before the deadline, so the dashboard "
"would render no keys",
)
_poll_chat_ok(client, created, "gemini-2.5-flash")
_assert_model_denied(client.chat_status(created, "gpt-5.5", f"say hi {unique_marker()}"), "gpt-5.5")
@pytest.mark.covers("mgmt.key.update.happy_path")
def test_editing_a_key_from_the_dashboard_persists_and_is_enforced(
self, client: ManagementClient, resources: ResourceManager
) -> None:
alias = f"e2e-mgmt-uiedit-{unique_marker()}"
target = _generate_key(
client,
resources,
KeyGenerateBody(models=["gemini-2.5-flash"], key_alias=alias, tpm_limit=100, rpm_limit=200),
)
_poll_chat_ok(client, target, "gemini-2.5-flash")
_assert_model_denied(client.chat_status(target, "gpt-5.5", f"say hi {unique_marker()}"), "gpt-5.5")
session = client.dashboard_login(UI_USERNAME, UI_PASSWORD)
resources.defer(lambda: client.proxy.delete_key(session.session_key))
def dashboard_saves_the_edit() -> bool | None:
match client.update_key(
KeyUpdateBody(key=target, models=["gpt-5.5"], tpm_limit=300, rpm_limit=400),
caller_key=session.session_key,
):
case Success():
return True
case _:
return None
_ = _poll(
client,
dashboard_saves_the_edit,
"the dashboard session key was never accepted on /key/update before the deadline",
)
info = client.proxy.key_info(target)
assert info.models == ["gpt-5.5"], (
f"/key/info reports models {info.models} after the dashboard edit to ['gpt-5.5']"
)
assert info.tpm_limit == 300, f"/key/info reports tpm_limit {info.tpm_limit} after the dashboard edit to 300"
assert info.rpm_limit == 400, f"/key/info reports rpm_limit {info.rpm_limit} after the dashboard edit to 400"
assert info.key_alias == alias, (
f"the dashboard edit renamed the key to {info.key_alias!r}, it should still be {alias!r}"
)
_poll_model_access_granted(client, target, "gpt-5.5")
_poll_chat_denied(client, target, "gemini-2.5-flash")
class TestKeyRegeneration:
@pytest.mark.covers("mgmt.key.regenerate.happy_path")
def test_regenerate_rotates_to_a_working_new_key(

View file

@ -256,6 +256,7 @@ class ChatBody(BaseModel):
reasoning_effort: str | None = None
thinking: ThinkingParam | None = None
service_tier: str | None = None
prompt_cache_key: str | None = None
tools: Sequence[ChatTool | McpChatTool] | None = None
tool_choice: str | None = None
guardrails: list[str] | None = None
@ -420,6 +421,7 @@ class AnthropicContentBlock(BaseModel):
text: str | None = None
id: str | None = None
name: str | None = None
input: dict[str, object] | None = None
class AnthropicToolResultBlock(BaseModel):
@ -892,7 +894,10 @@ class CredentialCreateResponse(BaseModel):
class KeyUpdateBody(BaseModel):
key: str
models: list[str]
models: list[str] | None = None
key_alias: str | None = None
tpm_limit: int | None = None
rpm_limit: int | None = None
class KeyBlockBody(BaseModel):
@ -907,6 +912,27 @@ class KeyListResponse(BaseModel):
total_count: int
# ---------- admin UI session ----------
class UiLoginBody(BaseModel):
username: str
password: str
class UiLoginResponse(BaseModel):
token: str
redirect_url: str
class UiSessionClaims(BaseModel):
user_id: str
key: str
user_role: str
login_method: Literal["sso", "username_password"]
exp: int
class TeamMemberEntry(BaseModel):
role: Literal["admin", "user"]
user_id: str

View file

@ -12,11 +12,16 @@ header is exercised with a real nonzero value instead of passing vacuously. The
backend is gpt-5.5 because it reports cached tokens on the second call; the
gpt-5.6 line reports cache writes and never a read, which would leave the
cache-read header at zero forever. The raw-transport send is used because the
typed chat client validates bodies and drops headers. OpenAI caching is
best-effort, so the prime+measure round retries with a fresh prefix before
failing.
typed chat client validates bodies and drops headers.
OpenAI publishes a primed prefix asynchronously and routes lookups by
prompt_cache_key, so a measure fired the instant the prime returns can miss a
prefix that is about to become readable. Each round pins a cache key and re-reads
the prefix it already paid to prime before spending a fresh one.
"""
import time
import pytest
from cost_rows import approx_equal, cacheable_prefix, register_priced_model
@ -31,6 +36,8 @@ pytestmark = pytest.mark.e2e
BACKEND = "openai/gpt-5.5"
OPENAI_API_KEY = "os.environ/OPENAI_API_KEY"
CACHE_ATTEMPTS = 3
CACHE_REREADS = 3
CACHE_SETTLE_SECONDS = 2.0
INPUT_RATE = 4e-05
OUTPUT_RATE = 8e-05
@ -70,7 +77,7 @@ class TestCostHeaders:
),
)
def priced_call(content: str) -> StreamingResponse:
def priced_call(content: str, cache_key: str) -> StreamingResponse:
response = client.proxy.transport.send(
"/chat/completions",
headers=client.proxy.transport.bearer(scoped_key),
@ -78,21 +85,30 @@ class TestCostHeaders:
model=model,
messages=[ChatMessage(role="user", content=content)],
max_completion_tokens=4000,
prompt_cache_key=cache_key,
),
)
assert response.ok, f"chat failed (status {response.status_code}): {response.body[:300]}"
return response
for _ in range(CACHE_ATTEMPTS):
prefix = cacheable_prefix(unique_marker())
priced_call(f"{prefix}\nReply with the single word ready.")
measured = priced_call(f"{prefix}\nReply with the single word measured.")
if _header_cost(measured, "x-litellm-response-cost-cache-read") > 0:
break
else:
def prime_then_reread() -> StreamingResponse | None:
marker = unique_marker()
prefix = cacheable_prefix(marker)
priced_call(f"{prefix}\nReply with the single word ready.", marker)
for _ in range(CACHE_REREADS):
time.sleep(CACHE_SETTLE_SECONDS)
response = priced_call(f"{prefix}\nReply with the single word measured.", marker)
if _header_cost(response, "x-litellm-response-cost-cache-read") > 0:
return response
return None
rounds = (prime_then_reread() for _ in range(CACHE_ATTEMPTS))
measured = next((response for response in rounds if response is not None), None)
if measured is None:
pytest.fail(
f"no cache read landed across {CACHE_ATTEMPTS} prime+measure rounds; "
"the cache-read cost header was never exercised with a nonzero value"
f"no cache read landed across {CACHE_ATTEMPTS} prime rounds of "
f"{CACHE_REREADS} re-reads each; the cache-read cost header was never "
"exercised with a nonzero value"
)
total = measured.response_cost

View file

@ -0,0 +1,59 @@
import { expect, test, type Page as PlaywrightPage } from "@playwright/test";
import { ADMIN_STORAGE_PATH } from "../../constants";
import { navigateToPage } from "../../helpers/navigation";
import { Page } from "../../fixtures/pages";
/**
* Opens Add Auto Router and returns the Template select's trigger, which is the
* shallowest real page that renders SelectContent with tall multi-line options.
*/
async function openTemplateSelect(page: PlaywrightPage) {
await navigateToPage(page, Page.Models);
await page.getByRole("tab", { name: "Auto-Routers" }).click();
await page.getByRole("button", { name: "Add Auto Router" }).click();
const trigger = page.getByTestId("template-selector");
await expect(trigger).toBeVisible();
return trigger;
}
test.describe("Auto Router template select anchoring", () => {
test.use({ storageState: ADMIN_STORAGE_PATH });
test("opens the options below the trigger rather than over it", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
const trigger = await openTemplateSelect(page);
const triggerBox = await trigger.boundingBox();
await trigger.click();
const popup = page.locator('[data-slot="select-content"]');
await expect(popup).toBeVisible();
const popupBox = await popup.boundingBox();
expect(triggerBox).not.toBeNull();
expect(popupBox).not.toBeNull();
// Item-aligned mode reports "none" and puts the active item over the trigger.
await expect(popup).toHaveAttribute("data-side", "bottom");
expect(popupBox!.y).toBeGreaterThanOrEqual(triggerBox!.y + triggerBox!.height);
});
test("flips above the trigger instead of covering it when there is no room below", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 560 });
const trigger = await openTemplateSelect(page);
await trigger.scrollIntoViewIfNeeded();
const triggerBox = await trigger.boundingBox();
await trigger.click();
const popup = page.locator('[data-slot="select-content"]');
await expect(popup).toBeVisible();
const popupBox = await popup.boundingBox();
expect(triggerBox).not.toBeNull();
expect(popupBox).not.toBeNull();
const overlaps =
popupBox!.y < triggerBox!.y + triggerBox!.height && popupBox!.y + popupBox!.height > triggerBox!.y;
expect(overlaps).toBe(false);
});
});

View file

@ -117,6 +117,11 @@ const ADMIN_AUTH = {
Authorization: `Bearer ${users[Role.ProxyAdmin].password}`,
};
// Five probes 2s apart outlast the e2e stack's proxy_config_reload_interval_seconds of 7.
const SETTLE_INTERVAL_MS = 2_000;
const SETTLE_PROBES = 5;
const SETTLE_TIMEOUT_MS = 60_000;
/**
* Apply a router_settings patch through the typed /config/update contract. The
* server merges it over existing settings (request wins), so only the passed keys
@ -133,6 +138,21 @@ async function patchRouterSettings(
expect(res.ok(), `seed /config/update failed: ${res.status()} ${await res.text()}`).toBeTruthy();
}
/**
* Spreads its samples across more than one reload cycle: a single reply only proves the one
* replica that served it has reloaded, not the sibling still on the pre-update config.
*/
async function sampleStatuses(probe: () => Promise<number>): Promise<readonly number[]> {
return Array.from({ length: SETTLE_PROBES }).reduce<Promise<readonly number[]>>(
async (taken, _unused, index) => {
const sofar = await taken;
if (index > 0) await new Promise((resolve) => setTimeout(resolve, SETTLE_INTERVAL_MS));
return [...sofar, await probe()];
},
Promise.resolve([]),
);
}
test.describe("Router Settings - Loadbalancing", () => {
test.use({ storageState: ADMIN_STORAGE_PATH });
@ -252,28 +272,34 @@ test.describe("Router Settings - Fallbacks serve the request", () => {
});
test("a request to an unreachable model is answered by its fallback", async ({ page, request }) => {
const chat = async () =>
request.post("/v1/chat/completions", {
headers: { ...ADMIN_AUTH, "Content-Type": "application/json" },
data: {
model: BROKEN_PRIMARY,
messages: [{ role: "user", content: "fallback probe" }],
},
});
const chatStatus = async () =>
(
await request.post("/v1/chat/completions", {
headers: { ...ADMIN_AUTH, "Content-Type": "application/json" },
data: {
model: BROKEN_PRIMARY,
messages: [{ role: "user", content: "fallback probe" }],
},
})
).status();
// The control: it proves the reply below could only have come from the fallback.
expect((await chat()).status(), "broken primary unexpectedly succeeded on its own").toBeGreaterThanOrEqual(400);
// The control: every replica must reject, or the reply below could have come from one
// that was still serving a fallback left behind by an earlier attempt.
await expect
.poll(async () => (await sampleStatuses(chatStatus)).every((status) => status >= 400), {
timeout: SETTLE_TIMEOUT_MS,
message: "broken primary unexpectedly succeeded on its own",
})
.toBe(true);
await patchRouterSettings(request, {
fallbacks: [{ [BROKEN_PRIMARY]: [PRIMARY] }],
} as Partial<NonNullable<ConfigYAML["router_settings"]>>);
// Same call now succeeds, served by the fallback model.
// One success is the whole claim here, so this waits for a first sighting rather than
// for every replica: demanding a streak would also assert a fallback hit rate.
await expect
.poll(async () => (await chat()).status(), {
timeout: 30_000,
message: "fallback never took effect",
})
.poll(chatStatus, { timeout: SETTLE_TIMEOUT_MS, message: "fallback never took effect" })
.toBe(200);
// And the playground renders a reply for a model whose own upstream is down.

View file

@ -12,7 +12,11 @@ sys.path.insert(
),
)
from litellm_proxy_extras.utils import ProxyExtrasDBManager
from litellm_proxy_extras.utils import (
PARTITIONED_SPEND_LOGS_PUSH_ERROR,
ProxyExtrasDBManager,
filter_partitioned_spend_logs_diff,
)
# Path to the migrations directory
_MIGRATIONS_DIR = os.path.abspath(
@ -475,3 +479,205 @@ class TestMigrationGuardScope:
if not self._run_rules([(TestMigrationGuardScope._NEW, by_name[name])])
]
assert not redundant, f"these no longer violate and should be removed: {redundant}"
_PARTITIONED_DRIFT_SQL = """-- AlterTable
ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;
-- AlterTable
ALTER TABLE "LiteLLM_SpendLogs" DROP CONSTRAINT "LiteLLM_SpendLogs_pkey",
ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD CONSTRAINT "LiteLLM_SpendLogs_pkey" PRIMARY KEY ("request_id");
-- DropTable
DROP TABLE "LiteLLM_SpendLogs_legacy";
"""
class TestPartitionedSpendLogsDriftFilter:
"""A doc-partitioned LiteLLM_SpendLogs (db_scripts/partition_spend_logs.sql) has a
composite primary key that schema.prisma cannot express, so `prisma migrate diff`
emits a primary-key rewrite that Postgres rejects, aborting the whole drift script
before its legitimate statements run."""
def test_pk_rewrite_and_runbook_artifact_drops_are_removed(self):
filtered = filter_partitioned_spend_logs_diff(_PARTITIONED_DRIFT_SQL)
assert 'DROP CONSTRAINT "LiteLLM_SpendLogs_pkey"' not in filtered
assert 'PRIMARY KEY ("request_id")' not in filtered
assert "LiteLLM_SpendLogs_legacy" not in filtered
def test_legitimate_statements_in_the_same_script_are_kept(self):
filtered = filter_partitioned_spend_logs_diff(_PARTITIONED_DRIFT_SQL)
assert 'ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;' in filtered
assert 'ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP' in filtered
assert 'ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP' in filtered
assert filtered.count('ALTER TABLE "LiteLLM_SpendLogs"') == 1
def test_an_alter_containing_only_the_pk_rewrite_is_dropped_entirely(self):
sql = (
'ALTER TABLE "LiteLLM_SpendLogs" DROP CONSTRAINT "LiteLLM_SpendLogs_pkey",\n'
'ADD CONSTRAINT "LiteLLM_SpendLogs_pkey" PRIMARY KEY ("request_id");\n'
)
assert filter_partitioned_spend_logs_diff(sql).strip() == ""
def test_other_tables_pk_changes_are_untouched(self):
sql = (
'ALTER TABLE "LiteLLM_TeamTable" DROP CONSTRAINT "LiteLLM_TeamTable_pkey",\n'
'ADD CONSTRAINT "LiteLLM_TeamTable_pkey" PRIMARY KEY ("team_id");\n'
)
filtered = filter_partitioned_spend_logs_diff(sql)
assert 'DROP CONSTRAINT "LiteLLM_TeamTable_pkey"' in filtered
assert 'PRIMARY KEY ("team_id")' in filtered
class _FakeCompleted:
stdout = ""
stderr = ""
class TestResolveAllMigrationsLedger:
def _run(self, monkeypatch, tmp_path, partitioned, execute_fails):
import subprocess as subprocess_module
import litellm_proxy_extras.utils as utils_module
monkeypatch.setenv("DATABASE_URL", "postgresql://u:p@localhost:5432/db")
monkeypatch.delenv("DIRECT_URL", raising=False)
monkeypatch.setattr(
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: partitioned)
)
monkeypatch.setattr(
ProxyExtrasDBManager,
"_get_migration_names",
staticmethod(lambda migrations_dir: ["20250326162113_baseline"]),
)
calls = []
def fake_run(cmd, **kwargs):
calls.append(cmd)
if "diff" in cmd:
kwargs["stdout"].write(_PARTITIONED_DRIFT_SQL)
return _FakeCompleted()
if "execute" in cmd:
executed_sql = open(cmd[cmd.index("--file") + 1]).read()
calls.append(("executed_sql", executed_sql))
if execute_fails:
raise subprocess_module.CalledProcessError(1, cmd, stderr="boom")
return _FakeCompleted()
return _FakeCompleted()
monkeypatch.setattr(utils_module.subprocess, "run", fake_run)
ProxyExtrasDBManager._resolve_all_migrations(str(tmp_path), "schema.prisma")
return calls
def _resolved(self, calls):
return [c for c in calls if isinstance(c, list) and "resolve" in c]
def _executed_sql(self, calls):
return next(c[1] for c in calls if isinstance(c, tuple) and c[0] == "executed_sql")
def test_failed_drift_apply_does_not_mark_migrations_applied(self, monkeypatch, tmp_path):
calls = self._run(monkeypatch, tmp_path, partitioned=False, execute_fails=True)
assert self._resolved(calls) == []
def test_successful_drift_apply_still_marks_migrations_applied(self, monkeypatch, tmp_path):
calls = self._run(monkeypatch, tmp_path, partitioned=False, execute_fails=False)
assert len(self._resolved(calls)) == 1
def test_partitioned_spend_logs_gets_the_filtered_drift_script(self, monkeypatch, tmp_path):
calls = self._run(monkeypatch, tmp_path, partitioned=True, execute_fails=False)
executed_sql = self._executed_sql(calls)
assert 'PRIMARY KEY ("request_id")' not in executed_sql
assert "LiteLLM_SpendLogs_legacy" not in executed_sql
assert 'ALTER TABLE "LiteLLM_BudgetTable" ADD COLUMN "updated_by" TEXT;' in executed_sql
assert 'ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP' in executed_sql
assert len(self._resolved(calls)) == 1
def test_unpartitioned_spend_logs_drift_script_is_untouched(self, monkeypatch, tmp_path):
calls = self._run(monkeypatch, tmp_path, partitioned=False, execute_fails=False)
assert self._executed_sql(calls) == _PARTITIONED_DRIFT_SQL
class TestPartitionedSpendLogsPushGuard:
def _forbid_subprocess(self, monkeypatch):
import litellm_proxy_extras.utils as utils_module
def fail_run(cmd, **kwargs):
raise AssertionError(f"subprocess.run should not be called, got: {cmd}")
monkeypatch.setattr(utils_module.subprocess, "run", fail_run)
def test_v1_db_push_fails_fast_with_guidance(self, monkeypatch):
monkeypatch.setattr(
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: True)
)
self._forbid_subprocess(monkeypatch)
with pytest.raises(RuntimeError) as err:
ProxyExtrasDBManager._run_migrations(use_migrate=False, use_v2_resolver=False)
assert str(err.value) == PARTITIONED_SPEND_LOGS_PUSH_ERROR
def test_v2_db_push_fails_fast_with_guidance(self, monkeypatch):
monkeypatch.setattr(
ProxyExtrasDBManager, "spend_logs_is_partitioned", staticmethod(lambda: True)
)
self._forbid_subprocess(monkeypatch)
with pytest.raises(RuntimeError) as err:
ProxyExtrasDBManager._setup_database_v2(use_migrate=False)
assert str(err.value) == PARTITIONED_SPEND_LOGS_PUSH_ERROR
class _FakeCursor:
def fetchone(self):
return (1,)
class _FakePsycopgConn:
def __init__(self, executed):
self._executed = executed
def __enter__(self):
return self
def __exit__(self, *args):
return False
def execute(self, query, params):
self._executed.append((query, params))
return _FakeCursor()
class TestSpendLogsPartitionDetectionSchemaScope:
"""A same-named LiteLLM_SpendLogs in another schema must not trip the
detector: the catalog lookup has to be scoped to Prisma's target schema."""
def _detect(self, monkeypatch, database_url):
import sys
import types
executed = []
fake_psycopg = types.ModuleType("psycopg")
fake_psycopg.connect = lambda url, **kwargs: _FakePsycopgConn(executed)
fake_psycopg.OperationalError = type("OperationalError", (Exception,), {})
fake_psycopg.DatabaseError = type("DatabaseError", (Exception,), {})
monkeypatch.setitem(sys.modules, "psycopg", fake_psycopg)
monkeypatch.setenv("DATABASE_URL", database_url)
assert ProxyExtrasDBManager.spend_logs_is_partitioned() is True
return executed[0]
def test_lookup_is_scoped_to_the_schema_url_param(self, monkeypatch):
query, params = self._detect(
monkeypatch, "postgresql://u:p@localhost:5432/db?schema=tenant_a"
)
assert "pg_namespace" in query
assert "n.nspname = %s" in query
assert params == ("tenant_a",)
def test_lookup_falls_back_to_public_without_a_schema_param(self, monkeypatch):
query, params = self._detect(monkeypatch, "postgresql://u:p@localhost:5432/db")
assert "n.nspname = %s" in query
assert params == ("public",)
def test_only_partitioned_relations_match(self, monkeypatch):
query, _ = self._detect(monkeypatch, "postgresql://u:p@localhost:5432/db")
assert "pg_partitioned_table" in query

View file

@ -23,26 +23,18 @@ class TestTogetherAI(BaseLLMChatTest):
pass
@pytest.mark.parametrize(
"model, expected_bool",
"model",
[
("meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo", True),
("nvidia/Llama-3.1-Nemotron-70B-Instruct-HF", False),
"meta-llama/Meta-Llama-3.1-8B-Instruct-Turbo",
"nvidia/Llama-3.1-Nemotron-70B-Instruct-HF",
],
)
def test_get_supported_response_format_together_ai(
self, model: str, expected_bool: bool
) -> None:
def test_get_supported_response_format_together_ai(self, model: str) -> None:
os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
litellm.model_cost = litellm.get_model_cost_map(url="")
optional_params = litellm.get_supported_openai_params(
model, custom_llm_provider="together_ai"
)
# Mapped provider
assert isinstance(optional_params, list)
if expected_bool:
assert "response_format" in optional_params
assert "tools" in optional_params
else:
assert "response_format" not in optional_params
assert "tools" not in optional_params
assert "response_format" in optional_params
assert "tools" in optional_params

View file

@ -45,6 +45,22 @@ def setup_and_teardown():
asyncio.set_event_loop(None) # Remove the reference to the loop
@pytest.fixture(scope="function", autouse=True)
async def drain_logging_worker():
"""
The logging queue is bound to the running loop, so anything left queued when a test's loop
goes away is carried onto the next test's loop and fires against its callbacks.
"""
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
yield
try:
await asyncio.wait_for(GLOBAL_LOGGING_WORKER.clear_queue(), timeout=10)
except asyncio.TimeoutError:
pass
def pytest_collection_modifyitems(config, items):
# Separate tests in 'test_amazing_proxy_custom_logger.py' and other tests
custom_logger_tests = [

View file

@ -2661,7 +2661,7 @@ async def test_run_direct_health_check_drops_only_the_rejected_kwarg(monkeypatch
rejected argument alongside working ones would probe deployments the operator opted out."""
import litellm.proxy.proxy_server as proxy_server
seen: list = []
seen: list[tuple[dict[str, str] | None, bool]] = []
async def fake_perform_health_check(
model_list,

View file

@ -375,6 +375,9 @@ def isolate_litellm_state():
litellm.in_memory_llm_clients_cache.flush_cache()
image_handling_module.in_memory_cache.flush_cache()
_reset_module_level_aws_auth_caches()
# litellm.get_model_info() memoizes ModelInfo built from litellm.model_cost, so a
# test that rebinds the cost map leaves later tests pricing against the old map.
litellm_utils_module._invalidate_model_cost_lowercase_map()
# Clear all callback lists to prevent cross-test contamination
if hasattr(litellm, "callbacks"):
@ -418,6 +421,7 @@ def isolate_litellm_state():
litellm_utils_module._runtime_registered_model_cost.clear()
litellm_utils_module._runtime_registered_model_cost.update(original_runtime_registered_model_cost)
litellm_utils_module._invalidate_model_cost_lowercase_map()
for _router in tuple(litellm_router_module._live_routers):
litellm_router_module._live_routers.discard(_router)

View file

@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import anyio
import httpx
import pytest
from litellm.proxy._experimental.mcp_server.outbound_credentials.httpx_auth import StaticHeaderAuth
from mcp import McpError
from mcp.shared.message import SessionMessage
from mcp.types import (
@ -1095,3 +1096,188 @@ def test_mcp_extra_matches_proxy_extra_and_supports_streamable_http():
specifier = Requirement(mcp_extra[0]).specifier
assert not specifier.contains("1.23.0")
assert specifier.contains("1.28.1")
@pytest.mark.parametrize(
"auth_type, default_header",
[
(MCPAuth.oauth2, "Authorization"),
(MCPAuth.bearer_token, "Authorization"),
(MCPAuth.api_key, "X-API-Key"),
],
)
def test_v1_auth_headers_default_to_the_auth_type_slot(auth_type: MCPAuth, default_header: str) -> None:
client = MCPClient(server_url="http://up.example.com/mcp", auth_type=auth_type)
client.update_auth_value("tok")
assert default_header in client._get_auth_headers()
@pytest.mark.parametrize("auth_type", [MCPAuth.oauth2, MCPAuth.bearer_token, MCPAuth.api_key])
def test_v1_auth_headers_honor_the_configured_slot(auth_type: MCPAuth) -> None:
"""The v1 stack mints its own client_credentials token (oauth2_token_cache) and writes it here,
so leaving this table hardcoded makes the knob a silent no-op for every server that resolves
through v1 rather than the v2 resolver."""
client = MCPClient(
server_url="http://up.example.com/mcp",
auth_type=auth_type,
auth_header_name="esb-oauth",
)
client.update_auth_value("tok")
headers = client._get_auth_headers()
assert "esb-oauth" in headers
assert "Authorization" not in headers
assert "X-API-Key" not in headers
def test_v1_static_headers_still_win_their_own_slot():
# extra_headers (which carries static_headers) is applied last on the v1 path, so a static
# Authorization survives untouched while the resolved credential sits on its own header.
client = MCPClient(
server_url="http://up.example.com/mcp",
auth_type=MCPAuth.oauth2,
auth_header_name="esb-oauth",
extra_headers={"Authorization": "Bearer static-upstream-mcp-token"},
)
client.update_auth_value("minted")
headers = client._get_auth_headers()
assert headers["esb-oauth"] == "Bearer minted"
assert headers["Authorization"] == "Bearer static-upstream-mcp-token"
@pytest.mark.asyncio
async def test_a_custom_credential_header_is_stripped_when_a_redirect_crosses_origin():
"""httpx drops Authorization across origins but keeps every other header, so a credential the
operator moved to its own slot would be replayed to whatever host the upstream redirects to.
Verified against real httpx redirect handling, not a hand-built request.
"""
seen: "list[tuple[str, str]]" = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append((request.url.host, request.headers.get("esb-oauth", "<stripped>")))
if request.url.host == "upstream.example.com":
return httpx.Response(302, headers={"Location": "https://attacker.example.com/collect"})
return httpx.Response(200)
client = MCPClient(
server_url="https://upstream.example.com/mcp",
auth_type=MCPAuth.oauth2,
auth_header_name="esb-oauth",
)
client.update_auth_value("minted-token")
factory = client._create_httpx_client_factory()
async with factory(headers=client._get_auth_headers(), timeout=None) as http_client:
http_client._transport = httpx.MockTransport(handler)
await http_client.get("https://upstream.example.com/mcp")
assert seen[0] == ("upstream.example.com", "Bearer minted-token")
assert seen[1] == ("attacker.example.com", "<stripped>")
@pytest.mark.asyncio
async def test_authorization_is_left_to_httpx_and_needs_no_guard():
# The default slot is already protected by httpx, so the client must not install a guard for it
# and must not interfere with the ordinary Authorization path.
url = "https://upstream.example.com/mcp"
from litellm.types.mcp import credential_redirect_hook
def guard_for(client: MCPClient):
return credential_redirect_hook(client.server_url, client._credential_slot)
assert guard_for(MCPClient(server_url=url, auth_type=MCPAuth.oauth2)) is None
assert guard_for(MCPClient(server_url=url, resolved_auth=StaticHeaderAuth("Bearer x"))) is None
# a v2 resolver slot is discovered from the auth object, without the caller naming it again
custom = MCPClient(server_url=url, resolved_auth=StaticHeaderAuth("Bearer x", header_name="esb-oauth"))
assert guard_for(custom) is not None
# and the same answer arrives via the v1 configured slot
assert guard_for(MCPClient(server_url=url, auth_header_name="ESB-OAuth")) is not None
def test_an_injected_header_cannot_shadow_the_configured_credential_slot():
"""The v2 path drops a colliding injected header so the resolved credential wins its slot. The
v1 path applies extra_headers last, so without this it silently sends the injected value and the
upstream rejects a credential the gateway thought it had sent.
"""
client = MCPClient(
server_url="https://upstream.example.com/mcp",
auth_type=MCPAuth.oauth2,
auth_header_name="esb-oauth",
extra_headers={"esb-oauth": "Bearer injected", "X-Trace": "keep"},
)
client.update_auth_value("minted-token")
headers = client._get_auth_headers()
assert headers["esb-oauth"] == "Bearer minted-token"
assert headers["X-Trace"] == "keep"
def test_without_a_configured_slot_the_existing_precedence_is_unchanged():
# extra_headers winning over authentication_token is long-standing v1 behavior; the fix above
# must apply only to the slot the operator explicitly named.
client = MCPClient(
server_url="https://upstream.example.com/mcp",
auth_type=MCPAuth.oauth2,
extra_headers={"Authorization": "Bearer injected"},
)
client.update_auth_value("minted-token")
assert client._get_auth_headers()["Authorization"] == "Bearer injected"
_REDIRECT_CASES = [
("https://upstream.example.com/mcp", "https://upstream.example.com/other"), # same origin
("https://upstream.example.com/mcp", "https://upstream.example.com:443/other"), # explicit default port
("https://upstream.example.com/mcp", "https://attacker.example.com/collect"), # different host
("https://upstream.example.com/mcp", "http://upstream.example.com/collect"), # scheme downgrade
("https://upstream.example.com/mcp", "https://upstream.example.com:8443/other"), # different port
("https://upstream.example.com/mcp", "https://sub.upstream.example.com/x"), # different host
("http://upstream.example.com/mcp", "https://upstream.example.com/other"), # http -> https upgrade
("http://upstream.example.com/mcp", "http://upstream.example.com/other"), # same origin, plain http
]
@pytest.mark.parametrize("start,target", _REDIRECT_CASES)
@pytest.mark.asyncio
async def test_the_guard_agrees_with_httpx_about_authorization(start: str, target: str) -> None:
"""Our custom slot must be dropped on exactly the redirects where httpx drops Authorization.
The rule is mirrored rather than imported, so this drives real httpx and compares the two
outcomes. A future httpx that changes its redirect rule reds here instead of silently leaving
the custom slot forwarded where Authorization is not (or stripped where it is not needed).
"""
seen: "list[tuple[str, str, str]]" = []
def handler(request: httpx.Request) -> httpx.Response:
seen.append(
(
str(request.url),
request.headers.get("authorization", "<stripped>"),
request.headers.get("esb-oauth", "<stripped>"),
)
)
if str(request.url) == start:
return httpx.Response(302, headers={"Location": target})
return httpx.Response(200)
client = MCPClient(server_url=start, auth_type=MCPAuth.oauth2, auth_header_name="esb-oauth")
factory = client._create_httpx_client_factory()
async with factory(headers={"Authorization": "Bearer AUTH", "esb-oauth": "Bearer ESB"}, timeout=None) as http:
http._transport = httpx.MockTransport(handler)
await http.get(start)
_url, authorization, esb = seen[-1]
assert (authorization == "<stripped>") == (esb == "<stripped>"), (
f"httpx and the guard disagree for {target}: authorization={authorization!r} esb-oauth={esb!r}"
)
def test_a_differently_cased_injected_header_cannot_shadow_the_slot() -> None:
# HTTP header names are case-insensitive and v2 drops the collision case-insensitively, so an
# exact-key check here would leave both spellings in the dict and let the injected value win.
client = MCPClient(
server_url="https://upstream.example.com/mcp",
auth_type=MCPAuth.oauth2,
auth_header_name="esb-oauth",
extra_headers={"ESB-OAuth": "Bearer injected", "X-Trace": "keep"},
)
client.update_auth_value("minted-token")
headers = client._get_auth_headers()
assert [v for k, v in headers.items() if k.lower() == "esb-oauth"] == ["Bearer minted-token"]
assert headers["X-Trace"] == "keep"

View file

@ -357,6 +357,92 @@ def test_release_without_eviction_keeps_provider_alive(monkeypatch):
cache.release(None) # default-route release is a no-op
# --- per-request service.name routing from trusted key/team config --- #
def test_tenant_service_name_precedence_and_blanks():
from litellm.integrations.otel.plumbing.routing import tenant_service_name
assert tenant_service_name({"otel_service_name": "team-svc"}) == "team-svc"
assert tenant_service_name({"otel_service_name_override": "override", "otel_service_name": "base"}) == "override"
assert tenant_service_name({"otel_service_name": " "}) is None
assert tenant_service_name({"logging_setting": "x"}) is None
assert tenant_service_name(None) is None
def test_key_override_survives_team_metadata_merge():
from litellm.integrations.otel.plumbing.routing import tenant_service_name
# Request setup merges team metadata over key metadata (last writer wins),
# so a key keeps its own destination via ``otel_service_name_override``,
# which a team defining only ``otel_service_name`` never touches.
merged = {"otel_service_name_override": "key-svc"}
merged.update({"otel_service_name": "team-svc"})
assert tenant_service_name(merged) == "key-svc"
def test_provider_cached_per_service_name():
cache = _cache("otel")
default = NoOpTracer()
routed = cache.route_for(default, None, {"otel_service_name": "payments-gateway"})
assert routed.tracer is not default
assert routed.detached is False # stays parented into the request trace
assert routed.provider is not None
assert routed.provider.resource.attributes["service.name"] == "payments-gateway"
cache.route_for(default, None, {"otel_service_name": "payments-gateway"})
assert len(cache._providers) == 1
cache.route_for(default, None, {"otel_service_name": "search-gateway"})
assert len(cache._providers) == 2
for provider in cache._providers.values():
provider.shutdown()
def test_service_name_routed_span_carries_team_service_name(monkeypatch):
# The artifact the exporter receives: the finished span's Resource must
# carry the team's service.name, not the env-configured default.
monkeypatch.setenv("OTEL_SERVICE_NAME", "proxy-default")
cache = _cache("otel")
default = NoOpTracer()
route = cache.route_for(default, None, {"otel_service_name": "payments-gateway"})
with route.tracer.start_as_current_span("chat gpt-4o-mini") as span:
pass
assert span.resource.attributes["service.name"] == "payments-gateway"
cache.release(route.provider)
unrouted = cache.route_for(default, None, {"logging_setting": "x"})
assert unrouted.tracer is default # env fallback: no scoped provider built
def test_client_dynamic_params_cannot_choose_service_name():
# ``StandardCallbackDynamicParams`` is populated from client-supplied
# request metadata; the service name may only come from server-set
# key/team config (the ``auth_metadata`` argument).
cache = _cache("otel")
default = NoOpTracer()
assert cache.route_for(default, {"otel_service_name": "attacker"}).tracer is default
assert cache.route_for(default, {"otel_service_name_override": "attacker"}).tracer is default
assert cache._providers == {}
def test_service_name_override_leaves_exporters_untouched():
cache = _cache(
"otel",
exporters=[
ExporterSpec(
kind="otlp_http",
endpoint="http://collector:4318",
headers="x=base-collector",
owner=None,
),
],
)
cfg = cache._routed_config({}, {}, None, "payments-gateway")
assert cfg.service_name == "payments-gateway"
(spec,) = cfg.exporters
assert spec.headers == "x=base-collector"
assert spec.endpoint == "http://collector:4318"
# --- New Relic: per-team api-key header + fixed-table region endpoint --- #

View file

@ -778,11 +778,15 @@ def test_mcp_span_roots_without_transport_or_propagated_context(
@pytest.mark.parametrize("make_payload, span_name", _MCP_SPAN_CASES)
def test_mcp_span_parents_to_propagated_meta_trace_context(make_payload, span_name):
def test_mcp_span_links_propagated_meta_trace_context_and_nests_under_transport(
make_payload, span_name
):
"""When the client propagates W3C trace context in the request's
``params._meta`` (SEP-414), the MCP span parents to it (one distributed trace)
and still links the transport span never falling through to the
ambient/session span."""
``params._meta`` (SEP-414), the MCP span still nests under the gateway's own
transport span one renderable trace and records the client's context as a
span *link*. Parenting to the remote context instead would root the span in a
trace whose root span never reaches the gateway's tracing backend, leaving the
span unreachable from the trace view."""
logger, exporter = _logger()
transport = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
@ -801,12 +805,65 @@ def test_mcp_span_parents_to_propagated_meta_trace_context(make_payload, span_na
reset_mcp_message_trace_carrier(token)
transport.end()
span = next(s for s in exporter.get_finished_spans() if s.name == span_name)
assert span.context.trace_id == 0x11111111111111111111111111111111
assert span.parent is not None
assert span.parent.span_id == 0x2222222222222222
assert [link.context.span_id for link in span.links] == [
transport.get_span_context().span_id
assert span.parent.span_id == transport.get_span_context().span_id
assert span.context.trace_id == transport.get_span_context().trace_id
assert [link.context.trace_id for link in span.links] == [
0x11111111111111111111111111111111
]
assert [link.context.span_id for link in span.links] == [0x2222222222222222]
@pytest.mark.parametrize("make_payload, span_name", _MCP_SPAN_CASES)
def test_mcp_span_without_transport_roots_and_links_propagated_context(
make_payload, span_name
):
"""With no transport span at all there is nothing of the gateway's to anchor
to, so the span starts its own root trace and the client context stays a
span link there too, so the event keeps one shape everywhere."""
logger, exporter = _logger()
token = set_mcp_message_trace_carrier(
{"traceparent": "00-11111111111111111111111111111111-2222222222222222-01"}
)
try:
asyncio.run(
logger.async_log_success_event(
{"standard_logging_object": make_payload()}, None, None, None
)
)
finally:
reset_mcp_message_trace_carrier(token)
span = next(s for s in exporter.get_finished_spans() if s.name == span_name)
assert span.parent is None
assert span.context.trace_id != 0x11111111111111111111111111111111
assert [link.context.span_id for link in span.links] == [0x2222222222222222]
def test_mcp_span_links_unsampled_client_traceparent():
"""A client traceparent with the sampled flag off ('-00') still yields a valid
remote context, so the link is recorded; the span's own recording follows the
transport's sampling decision, never the client's flag."""
logger, exporter = _logger()
transport = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
)
set_request_root_span(transport)
token = set_mcp_message_trace_carrier(
{"traceparent": "00-11111111111111111111111111111111-2222222222222222-00"}
)
try:
asyncio.run(
logger.async_log_success_event(
{"standard_logging_object": _mcp_list_payload()}, None, None, None
)
)
finally:
reset_mcp_message_trace_carrier(token)
transport.end()
span = next(s for s in exporter.get_finished_spans() if s.name == "tools/list")
assert span.parent is not None
assert span.parent.span_id == transport.get_span_context().span_id
assert [link.context.span_id for link in span.links] == [0x2222222222222222]
@pytest.mark.parametrize("make_payload, span_name", _MCP_SPAN_CASES)
@ -839,8 +896,11 @@ def test_mcp_span_ignores_client_supplied_baggage(make_payload, span_name):
reset_mcp_message_trace_carrier(token)
transport.end()
span = next(s for s in exporter.get_finished_spans() if s.name == span_name)
# Trace context still honored: proves the carrier was processed, not dropped wholesale.
assert span.parent is not None and span.parent.span_id == 0x2222222222222222
# Trace context still honored (as a link): proves the carrier was processed,
# not dropped wholesale.
assert [link.context.span_id for link in span.links] == [0x2222222222222222]
assert span.parent is not None
assert span.parent.span_id == transport.get_span_context().span_id
# Identity is the authenticated payload's team, never the client's spoofed value.
assert span.attributes[LiteLLM.TEAM_ID] == "t1"
assert "litellm.metadata.user_api_key_user_id" not in span.attributes
@ -888,10 +948,10 @@ def test_mcp_span_malformed_traceparent_nests_under_transport():
assert span.links == ()
def test_mcp_span_links_this_messages_transport_when_context_is_propagated():
"""On the semconv path the transport is recorded as a link, and that link must
point at the POST carrying this message too. Reading the stale session anchor
would attribute the tool call to whichever request opened the session."""
def test_mcp_span_with_propagated_context_nests_under_this_messages_transport():
"""With client context propagated, the span must still anchor to the POST
carrying this message, not the stale session anchor otherwise the tool call
is attributed to whichever request opened the session."""
logger, exporter = _logger()
session_opener = logger._emitter.start_span(
SpanRole.PROXY_REQUEST, LITELLM_PROXY_REQUEST_SPAN_NAME
@ -916,10 +976,10 @@ def test_mcp_span_links_this_messages_transport_when_context_is_propagated():
session_opener.end()
this_message.end()
span = next(s for s in exporter.get_finished_spans() if s.name == "tools/list")
assert span.parent is not None and span.parent.span_id == 0x2222222222222222
assert [link.context.span_id for link in span.links] == [
this_message.get_span_context().span_id
]
assert span.parent is not None
assert span.parent.span_id == this_message.get_span_context().span_id
assert span.context.trace_id == this_message.get_span_context().trace_id
assert [link.context.span_id for link in span.links] == [0x2222222222222222]
def test_pre_call_idempotent_keeps_first_span():

View file

@ -107,32 +107,29 @@ def test_registry_parent_integrity_no_orphans():
def test_registry_hierarchy_shape():
# MCP roles have no in-process parent: per the MCP semconv they root (or adopt
# the client's propagated _meta context), so they sit alongside PROXY_REQUEST.
assert set(root_roles()) == {
SpanRole.PROXY_REQUEST,
SpanRole.MCP_TOOL_CALL,
SpanRole.MCP_LIST_TOOLS,
}
assert set(root_roles()) == {SpanRole.PROXY_REQUEST}
# Guardrails parent to the request span, not the LLM call: a pre-call
# guardrail runs before the LLM call exists, so it's a sibling of it.
# guardrail runs before the LLM call exists, so it's a sibling of it. MCP
# spans nest under the transport span of the request carrying that message.
assert set(child_roles(SpanRole.PROXY_REQUEST)) == {
SpanRole.LLM_CALL,
SpanRole.GUARDRAIL,
SpanRole.DB_CALL,
SpanRole.SERVICE,
SpanRole.MCP_TOOL_CALL,
SpanRole.MCP_LIST_TOOLS,
}
assert SPAN_REGISTRY[SpanRole.LLM_CALL].kind is LiteLLMSpanKind.CLIENT
# The proxy is an MCP client to the upstream tool server: CLIENT span. Listing
# tools is the same client relationship, so it's a CLIENT span too.
assert SPAN_REGISTRY[SpanRole.MCP_TOOL_CALL].kind is LiteLLMSpanKind.CLIENT
assert SPAN_REGISTRY[SpanRole.MCP_LIST_TOOLS].kind is LiteLLMSpanKind.CLIENT
# MCP spans don't nest under the transport: they link the PROXY_REQUEST span
# instead of parenting to it (OTel GenAI MCP semconv).
assert SPAN_REGISTRY[SpanRole.MCP_TOOL_CALL].parent is None
assert SPAN_REGISTRY[SpanRole.MCP_LIST_TOOLS].parent is None
assert SPAN_REGISTRY[SpanRole.MCP_TOOL_CALL].links is SpanRole.PROXY_REQUEST
assert SPAN_REGISTRY[SpanRole.MCP_LIST_TOOLS].links is SpanRole.PROXY_REQUEST
# MCP spans nest under the transport span of the request carrying that
# message (resolved per message at emit time); a client-propagated context
# becomes a span link to that remote context, which is not a registry role
# (SpanSpec declares no link field at all).
assert SPAN_REGISTRY[SpanRole.MCP_TOOL_CALL].parent is SpanRole.PROXY_REQUEST
assert SPAN_REGISTRY[SpanRole.MCP_LIST_TOOLS].parent is SpanRole.PROXY_REQUEST
assert SPAN_REGISTRY[SpanRole.PROXY_REQUEST].kind is LiteLLMSpanKind.SERVER
assert SPAN_REGISTRY[SpanRole.GUARDRAIL].parent is SpanRole.PROXY_REQUEST
# An outbound datastore call is a CLIENT span; an internal service is INTERNAL.

View file

@ -10,8 +10,8 @@ import pytest
from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import (
StandardBuiltInToolCostTracking,
get_web_search_requests,
)
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
from litellm.types.utils import ModelResponse, ServerToolUse, Usage

View file

@ -1002,6 +1002,17 @@ def test_an_exception_without_a_status_is_still_a_connection_error(quiet_excepti
)
def test_an_unmapped_exception_with_no_model_or_provider_is_a_connection_error(quiet_exception_mapping):
with pytest.raises(litellm.APIConnectionError) as raised:
exception_type(
model=None,
original_exception=ValueError("boom"),
custom_llm_provider=None,
)
assert "boom" in raised.value.message
CONTEXT_WINDOW_MESSAGE = "This model's maximum context length is 4096 tokens."
CONTENT_POLICY_MESSAGE = (
'{"error": {"type": "invalid_request_error", "code": "content_policy_violation"}}'

View file

@ -2957,3 +2957,157 @@ async def test_log_messages_routes_async_logging_through_bounded_worker():
logging_obj.success_handler.assert_not_called()
# the bare create_task path must no longer be used for success logging
mock_create_task.assert_not_called()
@pytest.mark.asyncio
async def test_provider_config_path_captures_transcription_usage():
"""A transcription.completed event with usage from the provider transform must
land in the logged messages so realtime cost calculation can bill it."""
from typing import Final
from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage, RealtimeResponseTypedDict
client_ws: Final = MagicMock()
client_ws.send_text = AsyncMock()
backend_ws: Final = MagicMock()
backend_ws.send = AsyncMock()
logging_obj: Final = MagicMock()
usage: Final[RealtimeInputAudioTranscriptionUsage] = {
"type": "tokens",
"input_tokens": 50,
"output_tokens": 6,
"total_tokens": 56,
"input_token_details": {"text_tokens": 0, "audio_tokens": 50},
}
transform_output: Final[RealtimeResponseTypedDict] = {
"response": {
"type": "conversation.item.input_audio_transcription.completed",
"event_id": "event_1",
"transcript": "ahoy",
"item_id": "item_1",
"content_index": 0,
"usage": usage,
},
"current_output_item_id": None,
"current_response_id": None,
"current_delta_chunks": None,
"current_conversation_id": None,
"current_item_chunks": None,
"current_delta_type": None,
"session_configuration_request": None,
}
provider_config: Final = MagicMock()
provider_config.transform_realtime_request = MagicMock(return_value=())
provider_config.transform_realtime_response = MagicMock(return_value=transform_output)
streaming: Final = RealTimeStreaming(
client_ws,
backend_ws,
logging_obj,
provider_config=provider_config,
model="gemini-3.5-transcribe-live",
)
await streaming._handle_provider_config_message("{}")
usage_events: Final = tuple(
message
for message in streaming.messages
if isinstance(message, dict)
and message.get("type") == "conversation.item.input_audio_transcription.completed"
and message.get("usage") == usage
)
assert len(usage_events) == 1
@pytest.mark.asyncio
async def test_session_close_flushes_unbilled_transcription_usage():
"""Trailing audio appended after the last transcript frame must still be billed:
on session close the provider's unbilled estimate is flushed into the logged
messages before log_messages runs, and never forwarded to the client."""
from typing import Final
from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage
client_ws: Final = MagicMock()
client_ws.send_text = AsyncMock()
backend_ws: Final = MagicMock()
backend_ws.recv = AsyncMock(side_effect=ConnectionClosed(None, None))
logging_obj: Final = MagicMock()
logging_obj.async_success_handler = AsyncMock()
logging_obj.success_handler = MagicMock()
usage: Final[RealtimeInputAudioTranscriptionUsage] = {
"type": "tokens",
"input_tokens": 153,
"output_tokens": 18,
"total_tokens": 171,
"input_token_details": {"text_tokens": 0, "audio_tokens": 153},
}
provider_config: Final = MagicMock()
provider_config.unbilled_usage_on_session_close = MagicMock(return_value=usage)
streaming: Final = RealTimeStreaming(
client_ws,
backend_ws,
logging_obj,
provider_config=provider_config,
model="gemini-3.5-transcribe-live",
)
logged_snapshots: Final[list[tuple]] = []
original_log_messages: Final = streaming.log_messages
async def _snapshot_then_log():
logged_snapshots.append(tuple(streaming.messages))
await original_log_messages()
streaming.log_messages = _snapshot_then_log
await streaming.backend_to_client_send_messages()
provider_config.unbilled_usage_on_session_close.assert_called_once_with("gemini-3.5-transcribe-live")
flushed: Final = tuple(
message
for message in streaming.messages
if isinstance(message, dict)
and message.get("type") == "conversation.item.input_audio_transcription.completed"
and message.get("usage") == usage
)
assert len(flushed) == 1
assert flushed[0] in logged_snapshots[0]
assert not client_ws.send_text.called
@pytest.mark.asyncio
async def test_session_close_flush_noop_without_unbilled_usage():
"""Everything already billed mid-stream: the session-close flush must not append
a duplicate transcription event."""
from typing import Final
client_ws: Final = MagicMock()
client_ws.send_text = AsyncMock()
backend_ws: Final = MagicMock()
backend_ws.recv = AsyncMock(side_effect=ConnectionClosed(None, None))
logging_obj: Final = MagicMock()
logging_obj.async_success_handler = AsyncMock()
logging_obj.success_handler = MagicMock()
provider_config: Final = MagicMock()
provider_config.unbilled_usage_on_session_close = MagicMock(return_value=None)
streaming: Final = RealTimeStreaming(
client_ws,
backend_ws,
logging_obj,
provider_config=provider_config,
model="gemini-3.5-transcribe-live",
)
await streaming.backend_to_client_send_messages()
assert not any(
isinstance(message, dict) and message.get("type") == "conversation.item.input_audio_transcription.completed"
for message in streaming.messages
)

View file

@ -17,7 +17,12 @@ from litellm.anthropic_interface import messages
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler
from litellm.types.utils import Delta, ModelResponse, StreamingChoices
from litellm.types.utils import (
Delta,
ModelResponse,
StandardLoggingPayloadErrorInformation,
StreamingChoices,
)
def test_anthropic_experimental_pass_through_messages_handler():
@ -1292,7 +1297,7 @@ class TestMessagesStreamingSuccessLogging:
class _FailureCapture(CustomLogger):
def __init__(self):
super().__init__()
self.error_information: List[Dict[str, Any]] = []
self.error_information: list[StandardLoggingPayloadErrorInformation] = []
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
payload = kwargs.get("standard_logging_object") or {}

View file

@ -8,10 +8,8 @@ See https://github.com/BerriAI/litellm/issues/26153.
import pytest
from litellm.llms.anthropic.cost_calculation import (
get_cost_for_anthropic_web_search,
get_web_search_requests,
)
from litellm.litellm_core_utils.llm_cost_calc.utils import get_web_search_requests
from litellm.llms.anthropic.cost_calculation import get_cost_for_anthropic_web_search
from litellm.types.utils import ModelInfo, ServerToolUse

View file

@ -12,6 +12,7 @@ import pytest
import litellm
from litellm.llms.bedrock.base_aws_llm import Boto3CredentialsInfo
from litellm.llms.bedrock.rerank.handler import BedrockRerankHandler
from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
# Mock response for Bedrock rerank
@ -402,6 +403,66 @@ def test_bedrock_rerank_extra_headers_and_headers_merge():
pytest.fail(f"Failed to merge and forward headers: {str(e)}")
def test_bedrock_rerank_forwarded_headers_excluded_from_sigv4_signature():
"""
A forwarded header like x-forwarded-for can be rewritten between LiteLLM
signing the request and AWS receiving it (e.g. by an intermediate load
balancer), which invalidates the signature if that header was part of
the signed set. It must still reach Bedrock, just unsigned.
"""
handler = BedrockRerankHandler()
prepared_request = handler._prepare_request(
model="cohere.rerank-v3-5:0",
api_base=None,
extra_headers={"x-forwarded-for": "203.0.113.5"},
data={"query": test_query, "documents": test_documents},
optional_params={
"aws_access_key_id": "test-access-key",
"aws_secret_access_key": "test-secret-key",
"aws_region_name": "us-east-1",
},
)
headers = prepared_request["prepped"].headers
signed_headers = headers["Authorization"].split("SignedHeaders=")[1].split(",")[0].split(";")
assert "x-forwarded-for" not in signed_headers, (
f"x-forwarded-for must not be part of the SigV4 signature, got SignedHeaders={signed_headers}"
)
assert headers["x-forwarded-for"] == "203.0.113.5", "forwarded header must still reach Bedrock, unsigned"
def test_bedrock_rerank_signs_with_sigv4_even_when_bedrock_api_key_is_set(monkeypatch):
"""
Bedrock API keys are only valid for Bedrock and Bedrock Runtime actions, not for
Agents for Amazon Bedrock Runtime ones. Rerank is served by bedrock-agent-runtime,
so it has to keep signing with SigV4 even when AWS_BEARER_TOKEN_BEDROCK is set.
"""
monkeypatch.setenv("AWS_BEARER_TOKEN_BEDROCK", "test-bedrock-api-key")
handler = BedrockRerankHandler()
prepared_request = handler._prepare_request(
model="cohere.rerank-v3-5:0",
api_base=None,
extra_headers=None,
data={"query": test_query, "documents": test_documents},
optional_params={
"aws_access_key_id": "test-access-key",
"aws_secret_access_key": "test-secret-key",
"aws_region_name": "us-east-1",
},
)
assert prepared_request["endpoint_url"].startswith("https://bedrock-agent-runtime.")
authorization = prepared_request["prepped"].headers["Authorization"]
assert authorization.startswith("AWS4-HMAC-SHA256"), (
f"rerank must sign with SigV4, got Authorization={authorization[:30]}"
)
@pytest.mark.asyncio
async def test_bedrock_rerank_records_llm_api_duration():
"""The bedrock rerank handler must feed httpx timing into the logging obj, so the

View file

@ -59,17 +59,17 @@ class GptProfile(NamedTuple):
GPT_5_6_PROFILES = [
GptProfile(
model_id="us.openai.gpt-5.6-sol",
input_cost=5.5e-06, input_cost_above_272k=1.1e-05,
cache_write=6.875e-06, cache_write_above_272k=1.375e-05,
cache_read=5.5e-07, cache_read_above_272k=1.1e-06,
output_cost=3.3e-05, output_cost_above_272k=4.95e-05,
input_cost=4.4e-06, input_cost_above_272k=8.8e-06,
cache_write=5.5e-06, cache_write_above_272k=1.1e-05,
cache_read=4.4e-07, cache_read_above_272k=8.8e-07,
output_cost=2.2e-05, output_cost_above_272k=3.3e-05,
),
GptProfile(
model_id="global.openai.gpt-5.6-sol",
input_cost=5e-06, input_cost_above_272k=1e-05,
cache_write=6.25e-06, cache_write_above_272k=1.25e-05,
cache_read=5e-07, cache_read_above_272k=1e-06,
output_cost=3e-05, output_cost_above_272k=4.5e-05,
input_cost=4e-06, input_cost_above_272k=8e-06,
cache_write=5e-06, cache_write_above_272k=1e-05,
cache_read=4e-07, cache_read_above_272k=8e-07,
output_cost=2e-05, output_cost_above_272k=3e-05,
),
GptProfile(
model_id="us.openai.gpt-5.6-terra",
@ -221,7 +221,7 @@ def test_bedrock_gpt_5_6_above_272k_tier_applies_to_cost(local_model_cost_map):
custom_llm_provider="bedrock",
)
assert cost == pytest.approx((300000 * 1.1e-05) + (1000 * 4.95e-05), rel=1e-9)
assert cost == pytest.approx((300000 * 8.8e-06) + (1000 * 3.3e-05), rel=1e-9)
def test_bedrock_gpt_5_6_bills_cache_read_tokens(local_model_cost_map):
@ -241,10 +241,10 @@ def test_bedrock_gpt_5_6_bills_cache_read_tokens(local_model_cost_map):
custom_llm_provider="bedrock",
)
expected = (2 * 5.5e-06) + (15609 * 5.5e-07) + (5 * 3.3e-05)
expected = (2 * 4.4e-06) + (15609 * 4.4e-07) + (5 * 2.2e-05)
assert cost == pytest.approx(expected, rel=1e-9)
# Without cache_read_input_token_cost the cached prefix bills at zero.
assert cost > (15611 * 5.5e-06) * 0.1
assert cost > (15611 * 4.4e-06) * 0.1
def test_bedrock_gpt_5_6_bills_cache_write_tokens(local_model_cost_map):
@ -263,7 +263,7 @@ def test_bedrock_gpt_5_6_bills_cache_write_tokens(local_model_cost_map):
custom_llm_provider="bedrock",
)
expected = (2 * 5.5e-06) + (15609 * 6.875e-06) + (5 * 3.3e-05)
expected = (2 * 4.4e-06) + (15609 * 5.5e-06) + (5 * 2.2e-05)
assert cost == pytest.approx(expected, rel=1e-9)

View file

@ -1683,10 +1683,19 @@ class TestBedrockMantleResponsesPricing:
assert info["cache_read_input_token_cost"] == pytest.approx(2.75e-07)
assert info["max_input_tokens"] == 1050000
def test_gpt_5_6_cyber_pricing_and_mode(self, local_cost_map):
info = litellm.get_model_info("bedrock_mantle/openai.gpt-5.6-cyber")
assert info["mode"] == "responses"
assert info["input_cost_per_token"] == pytest.approx(1.375e-05)
assert info["cache_creation_input_token_cost"] == pytest.approx(1.71875e-05)
assert info["cache_read_input_token_cost"] == pytest.approx(1.375e-06)
assert info["output_cost_per_token"] == pytest.approx(8.25e-05)
assert info["max_input_tokens"] == 272000
@pytest.mark.parametrize(
"model, input_cost, cache_creation_cost, cache_read_cost, output_cost",
[
("openai.gpt-5.6-sol", 5.5e-06, 6.875e-06, 5.5e-07, 3.3e-05),
("openai.gpt-5.6-sol", 4.4e-06, 5.5e-06, 4.4e-07, 2.2e-05),
("openai.gpt-5.6-terra", 2.2e-06, 2.75e-06, 2.2e-07, 1.32e-05),
("openai.gpt-5.6-luna", 2.2e-07, 2.75e-07, 2.2e-08, 1.32e-06),
],
@ -1709,7 +1718,7 @@ class TestBedrockMantleResponsesPricing:
@pytest.mark.parametrize(
"model, input_cost, output_cost",
[
("openai.gpt-5.6-sol", 5.5e-06, 3.3e-05),
("openai.gpt-5.6-sol", 4.4e-06, 2.2e-05),
("openai.gpt-5.6-terra", 2.2e-06, 1.32e-05),
("openai.gpt-5.6-luna", 2.2e-07, 1.32e-06),
],

View file

@ -61,6 +61,8 @@ PUBLISHED_DBU_PER_MILLION: Final = {
"databricks/databricks-gemini-3-1-flash-lite": ("4.464", "26.786", "4.464", "0.446"),
"databricks/databricks-gemini-2-5-pro": ("22.321", "178.571", "22.321", "2.232"),
"databricks/databricks-gemini-2-5-flash": ("5.357", "44.643", "5.357", "0.536"),
"databricks/databricks-kimi-k3": ("42.857", "214.286", "42.857", "4.286"),
"databricks/databricks-glm-5-2": ("20.000", "62.857", "20.000", "3.714"),
}
PROMOTIONAL_DISCOUNT: Final = 0.80
PROMOTION_EXPIRES: Final = "2027-01-31"

View file

@ -0,0 +1,248 @@
import base64
import json
import httpx
import pytest
import litellm
from litellm.llms.gemini.audio_transcription.transformation import (
GeminiAudioTranscriptionConfig,
)
from litellm.llms.gemini.common_utils import GeminiError
from litellm.types.utils import LlmProviders
from litellm.utils import ProviderConfigManager
AUDIO_BYTES = b"RIFF....WAVEfmt fake-wav-bytes"
COMPLETED_RESPONSE = {
"id": "v1_abc123",
"status": "completed",
"usage": {
"total_tokens": 200,
"total_input_tokens": 200,
"input_tokens_by_modality": [
{"modality": "text", "tokens": 1},
{"modality": "audio", "tokens": 199},
],
"total_output_tokens": 0,
},
"steps": [
{
"type": "model_generation",
"content": [
{
"type": "text",
"text": "Hello world.",
"annotations": [
{
"type": "word_info",
"text": "Hello",
"speaker": "spk:0",
"start_offset": "0.100s",
"end_offset": "0.400s",
},
{
"type": "word_info",
"text": "world.",
"speaker": "spk:1",
"start_offset": "0.500s",
"end_offset": "0.900s",
},
],
}
],
}
],
}
def make_response(payload):
return httpx.Response(200, json=payload, request=httpx.Request("POST", "https://example.test"))
@pytest.fixture
def config():
return GeminiAudioTranscriptionConfig()
def test_provider_config_manager_returns_gemini_config():
provider_config = ProviderConfigManager.get_provider_audio_transcription_config(
model="gemini-3.5-transcribe", provider=LlmProviders.GEMINI
)
assert isinstance(provider_config, GeminiAudioTranscriptionConfig)
class TestValidateEnvironment:
def test_sets_api_key_and_revision_headers(self, config):
headers = config.validate_environment(
headers={},
model="gemini-3.5-transcribe",
messages=[],
optional_params={},
litellm_params={},
api_key="test-key",
)
assert headers["x-goog-api-key"] == "test-key"
assert headers["Api-Revision"] == "2026-05-20"
assert headers["Content-Type"] == "application/json"
def test_missing_api_key_raises(self, config, monkeypatch):
monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
monkeypatch.delenv("GEMINI_API_KEY", raising=False)
with pytest.raises(GeminiError) as excinfo:
config.validate_environment(
headers={},
model="gemini-3.5-transcribe",
messages=[],
optional_params={},
litellm_params={},
)
assert excinfo.value.status_code == 401
class TestGetCompleteUrl:
def test_defaults_to_interactions_endpoint(self, config):
url = config.get_complete_url(
api_base=None,
api_key=None,
model="gemini-3.5-transcribe",
optional_params={},
litellm_params={},
)
assert url == "https://generativelanguage.googleapis.com/v1beta/interactions"
def test_api_base_override(self, config):
url = config.get_complete_url(
api_base="http://localhost:8080",
api_key=None,
model="gemini-3.5-transcribe",
optional_params={},
litellm_params={},
)
assert url == "http://localhost:8080/v1beta/interactions"
class TestTransformRequest:
def test_builds_json_interaction_request(self, config):
request_data = config.transform_audio_transcription_request(
model="gemini/gemini-3.5-transcribe",
audio_file=("sample.wav", AUDIO_BYTES, "audio/wav"),
optional_params={},
litellm_params={},
)
assert request_data.files is None
assert json.loads(json.dumps(request_data.data)) == {
"model": "gemini-3.5-transcribe",
"input": [
{
"type": "audio",
"data": base64.b64encode(AUDIO_BYTES).decode("utf-8"),
"mime_type": "audio/wav",
}
],
}
def test_language_maps_to_bcp47_language_codes(self, config):
request_data = config.transform_audio_transcription_request(
model="gemini-3.5-transcribe",
audio_file=("sample.wav", AUDIO_BYTES, "audio/wav"),
optional_params={"language": "en"},
litellm_params={},
)
transcription_config = request_data.data["generation_config"]["transcription_config"]
assert json.loads(json.dumps(transcription_config)) == {"language_codes": ["en-US"]}
def test_word_timestamp_granularity_maps_to_verbatim_diarization_mode(self, config):
request_data = config.transform_audio_transcription_request(
model="gemini-3.5-transcribe",
audio_file=("sample.wav", AUDIO_BYTES, "audio/wav"),
optional_params={"timestamp_granularities": ["word"]},
litellm_params={},
)
transcription_config = request_data.data["generation_config"]["transcription_config"]
assert json.loads(json.dumps(transcription_config)) == {
"mode": {
"type": "verbatim",
"timestamp_granularities": ["word"],
"diarization_mode": "speaker",
}
}
def test_segment_granularity_sends_no_mode(self, config):
request_data = config.transform_audio_transcription_request(
model="gemini-3.5-transcribe",
audio_file=("sample.wav", AUDIO_BYTES, "audio/wav"),
optional_params={"timestamp_granularities": ["segment"]},
litellm_params={},
)
assert "generation_config" not in request_data.data
class TestTransformResponse:
def test_completed_interaction_maps_to_transcription_response(self, config):
response = config.transform_audio_transcription_response(make_response(COMPLETED_RESPONSE))
assert response.text == "Hello world."
assert response["task"] == "transcribe"
assert response["words"] == [
{"word": "Hello", "start": 0.1, "end": 0.4, "speaker": "spk:0"},
{"word": "world.", "start": 0.5, "end": 0.9, "speaker": "spk:1"},
]
assert response["duration"] == 0.9
assert response.usage.input_tokens == 200
assert response.usage.output_tokens == 0
assert response.usage.total_tokens == 200
assert response.usage.input_token_details.audio_tokens == 199
assert response.usage.input_token_details.text_tokens == 1
def test_non_completed_status_raises(self, config):
with pytest.raises(GeminiError, match="did not complete"):
config.transform_audio_transcription_response(
make_response({**COMPLETED_RESPONSE, "status": "in_progress"})
)
def test_non_json_response_raises(self, config):
raw = httpx.Response(200, text="<html>oops</html>", request=httpx.Request("POST", "https://example.test"))
with pytest.raises(GeminiError, match="non-JSON"):
config.transform_audio_transcription_response(raw)
def test_word_without_offsets_survives(self, config):
payload = json.loads(json.dumps(COMPLETED_RESPONSE))
payload["steps"][0]["content"][0]["annotations"] = [{"type": "word_info", "text": "Hello"}]
response = config.transform_audio_transcription_response(make_response(payload))
assert response["words"] == [{"word": "Hello"}]
assert response.get("duration") is None
class TestCostRegression:
@pytest.fixture
def local_cost_map(self, monkeypatch):
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
monkeypatch.setattr(litellm, "model_cost", litellm.get_model_cost_map(url=""))
def test_registry_entries(self, local_cost_map):
batch_entry = litellm.model_cost["gemini/gemini-3.5-transcribe"]
assert batch_entry["mode"] == "audio_transcription"
assert batch_entry["input_cost_per_audio_token"] == 2e-06
assert batch_entry["input_cost_per_token"] == 2e-06
assert batch_entry["output_cost_per_token"] == 1.2e-05
assert batch_entry["supported_endpoints"] == ["/v1/audio/transcriptions"]
live_entry = litellm.model_cost["gemini/gemini-3.5-transcribe-live"]
assert live_entry["mode"] == "audio_transcription"
assert live_entry["input_cost_per_audio_token"] == 3.5e-06
assert live_entry["input_cost_per_token"] == 3.5e-06
assert live_entry["output_cost_per_token"] == 2.1e-05
assert live_entry["supported_endpoints"] == ["/v1/realtime"]
def test_completion_cost_bills_provider_reported_tokens(self, config, local_cost_map):
payload = json.loads(json.dumps(COMPLETED_RESPONSE))
payload["usage"]["total_output_tokens"] = 10
payload["usage"]["total_tokens"] = 210
response = config.transform_audio_transcription_response(make_response(payload))
cost = litellm.completion_cost(
completion_response=response,
model="gemini/gemini-3.5-transcribe",
call_type="transcription",
)
assert cost == pytest.approx(199 * 2e-06 + 1 * 2e-06 + 10 * 1.2e-05)

View file

@ -1864,3 +1864,282 @@ def test_map_openai_params_drops_stock_voice_case_insensitively():
passthrough = cfg.map_openai_params(optional_params={}, non_default_params={"voice": "Kore"})
assert passthrough["generationConfig"]["speechConfig"]["voiceConfig"]["prebuiltVoiceConfig"]["voiceName"] == "Kore"
@pytest.fixture(autouse=False)
def patch_gemini_transcribe_live_cost_map_entry(monkeypatch):
"""Inject the gemini-3.5-transcribe-live registry entry locally.
litellm.model_cost is fetched from main branch at import time, so in CI
the entry may not exist yet. Also stamp supported_output_modalities on a
chat model to prove mode, not output modalities, drives the discriminator.
"""
for m in ["gemini-3.5-transcribe-live", "gemini/gemini-3.5-transcribe-live"]:
entry = dict(litellm.model_cost.get(m, {}))
entry["mode"] = "audio_transcription"
monkeypatch.setitem(litellm.model_cost, m, entry)
chat_entry = dict(litellm.model_cost.get("gemini-2.5-flash", {}))
chat_entry["supported_output_modalities"] = ["text"]
monkeypatch.setitem(litellm.model_cost, "gemini-2.5-flash", chat_entry)
@pytest.mark.parametrize("model", ["gemini-3.5-transcribe-live", "gemini/gemini-3.5-transcribe-live"])
def test_gemini_transcribe_live_eager_setup_uses_text_modality(model, patch_gemini_transcribe_live_cost_map_entry):
"""Regression: the hardcoded AUDIO eager setup closes transcribe-live sessions with 1007."""
config = GeminiRealtimeConfig()
setup = json.loads(config.session_configuration_request(model))["setup"]
assert setup["generationConfig"]["responseModalities"] == ["TEXT"]
def test_gemini_transcribe_live_session_update_defaults_to_text_modality(
patch_gemini_transcribe_live_cost_map_entry,
):
config = GeminiRealtimeConfig()
session_update = {
"type": "session.update",
"session": {"instructions": "Transcribe the audio."},
}
messages = config.transform_realtime_request(
json.dumps(session_update),
"gemini-3.5-transcribe-live",
session_configuration_request=None,
)
setup = json.loads(messages[0])["setup"]
assert setup["generationConfig"]["responseModalities"] == ["TEXT"]
@pytest.mark.parametrize("modalities", [["audio"], ["audio", "text"]])
def test_gemini_transcribe_live_coerces_audio_modality_to_text(modalities, patch_gemini_transcribe_live_cost_map_entry):
config = GeminiRealtimeConfig()
session_update = {
"type": "session.update",
"session": {"modalities": modalities},
}
messages = config.transform_realtime_request(
json.dumps(session_update),
"gemini-3.5-transcribe-live",
session_configuration_request=None,
)
setup = json.loads(messages[0])["setup"]
assert setup["generationConfig"]["responseModalities"] == ["TEXT"]
def test_gemini_chat_model_with_text_output_modalities_keeps_audio_eager_setup(
patch_gemini_transcribe_live_cost_map_entry,
):
"""Chat entries also declare supported_output_modalities ["text"]; they must keep AUDIO."""
config = GeminiRealtimeConfig()
setup = json.loads(config.session_configuration_request("gemini-2.5-flash"))["setup"]
assert setup["generationConfig"]["responseModalities"] == ["AUDIO"]
def test_generation_complete_without_prior_delta_keeps_turn_usage(patch_gemini_audio_cost_map_entries):
from typing import Final
from litellm.types.llms.gemini import BidiGenerateContentServerMessage
from litellm.types.realtime import RealtimeResponseTransformInput
config: Final = GeminiRealtimeConfig()
turn_end_frame: Final[BidiGenerateContentServerMessage] = {
"serverContent": {"generationComplete": True, "turnComplete": True},
"usageMetadata": {
"promptTokenCount": 200,
"totalTokenCount": 200,
"promptTokensDetails": [
{"modality": "AUDIO", "tokenCount": 199},
{"modality": "TEXT", "tokenCount": 1},
],
},
}
transform_input: Final[RealtimeResponseTransformInput] = {
"session_configuration_request": None,
"current_output_item_id": None,
"current_response_id": None,
"current_conversation_id": None,
"current_delta_chunks": None,
"current_item_chunks": None,
"current_delta_type": None,
}
result: Final = config.transform_realtime_response(
json.dumps(turn_end_frame),
"gemini-3.5-transcribe-live",
MagicMock(),
realtime_response_transform_input=transform_input,
)
done_events: Final = tuple(event for event in result["response"] if event["type"] == "response.done")
assert len(done_events) == 1
assert done_events[0]["response"]["usage"]["input_tokens"] == 200
def test_bare_generation_complete_without_prior_delta_is_dropped(patch_gemini_audio_cost_map_entries):
from typing import Final
from litellm.types.llms.gemini import BidiGenerateContentServerMessage
from litellm.types.realtime import RealtimeResponseTransformInput
config: Final = GeminiRealtimeConfig()
bare_frame: Final[BidiGenerateContentServerMessage] = {"serverContent": {"generationComplete": True}}
transform_input: Final[RealtimeResponseTransformInput] = {
"session_configuration_request": None,
"current_output_item_id": None,
"current_response_id": None,
"current_conversation_id": None,
"current_delta_chunks": None,
"current_item_chunks": None,
"current_delta_type": None,
}
result: Final = config.transform_realtime_response(
json.dumps(bare_frame),
"gemini-3.5-transcribe-live",
MagicMock(),
realtime_response_transform_input=transform_input,
)
assert result["response"] == []
def _input_audio_append_message(raw_byte_count: int) -> str:
import base64
return json.dumps(
{"type": "input_audio_buffer.append", "audio": base64.b64encode(b"\x00" * raw_byte_count).decode()}
)
def test_transcribe_live_completed_event_carries_estimated_usage(patch_gemini_transcribe_live_cost_map_entry):
"""Gemini Live sends no usageMetadata for transcribe sessions, so LiteLLM bills
from streamed audio duration at Google's published estimate (25 audio tok/sec in,
175 text tok/min out): 96000 pcm16 bytes = 2s at 24kHz -> 50 in / 6 out."""
from typing import Final
from litellm.types.llms.gemini import BidiGenerateContentServerMessage
from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage, RealtimeResponseTransformInput
config: Final = GeminiRealtimeConfig()
config.transform_realtime_request(_input_audio_append_message(96000), "gemini-3.5-transcribe-live")
transcript_frame: Final[BidiGenerateContentServerMessage] = {
"serverContent": {"inputTranscription": {"text": "ahoy there"}}
}
transform_input: Final[RealtimeResponseTransformInput] = {
"session_configuration_request": None,
"current_output_item_id": None,
"current_response_id": None,
"current_conversation_id": None,
"current_delta_chunks": None,
"current_item_chunks": None,
"current_delta_type": None,
}
result: Final = config.transform_realtime_response(
json.dumps(transcript_frame),
"gemini-3.5-transcribe-live",
MagicMock(),
realtime_response_transform_input=transform_input,
)
completed: Final = tuple(
event
for event in result["response"]
if event["type"] == "conversation.item.input_audio_transcription.completed"
)
assert len(completed) == 1
assert completed[0]["transcript"] == "ahoy there"
expected_usage: Final[RealtimeInputAudioTranscriptionUsage] = {
"type": "tokens",
"input_tokens": 50,
"output_tokens": 6,
"total_tokens": 56,
"input_token_details": {"text_tokens": 0, "audio_tokens": 50},
}
assert completed[0]["usage"] == expected_usage
second: Final = config.transform_realtime_response(
json.dumps(transcript_frame),
"gemini-3.5-transcribe-live",
MagicMock(),
realtime_response_transform_input=transform_input,
)
second_completed: Final = tuple(
event
for event in second["response"]
if event["type"] == "conversation.item.input_audio_transcription.completed"
)
assert len(second_completed) == 1
assert "usage" not in second_completed[0]
def test_non_transcription_live_model_completed_event_has_no_usage(patch_gemini_audio_cost_map_entries):
"""Conversational Live models get their audio tokens from usageMetadata via
response.done; attaching estimated usage to their transcription events would
double-bill, so the estimate is gated to audio_transcription-mode models."""
from typing import Final
from litellm.types.llms.gemini import BidiGenerateContentServerMessage
from litellm.types.realtime import RealtimeResponseTransformInput
config: Final = GeminiRealtimeConfig()
config.transform_realtime_request(_input_audio_append_message(96000), "gemini-3.1-flash-live-preview")
transcript_frame: Final[BidiGenerateContentServerMessage] = {
"serverContent": {"inputTranscription": {"text": "ahoy there"}}
}
transform_input: Final[RealtimeResponseTransformInput] = {
"session_configuration_request": None,
"current_output_item_id": None,
"current_response_id": None,
"current_conversation_id": None,
"current_delta_chunks": None,
"current_item_chunks": None,
"current_delta_type": None,
}
result: Final = config.transform_realtime_response(
json.dumps(transcript_frame),
"gemini-3.1-flash-live-preview",
MagicMock(),
realtime_response_transform_input=transform_input,
)
completed: Final = tuple(
event
for event in result["response"]
if event["type"] == "conversation.item.input_audio_transcription.completed"
)
assert len(completed) == 1
assert "usage" not in completed[0]
def test_unbilled_usage_on_session_close_flushes_trailing_audio(patch_gemini_transcribe_live_cost_map_entry):
"""Audio appended after the last transcript frame is still unbilled when the
session closes; the session-close hook must hand back the estimate exactly once
so the streaming layer can bill it (144000 pcm16 bytes = 3s -> 75 in / 9 out)."""
from typing import Final
from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage
config: Final = GeminiRealtimeConfig()
config.transform_realtime_request(_input_audio_append_message(144000), "gemini-3.5-transcribe-live")
usage: Final = config.unbilled_usage_on_session_close("gemini-3.5-transcribe-live")
expected: Final[RealtimeInputAudioTranscriptionUsage] = {
"type": "tokens",
"input_tokens": 75,
"output_tokens": 9,
"total_tokens": 84,
"input_token_details": {"text_tokens": 0, "audio_tokens": 75},
}
assert usage == expected
assert config.unbilled_usage_on_session_close("gemini-3.5-transcribe-live") is None

View file

@ -18,8 +18,14 @@ from litellm.types.utils import LlmProviders, ModelResponse
TOOL_CALLING_MODEL = "openai/gpt-oss-20b"
REASONING_MODEL = "deepseek-ai/DeepSeek-V3.1"
PLAIN_MODEL = "Qwen/Qwen3-235B-A22B-fp8-tput"
UNMAPPED_MODEL = "example-org/brand-new-model"
NO_TOOLS_MODEL = "example-org/no-tools-model"
ADJUSTABLE_REASONING_MODEL = "openai/gpt-oss-120b"
HYBRID_REASONING_MODEL = "Qwen/Qwen3.5-9B"
HIGH_MAX_REASONING_MODEL = "deepseek-ai/DeepSeek-V4-Pro"
REGISTRY_FLAGGED_REASONING_MODEL = "zai-org/GLM-4.6"
NON_REASONING_MODEL = "meta-llama/Llama-3.3-70B-Instruct-Turbo"
NO_SCHEMA_MODEL = "example-org/no-schema-model"
TOOL_PARAMS = ("tools", "tool_choice", "function_call")
@ -39,6 +45,15 @@ JSON_SCHEMA_RESPONSE_FORMAT = {
REGEX_RESPONSE_FORMAT = {"type": "regex", "pattern": "(positive|neutral|negative)"}
def _map_reasoning_effort(model: str, effort: str) -> dict:
return TogetherAIChatConfig().map_openai_params(
non_default_params={"reasoning_effort": effort},
optional_params={},
model=model,
drop_params=False,
)
@pytest.fixture(autouse=True)
def force_local_model_cost(monkeypatch):
monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True")
@ -191,6 +206,116 @@ def test_map_openai_params_schema_model_passes_response_format_through(response_
assert mapped["response_format"] == response_format
@pytest.mark.parametrize(
"model",
[ADJUSTABLE_REASONING_MODEL, HYBRID_REASONING_MODEL, HIGH_MAX_REASONING_MODEL, REGISTRY_FLAGGED_REASONING_MODEL],
)
def test_supported_params_includes_reasoning_effort_for_reasoning_models(model):
supported = TogetherAIChatConfig().get_supported_openai_params(model=model)
assert "reasoning_effort" in supported
@pytest.mark.parametrize("model", [NON_REASONING_MODEL, PLAIN_MODEL])
def test_supported_params_excludes_reasoning_effort_for_non_reasoning_models(model):
supported = TogetherAIChatConfig().get_supported_openai_params(model=model)
assert "reasoning_effort" not in supported
@pytest.mark.parametrize(
"effort, expected",
[("low", "low"), ("medium", "medium"), ("high", "high"), ("minimal", "low"), ("xhigh", "high"), ("max", "high")],
)
def test_adjustable_model_translates_reasoning_effort(effort, expected):
mapped = _map_reasoning_effort(ADJUSTABLE_REASONING_MODEL, effort)
assert mapped["reasoning_effort"] == expected
assert "reasoning" not in mapped
def test_adjustable_model_cannot_disable_reasoning_so_none_becomes_low():
mapped = _map_reasoning_effort(ADJUSTABLE_REASONING_MODEL, "none")
assert mapped["reasoning_effort"] == "low"
assert "reasoning" not in mapped
@pytest.mark.parametrize(
"effort, expected",
[("low", "low"), ("medium", "medium"), ("high", "high"), ("minimal", "low"), ("xhigh", "high"), ("max", "high")],
)
def test_hybrid_model_translates_reasoning_effort(effort, expected):
mapped = _map_reasoning_effort(HYBRID_REASONING_MODEL, effort)
assert mapped["reasoning_effort"] == expected
assert "reasoning" not in mapped
@pytest.mark.parametrize("model", [HYBRID_REASONING_MODEL, HIGH_MAX_REASONING_MODEL, REGISTRY_FLAGGED_REASONING_MODEL])
def test_reasoning_effort_none_becomes_reasoning_toggle(model):
mapped = _map_reasoning_effort(model, "none")
assert mapped["reasoning"] == {"enabled": False}
assert "reasoning_effort" not in mapped
def test_reasoning_effort_none_does_not_clobber_user_reasoning():
mapped = TogetherAIChatConfig().map_openai_params(
non_default_params={"reasoning_effort": "none"},
optional_params={"reasoning": {"enabled": True}},
model=HYBRID_REASONING_MODEL,
drop_params=False,
)
assert mapped["reasoning"] == {"enabled": True}
assert "reasoning_effort" not in mapped
@pytest.mark.parametrize(
"effort, expected",
[("minimal", "high"), ("low", "high"), ("medium", "high"), ("high", "high"), ("xhigh", "max"), ("max", "max")],
)
def test_deepseek_v4_pro_remaps_to_high_max(effort, expected):
mapped = _map_reasoning_effort(HIGH_MAX_REASONING_MODEL, effort)
assert mapped["reasoning_effort"] == expected
def test_deepseek_v4_pro_dated_variant_remaps_via_prefix():
mapped = _map_reasoning_effort(f"{HIGH_MAX_REASONING_MODEL}-0813", "low")
assert mapped["reasoning_effort"] == "high"
@pytest.mark.parametrize("model", [ADJUSTABLE_REASONING_MODEL, HYBRID_REASONING_MODEL, HIGH_MAX_REASONING_MODEL])
def test_reasoning_effort_default_is_dropped(model):
mapped = _map_reasoning_effort(model, "default")
assert "reasoning_effort" not in mapped
assert "reasoning" not in mapped
def test_get_optional_params_translates_reasoning_effort_for_together():
optional_params = litellm.get_optional_params(
model=ADJUSTABLE_REASONING_MODEL,
custom_llm_provider="together_ai",
reasoning_effort="max",
)
assert optional_params["reasoning_effort"] == "high"
def test_get_optional_params_rejects_reasoning_effort_for_non_reasoning_together_model():
with pytest.raises(litellm.UnsupportedParamsError):
litellm.get_optional_params(
model=NON_REASONING_MODEL,
custom_llm_provider="together_ai",
reasoning_effort="low",
drop_params=False,
)
@pytest.mark.parametrize("drop_params", [False, True])
def test_map_openai_params_unmapped_model_passes_response_format_through(drop_params, together_warning_log):
mapped = TogetherAIChatConfig().map_openai_params(

View file

@ -0,0 +1,75 @@
"""
Registry regression tests for xAI entries in the model cost map.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).parents[4]
PRICES_PATH = REPO_ROOT / "model_prices_and_context_window.json"
BACKUP_PRICES_PATH = REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json"
# Retired by xAI and no longer served: requests to these slugs 404 rather than
# redirecting, and they are absent from https://docs.x.ai/docs/models
RETIRED_MODELS = (
"xai/grok-2",
"xai/grok-2-1212",
"xai/grok-2-latest",
"xai/grok-2-vision",
"xai/grok-2-vision-1212",
"xai/grok-2-vision-latest",
"xai/grok-beta",
"xai/grok-vision-beta",
)
# https://docs.x.ai/developers/model-capabilities/text/multi-agent
# "The multi-agent model does not work with the OpenAI Chat Completions API."
RESPONSES_ONLY_MODELS = (
"xai/grok-4.20-multi-agent-0309",
"xai/grok-4.20-multi-agent-beta-0309",
)
MAP_PATHS = (PRICES_PATH, BACKUP_PRICES_PATH)
@pytest.fixture(scope="module", params=[p.name for p in MAP_PATHS])
def cost_map(request: pytest.FixtureRequest) -> dict:
path = next(p for p in MAP_PATHS if p.name == request.param)
return json.loads(path.read_text(encoding="utf-8"))
@pytest.mark.parametrize("model", RETIRED_MODELS)
def test_retired_xai_models_are_not_advertised(cost_map: dict, model: str):
assert model not in cost_map
@pytest.mark.parametrize("model", RESPONSES_ONLY_MODELS)
def test_multi_agent_models_are_responses_only(cost_map: dict, model: str):
entry = cost_map[model]
assert entry["supported_endpoints"] == ["/v1/responses"]
assert entry["mode"] == "responses"
assert "/v1/chat/completions" not in entry["supported_endpoints"]
def test_surviving_xai_chat_models_still_serve_chat_completions(cost_map: dict):
"""Guard against the removal above over-reaching into live models."""
chat_models = [
key
for key, value in cost_map.items()
if isinstance(value, dict) and value.get("litellm_provider") == "xai" and value.get("mode") == "chat"
]
assert "xai/grok-4.3" in chat_models
assert "xai/grok-4.6" in chat_models
assert not any(key.startswith("xai/grok-2") for key in chat_models)
def test_both_cost_maps_agree_on_xai_entries():
prices = json.loads(PRICES_PATH.read_text(encoding="utf-8"))
backup = json.loads(BACKUP_PRICES_PATH.read_text(encoding="utf-8"))
xai_keys = {k for k, v in prices.items() if isinstance(v, dict) and v.get("litellm_provider") == "xai"}
assert xai_keys
assert {k: prices[k] for k in xai_keys} == {k: backup[k] for k in xai_keys}

View file

@ -10,6 +10,7 @@ from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import (
oauth_protected_resource_path,
@ -598,3 +599,92 @@ def test_client_credentials_uses_admin_entered_token_url_when_issuer_yield_empti
assert spec is not None
assert isinstance(spec.config, ClientCredentialsConfig)
assert spec.config.token_url == "https://idp.example.com/token"
_M2M_FIELDS = dict(
auth_type=MCPAuth.oauth2,
oauth2_flow="client_credentials",
client_id="cid",
client_secret="csec",
token_url="https://idp.example.com/token",
)
_OBO_FIELDS = dict(
auth_type=MCPAuth.oauth2_token_exchange,
client_id="cid",
client_secret="csec",
token_exchange_endpoint="https://idp.example.com/token",
)
_ID_JAG_FIELDS = dict(
auth_type=MCPAuth.oauth2_id_jag,
client_id="cid",
client_secret="csec",
token_exchange_endpoint="https://idp.example.com/token",
id_jag_resource_token_endpoint="https://mcp-as.example.com/token",
audience="api://mcp",
)
_AUTHZ_CODE_FIELDS = dict(auth_type=MCPAuth.oauth2, url="https://up.example.com/mcp")
_STATIC_FIELDS = dict(auth_type=MCPAuth.bearer_token, authentication_token="static-tok")
_ARM_FIELDS = (
("client_credentials", _M2M_FIELDS),
("token_exchange", _OBO_FIELDS),
("id_jag", _ID_JAG_FIELDS),
("authorization_code", _AUTHZ_CODE_FIELDS),
("api_key", _STATIC_FIELDS),
)
@pytest.mark.parametrize("name,fields", _ARM_FIELDS, ids=[n for n, _ in _ARM_FIELDS])
def test_upstream_token_header_reaches_every_arms_config(name, fields):
# to_server_spec builds each arm's config from a hand-written kwargs list, so an arm that
# forgets to read the field fails silently: the server keeps writing to Authorization.
spec = to_server_spec(_server(upstream_token_header="esb-oauth", **fields))
assert spec is not None
assert spec.config.header_name == "esb-oauth"
@pytest.mark.parametrize("name,fields", _ARM_FIELDS, ids=[n for n, _ in _ARM_FIELDS])
def test_omitting_the_field_keeps_each_arms_shipped_default(name, fields):
spec = to_server_spec(_server(**fields))
assert spec is not None
assert spec.config.header_name == "Authorization"
def test_api_key_scheme_default_survives_when_the_field_is_unset():
spec = to_server_spec(_server(auth_type=MCPAuth.api_key, authentication_token="k"))
assert spec is not None
assert spec.config.header_name == "X-API-Key"
assert spec.config.value_prefix == ""
def test_the_field_overrides_the_api_key_scheme_default():
spec = to_server_spec(_server(auth_type=MCPAuth.api_key, authentication_token="k", upstream_token_header="X-Esb"))
assert spec is not None
assert spec.config.header_name == "X-Esb"
@pytest.mark.parametrize("bad", ["with space", "has:colon", "trailing\r\nX-Injected", 'quoted"name'])
def test_a_malformed_header_name_is_refused_when_the_server_is_built(bad):
"""Validation belongs at ingestion, not at spec building. Raising inside to_server_spec would
abort the whole aggregate tools/list, so one mistyped server would silently empty the tool list
for every other server too. Refusing at MCPServer construction fails the config load loudly
instead, and means no malformed value can ever reach an arm.
"""
with pytest.raises(ValidationError):
_server(upstream_token_header=bad, **_M2M_FIELDS)
def test_a_valid_header_name_is_trimmed_at_ingestion():
assert _server(upstream_token_header=" esb-oauth ", **_M2M_FIELDS).upstream_token_header == "esb-oauth"
@pytest.mark.parametrize("blank", ["", " ", "\t"])
def test_a_blank_header_name_means_unset_rather_than_an_error(blank):
"""The management API treats a blank as "not supplied" and stores it, so raising here made every
later rebuild of that server 500 instead of falling back to the default Authorization behavior.
"""
server = _server(upstream_token_header=blank, **_M2M_FIELDS)
assert server.upstream_token_header is None
spec = to_server_spec(server)
assert spec is not None
assert spec.config.header_name == "Authorization"

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