mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_together_glm53_flash
This commit is contained in:
commit
ae89f9cf74
180 changed files with 22558 additions and 2334 deletions
18
.github/workflows/check-ui-api-types.yml
vendored
18
.github/workflows/check-ui-api-types.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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==",
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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==",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
0
litellm/llms/gemini/audio_transcription/__init__.py
Normal file
0
litellm/llms/gemini/audio_transcription/__init__.py
Normal file
250
litellm/llms/gemini/audio_transcription/transformation.py
Normal file
250
litellm/llms/gemini/audio_transcription/transformation.py
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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] = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
81
litellm/types/llms/gemini_audio_transcription.py
Normal file
81
litellm/types/llms/gemini_audio_transcription.py
Normal 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, ...] = ()
|
||||
|
|
@ -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``."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 --- #
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}}'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
75
tests/test_litellm/llms/xai/test_xai_model_registry.py
Normal file
75
tests/test_litellm/llms/xai/test_xai_model_registry.py
Normal 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}
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue