chore(memory): merge current gateway main

This commit is contained in:
moe-berri 2026-09-14 13:28:03 -07:00
commit 57b6496a1e
970 changed files with 21120 additions and 8611 deletions

View file

@ -1084,9 +1084,7 @@ jobs:
name: Run tests
command: |
mkdir -p test-results
TEST_FILES=$(printf "%s\n%s\n" \
"$(circleci tests glob "tests/ocr_tests/**/test_*.py")" \
"tests/test_litellm/ocr/test_rust_bridge.py")
TEST_FILES=$(circleci tests glob "tests/ocr_tests/**/test_*.py")
echo "$TEST_FILES" | circleci tests run \
--verbose \
--command="tr ' ' '\\n' | awk '/\\.py/ {print; next} {sub(/\\.[A-Z][^.]*$/, \"\"); gsub(/\\./, \"/\"); print \$0 \".py\"}' | xargs uv run --no-sync python -m pytest \

View file

@ -47,6 +47,10 @@ After: the same request comes back with real token counts, so the dashboard show
<!-- e.g., "Fixes #000" -->
## Affected release
<!-- Only for a fix to a regression in a released or rc version (perf, memory, crash, or behavior): name the version it regressed in, e.g. "regression in v1.100.0" or "since v1.101.0-rc.1", and add the `backport-stable` label so the fix is cherry-picked onto the rc line before the stable is tagged. Leave the section blank otherwise -->
## Linear ticket
<!-- if you are an internal contributor, add "Resolves " followed by the Linear ticket e.g., "Resolves LIT-1234" to link the Linear ticket to the GitHub PR. If you don't have one, leave the section blank rather than guessing -->
@ -154,3 +158,4 @@ Example checklists:
## Final Attestation
- [ ] The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

View file

@ -1,6 +1,8 @@
import asyncio
import aiohttp
import json
import math
from typing import Any
# Asynchronously fetch data from a given URL
async def fetch_data(url):
@ -21,11 +23,157 @@ async def fetch_data(url):
print("Error fetching data from URL:", e)
return None
FRIENDLI_API_URL = "https://api.friendli.ai/serverless/v1/models"
FRIENDLI_PROVIDER = "friendliai"
INHERITABLE_BASE_KEYS = (
"supports_pdf_input",
"supports_assistant_prefill",
"supports_adaptive_thinking",
"supports_output_config",
)
REASONING_EFFORT_LEVEL_ORDER = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
def _find_base_model_entry(base_model: str, local_data: dict) -> str | None:
if not base_model:
return None
bm_tail = base_model.split("/")[-1].lower()
if base_model in local_data:
return base_model
for key in local_data:
if key.startswith("sample_spec") or key == "fallback_generalizations":
continue
if key.split("/")[-1].lower() == bm_tail:
return key
return None
def _reasoning_effort_levels(reasoning_options: list) -> list:
offered = {
val
for opt in reasoning_options or []
if opt.get("type") == "effort"
for val in opt.get("values", [])
}
return [level for level in REASONING_EFFORT_LEVEL_ORDER if level in offered]
def _valid_token_price(value: object) -> bool:
try:
price = float(value) # pyright: ignore[reportArgumentType] # non-numeric values are rejected via the except
except (TypeError, ValueError):
return False
return math.isfinite(price) and price >= 0
def _has_valid_token_prices(pricing: dict | None) -> bool:
prices = pricing or {}
return _valid_token_price(prices.get("input")) and _valid_token_price(prices.get("output"))
def _pricing(pricing: dict) -> dict:
out: dict[str, Any] = {}
if not pricing:
return out
if "input" in pricing:
out["input_cost_per_token"] = float(pricing["input"])
if "output" in pricing:
out["output_cost_per_token"] = float(pricing["output"])
if "input_cache_read" in pricing and pricing["input_cache_read"] is not None:
out["cache_read_input_token_cost"] = float(pricing["input_cache_read"])
return out
def _modality_flags(input_mods: list) -> dict:
mods = input_mods or []
has_image = "image" in mods
return {
"supports_vision": has_image,
"supports_image_input": has_image,
"supports_video_input": "video" in mods,
}
def transform_friendli_data(data: list, local_data: dict) -> dict:
transformed: dict[str, dict] = {}
if not data:
return transformed
for model in data:
# An unpriced row must never wholesale-replace an already priced local entry:
# missing prices cost-calculate as zero, silently zeroing tracked spend
if not _has_valid_token_prices(model.get("pricing")):
continue
model_id = model["id"]
base_model = model.get("base_model") or ""
entry: dict[str, Any] = {
"litellm_provider": FRIENDLI_PROVIDER,
}
base_key = _find_base_model_entry(base_model, local_data)
if base_key:
base_entry = local_data[base_key]
for k in INHERITABLE_BASE_KEYS:
if k in base_entry:
entry[k] = base_entry[k]
ctx = model.get("context_length")
if ctx is not None:
entry["max_input_tokens"] = int(ctx)
max_out = model.get("max_completion_tokens")
if max_out is not None:
entry["max_output_tokens"] = int(max_out)
entry["max_tokens"] = int(max_out)
pricing = _pricing(model.get("pricing", {}))
entry.update(pricing)
entry["supports_prompt_caching"] = "cache_read_input_token_cost" in pricing
reasoning = model.get("reasoning") is True
entry["supports_reasoning"] = reasoning
if reasoning:
entry["reasoning_effort_levels"] = _reasoning_effort_levels(
model.get("reasoning_options", [])
)
func = model.get("functionality", {})
entry["supports_function_calling"] = func.get("tool_call") is True
entry["supports_parallel_function_calling"] = func.get("parallel_tool_call") is True
is_struct = func.get("structured_output") is True
entry["supports_response_schema"] = is_struct
entry["supports_native_structured_output"] = is_struct
entry["supports_system_messages"] = func.get("system_messages") is True
entry["supports_tool_choice"] = func.get("tool_choice") is True
entry.update(_modality_flags(model.get("input_modalities", [])))
entry["mode"] = model.get("mode", "chat")
desc = model.get("description")
if desc:
entry["comment"] = desc
dep = model.get("deprecation_date")
if dep:
entry["deprecation_date"] = dep.split("T")[0]
entry["source"] = FRIENDLI_API_URL
transformed[f"{FRIENDLI_PROVIDER}/{model_id}"] = entry
return transformed
# Synchronize local data with remote data
def sync_local_data_with_remote(local_data, remote_data):
def sync_local_data_with_remote(local_data, remote_data, replace_keys=frozenset()):
# Update existing keys in local_data with values from remote_data
# (replace_keys entries are swapped wholesale so a field the remote catalog
# dropped, e.g. cache pricing, cannot survive as a stale value)
for key in (set(local_data) & set(remote_data)):
local_data[key].update(remote_data[key])
if key in replace_keys:
local_data[key] = remote_data[key]
else:
local_data[key].update(remote_data[key])
# Add new keys from remote_data to local_data
for key in (set(remote_data) - set(local_data)):
@ -46,6 +194,8 @@ def write_to_file(file_path, data):
# Update the existing models and add the missing models for OpenRouter
def transform_openrouter_data(data):
transformed = {}
if not data:
return transformed
for row in data:
# Add the fields 'max_tokens' and 'input_cost_per_token'
obj = {
@ -84,7 +234,14 @@ def transform_openrouter_data(data):
# Update the existing models and add the missing models for Vercel AI Gateway
def transform_vercel_ai_gateway_data(data):
transformed = {}
if not data:
return transformed
for row in data:
# Rows without token pricing or token limits (video/embedding models) previously KeyError'd the whole sync
if any(row.get(k) is None for k in ("context_window", "max_tokens")) or any(
row.get("pricing", {}).get(k) is None for k in ("input", "output")
):
continue
obj = {
"max_tokens": row["context_window"],
"input_cost_per_token": float(row["pricing"]["input"]),
@ -143,13 +300,16 @@ def main():
vercel_data = asyncio.run(fetch_data(vercel_ai_gateway_url))
# Transform the fetched Vercel AI Gateway data
vercel_data = transform_vercel_ai_gateway_data(vercel_data)
friendli_data = asyncio.run(fetch_data(FRIENDLI_API_URL))
friendli_data = transform_friendli_data(friendli_data, local_data)
# Combine both datasets
all_remote_data = {**openrouter_data, **vercel_data}
all_remote_data = {**openrouter_data, **vercel_data, **friendli_data}
# If both local and openrouter data are available, synchronize and save
if local_data and all_remote_data:
sync_local_data_with_remote(local_data, all_remote_data)
sync_local_data_with_remote(local_data, all_remote_data, replace_keys=frozenset(friendli_data))
write_to_file(local_file_path, local_data)
else:
print("Failed to fetch model data from either local file or URL.")

View file

@ -1,42 +0,0 @@
name: Guard main branch
on:
pull_request:
branches:
- main
merge_group:
permissions: {}
# DO NOT RENAME the job's `name:` — it is referenced by GitHub branch
# protection as a required status check on `main`. Renaming silently
# breaks the gate.
jobs:
guard:
name: Verify PR source branch
runs-on: ubuntu-latest
timeout-minutes: 2
steps:
- name: Reject merge_group events
if: github.event_name == 'merge_group'
run: |
echo "::error::Merge queue is not supported for main. Disable merge queue or update this guard."
exit 1
- name: Check head branch name
env:
HEAD_REF: ${{ github.head_ref }}
HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
BASE_REPO: ${{ github.repository }}
run: |
echo "PR head repo: $HEAD_REPO"
echo "PR head branch: $HEAD_REF"
if [ "$HEAD_REPO" != "$BASE_REPO" ]; then
echo "::error::PRs to main must originate from the canonical repository ($BASE_REPO), not a fork ($HEAD_REPO). External contributors should open PRs against 'litellm_internal_staging' instead."
exit 1
fi
if [ "$HEAD_REF" = "litellm_internal_staging" ] || [[ "$HEAD_REF" == litellm_hotfix_?* ]]; then
echo "Allowed source branch."
exit 0
fi
echo "::error::PRs to main must originate from 'litellm_internal_staging' or a 'litellm_hotfix_*' branch. Got: '$HEAD_REF'. If this is a contribution, retarget the PR against 'litellm_internal_staging' instead."
exit 1

View file

@ -27,10 +27,13 @@ import litellm
from litellm import Router, verbose_logger
from litellm._uuid import uuid
from litellm.caching.caching import DualCache
from litellm.constants import MAX_FILE_LIST_LIMIT
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.prompt_templates.common_utils import (
extract_file_metadata,
)
from openai.types.file_deleted import FileDeleted
from litellm.llms.base_llm.files.transformation import BaseFileEndpoints
from litellm.llms.base_llm.managed_resources.isolation import (
build_list_page,
@ -48,7 +51,6 @@ from litellm.proxy._types import (
from litellm.proxy.openai_files_endpoints.common_utils import (
BATCH_CREATE_HIDDEN_PARAM,
FILE_LIST_CONTINUATION_CHUNK_SIZE,
MAX_FILE_LIST_LIMIT,
_is_base64_encoded_unified_file_id,
apply_unified_file_ids,
decode_model_from_file_id,
@ -1787,7 +1789,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
litellm_parent_otel_span: Optional[Span],
llm_router: Router,
**data: Dict,
) -> OpenAIFileObject:
) -> FileDeleted:
# Check if file deletion should be blocked due to batch references
await self._check_file_deletion_allowed(file_id)
@ -1795,7 +1797,6 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
# file_id = convert_b64_uid_to_unified_uid(file_id)
model_file_id_mapping = await self.get_model_file_id_mapping([file_id], litellm_parent_otel_span)
delete_response = None
specific_model_file_id_mapping = model_file_id_mapping.get(file_id)
if specific_model_file_id_mapping:
# Remove conflicting keys from data to avoid duplicate keyword arguments
@ -1810,23 +1811,14 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
else {}
),
}
delete_response = await llm_router.afile_delete(model=model_id, file_id=model_file_id, **delete_data)
await llm_router.afile_delete(model=model_id, file_id=model_file_id, **delete_data)
stored_file_object = await self.delete_unified_file_id(file_id, litellm_parent_otel_span)
await self.delete_unified_file_id(file_id, litellm_parent_otel_span)
# Record successful deletion metric only on actual success
if stored_file_object or delete_response:
prom_logger = self._get_prometheus_logger()
if prom_logger:
prom_logger.record_managed_file_deleted(result="success")
if stored_file_object:
return OpenAIFileObject.model_validate(stored_file_object).model_copy(update={"id": file_id})
elif delete_response:
delete_response.id = file_id
return delete_response
else:
raise Exception(f"LiteLLM Managed File object with id={file_id} not found")
prom_logger = self._get_prometheus_logger()
if prom_logger:
prom_logger.record_managed_file_deleted(result="success")
return FileDeleted(id=file_id, object="file", deleted=True)
async def afile_content(
self,

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "LiteLLM_SpendLogs" ADD COLUMN IF NOT EXISTS "litellm_call_id" TEXT;

View file

@ -0,0 +1,12 @@
-- CreateIndex (CONCURRENTLY)
--
-- Disclaimer:
-- - CREATE INDEX CONCURRENTLY cannot run inside a transaction. This migration must stay a
-- single statement so Prisma Migrate on PostgreSQL can apply it outside a transaction.
-- - Builds are slower and use more I/O than a blocking CREATE INDEX; if the build is
-- interrupted, Postgres may leave an INVALID index that must be dropped and recreated.
-- - Do not edit this file after it has been applied to any database: Prisma checksums
-- migrations; add a new migration instead.
-- - Requires PostgreSQL that supports CONCURRENTLY with IF NOT EXISTS (use a new migration
-- without IF NOT EXISTS if you must support older versions).
CREATE INDEX CONCURRENTLY IF NOT EXISTS "LiteLLM_SpendLogs_litellm_call_id_idx" ON "LiteLLM_SpendLogs"("litellm_call_id");

View file

@ -37,11 +37,13 @@ raised it above the deploy default keeps that larger budget for deploy unless
the deploy override says otherwise.
"""
import importlib.util
import math
import os
import shutil
import signal
import subprocess
import sys
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
@ -64,6 +66,7 @@ DEFAULT_PRISMA_BOOTSTRAP_TIMEOUT = 600.0
DEFAULT_PRISMA_MIGRATE_DEPLOY_TIMEOUT = 600.0
BOOTSTRAP_ARG = "--version"
PRISMA_CONSOLE_SCRIPT = "prisma"
@dataclass(frozen=True)
@ -184,6 +187,28 @@ def _kill_process_group(process: "subprocess.Popen[str]") -> None:
return
def prisma_cli_available() -> bool:
"""Whether some way of running the Prisma CLI exists: the console script on PATH or the importable package."""
if shutil.which(PRISMA_CONSOLE_SCRIPT) is not None:
return True
return importlib.util.find_spec(PRISMA_CONSOLE_SCRIPT) is not None
def resolve_prisma_argv(argv: Sequence[str]) -> tuple[str, ...]:
"""Route a bare ``prisma`` command through ``python -m prisma`` when the console script is not on PATH.
The console script and ``python -m prisma`` are the same entry point, but
only the module form survives an interpreter whose ``bin`` directory is
missing from PATH, which is how the proxy gets started under launchers and
init systems. Any other executable name is left untouched.
"""
if not argv or argv[0] != PRISMA_CONSOLE_SCRIPT:
return tuple(argv)
if shutil.which(PRISMA_CONSOLE_SCRIPT) is not None:
return tuple(argv)
return (sys.executable, "-m", PRISMA_CONSOLE_SCRIPT, *argv[1:])
def run_prisma(
argv: Sequence[str],
*,
@ -200,7 +225,7 @@ def run_prisma(
text unless ``stdout``/``stderr`` say otherwise.
"""
with subprocess.Popen(
argv,
resolve_prisma_argv(argv),
env=env,
stdout=stdout,
stderr=stderr,

View file

@ -659,12 +659,14 @@ model LiteLLM_SpendLogs {
mcp_namespaced_tool_name String?
agent_id String?
proxy_server_request Json? @default("{}")
litellm_call_id String?
created_at DateTime @default(now()) @map("created_at")
updated_at DateTime @default(now()) @updatedAt @map("updated_at")
@@index([startTime])
@@index([startTime, request_id])
@@index([end_user])
@@index([session_id])
@@index([litellm_call_id])
}
model LiteLLM_BudgetWindowSpend {

View file

@ -58,6 +58,13 @@ impl PythonLogger {
params.set_item(name, value)?;
}
}
for name in custom_pricing_fields(py)? {
if let Some(value) = kwargs.bind(py).get_item(&name)?
&& !value.is_none()
{
params.set_item(name, value)?;
}
}
update.set_item("litellm_params", params)?;
update.set_item("custom_llm_provider", &pre_call.custom_llm_provider)?;
self.object(py)
@ -120,6 +127,17 @@ impl PythonLogger {
}
}
fn custom_pricing_fields(py: Python<'_>) -> PyResult<Vec<String>> {
py.import("litellm.types.utils")?
.getattr("CustomPricingLiteLLMParams")?
.getattr("model_fields")?
.cast_into::<PyDict>()?
.keys()
.iter()
.map(|name| name.extract::<String>())
.collect()
}
fn redact(
py: Python<'_>,
params: &Bound<'_, PyDict>,

View file

@ -501,6 +501,7 @@ disable_copilot_system_to_assistant: bool = False # If false (default), convert
public_mcp_servers: Optional[List[str]] = None
public_mcp_hub_strict_whitelist: bool = True
public_model_groups: Optional[List[str]] = None
public_skills_index: bool = False
public_agent_groups: Optional[List[str]] = None
agent_search_embedding_model: Optional[str] = None
mcp_tool_search: Optional[Mapping[str, object]] = None

View file

@ -1,5 +1,6 @@
import ast
import contextvars
import functools
import logging
import os
import sys
@ -225,6 +226,35 @@ class AccessLogRedactionFilter(logging.Filter):
_access_log_filter: Final = AccessLogRedactionFilter()
@functools.lru_cache(maxsize=1)
def _parse_disabled_access_log_paths(raw: str) -> frozenset[str]:
return frozenset(stripped for path in raw.split(",") if (stripped := path.strip()))
def _disabled_access_log_paths() -> frozenset[str]:
"""Read the variable per record so a value loaded later via proxy config
environment_variables or dotenv is honored."""
return _parse_disabled_access_log_paths(os.getenv("LITELLM_DISABLE_ACCESS_LOG_PATHS", ""))
class AccessLogPathFilter(logging.Filter):
"""Drops uvicorn.access records for request paths listed in LITELLM_DISABLE_ACCESS_LOG_PATHS.
uvicorn passes record.args as (client_addr, method, full_path, http_version, status_code).
"""
def filter(self, record: logging.LogRecord) -> bool:
if not isinstance(record.args, tuple) or len(record.args) < 3:
return True
full_path: Final = record.args[2]
if not isinstance(full_path, str):
return True
return full_path.partition("?")[0] not in _disabled_access_log_paths()
_access_log_path_filter: Final = AccessLogPathFilter()
def _get_max_string_length_stdout_log() -> int:
"""Read the limit per record so a value loaded later via proxy config
environment_variables is honored."""
@ -663,6 +693,7 @@ def _redact_third_party_loggers() -> None:
for name in _REDACTED_THIRD_PARTY_LOGGERS:
logging.getLogger(name).addFilter(_secret_filter)
for name in _REDACTED_ACCESS_LOGGERS:
logging.getLogger(name).addFilter(_access_log_path_filter)
logging.getLogger(name).addFilter(_access_log_filter)

View file

@ -16,7 +16,7 @@ import inspect
import json
import logging
import time
from collections.abc import Awaitable, Callable, Sequence
from collections.abc import Awaitable, Callable, Iterator, Sequence
from contextvars import ContextVar
from dataclasses import dataclass
from datetime import timedelta
@ -317,19 +317,37 @@ def _is_redis_health_failure(exc: BaseException) -> bool:
def _redis_timeout_error_types() -> tuple[type, ...]:
"""Health failures that are timeouts rather than unambiguous connectivity errors.
``builtins.TimeoutError`` covers ``asyncio.TimeoutError`` and ``socket.timeout``
(aliases since py3.11 / py3.10). ``redis.exceptions.TimeoutError`` does not subclass
either, so it is listed explicitly.
``builtins.TimeoutError`` covers ``socket.timeout`` (an alias since py3.10) and, from
py3.11, ``asyncio.TimeoutError``; on py3.10 ``asyncio.TimeoutError`` is still its own
class, so it is listed explicitly. ``redis.exceptions.TimeoutError`` subclasses neither.
"""
try:
from redis.exceptions import TimeoutError as RedisTimeoutError
except ImportError:
return (TimeoutError,)
return (RedisTimeoutError, TimeoutError)
return (TimeoutError, asyncio.TimeoutError)
return (RedisTimeoutError, TimeoutError, asyncio.TimeoutError)
_MAX_EXCEPTION_CAUSE_DEPTH: Final = 20
def _explicit_causes(exc: BaseException) -> Iterator[BaseException]:
current = exc # rebind-ok: advances one link per iteration of the bounded walk
for _ in range(_MAX_EXCEPTION_CAUSE_DEPTH):
yield current
if current.__cause__ is None:
return
current = current.__cause__
def _is_redis_timeout_failure(exc: BaseException) -> bool:
return isinstance(exc, _redis_timeout_error_types())
"""True when ``exc`` or any exception it was explicitly raised ``from`` is a timeout.
redis-py's blocking pool reports a pool wait timeout as ``ConnectionError`` chained from
``asyncio.TimeoutError``, which is a busy pool rather than an unreachable Redis.
"""
timeout_types: Final = _redis_timeout_error_types()
return any(isinstance(link, timeout_types) for link in _explicit_causes(exc))
class _BreakerMetrics:

View file

@ -53,6 +53,7 @@ S3_BOUNDED_OBJECT_KEY_HEAD_BYTES: Final = 64
S3_PREFIX_DIGEST_CHARS: Final = 16
# s3 allows 2048 bytes of combined metadata headers, which Content-Disposition counts against
MAX_S3_OBJECT_DOWNLOAD_FILENAME_BYTES: Final = 1024
MAX_FILE_LIST_LIMIT: Final = 10000
DEFAULT_SQS_FLUSH_INTERVAL_SECONDS: Final = int(os.getenv("DEFAULT_SQS_FLUSH_INTERVAL_SECONDS", 10))
DEFAULT_NUM_WORKERS_LITELLM_PROXY: Final = int(os.getenv("DEFAULT_NUM_WORKERS_LITELLM_PROXY", 1))
budget_reservation_disabled_info_emitted = False
@ -143,6 +144,7 @@ DEFAULT_MCP_SEMANTIC_FILTER_SIMILARITY_THRESHOLD: Final = float(
os.getenv("DEFAULT_MCP_SEMANTIC_FILTER_SIMILARITY_THRESHOLD", 0.3)
)
MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH: Final = int(os.getenv("MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH", 150))
MAX_LITELLM_CALL_ID_LENGTH: Final = 256
MAX_GUARDRAIL_SCAN_METADATA_HEADER_LENGTH: Final = 2048
DEFAULT_AUTO_ROUTER_MAX_INPUT_CHARS: Final = 2000
@ -571,6 +573,7 @@ ANTHROPIC_MESSAGES_MAX_DETACHED_STREAM_DRAINS: Final = int(
LOGGING_WORKER_CONCURRENCY: Final = int(os.getenv("LOGGING_WORKER_CONCURRENCY", 100)) # Must be above 0
LOGGING_WORKER_MAX_QUEUE_SIZE: Final = int(os.getenv("LOGGING_WORKER_MAX_QUEUE_SIZE", 50_000))
LOGGING_WORKER_MAX_TIME_PER_COROUTINE: Final = float(os.getenv("LOGGING_WORKER_MAX_TIME_PER_COROUTINE", 20.0))
LOGGING_WORKER_TIMEOUT_SUMMARY_WINDOW_SECONDS: Final = 5.0
LOGGING_WORKER_CLEAR_PERCENTAGE: Final = int(
os.getenv("LOGGING_WORKER_CLEAR_PERCENTAGE", 50)
) # Percentage of queue to clear (default: 50%)

View file

@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Final, Literal, cast
from httpx import Response
from pydantic import BaseModel
from typing_extensions import ReadOnly, TypedDict
import litellm
import litellm._logging
@ -96,6 +97,7 @@ from litellm.llms.vertex_ai.cost_calculator import cost_router as google_cost_ro
from litellm.llms.xai.cost_calculator import cost_per_token as xai_cost_per_token
from litellm.responses.utils import ResponseAPILoggingUtils
from litellm.types.agents import LiteLLMSendMessageResponse
from litellm.types.llms.base import CachedTokensDetails
from litellm.types.llms.openai import (
HttpxBinaryResponseContent,
ImageGenerationRequestQuality,
@ -310,6 +312,15 @@ def _transcription_usage_has_token_details(
return (prompt_tokens_val > 0) or (completion_tokens_val > 0)
OCRPricingField = Literal["ocr_cost_per_page", "ocr_cost_per_credit", "annotation_cost_per_page"]
class OCRPricing(TypedDict, total=False):
ocr_cost_per_page: ReadOnly[float | None]
ocr_cost_per_credit: ReadOnly[float | None]
annotation_cost_per_page: ReadOnly[float | None]
def cost_per_token(
model: str = "",
prompt_tokens: int = 0,
@ -344,6 +355,7 @@ def cost_per_token(
response: Any | None = None,
### REQUEST MODEL ###
request_model: str | None = None, # original request model for router detection
custom_model_info: OCRPricing | None = None,
) -> tuple[float, float]:
"""
Calculates the cost per token for a given model, prompt tokens, and completion tokens.
@ -470,7 +482,8 @@ def cost_per_token(
else:
model_with_provider = f"{custom_llm_provider}/{model}"
if region_name is not None:
model_with_provider_and_region: Final = f"{custom_llm_provider}/{region_name}/{model}"
bare_model: Final = model[len(_prov_prefix) :] if model_is_str and model.startswith(_prov_prefix) else model
model_with_provider_and_region: Final = f"{custom_llm_provider}/{region_name}/{bare_model}"
if model_with_provider_and_region in model_cost_ref: # use region based pricing, if it's available
model_with_provider = model_with_provider_and_region
else:
@ -558,6 +571,7 @@ def cost_per_token(
model=model,
custom_llm_provider=custom_llm_provider,
response=response,
model_info=custom_model_info,
)
elif (
call_type == "aretrieve_batch"
@ -766,6 +780,7 @@ def _select_model_name_for_cost_calc(
custom_pricing: bool | None = None,
custom_llm_provider: str | None = None,
router_model_id: str | None = None,
region_name: str | None = None,
) -> str | None:
"""
1. If custom pricing is true, return received model name
@ -787,8 +802,8 @@ def _select_model_name_for_cost_calc(
provider_response_model: Final = _get_hidden_str_for_cost_calc(hidden_params, "provider_response_model")
explicit_pricing: Final = custom_pricing is True or base_model is not None
priced_from_response: Final = provider_response_model is not None or completion_response_model is not None
region_name: Final = (
_get_hidden_str_for_cost_calc(hidden_params, "region_name")
priced_region: Final = (
_get_hidden_str_for_cost_calc(hidden_params, "region_name") or region_name
if not explicit_pricing and priced_from_response
else None
)
@ -825,8 +840,10 @@ def _select_model_name_for_cost_calc(
and custom_llm_provider is not None
and not _model_contains_known_llm_provider(return_model)
): # add provider prefix if not already present, to match model_cost
provider_prefix: Final = custom_llm_provider if region_name is None else f"{custom_llm_provider}/{region_name}"
return_model = _strip_unregistered_leading_segments(f"{provider_prefix}/{return_model}", region_name)
provider_prefix: Final = (
custom_llm_provider if priced_region is None else f"{custom_llm_provider}/{priced_region}"
)
return_model = _strip_unregistered_leading_segments(f"{provider_prefix}/{return_model}", priced_region)
return return_model
@ -1288,6 +1305,7 @@ def completion_cost(
service_tier = _normalize_service_tier(service_tier)
explicit_pricing: Final = custom_pricing is True or base_model is not None
selected_model: Final = _select_model_name_for_cost_calc(
model=model,
completion_response=completion_response,
@ -1295,6 +1313,7 @@ def completion_cost(
custom_pricing=custom_pricing,
base_model=base_model,
router_model_id=router_model_id,
region_name=region_name,
)
potential_model_names: Final = [
@ -1432,20 +1451,9 @@ def completion_cost(
)
elif call_type in _VIDEO_CALL_TYPES:
### VIDEO GENERATION COST CALCULATION ###
# Extract custom model_info for deployment-specific pricing
_video_model_info: ModelInfo | None = None
if custom_pricing and litellm_logging_obj is not None:
_litellm_params = getattr(litellm_logging_obj, "litellm_params", None)
if _litellm_params is not None:
_video_model_info = next(
(
model_info
for _metadata_key in ("metadata", "litellm_metadata")
if (model_info := (_litellm_params.get(_metadata_key) or {}).get("model_info"))
is not None
),
None,
)
_video_model_info: ModelInfo | None = _deployment_model_info(
litellm_logging_obj, custom_pricing, router_model_id
)
usage_obj = getattr(completion_response, "usage", None)
duration_seconds: float | None = None
@ -1650,7 +1658,7 @@ def completion_cost(
completion_tokens=completion_tokens or 0,
custom_llm_provider=custom_llm_provider,
response_time_ms=total_time,
region_name=region_name,
region_name=None if explicit_pricing else region_name,
custom_cost_per_second=custom_cost_per_second,
custom_cost_per_token=custom_cost_per_token,
prompt_characters=prompt_characters,
@ -1665,6 +1673,7 @@ def completion_cost(
data_residency=data_residency,
vertex_location=vertex_location,
response=completion_response,
custom_model_info=_ocr_model_info(litellm_logging_obj, custom_pricing, router_model_id),
)
# Get additional costs from provider (e.g., routing fees, infrastructure costs)
@ -1859,6 +1868,7 @@ def response_cost_calculator(
data_residency: str | None = None, # for OpenAI regional-processing uplift (e.g. "eu", "us")
### VERTEX LOCATION ###
vertex_location: str | None = None, # for Vertex AI regional-endpoint uplift (e.g. "us-east5", "global")
region_name: str | None = None,
) -> float:
"""
Returns
@ -1892,22 +1902,89 @@ def response_cost_calculator(
service_tier=service_tier,
data_residency=data_residency,
vertex_location=vertex_location,
region_name=region_name,
)
return response_cost
except Exception as e:
raise e
def _deployment_model_info(
litellm_logging_obj: LitellmLoggingObject | None,
custom_pricing: bool | None,
router_model_id: str | None,
) -> ModelInfo | None:
if not custom_pricing:
return None
registered_deployment_info: Final = (
_cost_map_model_info(router_model_id, None)
if router_model_id is not None and router_model_id in litellm.model_cost
else None
)
if registered_deployment_info is not None:
return registered_deployment_info
if litellm_logging_obj is None:
return None
litellm_params: Final = getattr(litellm_logging_obj, "litellm_params", None)
if litellm_params is None:
return None
return next(
(
model_info
for metadata_key in ("metadata", "litellm_metadata")
if (metadata := litellm_params.get(metadata_key)) and (model_info := metadata.get("model_info")) is not None
),
None,
)
def _ocr_model_info(
litellm_logging_obj: LitellmLoggingObject | None,
custom_pricing: bool | None,
router_model_id: str | None,
) -> OCRPricing | None:
deployment_info: Final = _deployment_model_info(litellm_logging_obj, custom_pricing, router_model_id)
litellm_params: Final = getattr(litellm_logging_obj, "litellm_params", None) if custom_pricing else None
if litellm_params is None:
return deployment_info
return _layered_ocr_pricing(litellm_params, deployment_info)
def _first_ocr_price(field: OCRPricingField, *sources: Mapping[str, object] | None) -> float | None:
return next(
(price for source in sources if source is not None and isinstance(price := source.get(field), int | float)),
None,
)
def _layered_ocr_pricing(*sources: Mapping[str, object] | None) -> OCRPricing:
return OCRPricing(
ocr_cost_per_page=_first_ocr_price("ocr_cost_per_page", *sources),
ocr_cost_per_credit=_first_ocr_price("ocr_cost_per_credit", *sources),
annotation_cost_per_page=_first_ocr_price("annotation_cost_per_page", *sources),
)
def _cost_map_model_info(model: str, custom_llm_provider: str | None) -> ModelInfo | None:
try:
return litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)
except Exception:
return None
def ocr_cost(
model: str,
custom_llm_provider: str | None,
response: object | None = None,
model_info: OCRPricing | None = None,
) -> tuple[float, float]:
"""
Args:
model: str - model name
custom_llm_provider: Optional[str] - custom LLM provider
response: Optional[Any] - response object
model_info: Optional[OCRPricing] - deployment-specific OCR pricing; each rate it sets
overrides the model cost map's, the rest fall back to the map
Returns:
Tuple[float, float]: cost of OCR processing
@ -1925,20 +2002,15 @@ def ocr_cost(
if response.usage_info is None:
raise ValueError("OCR response usage_info is None")
try:
model_info: ModelInfo | None = litellm.get_model_info(model=model, custom_llm_provider=custom_llm_provider)
except Exception:
model_info = None
credits: Final = getattr(response.usage_info, "credits", None)
cost_per_credit = None
if model_info is not None:
cost_per_credit = model_info.get("ocr_cost_per_credit")
pricing: Final = _layered_ocr_pricing(model_info, _cost_map_model_info(model, custom_llm_provider))
cost_per_credit: Final = pricing.get("ocr_cost_per_credit")
if credits is not None and cost_per_credit is not None:
return cost_per_credit * credits, 0.0
ocr_cost_per_page: Final = model_info.get("ocr_cost_per_page") if model_info is not None else None
annotation_cost_per_page: Final = model_info.get("annotation_cost_per_page") if model_info is not None else None
ocr_cost_per_page: Final = pricing.get("ocr_cost_per_page")
annotation_cost_per_page: Final = pricing.get("annotation_cost_per_page")
annotation_rate: Final = annotation_cost_per_page if annotation_cost_per_page is not None else ocr_cost_per_page
pages_processed: Final = response.usage_info.pages_processed
@ -2310,6 +2382,46 @@ def _summable_prompt_token_fields(prompt_tokens_details: BaseModel) -> list[str]
return [attr for attr in field_names if attr != "cache_creation_tokens"]
def _combine_cached_tokens_details(
current: CachedTokensDetails | None, new: CachedTokensDetails
) -> CachedTokensDetails:
def _sum_optional(current_value: int | None, new_value: int | None) -> int | None:
if current_value is None and new_value is None:
return None
return (current_value or 0) + (new_value or 0)
return CachedTokensDetails(
text_tokens=_sum_optional(current.text_tokens if current is not None else None, new.text_tokens),
audio_tokens=_sum_optional(current.audio_tokens if current is not None else None, new.audio_tokens),
image_tokens=_sum_optional(current.image_tokens if current is not None else None, new.image_tokens),
)
def _combine_prompt_tokens_details(
current: PromptTokensDetailsWrapper | None, new: PromptTokensDetailsWrapper
) -> PromptTokensDetailsWrapper:
base: Final = current if current is not None else PromptTokensDetailsWrapper()
base_values: Final = MappingProxyType(
{attr: getattr(base, attr) for attr in type(base).model_fields if hasattr(base, attr)}
)
summed: Final = MappingProxyType(
{
attr: (getattr(base, attr, 0) or 0) + (getattr(new, attr) or 0)
for attr in _summable_prompt_token_fields(new)
if hasattr(new, attr) and isinstance(getattr(new, attr) or 0, (int, float))
}
)
new_cached_tokens_details: Final = getattr(new, "cached_tokens_details", None)
cached_tokens_details: Final = (
_combine_cached_tokens_details(getattr(base, "cached_tokens_details", None), new_cached_tokens_details)
if isinstance(new_cached_tokens_details, CachedTokensDetails)
else getattr(base, "cached_tokens_details", None)
)
return PromptTokensDetailsWrapper(
**MappingProxyType({**base_values, **summed, "cached_tokens_details": cached_tokens_details})
)
class BaseTokenUsageProcessor:
@staticmethod
def combine_usage_objects(usage_objects: list[Usage]) -> Usage:
@ -2318,7 +2430,6 @@ class BaseTokenUsageProcessor:
"""
from litellm.types.utils import (
CompletionTokensDetailsWrapper,
PromptTokensDetailsWrapper,
Usage,
)
@ -2337,27 +2448,10 @@ class BaseTokenUsageProcessor:
and isinstance(current_val, (int, float))
):
setattr(combined, attr, current_val + new_val)
# Handle nested prompt_tokens_details
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
if not hasattr(combined, "prompt_tokens_details") or not combined.prompt_tokens_details:
combined.prompt_tokens_details = PromptTokensDetailsWrapper()
# Check what keys exist in the model's prompt_tokens_details
# Access model_fields on the class, not the instance, to avoid Pydantic 2.11+ deprecation warnings
for attr in _summable_prompt_token_fields(usage.prompt_tokens_details):
if (
hasattr(usage.prompt_tokens_details, attr)
and not attr.startswith("_")
and not callable(_attribute_value(usage.prompt_tokens_details, attr))
):
current_val = getattr(combined.prompt_tokens_details, attr, 0) or 0
new_val = getattr(usage.prompt_tokens_details, attr, 0) or 0
if new_val is not None and isinstance(new_val, (int, float)):
setattr(
combined.prompt_tokens_details,
attr,
current_val + new_val,
)
combined.prompt_tokens_details = _combine_prompt_tokens_details(
getattr(combined, "prompt_tokens_details", None), usage.prompt_tokens_details
)
# Handle nested completion_tokens_details
if hasattr(usage, "completion_tokens_details") and usage.completion_tokens_details:

View file

@ -682,6 +682,10 @@ def file_list(
)
if provider_config is not None:
litellm_params_dict: Final = get_litellm_params(**kwargs)
add_trusted_model_credentials_to_litellm_params(
litellm_params_dict=litellm_params_dict,
kwargs=kwargs,
)
litellm_params_dict["api_key"] = optional_params.api_key
litellm_params_dict["api_base"] = optional_params.api_base

View file

@ -29,8 +29,6 @@ class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils):
def __init__(self, bucket_name: str | None = None) -> None:
from litellm.proxy.proxy_server import premium_user
super().__init__(bucket_name=bucket_name)
self.batch_size = int(os.getenv("GCS_BATCH_SIZE", GCS_DEFAULT_BATCH_SIZE))
self.flush_interval = int(os.getenv("GCS_FLUSH_INTERVAL", GCS_DEFAULT_FLUSH_INTERVAL_SECONDS))
self.use_batched_logging = (
@ -38,6 +36,7 @@ class GCSBucketLogger(GCSBucketBase, AdditionalLoggingUtils):
)
self.flush_lock = asyncio.Lock()
super().__init__(
bucket_name=bucket_name,
flush_lock=self.flush_lock,
batch_size=self.batch_size,
flush_interval=self.flush_interval,

View file

@ -12,6 +12,7 @@ from typing import Final
from litellm.integrations.otel.mappers.base import AttributeMap, AttrValue, SpanData
from litellm.integrations.otel.mappers.utils import (
MAX_MESSAGE_ATTRS_PER_SPAN,
MAX_TOOL_DEFINITION_ATTRS_PER_SPAN,
collect,
drop_none,
@ -26,6 +27,8 @@ from litellm.integrations.otel.model.payloads import (
ToolDefinition,
)
_MAX_INDEXED_MESSAGES: Final = MAX_MESSAGE_ATTRS_PER_SPAN // 2
class OpenInferenceMapper:
"""Emits OpenInference attributes for LLM_CALL spans.
@ -84,27 +87,44 @@ class OpenInferenceMapper:
return {}
def _llm_call(self, data: LLMCallSpanData) -> AttributeMap:
outputs: Final = output_messages(data)
indexed_in, indexed_out = self._indexed_split(len(data.messages_in), len(outputs))
return {
**collect(self._LLM_CALL_ATTRS, data),
**collect(self._BLOB_ATTRS, data),
**self._messages("llm.input_messages", "input.value", data.messages_in),
**self._messages("llm.output_messages", "output.value", output_messages(data)),
**self._messages(
"llm.input_messages",
"input.value",
data.messages_in,
self._prompt_positions(len(data.messages_in), indexed_in),
),
**self._messages("llm.output_messages", "output.value", outputs, range(indexed_out)),
**self._tools(data),
}
@staticmethod
def _messages(prefix: str, value_key: str, messages: Sequence[object]) -> AttributeMap:
"""Per-message ``{prefix}.{idx}.message.*`` keys + the ``value_key`` blob."""
def _indexed_split(inputs: int, outputs: int) -> tuple[int, int]:
"""Prompt and response share one allowance; the response is reserved at least half of it."""
indexed_out: Final = min(outputs, max(_MAX_INDEXED_MESSAGES // 2, _MAX_INDEXED_MESSAGES - inputs))
return _MAX_INDEXED_MESSAGES - indexed_out, indexed_out
@staticmethod
def _prompt_positions(total: int, indexed: int) -> tuple[int, ...]:
"""Prompt messages that get per-index attributes: message 0 and the most recent turns."""
if total <= indexed:
return tuple(range(total))
return (0, *range(total - indexed + 1, total))
@staticmethod
def _messages(prefix: str, value_key: str, messages: Sequence[object], positions: Sequence[int]) -> AttributeMap:
"""``{prefix}.{idx}.message.*`` keys for the messages at ``positions`` + the ``value_key`` blob of all."""
parsed: Final = [(m.get("role") if isinstance(m, dict) else None, message_content(m)) for m in messages]
attrs: Final = drop_none(
{
key: value
for idx, (role, content) in enumerate(parsed)
for idx, (role, content) in ((idx, parsed[idx]) for idx in positions)
for key, value in (
(
f"{prefix}.{idx}.message.role",
role if isinstance(role, str) else None,
),
(f"{prefix}.{idx}.message.role", role if isinstance(role, str) else None),
(f"{prefix}.{idx}.message.content", content),
)
}

View file

@ -32,6 +32,14 @@ core telemetry no matter how many vocabularies are configured.
"""
MAX_MESSAGE_ATTRS_PER_SPAN: Final = DEFAULT_SPAN_ATTRIBUTE_LIMIT // 8
"""Span-wide ceiling on per-index chat message attributes, prompt and response together.
An eighth is the largest share that still fits beside the tool ceiling and the core
of every vocabulary at once. The complete conversation still rides the JSON blobs.
"""
def tool_attr_budget(vocabularies: int) -> int:
"""Split the span-wide tool-definition ceiling across active vocabularies."""
return MAX_TOOL_DEFINITION_ATTRS_PER_SPAN // max(vocabularies, 1)

View file

@ -41,6 +41,7 @@ from litellm.proxy._types import (
LiteLLM_UserTable,
UserAPIKeyAuth,
)
from litellm.proxy.hooks.rate_limiter_utils import PROXY_LLM_PROVIDER_FALLBACK
from litellm.repositories.base_repository import BaseRepository
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.team_repository import TeamRepository
@ -2581,6 +2582,15 @@ class PrometheusLogger(CustomLogger):
)
return None
@staticmethod
def _extract_api_provider_from_exception(exception: Exception) -> str | None:
if not isinstance(exception, litellm.exceptions.RateLimitError):
return None
llm_provider: Final = exception.llm_provider
if not llm_provider or llm_provider == PROXY_LLM_PROVIDER_FALLBACK:
return None
return llm_provider
async def async_post_call_failure_hook(
self,
request_data: dict,
@ -2616,7 +2626,9 @@ class PrometheusLogger(CustomLogger):
_metadata: Final = request_data.get("metadata", {}) or {}
model_id: Final = _metadata.get("model_info", {}).get("id") or request_data.get("model_info", {}).get("id")
rate_limit_category, rate_limit_type = self._extract_rate_limit_labels(original_exception)
api_provider: Final = self._extract_api_provider_from_request_data(request_data)
api_provider: Final = self._extract_api_provider_from_request_data(
request_data
) or self._extract_api_provider_from_exception(original_exception)
enum_values: Final = UserAPIKeyLabelValues(
end_user=user_api_key_dict.end_user_id,
user=user_api_key_dict.user_id,

View file

@ -601,12 +601,15 @@ def _get_openai_compatible_provider_info(
dynamic_api_key,
) = litellm.GroqChatConfig()._get_openai_compatible_provider_info(api_base, api_key)
elif custom_llm_provider == "bedrock_mantle":
from litellm.llms.bedrock_mantle.common_utils import split_mantle_region_prefix
(
api_base,
dynamic_api_key,
) = litellm.BedrockMantleChatConfig()._get_openai_compatible_provider_info(
api_base, api_key, litellm_params=litellm_params, model=model
)
model = split_mantle_region_prefix(model)[1] # rebind-ok: the prefix is routing only, not a Mantle model id
elif custom_llm_provider == "nvidia_nim":
# nvidia_nim is openai compatible, we just need to set this to custom_openai and have the api_base be https://api.endpoints.anyscale.com/v1
api_base = api_base or get_secret("NVIDIA_NIM_API_BASE") or "https://integrate.api.nvidia.com/v1"

View file

@ -454,6 +454,17 @@ def _resolve_vertex_location_for_cost(
return VertexBase.get_vertex_region(configured_location, model)
def _resolve_mantle_region_for_cost(
custom_llm_provider: str | None,
litellm_params: Mapping[str, object] | None,
) -> str | None:
if custom_llm_provider != "bedrock_mantle":
return None
from litellm.llms.bedrock_mantle.common_utils import resolve_mantle_region
return resolve_mantle_region(litellm_params or MappingProxyType({}))
def _provider_response_id(source: object) -> str | None:
candidate: Final = source.get("id") if isinstance(source, dict) else getattr(source, "id", None)
return candidate if isinstance(candidate, str) and candidate else None
@ -545,7 +556,7 @@ class Logging(LiteLLMLoggingBaseClass):
# ids leaking into a different, later request on the same thread. Sync
# support is deferred to a follow-up PR with its own safe-restore
# mechanism; async calls (the proxy's only call path) are unaffected.
if supports_correlation_logging:
if supports_correlation_logging and litellm.request_correlation_in_logs:
set_trace_id(self.litellm_trace_id)
set_session_id(self.litellm_session_id)
# set_trace_id()/set_session_id() sanitize (strip control chars, bound
@ -1768,6 +1779,10 @@ class Logging(LiteLLMLoggingBaseClass):
optional_params=self.optional_params,
model=litellm_model_name or self.model,
),
"region_name": _resolve_mantle_region_for_cost(
custom_llm_provider=self.model_call_details.get("custom_llm_provider", None),
litellm_params=self.model_call_details.get("litellm_params"),
),
}
except Exception as e: # error creating kwargs for cost calculation
debug_info = StandardLoggingModelCostFailureDebugInformation(
@ -2427,7 +2442,7 @@ class Logging(LiteLLMLoggingBaseClass):
call) would leave the outer request's subsequent log lines stamped with
the nested call's trace_id/session_id instead of its own.
Uses a plain set() of the captured pre-call value rather than
Uses a plain contextvar set() of the captured pre-call value rather than
contextvars.Token-based reset(), since this can end up called from a
different asyncio Task/context than __init__ ran in (e.g. the request
task's own wrapper() finally block, plus async_success_handler
@ -2438,8 +2453,8 @@ class Logging(LiteLLMLoggingBaseClass):
that Task's view of the contextvars, so calling it multiple times
(once per Task involved in this attempt) is required, not just safe.
"""
set_trace_id(self._pre_call_trace_id)
set_session_id(self._pre_call_session_id)
trace_id_var.set(self._pre_call_trace_id)
session_id_var.set(self._pre_call_session_id)
def _restore_correlation_context_if_unclaimed(self) -> None:
"""Guarded variant for __del__-triggered cleanup only.

View file

@ -9,6 +9,8 @@ from types import MappingProxyType
from typing import Any, Final, Literal, TypedDict, cast
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from typing_extensions import ReadOnly
import litellm
from litellm._internal_context import current_billing_time
from litellm._logging import verbose_logger
@ -772,6 +774,7 @@ def calculate_cache_writing_cost(
class PromptTokensDetailsResult(TypedDict):
cache_hit_tokens: int
cache_hit_audio_tokens: ReadOnly[int]
cache_creation_tokens: int
cache_creation_token_details: CacheCreationTokenDetails | None
text_tokens: int
@ -802,12 +805,34 @@ def parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult:
)
or None
)
text_tokens: Final = (
cast(int | None, getattr(usage.prompt_tokens_details, "text_tokens", None))
or 0 # default to prompt tokens, if this field is not set
cached_tokens_details: Final = getattr(usage.prompt_tokens_details, "cached_tokens_details", None)
cached_audio_tokens: Final = min(
_get_token_detail_value(cached_tokens_details, "audio_tokens") or 0, cache_hit_tokens
)
cached_text_tokens: Final = min(
_get_token_detail_value(cached_tokens_details, "text_tokens") or 0,
cache_hit_tokens - cached_audio_tokens,
)
cached_image_tokens: Final = min(
_get_token_detail_value(cached_tokens_details, "image_tokens") or 0,
cache_hit_tokens - cached_audio_tokens - cached_text_tokens,
)
text_tokens: Final = max(
(
cast(int | None, getattr(usage.prompt_tokens_details, "text_tokens", None))
or 0 # default to prompt tokens, if this field is not set
)
- cached_text_tokens,
0,
)
audio_tokens: Final = max(
(cast(int | None, getattr(usage.prompt_tokens_details, "audio_tokens", 0)) or 0) - cached_audio_tokens,
0,
)
image_tokens: Final = max(
(cast(int | None, getattr(usage.prompt_tokens_details, "image_tokens", 0)) or 0) - cached_image_tokens,
0,
)
audio_tokens: Final = cast(int | None, getattr(usage.prompt_tokens_details, "audio_tokens", 0)) or 0
image_tokens: Final = cast(int | None, getattr(usage.prompt_tokens_details, "image_tokens", 0)) or 0
video_tokens: Final = _coerce_token_count(getattr(usage.prompt_tokens_details, "video_tokens", 0))
character_count: Final = (
cast(
@ -835,6 +860,7 @@ def parse_prompt_tokens_details(usage: Usage) -> PromptTokensDetailsResult:
return PromptTokensDetailsResult(
cache_hit_tokens=cache_hit_tokens,
cache_hit_audio_tokens=cached_audio_tokens,
cache_creation_tokens=cache_creation_tokens,
cache_creation_token_details=cache_creation_token_details,
text_tokens=text_tokens,
@ -918,7 +944,16 @@ def _calculate_input_cost(
prompt_cost = float(prompt_tokens_details["text_tokens"]) * prompt_base_cost
### CACHE READ COST - Now uses tiered pricing
prompt_cost += float(prompt_tokens_details["cache_hit_tokens"]) * cache_read_cost
cache_hit_audio_tokens: Final = prompt_tokens_details["cache_hit_audio_tokens"]
audio_cache_read_rate: Final = _get_cost_per_unit(
model_info,
_get_service_tier_cost_key("cache_read_input_audio_token_cost", service_tier),
None,
)
prompt_cost += float(prompt_tokens_details["cache_hit_tokens"] - cache_hit_audio_tokens) * cache_read_cost
prompt_cost += float(cache_hit_audio_tokens) * (
audio_cache_read_rate if audio_cache_read_rate is not None else cache_read_cost
)
### AUDIO COST
if prompt_tokens_details["audio_tokens"]:
@ -1149,6 +1184,7 @@ def generic_cost_per_token(
### PROCESSING COST
prompt_tokens_details = PromptTokensDetailsResult(
cache_hit_tokens=0,
cache_hit_audio_tokens=0,
cache_creation_tokens=0,
cache_creation_token_details=None,
text_tokens=usage.prompt_tokens,
@ -1319,6 +1355,7 @@ class BilledTokenRates:
input_cost_per_token: float
output_cost_per_token: float
cache_read_input_token_cost: float
cache_read_input_audio_token_cost: float
cache_creation_input_token_cost: float
cache_creation_input_token_cost_above_1hr: float
output_cost_per_reasoning_token: float
@ -1330,6 +1367,7 @@ class BilledTokenRates:
input_cost_per_token=self.input_cost_per_token * multiplier,
output_cost_per_token=self.output_cost_per_token * multiplier,
cache_read_input_token_cost=self.cache_read_input_token_cost * multiplier,
cache_read_input_audio_token_cost=self.cache_read_input_audio_token_cost * multiplier,
cache_creation_input_token_cost=self.cache_creation_input_token_cost * multiplier,
cache_creation_input_token_cost_above_1hr=self.cache_creation_input_token_cost_above_1hr * multiplier,
output_cost_per_reasoning_token=self.output_cost_per_reasoning_token * multiplier,
@ -1353,15 +1391,16 @@ def _reasoning_token_count(usage: Usage) -> int:
return parsed or _coerce_token_count(getattr(usage, "reasoning_tokens", 0))
def _cache_token_counts(usage: Usage) -> tuple[int, int, CacheCreationTokenDetails | None]:
"""(cache read tokens, cache creation tokens, cache creation details): read from prompt_tokens_details
first, then the private top-level counters the Usage constructor mirrors cache tokens onto for
providers/callers that bypass the details."""
def _cache_token_counts(usage: Usage) -> tuple[int, int, int, CacheCreationTokenDetails | None]:
"""(cache read tokens, cached audio tokens, cache creation tokens, cache creation details): read from
prompt_tokens_details first, then the private top-level counters the Usage constructor mirrors cache
tokens onto for providers/callers that bypass the details."""
parsed: Final = parse_prompt_tokens_details(usage) if usage.prompt_tokens_details is not None else None
parsed_read: Final = parsed["cache_hit_tokens"] if parsed is not None else 0
parsed_creation: Final = parsed["cache_creation_tokens"] if parsed is not None else 0
return (
parsed_read or _coerce_token_count(getattr(usage, "_cache_read_input_tokens", 0)),
parsed["cache_hit_audio_tokens"] if parsed is not None else 0,
parsed_creation or _coerce_token_count(getattr(usage, "_cache_creation_input_tokens", 0)),
parsed["cache_creation_token_details"] if parsed is not None else None,
)
@ -1372,11 +1411,13 @@ def _custom_pricing_rates(custom_cost_per_token: CostPerToken) -> BilledTokenRat
cache rates (else the input rate) and reasoning at the output rate, as _cost_per_token_custom_pricing_helper does."""
input_rate: Final = custom_cost_per_token["input_cost_per_token"]
output_rate: Final = custom_cost_per_token["output_cost_per_token"]
cache_read_rate: Final = custom_cost_per_token.get("cache_read_input_token_cost", input_rate)
cache_creation_rate: Final = custom_cost_per_token.get("cache_creation_input_token_cost", input_rate)
return BilledTokenRates(
input_cost_per_token=input_rate,
output_cost_per_token=output_rate,
cache_read_input_token_cost=custom_cost_per_token.get("cache_read_input_token_cost", input_rate),
cache_read_input_token_cost=cache_read_rate,
cache_read_input_audio_token_cost=cache_read_rate,
cache_creation_input_token_cost=cache_creation_rate,
cache_creation_input_token_cost_above_1hr=cache_creation_rate,
output_cost_per_reasoning_token=output_rate,
@ -1413,6 +1454,11 @@ def _cost_map_billed_rates(
completion_base_cost=completion_base_cost,
current_time=billing_time,
)
audio_cache_read_rate: Final = _get_cost_per_unit(
model_info,
_get_service_tier_cost_key("cache_read_input_audio_token_cost", service_tier),
None,
)
multiplier: Final = (
_get_regional_uplift_multiplier(model_info, data_residency)
* get_vertex_regional_endpoint_uplift(model_info, vertex_location)
@ -1422,6 +1468,9 @@ def _cost_map_billed_rates(
input_cost_per_token=prompt_base_cost,
output_cost_per_token=completion_base_cost,
cache_read_input_token_cost=cache_read_cost_rate,
cache_read_input_audio_token_cost=(
audio_cache_read_rate if audio_cache_read_rate is not None else cache_read_cost_rate
),
cache_creation_input_token_cost=cache_creation_cost_rate,
cache_creation_input_token_cost_above_1hr=cache_creation_cost_above_1hr_rate,
output_cost_per_reasoning_token=reasoning_rate,
@ -1494,7 +1543,9 @@ def get_token_type_cost_breakdown(
if rates is None:
return TokenTypeCostBreakdown(0.0, 0.0, 0.0)
cache_read_tokens, cache_creation_tokens, cache_creation_token_details = _cache_token_counts(usage)
cache_read_tokens, cached_audio_tokens, cache_creation_tokens, cache_creation_token_details = _cache_token_counts(
usage
)
cache_creation_cost: Final = (
float(cache_creation_tokens) * rates.cache_creation_input_token_cost
if custom_cost_per_token is not None
@ -1507,7 +1558,10 @@ def get_token_type_cost_breakdown(
)
return TokenTypeCostBreakdown(
reasoning_cost=float(_reasoning_token_count(usage)) * rates.output_cost_per_reasoning_token,
cache_read_cost=float(cache_read_tokens) * rates.cache_read_input_token_cost,
cache_read_cost=(
float(cache_read_tokens - cached_audio_tokens) * rates.cache_read_input_token_cost
+ float(cached_audio_tokens) * rates.cache_read_input_audio_token_cost
),
cache_creation_cost=cache_creation_cost,
rates=rates,
)

View file

@ -18,11 +18,16 @@ from litellm.constants import (
LOGGING_WORKER_CONCURRENCY,
LOGGING_WORKER_MAX_QUEUE_SIZE,
LOGGING_WORKER_MAX_TIME_PER_COROUTINE,
LOGGING_WORKER_TIMEOUT_SUMMARY_WINDOW_SECONDS,
MAX_ITERATIONS_TO_CLEAR_QUEUE,
MAX_TIME_TO_CLEAR_QUEUE,
)
def _coroutine_name(coroutine: Coroutine) -> str:
return getattr(coroutine, "__qualname__", None) or getattr(coroutine, "__name__", None) or type(coroutine).__name__
class LoggingTask(TypedDict):
"""
A logging task with its associated context to ensure logging is executed in
@ -47,10 +52,12 @@ class LoggingWorker:
timeout: float = LOGGING_WORKER_MAX_TIME_PER_COROUTINE,
max_queue_size: int = LOGGING_WORKER_MAX_QUEUE_SIZE,
concurrency: int = LOGGING_WORKER_CONCURRENCY,
timeout_summary_window: float = LOGGING_WORKER_TIMEOUT_SUMMARY_WINDOW_SECONDS,
):
self.timeout = timeout
self.max_queue_size = max_queue_size
self.concurrency = concurrency
self.timeout_summary_window = timeout_summary_window
self._queue: asyncio.Queue[LoggingTask] | None = None
self._worker_task: asyncio.Task | None = None
self._running_tasks: set[asyncio.Task] = set()
@ -59,6 +66,10 @@ class LoggingWorker:
self._bound_loop: asyncio.AbstractEventLoop | None = None
self._last_aggressive_clear_time: float = 0.0
self._aggressive_clear_in_progress: bool = False
self._timeout_total: int = 0
self._timeout_burst_count: int = 0
self._timeout_last_callback: str | None = None
self._timeout_summary_task: asyncio.Task | None = None
# Register cleanup handler to flush remaining events on exit
atexit.register(self._flush_on_exit)
@ -136,6 +147,8 @@ class LoggingWorker:
self._sem = None
self._worker_task = None
self._running_tasks.clear()
self._timeout_summary_task = None
self._timeout_burst_count = 0
self._queue = new_queue
self._bound_loop = current_loop
return
@ -156,12 +169,15 @@ class LoggingWorker:
"""Runs the logging task and handles cleanup. Releases semaphore when done."""
try:
if self._queue is not None:
# Run the coroutine in its original context
callback_task: Final = task["context"].run(asyncio.create_task, task["coroutine"])
try:
# Run the coroutine in its original context
await asyncio.wait_for(
task["context"].run(asyncio.create_task, task["coroutine"]),
timeout=self.timeout,
)
await asyncio.wait_for(callback_task, timeout=self.timeout)
except asyncio.TimeoutError as e:
if callback_task.cancelled():
self._record_callback_timeout(task["coroutine"])
else:
verbose_logger.exception("LoggingWorker error: %s", e)
except Exception as e:
verbose_logger.exception("LoggingWorker error: %s", e)
finally:
@ -171,6 +187,35 @@ class LoggingWorker:
# Always release semaphore, even if queue is None
sem.release()
def _record_callback_timeout(self, coroutine: Coroutine) -> None:
"""Count a callback timeout and arm a debounced summary, so a burst of timeouts
(e.g. a slow Redis timing out many callbacks at once) logs one bounded line rather
than a full ERROR stacktrace per callback."""
self._timeout_total += 1
self._timeout_burst_count += 1
self._timeout_last_callback = _coroutine_name(coroutine)
if self._timeout_summary_task is None or self._timeout_summary_task.done():
self._timeout_summary_task = asyncio.create_task(self._flush_timeout_summary())
async def _flush_timeout_summary(self) -> None:
"""After the burst settles, log one bounded summary covering every timeout in it."""
await asyncio.sleep(self.timeout_summary_window)
self._emit_timeout_summary()
def _emit_timeout_summary(self) -> None:
"""Log one bounded summary for the current burst and reset the burst counter."""
burst_count: Final = self._timeout_burst_count
self._timeout_burst_count = 0
if burst_count <= 0:
return
verbose_logger.warning(
"LoggingWorker: %d callback(s) timed out after %ss (callback: %s); %d timed out since start",
burst_count,
self.timeout,
self._timeout_last_callback,
self._timeout_total,
)
async def _worker_loop(self) -> None:
"""Main worker loop that gets tasks and schedules them to run concurrently."""
try:
@ -406,6 +451,11 @@ class LoggingWorker:
async def stop(self) -> None:
"""Stop the logging worker and clean up resources."""
if self._timeout_summary_task is not None:
self._timeout_summary_task.cancel()
self._timeout_summary_task = None
self._emit_timeout_summary()
if self._worker_task is None and not self._running_tasks:
# No worker launched and no in-flight tasks to drain.
return

View file

@ -445,6 +445,12 @@ class RealTimeStreaming:
)
sent = False
for msg in transformed:
if isinstance(msg, bytes):
await self.provider_config.pace_backend_send(msg)
await self.backend_ws.send(msg)
self._content_sent_after_setup = True
sent = True
continue
try:
msg_obj = _decode_json_object(msg)
except (json.JSONDecodeError, TypeError):
@ -1013,7 +1019,7 @@ class RealTimeStreaming:
cast(str, transcript),
item_id=cast(str | None, event.get("item_id")),
)
if not blocked:
if not blocked and not self._is_transcription_session:
await self._send_to_backend(json.dumps({"type": "response.create"}))
continue
## LOGGING

View file

@ -19,6 +19,8 @@ from typing_extensions import assert_never
from litellm._logging import verbose_logger
from litellm._uuid import uuid
from litellm.exceptions import MidStreamFallbackError
from litellm.llms.base_llm.chat.transformation import BaseLLMException
from litellm.types.llms.anthropic import (
AppliedEdit,
CompactionBlock,
@ -58,6 +60,25 @@ def _optional_attr_sequence(obj: object, name: str) -> Sequence[object]:
return value if value else ()
def _error_status_and_message(exc: Exception) -> tuple[int, str]:
if isinstance(exc, (BaseLLMException, MidStreamFallbackError)):
return exc.status_code, exc.message
return 500, str(exc) or "Upstream stream ended before completion"
def _mid_stream_error_sse_event(exc: Exception) -> bytes:
from litellm.anthropic_interface.exceptions.exception_mapping_utils import (
AnthropicExceptionMapping,
)
status_code, message = _error_status_and_message(exc)
error_response = AnthropicExceptionMapping.transform_to_anthropic_error(
status_code=status_code,
raw_message=message,
)
return f"event: error\ndata: {json.dumps(error_response)}\n\n".encode()
def _delta_payload_field(delta_type: StreamingContentBlockDeltaType) -> str:
match delta_type:
case "text_delta":
@ -990,14 +1011,17 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper):
Async version of anthropic_sse_wrapper.
Convert AnthropicStreamWrapper dict chunks to Server-Sent Events format.
"""
async for chunk in self:
if isinstance(chunk, dict):
event_type: str = str(chunk.get("type", "message"))
payload = f"event: {event_type}\ndata: {json.dumps(chunk)}\n\n"
yield payload.encode()
else:
# For non-dict chunks, forward the original value unchanged
yield chunk
try:
async for chunk in self:
if isinstance(chunk, dict):
event_type: str = str(chunk.get("type", "message"))
payload = f"event: {event_type}\ndata: {json.dumps(chunk)}\n\n"
yield payload.encode()
else:
yield chunk
except Exception as e: # noqa: BLE001 # boundary before the socket: any upstream failure becomes an Anthropic error event
verbose_logger.exception("Anthropic Adapter - mid-stream error, emitting Anthropic error event: %s", e)
yield _mid_stream_error_sse_event(e)
def _increment_content_block_index(self):
self.current_content_block_index += 1

View file

@ -50,6 +50,14 @@ class BaseLLMModelInfo(ABC):
"""
return None
def get_model_cost_key(self, model: str) -> str | None:
"""
Maps the model name a user sends to the key `litellm.model_cost` stores it under, when the two differ.
`get_model_info` tries this key once the exact `model` and `provider/model` keys miss. The default None means
the provider's user-facing names already match the cost map, so there is nothing extra to try.
"""
return None
@abstractmethod
def get_models(self, api_key: str | None = None, api_base: str | None = None) -> list[str]:
"""

View file

@ -1,5 +1,5 @@
from abc import ABC, abstractmethod
from collections.abc import Iterator
from collections.abc import Iterator, Mapping
from typing import TYPE_CHECKING, Any, Union
import httpx
@ -160,6 +160,15 @@ class BaseFilesConfig(BaseConfig):
) -> tuple[str, dict]:
"""Transform file list request into provider-specific format."""
def transform_list_files_next_request(
self,
raw_response: httpx.Response,
optional_params: Mapping[str, object],
litellm_params: dict, # mutable-ok: carries provider stashes from the request transform to the response one
) -> tuple[str, dict[str, str]] | None:
"""Request for the page after `raw_response`, or None once the listing is complete."""
return None
@abstractmethod
def transform_list_files_response(
self,
@ -258,7 +267,7 @@ class BaseFileEndpoints(ABC):
litellm_parent_otel_span: Span | None,
llm_router: Router,
**data: dict,
) -> OpenAIFileObject:
) -> FileDeleted:
pass
@abstractmethod

View file

@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any
import httpx
@ -54,9 +55,12 @@ class BaseRealtimeConfig(ABC):
message: str,
model: str,
session_configuration_request: str | None = None,
) -> list[str]:
) -> Sequence[str | bytes]:
pass
async def pace_backend_send(self, message: bytes) -> None:
return None
def is_setup_message(self, msg_obj: dict) -> bool:
return False
@ -79,7 +83,7 @@ class BaseRealtimeConfig(ABC):
model: str,
logging_session_id: str,
session_configuration_request: str | None = None,
) -> dict | OpenAIRealtimeStreamSessionEvents | None:
) -> Mapping[str, object] | OpenAIRealtimeStreamSessionEvents | None:
"""
Optional hook for providers that defer session setup until client `session.update`.

View file

@ -1,14 +1,18 @@
import base64
import json
import os
import posixpath
import time
import xml.etree.ElementTree as ET
from collections.abc import Iterable, Mapping, MutableMapping, Sequence
from contextlib import suppress
from dataclasses import dataclass
from datetime import datetime
from functools import cache
from itertools import chain
from types import MappingProxyType
from typing import Any, Final, Literal, TypeAlias, TypedDict
from urllib.parse import unquote
from urllib.parse import quote, unquote, urlencode
import httpx
from httpx import Headers, Response
@ -23,6 +27,7 @@ from litellm.files.utils import FilesAPIUtils
from litellm.litellm_core_utils.aws_partition import get_aws_dns_suffix
from litellm.litellm_core_utils.cloud_storage_security import (
BEDROCK_MANAGED_S3_BATCH_PREFIX,
BEDROCK_MANAGED_S3_OUTPUT_PREFIX,
BEDROCK_MANAGED_S3_PREFIXES,
BEDROCK_MANAGED_S3_UPLOAD_PREFIX,
build_managed_cloud_object_name,
@ -62,6 +67,10 @@ from ..common_utils import BedrockError, merge_bedrock_aws_request_params, resol
S3_SIGNED_REQUEST_HEADERS_PARAM: Final = "_s3_signed_request_headers"
LIST_FILES_PURPOSE_PARAM: Final = "_s3_list_files_purpose"
LIST_FILES_LOCATION_PARAM: Final = "_s3_list_files_location"
class _S3DeleteContext(BaseModel):
file_id: str = Field(min_length=1)
@ -152,6 +161,13 @@ class _BedrockS3RequestParams(BaseModel):
s3_endpoint_url: str | None = None
@dataclass(frozen=True, slots=True)
class _S3RequestTarget:
endpoint_url: str
aws_region_name: str
request_params: _BedrockS3RequestParams
class _TrustedS3ModelCredentials(BaseModel):
"""The S3 buckets the server trusts file ids against, from the deployment snapshot."""
@ -248,6 +264,128 @@ def _validate_file_id_against_configured_buckets(
return validate_against(configured_bucket_names[-1])
_REJECTED_FILE_ID_REQUEST_URL: Final = "https://litellm.ai"
def _rejected_file_id(reason: ValueError) -> BedrockError:
message: Final = str(reason)
return BedrockError(
status_code=400,
message=message,
response=httpx.Response(
status_code=400,
text=message,
request=httpx.Request(method="GET", url=_REJECTED_FILE_ID_REQUEST_URL),
),
)
def _resolve_managed_s3_object(file_id: str, litellm_params: Mapping[str, object]) -> tuple[str, str]:
configured_bucket_names: Final = get_configured_s3_bucket_names(litellm_params)
allow_legacy_cloud_file_ids: Final = should_allow_legacy_cloud_file_ids(litellm_params)
try:
return _validate_file_id_against_configured_buckets(
s3_uri=extract_s3_uri_from_file_id(file_id),
configured_bucket_names=configured_bucket_names,
allow_legacy_cloud_file_ids=allow_legacy_cloud_file_ids,
)
except ValueError as reason:
raise _rejected_file_id(reason) from reason
_ANY_MANAGED_LISTING_PREFIX: Final = os.path.commonprefix(BEDROCK_MANAGED_S3_PREFIXES)
_MANAGED_LISTING_PREFIX_BY_PURPOSE: Final = MappingProxyType(
{
"batch": os.path.commonprefix((BEDROCK_MANAGED_S3_BATCH_PREFIX, BEDROCK_MANAGED_S3_UPLOAD_PREFIX)),
"batch_output": BEDROCK_MANAGED_S3_OUTPUT_PREFIX,
}
)
_EMPTY_LISTING_QUERY: Final = (("list-type", "2"), ("max-keys", "0"))
def _managed_listing_prefix(configured_prefix: str, purpose: str | None) -> str | None:
managed_prefix: Final = _MANAGED_LISTING_PREFIX_BY_PURPOSE.get(purpose) if purpose else _ANY_MANAGED_LISTING_PREFIX
if managed_prefix is None:
return None
return f"{configured_prefix}/{managed_prefix}" if configured_prefix else managed_prefix
def _listing_query(configured_prefix: str, purpose: str | None) -> tuple[tuple[str, str], ...]:
listing_prefix: Final = _managed_listing_prefix(configured_prefix, purpose)
if listing_prefix is None:
return _EMPTY_LISTING_QUERY
return (("list-type", "2"), ("prefix", listing_prefix))
def _requested_listing_purpose(litellm_params: Mapping[str, object]) -> str | None:
requested_purpose: Final = litellm_params.get(LIST_FILES_PURPOSE_PARAM)
return requested_purpose if isinstance(requested_purpose, str) else None
def _walked_listing_purpose(litellm_params: Mapping[str, object]) -> str | None:
walked_purpose: Final = litellm_params.get(LIST_FILES_LOCATION_PARAM)
return walked_purpose if isinstance(walked_purpose, str) else _requested_listing_purpose(litellm_params)
def _output_location_still_unlisted(litellm_params: Mapping[str, object]) -> bool:
if _walked_listing_purpose(litellm_params) is not None:
return False
return _listing_bucket_name(litellm_params, "batch_output") != _listing_bucket_name(litellm_params, None)
def _listing_bucket_name(litellm_params: Mapping[str, object], purpose: str | None) -> str:
if purpose != "batch_output":
return get_configured_s3_bucket_name(litellm_params)
trusted: Final = _trusted_s3_model_credentials(litellm_params)
return (
trusted.s3_output_bucket_name
or os.getenv("AWS_S3_OUTPUT_BUCKET_NAME")
or get_configured_s3_bucket_name(litellm_params)
)
def _listed_object_created_at(entry: ET.Element) -> int:
last_modified: Final = entry.findtext("{*}LastModified")
if not last_modified:
return 0
return int(datetime.fromisoformat(last_modified.replace("Z", "+00:00")).timestamp())
def _listed_managed_file(
entry: ET.Element,
bucket_name: str,
configured_bucket_name: str,
allow_legacy_cloud_file_ids: bool,
) -> OpenAIFileObject | None:
object_key: Final = entry.findtext("{*}Key")
if not object_key:
return None
file_id: Final = f"s3://{bucket_name}/{object_key}"
try:
validate_managed_cloud_file_id(
file_id=file_id,
scheme="s3://",
configured_bucket_name=configured_bucket_name,
allowed_object_prefixes=BEDROCK_MANAGED_S3_PREFIXES,
allow_legacy_cloud_file_ids=allow_legacy_cloud_file_ids,
)
except ValueError:
return None
_, configured_prefix = split_configured_cloud_bucket_name(configured_bucket_name)
relative_key: Final = object_key[len(configured_prefix) + 1 :] if configured_prefix else object_key
return OpenAIFileObject(
id=file_id,
bytes=int(entry.findtext("{*}Size") or 0),
created_at=_listed_object_created_at(entry),
filename=posixpath.basename(object_key),
object="file",
purpose="batch_output" if relative_key.startswith(BEDROCK_MANAGED_S3_OUTPUT_PREFIX) else "batch",
status="uploaded",
)
def _uploaded_object_size(litellm_params: Mapping[str, object], raw_response: Response) -> int:
"""
S3 answers PutObject with an empty body, so the stored object size comes from the
@ -1213,18 +1351,86 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
def transform_list_files_request(
self,
purpose: str | None,
optional_params: dict,
litellm_params: dict,
) -> tuple[str, dict]:
raise NotImplementedError("BedrockFilesConfig does not support file listing")
optional_params: Mapping[str, object],
litellm_params: MutableMapping[str, object],
) -> tuple[str, dict[str, str]]:
litellm_params[LIST_FILES_PURPOSE_PARAM] = purpose # rebind-ok: handed to the response transform
litellm_params[LIST_FILES_LOCATION_PARAM] = purpose # rebind-ok: names the location the next page walks
return self._signed_listing_request(purpose, optional_params, litellm_params, continuation_token=None)
def transform_list_files_next_request(
self,
raw_response: httpx.Response,
optional_params: Mapping[str, object],
litellm_params: MutableMapping[str, object],
) -> tuple[str, dict[str, str]] | None:
if raw_response.status_code >= 400:
return None
continuation_token: Final = ET.fromstring(raw_response.content).findtext("{*}NextContinuationToken")
if continuation_token:
return self._signed_listing_request(
_walked_listing_purpose(litellm_params), optional_params, litellm_params, continuation_token
)
if not _output_location_still_unlisted(litellm_params):
return None
litellm_params[LIST_FILES_LOCATION_PARAM] = "batch_output" # rebind-ok: the input location is fully listed
return self._signed_listing_request("batch_output", optional_params, litellm_params, continuation_token=None)
def _signed_listing_request(
self,
purpose: str | None,
optional_params: Mapping[str, object],
litellm_params: MutableMapping[str, object],
continuation_token: str | None,
) -> tuple[str, dict[str, str]]:
bucket_name, configured_prefix = split_configured_cloud_bucket_name(
_listing_bucket_name(litellm_params, purpose)
)
target: Final = self._s3_request_target(optional_params=optional_params, litellm_params=litellm_params)
url: Final = f"{target.endpoint_url}/{bucket_name}/"
listing_query: Final = _listing_query(configured_prefix, purpose)
continuation_query: Final = (("continuation-token", continuation_token),) if continuation_token else ()
query: Final[dict[str, str]] = dict( # mutable-ok: the base files contract returns the query as a dict
listing_query + continuation_query
)
signed_headers: Final = self._sign_s3_request_without_body(
method="GET",
api_base=f"{url}?{urlencode(query, quote_via=quote, safe='')}",
aws_region_name=target.aws_region_name,
request_params=target.request_params,
)
litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] = signed_headers # rebind-ok: handed to validate_environment
return url, query
def transform_list_files_response(
self,
raw_response: httpx.Response,
logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
litellm_params: Mapping[str, object],
) -> list[OpenAIFileObject]:
raise NotImplementedError("BedrockFilesConfig does not support file listing")
if raw_response.status_code >= 400:
raise BedrockError(
status_code=raw_response.status_code,
message=raw_response.text,
headers=raw_response.headers,
response=raw_response,
)
purpose: Final = _requested_listing_purpose(litellm_params)
configured_bucket_name: Final = _listing_bucket_name(litellm_params, _walked_listing_purpose(litellm_params))
allow_legacy_cloud_file_ids: Final = should_allow_legacy_cloud_file_ids(litellm_params)
listing: Final = ET.fromstring(raw_response.content)
bucket_name: Final = (
listing.findtext("{*}Name") or split_configured_cloud_bucket_name(configured_bucket_name)[0]
)
listed_files: Final = (
_listed_managed_file(entry, bucket_name, configured_bucket_name, allow_legacy_cloud_file_ids)
for entry in listing.iterfind("{*}Contents")
)
return [ # mutable-ok: the base files contract returns a list
listed_file
for listed_file in listed_files
if listed_file is not None and (purpose is None or listed_file.purpose == purpose)
]
def transform_file_content_request(
self,
@ -1255,39 +1461,54 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
optional_params: Mapping[str, object],
litellm_params: MutableMapping[str, object],
) -> tuple[str, dict[str, str]]:
s3_uri: Final = extract_s3_uri_from_file_id(file_id)
bucket_name, object_key = _validate_file_id_against_configured_buckets(
s3_uri=s3_uri,
configured_bucket_names=get_configured_s3_bucket_names(litellm_params),
allow_legacy_cloud_file_ids=should_allow_legacy_cloud_file_ids(litellm_params),
bucket_name, object_key = _resolve_managed_s3_object(file_id=file_id, litellm_params=litellm_params)
target: Final = self._s3_request_target(optional_params=optional_params, litellm_params=litellm_params)
url: Final = f"{target.endpoint_url}/{bucket_name}/{encode_s3_object_key_for_url(object_key)}"
signed_headers: Final = self._sign_s3_request_without_body(
method=method,
api_base=url,
aws_region_name=target.aws_region_name,
request_params=target.request_params,
)
litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] = signed_headers # rebind-ok: handed to validate_environment
return url, {} # mutable-ok: the base files contract returns the query as a dict
request_params: Final = _BedrockS3RequestParams.model_validate({**litellm_params, **optional_params})
def _s3_request_target(
self,
optional_params: Mapping[str, object],
litellm_params: Mapping[str, object],
) -> _S3RequestTarget:
"""
The shared files handler passes optional_params={}, so AWS credentials and
region arrive via litellm_params here (unlike the upload path).
s3_region_name wins over aws_region_name, same priority as get_complete_file_url.
"""
request_params: Final = _BedrockS3RequestParams.model_validate(
MappingProxyType({**litellm_params, **optional_params})
)
region_preference: Final = request_params.s3_region_name or request_params.aws_region_name
region_params: Final[dict[str, str | None]] = {"aws_region_name": region_preference}
aws_region_name: Final = self._get_aws_region_name(optional_params=region_params, model="")
s3_endpoint_url: Final = (
aws_region_name: Final = self._get_aws_region_name(
optional_params={"aws_region_name": region_preference}, # mutable-ok: BaseAWSLLM takes a dict
model="",
)
endpoint_url: Final = (
request_params.s3_endpoint_url or f"https://s3.{aws_region_name}.{get_aws_dns_suffix(aws_region_name)}"
).rstrip("/")
url: Final = f"{s3_endpoint_url}/{bucket_name}/{encode_s3_object_key_for_url(object_key)}"
litellm_params[S3_SIGNED_REQUEST_HEADERS_PARAM] = self._sign_s3_request_without_body(
api_base=url,
aws_region_name=aws_region_name,
request_params=request_params,
method=method,
return _S3RequestTarget(
endpoint_url=endpoint_url, aws_region_name=aws_region_name, request_params=request_params
)
return url, {}
def _sign_s3_request_without_body(
self,
method: Literal["GET", "DELETE"],
api_base: str,
aws_region_name: str,
request_params: _BedrockS3RequestParams,
method: Literal["GET", "DELETE"] = "GET",
) -> dict[str, str]:
) -> Mapping[str, str]:
"""
SigV4-sign a bodiless S3 request (GetObject, DeleteObject, ListObjectsV2),
mirroring `_sign_s3_request` (PUT).
"""
try:
import hashlib
@ -1313,11 +1534,11 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
aws_request: Final = AWSRequest( # any-ok: botocore AWSRequest is untyped
method=method,
url=api_base,
headers={"x-amz-content-sha256": empty_body_hash},
headers={"x-amz-content-sha256": empty_body_hash}, # mutable-ok: botocore AWSRequest takes a dict
)
auth: Final = S3SigV4Auth(credentials, "s3", aws_region_name) # any-ok: botocore untyped
auth.add_auth(aws_request) # any-ok: botocore request mutation is untyped
return dict(aws_request.headers) # any-ok: botocore headers are untyped
return MappingProxyType(dict(aws_request.headers)) # any-ok: botocore headers are untyped
def transform_file_content_response(
self,
@ -1330,6 +1551,7 @@ class BedrockFilesConfig(BaseAWSLLM, BaseFilesConfig):
status_code=raw_response.status_code,
message=raw_response.text,
headers=raw_response.headers,
response=raw_response,
)
return HttpxBinaryResponseContent(response=raw_response)

View file

@ -29,7 +29,7 @@ from litellm.types.router import GenericLiteLLMParams
from ...base_llm.chat.transformation import BaseLLMException
from ...bedrock.common_utils import BedrockError
from ...openai_like.chat.transformation import OpenAILikeChatConfig
from ..common_utils import mantle_base_segment
from ..common_utils import mantle_base_segment, split_mantle_region_prefix
class BedrockMantleChatConfig(BedrockMantleAuthMixin, OpenAILikeChatConfig):
@ -61,8 +61,10 @@ class BedrockMantleChatConfig(BedrockMantleAuthMixin, OpenAILikeChatConfig):
litellm_params: GenericLiteLLMParams | None = None,
model: str | None = None,
) -> tuple[str | None, str | None]:
prefix_region, base_model = split_mantle_region_prefix(model) if model else (None, None)
region: Final = (
(litellm_params.aws_region_name if litellm_params else None)
or prefix_region
or get_secret_str("BEDROCK_MANTLE_REGION")
or get_secret_str("AWS_REGION_NAME")
or get_secret_str("AWS_REGION")
@ -75,7 +77,7 @@ class BedrockMantleChatConfig(BedrockMantleAuthMixin, OpenAILikeChatConfig):
api_base = (
api_base
or get_secret_str("BEDROCK_MANTLE_API_BASE")
or f"https://bedrock-mantle.{region}.api.aws/{mantle_base_segment(model, litellm.model_cost)}"
or f"https://bedrock-mantle.{region}.api.aws/{mantle_base_segment(base_model, litellm.model_cost)}"
)
dynamic_api_key: Final = self._resolve_bearer_token(api_key)
return api_base, dynamic_api_key

View file

@ -24,9 +24,11 @@ from botocore.exceptions import (
)
from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM, SignsRequestsWithAWS
from litellm.llms.bedrock.common_utils import AmazonBedrockGlobalConfig
from litellm.secret_managers.main import get_secret_str
BEDROCK_MANTLE_DEFAULT_REGION: Final = "us-east-1"
BEDROCK_REGIONS: Final = frozenset(AmazonBedrockGlobalConfig().get_all_regions())
# Standard Mantle host: https://bedrock-mantle.<region>.api.aws (group 1 = region).
MANTLE_HOST_RE: Final = re.compile(r"^https?://bedrock-mantle\.([^/.]+)\.api\.aws(?=/|$)", re.IGNORECASE)
@ -36,6 +38,13 @@ def resolve_mantle_bearer_token(api_key: str | None) -> str | None:
return api_key or get_secret_str("BEDROCK_MANTLE_API_KEY") or get_secret_str("AWS_BEARER_TOKEN_BEDROCK")
def split_mantle_region_prefix(model: str) -> tuple[str | None, str]:
head, sep, tail = model.partition("/")
if sep and head in BEDROCK_REGIONS:
return head, tail
return None, model
def resolve_mantle_region(params: Mapping[str, object]) -> str:
region: Final = params.get("aws_region_name")
if isinstance(region, str) and region:
@ -130,7 +139,7 @@ def mantle_supports_responses(model: str | None, model_cost: dict) -> bool:
gpt-oss substring), so a substring gate would be wrong. A model absent from
model_cost simply has no signal and returns False (chat-completions emulation).
"""
entry: Final = model_cost.get(f"bedrock_mantle/{model}", {})
entry: Final = model_cost.get(f"bedrock_mantle/{split_mantle_region_prefix(model)[1]}", {}) if model else {}
if "/v1/responses" in (entry.get("supported_endpoints") or []):
return True
return entry.get("mode") == "responses"
@ -147,5 +156,5 @@ def mantle_base_segment(model: str | None, model_cost: dict) -> str:
the base for the model's whole OpenAI-compatible surface, so both the chat and
responses configs derive from it -- there is no separate model-name rule.
"""
entry: Final = model_cost.get(f"bedrock_mantle/{model}", {})
entry: Final = model_cost.get(f"bedrock_mantle/{split_mantle_region_prefix(model)[1]}", {}) if model else {}
return "openai/v1" if entry.get("use_openai_responses_path") is True else "v1"

View file

@ -18,7 +18,7 @@ import litellm.types
import litellm.types.utils
from litellm._logging import _redact_string, verbose_logger
from litellm.anthropic_beta_headers_manager import update_headers_with_filtered_beta
from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
from litellm.constants import MAX_FILE_LIST_LIMIT, REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES
from litellm.litellm_core_utils.agentic_loop_settings import (
DEFAULT_MAX_AGENTIC_LOOPS,
validated_max_agentic_loops,
@ -4981,15 +4981,16 @@ class BaseLLMHTTPHandler:
)
try:
response: Final = sync_httpx_client.get(url=url, headers=headers, params=params)
response: Final = sync_httpx_client.get(url=url, headers=headers, params=params, timeout=timeout)
except Exception as e:
raise self._handle_error(e=e, provider_config=provider_config)
return provider_config.transform_list_files_response(
raw_response=response,
logging_obj=logging_obj,
litellm_params=litellm_params,
files_per_page: Final = self._files_per_listing_page(
response, provider_config, logging_obj, litellm_params, headers, sync_httpx_client, timeout
)
return [ # mutable-ok: the files contract returns the listing as a list
listed_file for page_files in files_per_page for listed_file in page_files
]
async def async_list_files(
self,
@ -5037,16 +5038,101 @@ class BaseLLMHTTPHandler:
)
try:
response: Final = await async_httpx_client.get(url=url, headers=headers, params=params)
response: Final = await async_httpx_client.get(url=url, headers=headers, params=params, timeout=timeout)
except Exception as e:
raise self._handle_error(e=e, provider_config=provider_config)
return provider_config.transform_list_files_response(
raw_response=response,
logging_obj=logging_obj,
files_per_page: Final = self._files_per_async_listing_page(
response, provider_config, logging_obj, litellm_params, headers, async_httpx_client, timeout
)
return [ # mutable-ok: the files contract returns the listing as a list
listed_file async for page_files in files_per_page for listed_file in page_files
]
def _files_per_listing_page(
self,
first_page: httpx.Response,
provider_config: BaseFilesConfig,
logging_obj: LiteLLMLoggingObj,
litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict
headers: dict, # mutable-ok: handed to validate_environment, which types it as a dict
client: HTTPHandler,
timeout: float | httpx.Timeout | None,
) -> Iterator[list[OpenAIFileObject]]: # mutable-ok: each page arrives as the list the files contract returns
latest_page = first_page # rebind-ok: advances one page per loop turn
listed_count = 0 # rebind-ok: grows per page so the listing stops at MAX_FILE_LIST_LIMIT, OpenAI's ceiling
while True:
page_files = provider_config.transform_list_files_response(
raw_response=latest_page, logging_obj=logging_obj, litellm_params=litellm_params
)
yield page_files[: MAX_FILE_LIST_LIMIT - listed_count]
listed_count += len(page_files)
next_request = self._next_listing_request(latest_page, provider_config, litellm_params, listed_count)
if next_request is None:
return
url, params = next_request
next_headers = self._next_listing_page_headers(provider_config, headers, litellm_params)
try:
latest_page = client.get(url=url, headers=next_headers, params=params, timeout=timeout)
except Exception as e: # noqa: BLE001 # _handle_error maps every failure kind, like the first page's fetch
raise self._handle_error(e=e, provider_config=provider_config)
async def _files_per_async_listing_page(
self,
first_page: httpx.Response,
provider_config: BaseFilesConfig,
logging_obj: LiteLLMLoggingObj,
litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict
headers: dict, # mutable-ok: handed to validate_environment, which types it as a dict
client: AsyncHTTPHandler,
timeout: float | httpx.Timeout | None,
) -> AsyncIterator[list[OpenAIFileObject]]: # mutable-ok: each page arrives as the list the files contract returns
latest_page = first_page # rebind-ok: advances one page per loop turn
listed_count = 0 # rebind-ok: grows per page so the listing stops at MAX_FILE_LIST_LIMIT, OpenAI's ceiling
while True:
page_files = provider_config.transform_list_files_response(
raw_response=latest_page, logging_obj=logging_obj, litellm_params=litellm_params
)
yield page_files[: MAX_FILE_LIST_LIMIT - listed_count]
listed_count += len(page_files)
next_request = self._next_listing_request(latest_page, provider_config, litellm_params, listed_count)
if next_request is None:
return
url, params = next_request
next_headers = self._next_listing_page_headers(provider_config, headers, litellm_params)
try:
latest_page = await client.get(url=url, headers=next_headers, params=params, timeout=timeout)
except Exception as e: # noqa: BLE001 # _handle_error maps every failure kind, like the first page's fetch
raise self._handle_error(e=e, provider_config=provider_config)
def _next_listing_page_headers(
self,
provider_config: BaseFilesConfig,
headers: dict, # mutable-ok: handed to validate_environment, which types it as a dict
litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict
) -> dict: # mutable-ok: validate_environment returns the header dict the files contract types
return provider_config.validate_environment(
api_key=litellm_params.get("api_key"),
headers=headers,
model="",
messages=[],
optional_params={},
litellm_params=litellm_params,
)
def _next_listing_request(
self,
latest_page: httpx.Response,
provider_config: BaseFilesConfig,
litellm_params: dict, # mutable-ok: handed to the files contract, which types it as a dict
listed_count: int,
) -> tuple[str, dict[str, str]] | None: # mutable-ok: the base files contract returns the query as a dict
if listed_count >= MAX_FILE_LIST_LIMIT:
return None
return provider_config.transform_list_files_next_request(
raw_response=latest_page, optional_params={}, litellm_params=litellm_params
)
def retrieve_file_content(
self,
file_content_request: "FileContentRequest",

View file

@ -272,6 +272,15 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
"thinking",
]
@staticmethod
def _uses_anthropic_thinking_param(model: str) -> bool:
from litellm.utils import supports_anthropic_thinking_payload
normalized: Final = model.lower().replace(".", "-")
return "claude" in normalized or supports_anthropic_thinking_payload(
model=normalized, custom_llm_provider="databricks"
)
def convert_anthropic_tool_to_databricks_tool(self, tool: AllAnthropicToolsValues | None) -> DatabricksTool | None:
if tool is None:
return None
@ -377,7 +386,7 @@ class DatabricksConfig(DatabricksBase, OpenAILikeChatConfig, AnthropicConfig):
"response_format", None
) # unsupported for claude models - if json_schema -> convert to tool call
if "reasoning_effort" in non_default_params and "claude" in model:
if "reasoning_effort" in non_default_params and self._uses_anthropic_thinking_param(model):
reasoning_effort_value: Final = non_default_params.get("reasoning_effort")
mapped_thinking: Final = AnthropicConfig._map_reasoning_effort(
reasoning_effort=reasoning_effort_value,

View file

@ -602,6 +602,9 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig):
return None
return max(matches, key=lambda match: len(match[0]))[1]
def get_model_cost_key(self, model: str) -> str:
return f"fireworks_ai/{resolve_fireworks_resource_name(model)}"
def get_provider_info(self, model: str) -> ProviderSpecificModelInfo:
supports_function_calling_value: Final = self._get_model_cost_capability(
model=model, capability="supports_function_calling"

View file

@ -0,0 +1,719 @@
import asyncio
import base64
import binascii
import json
import math
import time
from collections.abc import Awaitable, Callable, Iterator, Mapping
from dataclasses import dataclass
from types import MappingProxyType
from typing import Final, Literal
from urllib.parse import urlparse, urlunparse
from pydantic import JsonValue, TypeAdapter, ValidationError
from litellm import verbose_logger
from litellm._uuid import uuid
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig
from litellm.secret_managers.main import get_secret_str
from litellm.types.llms.meta import MuseAudioEncoding, MuseHandshake, MuseMode, MuseSampleRate
from litellm.types.llms.openai import (
OpenAIRealtimeErrorEvent,
OpenAIRealtimeEvents,
OpenAIRealtimeInputAudioBufferSpeechEvent,
OpenAIRealtimeInputAudioTranscriptionCompleted,
OpenAIRealtimeInputAudioTranscriptionDelta,
OpenAIRealtimeServerVadTurnDetection,
OpenAIRealtimeTranscriptionSession,
OpenAIRealtimeTranscriptionSessionCreated,
OpenAIRealtimeTranscriptionSettings,
)
from litellm.types.realtime import (
RealtimeInputAudioTranscriptionDurationUsage,
RealtimeInputAudioTranscriptionUsage,
RealtimeResponseTransformInput,
RealtimeResponseTypedDict,
)
MUSE_MODEL: Final = "muse-voice-transcribe-1.0"
DEFAULT_MUSE_REALTIME_URL: Final = "wss://api.meta.ai/v1/asr/realtime"
SUPPORTED_SAMPLE_RATES: Final = frozenset((16_000, 24_000))
SUPPORTED_LANGUAGES: Final = (
"Arabic",
"Bengali",
"Dutch",
"English",
"French",
"German",
"Hebrew",
"Hindi",
"Indonesian",
"Italian",
"Japanese",
"Kannada",
"Korean",
"Malay",
"Mandarin Chinese",
"Marathi",
"Polish",
"Portuguese",
"Spanish",
"Tagalog",
"Tamil",
"Telugu",
"Thai",
"Turkish",
"Vietnamese",
)
_LANGUAGE_NAMES: Final = MappingProxyType({language.casefold(): language for language in SUPPORTED_LANGUAGES})
_LANGUAGE_CODES: Final = MappingProxyType(
{
"ar": "Arabic",
"bn": "Bengali",
"de": "German",
"en": "English",
"es": "Spanish",
"fil": "Tagalog",
"fr": "French",
"he": "Hebrew",
"hi": "Hindi",
"id": "Indonesian",
"it": "Italian",
"iw": "Hebrew",
"ja": "Japanese",
"kn": "Kannada",
"ko": "Korean",
"ms": "Malay",
"mr": "Marathi",
"nl": "Dutch",
"pl": "Polish",
"pt": "Portuguese",
"ta": "Tamil",
"te": "Telugu",
"th": "Thai",
"tl": "Tagalog",
"tr": "Turkish",
"vi": "Vietnamese",
"zh": "Mandarin Chinese",
}
)
_SUPPORTED_TRANSCRIPTION_KEYS: Final = frozenset(("model", "language"))
_MAX_AUDIO_BACKLOG_SECONDS: Final = 4
_PACKET_MS: Final = 80
_END_STREAM: Final = '{"type":"endStream"}'
_PROVIDER_ERROR_MESSAGE: Final = "Meta Muse realtime transcription failed"
_JSON_ADAPTER: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue)
_EMPTY_OBJECT: Final[Mapping[str, JsonValue]] = MappingProxyType({})
_SERVER_VAD: Final[OpenAIRealtimeServerVadTurnDetection] = {"type": "server_vad"}
class MuseProtocolError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class MuseSessionConfig:
model: str
mode: MuseMode
sample_rate: MuseSampleRate
language_bias: tuple[str, ...]
@property
def audio_encoding(self) -> MuseAudioEncoding:
return "PCM_16KHZ" if self.sample_rate == 16_000 else "PCM_24KHZ"
@property
def bytes_per_second(self) -> int:
return self.sample_rate * 2
@property
def packet_bytes(self) -> int:
return self.bytes_per_second * _PACKET_MS // 1000
@property
def max_encoded_append_bytes(self) -> int:
return 4 * ((self.bytes_per_second * _MAX_AUDIO_BACKLOG_SECONDS + 2) // 3)
def handshake(self, access_token: str) -> MuseHandshake:
base: Final[MuseHandshake] = {
"authorization": {"accessToken": access_token},
"audioEncoding": self.audio_encoding,
"model": self.model,
"mode": self.mode,
"partialMode": "CUMULATIVE",
"emitAudioProgress": True,
}
if not self.language_bias:
return base
biased: Final[MuseHandshake] = {**base, "languageBias": self.language_bias}
return biased
def openai_session(self, session_id: str) -> OpenAIRealtimeTranscriptionSession:
session: Final[OpenAIRealtimeTranscriptionSession] = {
"id": session_id,
"object": "realtime.transcription_session",
"type": "transcription",
"audio": {
"input": {
"format": {"type": "audio/pcm", "rate": self.sample_rate},
"transcription": self._transcription_settings(),
"turn_detection": None if self.mode == "PUSH_TO_TALK" else _SERVER_VAD,
}
},
}
return session
def _transcription_settings(self) -> OpenAIRealtimeTranscriptionSettings:
base: Final[OpenAIRealtimeTranscriptionSettings] = {"model": self.model}
if not self.language_bias:
return base
localized: Final[OpenAIRealtimeTranscriptionSettings] = {**base, "language": self.language_bias[0]}
return localized
_DEFAULT_SESSION_CONFIG: Final = MuseSessionConfig(
model=MUSE_MODEL, mode="ENDPOINTING", sample_rate=24_000, language_bias=()
)
def _json_object(payload: str) -> Mapping[str, JsonValue]:
try:
value: Final = _JSON_ADAPTER.validate_json(payload)
except ValidationError:
raise MuseProtocolError("invalid JSON object") from None
if not isinstance(value, dict):
raise MuseProtocolError("message must be a JSON object")
return value
def _mapping(value: JsonValue | None, name: str) -> Mapping[str, JsonValue]:
if value is None:
return _EMPTY_OBJECT
if not isinstance(value, dict):
raise MuseProtocolError(f"{name} must be an object")
return value
def _string(value: JsonValue | None, name: str) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise MuseProtocolError(f"{name} must be a string")
return value
def _normalize_model(model: str) -> str:
return model.removeprefix("meta/").strip()
def _event_id() -> str:
return f"event_{uuid.uuid4().hex}"
def normalize_language(language: str) -> str:
value: Final = language.strip()
if not value:
raise MuseProtocolError("language must be non-empty")
documented_name: Final = _LANGUAGE_NAMES.get(value.casefold())
if documented_name is not None:
return documented_name
primary: Final = value.replace("_", "-").split("-", 1)[0].casefold()
mapped_name: Final = _LANGUAGE_CODES.get(primary)
if mapped_name is None:
raise MuseProtocolError("unsupported Muse Voice language")
return mapped_name
def normalize_access_token(api_key: str) -> str:
stripped: Final = api_key.strip()
if not stripped:
raise ValueError("Meta API key is required")
parts: Final = stripped.split(None, 1)
if parts[0].casefold() != "bearer":
return f"Bearer {stripped}"
if len(parts) != 2 or not parts[1].strip():
raise ValueError("Meta API key must include a token after Bearer")
return f"Bearer {parts[1].strip()}"
def build_muse_realtime_url(api_base: str | None) -> str:
if api_base is None:
return DEFAULT_MUSE_REALTIME_URL
parsed: Final = urlparse(api_base.strip())
scheme: Final = "wss" if parsed.scheme == "https" else parsed.scheme
if (
scheme != "wss"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.fragment
):
raise ValueError("Meta api_base must be an absolute wss:// or https:// URL without credentials or a fragment")
netloc: Final = f"{parsed.hostname}:{parsed.port}" if parsed.port is not None else parsed.hostname
return urlunparse((scheme, netloc, "/v1/asr/realtime", "", "", ""))
def _parse_sample_rate(session: Mapping[str, JsonValue]) -> MuseSampleRate:
beta_format: Final = session.get("input_audio_format")
audio: Final = _mapping(session.get("audio"), "session.audio")
audio_input: Final = _mapping(audio.get("input"), "session.audio.input")
ga_format: Final = audio_input.get("format")
if beta_format is not None and ga_format is not None:
raise MuseProtocolError("input audio format must use either beta or GA layout")
if beta_format is not None:
if beta_format != "pcm16":
raise MuseProtocolError("Muse Voice requires pcm16 input audio")
return 24_000
if ga_format is None:
return 24_000
if isinstance(ga_format, str):
if ga_format != "pcm16":
raise MuseProtocolError("Muse Voice requires audio/pcm input audio")
return 24_000
format_mapping: Final = _mapping(ga_format, "session.audio.input.format")
if format_mapping.get("type") != "audio/pcm":
raise MuseProtocolError("Muse Voice requires audio/pcm input audio")
channels: Final = format_mapping.get("channels", 1)
if isinstance(channels, bool) or channels != 1:
raise MuseProtocolError("Muse Voice requires mono input audio")
rate: Final = format_mapping.get("rate", 24_000)
if isinstance(rate, bool) or not isinstance(rate, int) or rate not in SUPPORTED_SAMPLE_RATES:
raise MuseProtocolError("Muse Voice supports PCM16 at 16000 Hz or 24000 Hz")
return 16_000 if rate == 16_000 else 24_000
def _parse_mode(session: Mapping[str, JsonValue], audio_input: Mapping[str, JsonValue]) -> MuseMode:
turn_detection_present: Final = "turn_detection" in session or "turn_detection" in audio_input
turn_detection: Final = session.get("turn_detection", audio_input.get("turn_detection"))
if turn_detection_present and turn_detection is None:
return "PUSH_TO_TALK"
if turn_detection is None:
return "ENDPOINTING"
turn_detection_mapping: Final = _mapping(turn_detection, "turn_detection")
if turn_detection_mapping.get("type") not in (None, "server_vad"):
raise MuseProtocolError("Muse Voice supports server_vad turn detection or null")
return "ENDPOINTING"
def parse_session_update(payload: str, expected_model: str) -> MuseSessionConfig:
message: Final = _json_object(payload)
if message.get("type") not in ("session.update", "transcription_session.update"):
raise MuseProtocolError("expected session.update")
session: Final = _mapping(message.get("session"), "session")
if not session:
raise MuseProtocolError("session.update requires a session object")
if session.get("type") not in (None, "transcription", "realtime"):
raise MuseProtocolError("Muse Voice supports transcription sessions only")
audio: Final = _mapping(session.get("audio"), "session.audio")
audio_input: Final = _mapping(audio.get("input"), "session.audio.input")
beta_transcription: Final = session.get("input_audio_transcription")
ga_transcription: Final = audio_input.get("transcription")
if beta_transcription is not None and ga_transcription is not None:
raise MuseProtocolError("input transcription must use either beta or GA layout")
transcription: Final = _mapping(
beta_transcription if beta_transcription is not None else ga_transcription,
"input audio transcription",
)
unsupported: Final = tuple(sorted(key for key in transcription if key not in _SUPPORTED_TRANSCRIPTION_KEYS))
if unsupported:
verbose_logger.warning("Meta realtime: dropping unsupported transcription settings %s", unsupported)
requested_model: Final = _string(transcription.get("model"), "transcription model")
normalized_model: Final = _normalize_model(expected_model)
if normalized_model != MUSE_MODEL:
raise MuseProtocolError("unsupported Meta realtime model")
if requested_model is not None and _normalize_model(requested_model) != normalized_model:
raise MuseProtocolError("realtime session model cannot be changed")
language: Final = _string(transcription.get("language"), "language")
return MuseSessionConfig(
model=normalized_model,
mode=_parse_mode(session, audio_input),
sample_rate=_parse_sample_rate(session),
language_bias=() if language is None else (normalize_language(language),),
)
def session_created_event(config: MuseSessionConfig, session_id: str) -> OpenAIRealtimeTranscriptionSessionCreated:
event: Final[OpenAIRealtimeTranscriptionSessionCreated] = {
"type": "session.created",
"event_id": _event_id(),
"session": config.openai_session(session_id),
}
return event
def error_event(message: str) -> OpenAIRealtimeErrorEvent:
event: Final[OpenAIRealtimeErrorEvent] = {
"type": "error",
"error": {"type": "server_error", "message": message},
}
return event
def _speech_event(
event_type: Literal["input_audio_buffer.speech_started", "input_audio_buffer.speech_stopped"], item_id: str
) -> OpenAIRealtimeInputAudioBufferSpeechEvent:
event: Final[OpenAIRealtimeInputAudioBufferSpeechEvent] = {
"type": event_type,
"event_id": _event_id(),
"item_id": item_id,
}
return event
def _delta_event(item_id: str, delta: str) -> OpenAIRealtimeInputAudioTranscriptionDelta:
event: Final[OpenAIRealtimeInputAudioTranscriptionDelta] = {
"type": "conversation.item.input_audio_transcription.delta",
"event_id": _event_id(),
"item_id": item_id,
"content_index": 0,
"delta": delta,
}
return event
def _completed_event(
item_id: str, transcript: str, usage: RealtimeInputAudioTranscriptionUsage | None
) -> OpenAIRealtimeInputAudioTranscriptionCompleted:
event: Final[OpenAIRealtimeInputAudioTranscriptionCompleted] = {
"type": "conversation.item.input_audio_transcription.completed",
"event_id": _event_id(),
"item_id": item_id,
"content_index": 0,
"transcript": transcript,
}
if usage is None:
return event
billed: Final[OpenAIRealtimeInputAudioTranscriptionCompleted] = {**event, "usage": usage}
return billed
def _required_turn_id(message: Mapping[str, JsonValue], event: str) -> str:
value: Final = message.get("turnId")
if isinstance(value, bool) or not isinstance(value, (str, int)):
raise MuseProtocolError(f"{event} event has invalid turnId")
turn_id: Final = str(value).strip()
if not turn_id:
raise MuseProtocolError(f"{event} event has invalid turnId")
return turn_id
def _new_suffix(previous: str, current: str) -> str:
return current[len(previous) :] if current.startswith(previous) else ""
@dataclass(slots=True)
class _TurnState:
item_id: str
started: bool = False
start_emitted: bool = False
latest_partial: str | None = None
emitted_partial: str = ""
final_text: str | None = None
completed_emitted: bool = False
stopped: bool = False
stopped_emitted: bool = False
def finish(self, transcript: str) -> None:
self.final_text = transcript
self.stopped = True
def drain(
self, take_usage: Callable[[], RealtimeInputAudioTranscriptionUsage | None]
) -> Iterator[OpenAIRealtimeEvents]:
has_content: Final = self.latest_partial is not None or self.final_text is not None
if (self.started or has_content) and not self.start_emitted:
self.start_emitted = True
yield _speech_event("input_audio_buffer.speech_started", self.item_id)
if self.latest_partial is not None and self.final_text is None:
delta: Final = _new_suffix(self.emitted_partial, self.latest_partial)
if delta:
self.emitted_partial = self.latest_partial
yield _delta_event(self.item_id, delta)
if self.stopped and not self.stopped_emitted:
self.stopped_emitted = True
yield _speech_event("input_audio_buffer.speech_stopped", self.item_id)
if self.final_text is not None and self.stopped_emitted and not self.completed_emitted:
self.completed_emitted = True
yield _completed_event(self.item_id, self.final_text, take_usage())
class MuseEventTransformer:
def __init__(self, *, turn_limit: int = 128) -> None:
self._turns: dict[str, _TurnState] = {} # mutable-ok: bounded, insertion-ordered per-turn emit state
self._turn_limit: Final = turn_limit
self._active_turn_id: str | None = None
self._mode: MuseMode = "ENDPOINTING"
self._last_audio_processed_ms: float = 0.0
self._unbilled_seconds: float = 0.0
def configure(self, config: MuseSessionConfig) -> None:
self._mode = config.mode
def transform(self, message: Mapping[str, JsonValue]) -> tuple[OpenAIRealtimeEvents, ...]:
event_type: Final = message.get("type")
if event_type == "error":
return (error_event(_PROVIDER_ERROR_MESSAGE),)
if event_type == "audioProgress":
self._update_audio_progress(message)
return ()
turn: Final = self._apply_turn_event(event_type, message)
if turn is None:
return ()
return tuple(turn.drain(self.take_unbilled_usage))
def take_unbilled_usage(self) -> RealtimeInputAudioTranscriptionUsage | None:
seconds: Final = self._unbilled_seconds
if seconds <= 0:
return None
self._unbilled_seconds = 0.0
usage: Final[RealtimeInputAudioTranscriptionDurationUsage] = {"type": "duration", "seconds": seconds}
return usage
def _apply_turn_event(self, event_type: JsonValue | None, message: Mapping[str, JsonValue]) -> _TurnState | None:
match event_type:
case "speechStart":
return self._speech_start(message)
case "transcript":
return self._transcript(message)
case "speechEnd":
return self._speech_end(message)
case "speechComplete":
return self._speech_complete(message)
case _:
return None
def _turn(self, turn_id: str) -> _TurnState:
existing: Final = self._turns.get(turn_id)
if existing is not None:
return existing
created: Final = _TurnState(item_id=turn_id)
self._turns[turn_id] = created
if len(self._turns) > self._turn_limit:
del self._turns[next(iter(self._turns))]
return created
def _speech_start(self, message: Mapping[str, JsonValue]) -> _TurnState:
turn: Final = self._turn(_required_turn_id(message, "speechStart"))
if turn.stopped:
return turn
turn.started = True
self._active_turn_id = turn.item_id
return turn
def _transcript(self, message: Mapping[str, JsonValue]) -> _TurnState | None:
transcript: Final = message.get("transcript")
if not isinstance(transcript, str):
raise MuseProtocolError("transcript event has invalid transcript")
if not transcript and message.get("turnId") is None and self._active_turn_id is None:
return None
turn: Final = self._turn(self._transcript_turn_id(message))
if message.get("final") is True:
self._finish(turn, transcript)
elif turn.final_text is None:
turn.latest_partial = transcript
return turn
def _speech_end(self, message: Mapping[str, JsonValue]) -> _TurnState:
turn: Final = self._turn(_required_turn_id(message, "speechEnd"))
turn.stopped = True
return turn
def _speech_complete(self, message: Mapping[str, JsonValue]) -> _TurnState:
transcript: Final = message.get("transcript")
if not isinstance(transcript, str):
raise MuseProtocolError("speechComplete event has invalid transcript")
turn: Final = self._turn(_required_turn_id(message, "speechComplete"))
self._finish(turn, transcript)
return turn
def _finish(self, turn: _TurnState, transcript: str) -> None:
turn.finish(transcript)
self._release_active(turn)
def _release_active(self, turn: _TurnState) -> None:
if self._active_turn_id == turn.item_id:
self._active_turn_id = None
def _update_audio_progress(self, message: Mapping[str, JsonValue]) -> None:
processed_ms: Final = message.get("audioProcessedMs")
if (
isinstance(processed_ms, bool)
or not isinstance(processed_ms, (int, float))
or not math.isfinite(processed_ms)
or processed_ms < 0
):
raise MuseProtocolError("audioProgress event has invalid audioProcessedMs")
if processed_ms <= self._last_audio_processed_ms:
return
self._unbilled_seconds += (float(processed_ms) - self._last_audio_processed_ms) / 1000
self._last_audio_processed_ms = float(processed_ms)
def _transcript_turn_id(self, message: Mapping[str, JsonValue]) -> str:
if message.get("turnId") is not None:
return _required_turn_id(message, "transcript")
if self._active_turn_id is not None:
return self._active_turn_id
if self._mode != "PUSH_TO_TALK":
raise MuseProtocolError("transcript event is missing turnId outside an active turn")
turn_id: Final = f"item_{uuid.uuid4().hex}"
self._active_turn_id = turn_id
return turn_id
class MetaRealtimeConfig(BaseRealtimeConfig):
def __init__(
self,
*,
monotonic: Callable[[], float] = time.monotonic,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> None:
self._monotonic: Final = monotonic
self._sleep: Final = sleep
self._transformer: Final = MuseEventTransformer()
self._access_token: str | None = None
self._config: MuseSessionConfig | None = None
self._pending_audio: bytes = b""
self._end_stream_sent: bool = False
self._pacing_origin: float | None = None
self._sent_duration: float = 0.0
def validate_environment(
self,
headers: dict[str, str], # mutable-ok: BaseRealtimeConfig contract
model: str,
api_key: str | None = None,
) -> dict[str, str]: # mutable-ok: BaseRealtimeConfig contract
token: Final = api_key or get_secret_str("META_API_KEY")
if token is None:
raise ValueError("api_key is required for Meta API calls")
self._access_token = normalize_access_token(token)
return headers
def get_complete_url(self, api_base: str | None, model: str, api_key: str | None = None) -> str:
if _normalize_model(model) != MUSE_MODEL:
raise ValueError(f"Unsupported Meta realtime model: {model}")
return build_muse_realtime_url(api_base)
def is_setup_message(self, msg_obj: Mapping[str, object]) -> bool:
return "authorization" in msg_obj
def transform_session_created_event(
self,
model: str,
logging_session_id: str,
session_configuration_request: str | None = None,
) -> OpenAIRealtimeTranscriptionSessionCreated:
return session_created_event(_DEFAULT_SESSION_CONFIG, logging_session_id)
def transform_realtime_request(
self,
message: str,
model: str,
session_configuration_request: str | None = None,
) -> tuple[str | bytes, ...]:
request: Final = _json_object(message)
event_type: Final = request.get("type")
if event_type in ("session.update", "transcription_session.update"):
return self._configure(message, model)
if event_type == "input_audio_buffer.append":
return self._append_audio(request)
if event_type == "input_audio_buffer.commit":
return self._flush_audio(end_stream=self._require_config().mode == "PUSH_TO_TALK")
if event_type == "input_audio_buffer.end":
return self._flush_audio(end_stream=True)
if event_type == "input_audio_buffer.clear":
self._pending_audio = b""
return ()
verbose_logger.debug("Meta realtime: dropping unsupported client event %s", event_type)
return ()
async def pace_backend_send(self, message: bytes) -> None:
now: Final = self._monotonic()
origin: Final = self._pacing_origin
effective_origin: Final = (
now - self._sent_duration if origin is None or now > origin + self._sent_duration else origin
)
delay: Final = effective_origin + self._sent_duration - now
if delay > 0:
await self._sleep(delay)
self._pacing_origin = effective_origin
self._sent_duration += len(message) / self._require_config().bytes_per_second
def unbilled_usage_on_session_close(self, model: str) -> RealtimeInputAudioTranscriptionUsage | None:
return self._transformer.take_unbilled_usage()
def transform_realtime_response(
self,
message: str | bytes,
model: str,
logging_obj: LiteLLMLoggingObj,
realtime_response_transform_input: RealtimeResponseTransformInput,
) -> RealtimeResponseTypedDict:
payload: Final = message.decode("utf-8") if isinstance(message, bytes) else message
result: Final[RealtimeResponseTypedDict] = {
"response": list(self._backend_events(payload)), # mutable-ok: RealtimeResponseTypedDict.response is a list
"current_output_item_id": realtime_response_transform_input.get("current_output_item_id"),
"current_response_id": realtime_response_transform_input.get("current_response_id"),
"current_delta_chunks": realtime_response_transform_input.get("current_delta_chunks"),
"current_conversation_id": realtime_response_transform_input.get("current_conversation_id"),
"current_item_chunks": realtime_response_transform_input.get("current_item_chunks"),
"current_delta_type": realtime_response_transform_input.get("current_delta_type"),
"session_configuration_request": realtime_response_transform_input.get("session_configuration_request"),
}
return result
def _backend_events(self, payload: str) -> tuple[OpenAIRealtimeEvents, ...]:
frame: Final = _json_object(payload)
session_id: Final = frame.get("sessionId")
if session_id is None:
return self._transformer.transform(frame)
if not isinstance(session_id, str) or not session_id.strip():
raise MuseProtocolError("provider returned an invalid handshake response")
return (session_created_event(self._require_config(), session_id.strip()),)
def _configure(self, message: str, model: str) -> tuple[str, ...]:
if self._config is not None:
verbose_logger.debug("Meta realtime: ignoring session.update after the Muse handshake was sent")
return ()
access_token: Final = self._access_token
if access_token is None:
raise MuseProtocolError("Meta API key was not validated before the session was configured")
config: Final = parse_session_update(message, model)
self._config = config
self._transformer.configure(config)
return (json.dumps(config.handshake(access_token), separators=(",", ":")),)
def _append_audio(self, request: Mapping[str, JsonValue]) -> tuple[bytes, ...]:
config: Final = self._require_config()
encoded: Final = request.get("audio")
if not isinstance(encoded, str):
raise MuseProtocolError("Audio must be a base64 string")
if len(encoded) > config.max_encoded_append_bytes:
raise MuseProtocolError("Audio append exceeds the four-second backlog limit")
try:
audio: Final = base64.b64decode(encoded, validate=True)
except (binascii.Error, ValueError):
raise MuseProtocolError("Audio must be valid base64") from None
if len(audio) % 2:
raise MuseProtocolError("PCM16 audio must contain complete samples")
buffered: Final = self._pending_audio + audio
packet_end: Final = len(buffered) - len(buffered) % config.packet_bytes
self._pending_audio = buffered[packet_end:]
return tuple(
buffered[start : start + config.packet_bytes] for start in range(0, packet_end, config.packet_bytes)
)
def _flush_audio(self, *, end_stream: bool) -> tuple[str | bytes, ...]:
remainder: Final = self._pending_audio
self._pending_audio = b""
frames: Final[tuple[bytes, ...]] = (remainder,) if remainder else ()
if not end_stream or self._end_stream_sent:
return frames
self._end_stream_sent = True
return (*frames, _END_STREAM)
def _require_config(self) -> MuseSessionConfig:
if self._config is None:
raise MuseProtocolError("session.update must configure the Muse session before audio is sent")
return self._config

View file

@ -173,7 +173,7 @@
"api_key_env": "META_API_KEY",
"api_base_env": "META_API_BASE",
"base_class": "openai_gpt",
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages"]
"supported_endpoints": ["/v1/chat/completions", "/v1/responses", "/v1/messages", "/v1/realtime"]
},
"cognition": {
"base_url": "https://api.cognition.ai/v1",

View file

@ -4497,7 +4497,7 @@
},
"azure/eu/o1-2024-12-17": {
"cache_read_input_token_cost": 8.25e-06,
"deprecation_date": "2026-10-21",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 1.65e-05,
"litellm_provider": "azure",
"max_input_tokens": 200000,
@ -4543,7 +4543,7 @@
},
"azure/eu/o3-mini-2025-01-31": {
"cache_read_input_token_cost": 6.05e-07,
"deprecation_date": "2026-10-01",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 1.21e-06,
"input_cost_per_token_batches": 6.05e-07,
"litellm_provider": "azure",
@ -5513,7 +5513,8 @@
},
"azure/gpt-realtime-2025-08-28": {
"cache_creation_input_audio_token_cost": 4e-06,
"cache_read_input_token_cost": 4e-06,
"cache_read_input_audio_token_cost": 4e-07,
"cache_read_input_token_cost": 4e-07,
"deprecation_date": "2027-03-02",
"input_cost_per_audio_token": 3.2e-05,
"input_cost_per_image_token": 5e-06,
@ -5546,7 +5547,8 @@
},
"azure/gpt-realtime-1.5-2026-02-23": {
"cache_creation_input_audio_token_cost": 4e-06,
"cache_read_input_token_cost": 4e-06,
"cache_read_input_audio_token_cost": 4e-07,
"cache_read_input_token_cost": 4e-07,
"deprecation_date": "2027-08-24",
"input_cost_per_audio_token": 3.2e-05,
"input_cost_per_image_token": 5e-06,
@ -5683,6 +5685,7 @@
},
"azure/gpt-realtime-mini": {
"cache_creation_input_audio_token_cost": 3e-07,
"cache_read_input_audio_token_cost": 3e-07,
"cache_read_input_token_cost": 6e-08,
"input_cost_per_audio_token": 1e-05,
"input_cost_per_image_token": 8e-07,
@ -5715,6 +5718,7 @@
},
"azure/gpt-realtime-mini-2025-10-06": {
"cache_creation_input_audio_token_cost": 3e-07,
"cache_read_input_audio_token_cost": 3e-07,
"cache_read_input_token_cost": 6e-08,
"input_cost_per_audio_token": 1e-05,
"input_cost_per_image_token": 8e-07,
@ -7409,6 +7413,80 @@
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"azure/gpt-chat-latest": {
"cache_read_input_token_cost": 5e-07,
"deprecation_date": "2026-12-02",
"input_cost_per_token": 5e-06,
"litellm_provider": "azure",
"max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3e-05,
"reasoning_effort_levels": [
"medium"
],
"source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"azure/chat-latest": {
"cache_read_input_token_cost": 5e-07,
"deprecation_date": "2026-12-02",
"input_cost_per_token": 5e-06,
"litellm_provider": "azure",
"max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3e-05,
"reasoning_effort_levels": [
"medium"
],
"source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"azure/us/gpt-5.6": {
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_272k_tokens": 1.375e-05,
@ -7675,6 +7753,43 @@
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"azure/us/gpt-chat-latest": {
"cache_read_input_token_cost": 5.5e-07,
"deprecation_date": "2026-12-02",
"input_cost_per_token": 5.5e-06,
"litellm_provider": "azure",
"max_input_tokens": 272000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 3.3e-05,
"reasoning_effort_levels": [
"medium"
],
"source": "https://azure.microsoft.com/en-us/pricing/details/cognitive-services/openai-service/",
"supported_endpoints": [
"/v1/chat/completions",
"/v1/responses"
],
"supported_modalities": [
"text",
"image"
],
"supported_output_modalities": [
"text"
],
"supports_function_calling": true,
"supports_native_streaming": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_web_search": true
},
"azure/eu/gpt-5.6": {
"cache_creation_input_token_cost": 6.875e-06,
"cache_creation_input_token_cost_above_272k_tokens": 1.375e-05,
@ -8730,7 +8845,7 @@
"supports_function_calling": true
},
"azure/o1": {
"deprecation_date": "2026-10-21",
"deprecation_date": "2026-11-19",
"cache_read_input_token_cost": 7.5e-06,
"input_cost_per_token": 1.5e-05,
"litellm_provider": "azure",
@ -8748,7 +8863,7 @@
},
"azure/o1-2024-12-17": {
"cache_read_input_token_cost": 7.5e-06,
"deprecation_date": "2026-10-21",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 1.5e-05,
"litellm_provider": "azure",
"max_input_tokens": 200000,
@ -8825,7 +8940,7 @@
"supports_vision": false
},
"azure/o3": {
"deprecation_date": "2026-10-21",
"deprecation_date": "2026-11-19",
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "azure",
@ -8855,7 +8970,7 @@
"supports_vision": true
},
"azure/o3-2025-04-16": {
"deprecation_date": "2026-10-21",
"deprecation_date": "2026-11-19",
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 2e-06,
"litellm_provider": "azure",
@ -8886,7 +9001,7 @@
},
"azure/o3-deep-research": {
"cache_read_input_token_cost": 2.5e-06,
"deprecation_date": "2026-12-26",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 1e-05,
"litellm_provider": "azure",
"max_input_tokens": 200000,
@ -8923,7 +9038,7 @@
"supports_web_search": true
},
"azure/o3-mini": {
"deprecation_date": "2026-10-01",
"deprecation_date": "2026-11-19",
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 1.1e-06,
"litellm_provider": "azure",
@ -8940,7 +9055,7 @@
},
"azure/o3-mini-2025-01-31": {
"cache_read_input_token_cost": 5.5e-07,
"deprecation_date": "2026-10-01",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 1.1e-06,
"litellm_provider": "azure",
"max_input_tokens": 200000,
@ -8954,7 +9069,7 @@
"supports_vision": false
},
"azure/o3-pro": {
"deprecation_date": "2026-12-17",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 2e-05,
"input_cost_per_token_batches": 1e-05,
"litellm_provider": "azure",
@ -8985,7 +9100,7 @@
"supports_vision": true
},
"azure/o3-pro-2025-06-10": {
"deprecation_date": "2026-12-17",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 2e-05,
"input_cost_per_token_batches": 1e-05,
"litellm_provider": "azure",
@ -9016,7 +9131,7 @@
"supports_vision": true
},
"azure/o4-mini": {
"deprecation_date": "2026-10-16",
"deprecation_date": "2026-11-19",
"cache_read_input_token_cost": 2.75e-07,
"input_cost_per_token": 1.1e-06,
"litellm_provider": "azure",
@ -9047,7 +9162,7 @@
},
"azure/o4-mini-2025-04-16": {
"cache_read_input_token_cost": 2.75e-07,
"deprecation_date": "2026-10-16",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 1.1e-06,
"litellm_provider": "azure",
"max_input_tokens": 200000,
@ -9600,7 +9715,7 @@
},
"azure/us/o1-2024-12-17": {
"cache_read_input_token_cost": 8.25e-06,
"deprecation_date": "2026-10-21",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 1.65e-05,
"litellm_provider": "azure",
"max_input_tokens": 200000,
@ -9645,7 +9760,7 @@
"supports_vision": false
},
"azure/us/o3-2025-04-16": {
"deprecation_date": "2026-10-21",
"deprecation_date": "2026-11-19",
"cache_read_input_token_cost": 5.5e-07,
"input_cost_per_token": 2.2e-06,
"litellm_provider": "azure",
@ -9676,7 +9791,7 @@
},
"azure/us/o3-mini-2025-01-31": {
"cache_read_input_token_cost": 6.05e-07,
"deprecation_date": "2026-10-01",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 1.21e-06,
"input_cost_per_token_batches": 6.05e-07,
"litellm_provider": "azure",
@ -9693,7 +9808,7 @@
},
"azure/us/o4-mini-2025-04-16": {
"cache_read_input_token_cost": 3.1e-07,
"deprecation_date": "2026-10-16",
"deprecation_date": "2026-11-19",
"input_cost_per_token": 1.21e-06,
"litellm_provider": "azure",
"max_input_tokens": 200000,
@ -14615,7 +14730,7 @@
},
"computer-use-preview": {
"input_cost_per_token": 3e-06,
"litellm_provider": "azure",
"litellm_provider": "openai",
"max_input_tokens": 8192,
"max_output_tokens": 1024,
"max_tokens": 1024,
@ -14633,12 +14748,14 @@
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_pdf_input": true,
"supports_prompt_caching": false,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_response_schema": false,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true
"supports_vision": true,
"source": "https://platform.openai.com/docs/models/computer-use-preview"
},
"dall-e-2": {
"deprecation_date": "2026-05-12",
@ -17597,6 +17714,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_tool_choice": true
},
"databricks/databricks-claude-fable-5": {
@ -17622,6 +17740,7 @@
"supports_mid_conversation_system": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": false,
@ -17651,6 +17770,7 @@
"supports_mid_conversation_system": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true,
@ -17676,6 +17796,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_tool_choice": true,
"prompt_cache_min_tokens": 4096
},
@ -17699,6 +17820,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_tool_choice": true,
"prompt_cache_min_tokens": 1024
},
@ -17722,6 +17844,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_tool_choice": true,
"prompt_cache_min_tokens": 1024
},
@ -17745,6 +17868,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_tool_choice": true,
"supports_output_config": true,
"prompt_cache_min_tokens": 4096
@ -17770,6 +17894,7 @@
"supports_legacy_thinking": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_tool_choice": true,
"prompt_cache_min_tokens": 4096
},
@ -17795,6 +17920,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true
@ -17822,6 +17948,7 @@
"supports_mid_conversation_system": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true
@ -17849,6 +17976,7 @@
"supports_mid_conversation_system": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true
@ -17875,6 +18003,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_tool_choice": true
},
"databricks/databricks-claude-sonnet-4-1": {
@ -17897,6 +18026,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_tool_choice": true
},
"databricks/databricks-claude-sonnet-4-5": {
@ -17919,6 +18049,7 @@
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_tool_choice": true,
"prompt_cache_min_tokens": 1024
},
@ -17943,6 +18074,7 @@
"supports_legacy_thinking": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_tool_choice": true,
"prompt_cache_min_tokens": 1024
},
@ -17969,6 +18101,7 @@
"supports_mid_conversation_system": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_anthropic_thinking_payload": true,
"supports_sampling_params": false,
"supports_tool_choice": true,
"supports_vision": true
@ -18047,6 +18180,7 @@
"output_dbu_cost_per_token": 3.5714e-05,
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
"supports_function_calling": true,
"supports_anthropic_thinking_payload": true,
"supports_prompt_caching": true,
"supports_tool_choice": true
},
@ -18067,6 +18201,7 @@
"output_dbu_cost_per_token": 0.000142857,
"source": "https://www.databricks.com/product/pricing/proprietary-foundation-model-serving",
"supports_function_calling": true,
"supports_anthropic_thinking_payload": true,
"supports_prompt_caching": true,
"supports_tool_choice": true
},
@ -23033,88 +23168,208 @@
"output_cost_per_token": 0.0,
"source": "https://fireworks.ai/pricing"
},
"friendliai/meta-llama-3.1-70b-instruct": {
"input_cost_per_token": 6e-07,
"litellm_provider": "friendliai",
"max_input_tokens": 8192,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 6e-07,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true
},
"friendliai/meta-llama-3.1-8b-instruct": {
"input_cost_per_token": 1e-07,
"litellm_provider": "friendliai",
"max_input_tokens": 8192,
"max_output_tokens": 8192,
"max_tokens": 8192,
"mode": "chat",
"output_cost_per_token": 1e-07,
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_system_messages": true,
"supports_tool_choice": true
},
"friendliai/zai-org/GLM-5.3-Flash": {
"litellm_provider": "friendliai",
"supports_reasoning": true,
"supports_function_calling": true,
"max_input_tokens": 1048576,
"max_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"input_cost_per_token": 1.5e-07,
"output_cost_per_token": 5e-07,
"cache_read_input_token_cost": 3e-08,
"supports_prompt_caching": true,
"supports_reasoning": true,
"reasoning_effort_levels": [
"low",
"high",
"max"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_native_structured_output": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"mode": "chat",
"comment": "Native multimodal GLM model for efficient coding and long-horizon agent tasks",
"source": "https://api.friendli.ai/serverless/v1/models",
"supports_vision": true,
"supports_image_input": true,
"supports_video_input": true
"supports_video_input": true,
"mode": "chat",
"comment": "Native multimodal GLM model for efficient coding and long-horizon agent tasks",
"source": "https://api.friendli.ai/serverless/v1/models"
},
"friendliai/zai-org/GLM-5.3": {
"litellm_provider": "friendliai",
"supports_reasoning": true,
"supports_function_calling": true,
"max_input_tokens": 1048576,
"max_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"input_cost_per_token": 1.26e-06,
"output_cost_per_token": 3.96e-06,
"cache_read_input_token_cost": 2.34e-07,
"supports_prompt_caching": true,
"supports_reasoning": true,
"reasoning_effort_levels": [
"low",
"high",
"max"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_native_structured_output": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": false,
"supports_image_input": false,
"supports_video_input": false,
"mode": "chat",
"comment": "Flagship GLM model for long-horizon coding, agents, and complex project delivery",
"source": "https://api.friendli.ai/serverless/v1/models",
"source": "https://api.friendli.ai/serverless/v1/models"
},
"friendliai/google/gemma-4-31B-it": {
"litellm_provider": "friendliai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"input_cost_per_token": 1.4e-07,
"output_cost_per_token": 4e-07,
"supports_prompt_caching": false,
"supports_reasoning": true,
"reasoning_effort_levels": [],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_native_structured_output": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_image_input": true,
"supports_video_input": false,
"mode": "chat",
"comment": "Largest Gemma 4 instruction model for open, self-hosted chat and reasoning",
"source": "https://api.friendli.ai/serverless/v1/models"
},
"friendliai/zai-org/GLM-5.2": {
"litellm_provider": "friendliai",
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_tokens": 1048576,
"input_cost_per_token": 1.4e-06,
"output_cost_per_token": 4.4e-06,
"cache_read_input_token_cost": 2.6e-07,
"supports_prompt_caching": true,
"supports_reasoning": true,
"reasoning_effort_levels": [
"high",
"max"
],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_native_structured_output": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": false,
"supports_image_input": false
"supports_image_input": false,
"supports_video_input": false,
"mode": "chat",
"comment": "Open flagship GLM for long-horizon coding agents and million-token context work",
"source": "https://api.friendli.ai/serverless/v1/models"
},
"friendliai/LGAI-EXAONE/K-EXAONE-2.0-750B-A37B": {
"litellm_provider": "friendliai",
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_tokens": 262144,
"input_cost_per_token": 6e-07,
"output_cost_per_token": 2.4e-06,
"cache_read_input_token_cost": 1.2e-07,
"supports_prompt_caching": true,
"supports_reasoning": true,
"reasoning_effort_levels": [],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_native_structured_output": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": false,
"supports_image_input": false,
"supports_video_input": false,
"mode": "chat",
"comment": "Frontier-scale multilingual language model developed by LG AI Research",
"deprecation_date": "2026-09-06",
"source": "https://api.friendli.ai/serverless/v1/models"
},
"friendliai/deepseek-ai/DeepSeek-V3.2": {
"litellm_provider": "friendliai",
"max_input_tokens": 163840,
"max_output_tokens": 163840,
"max_tokens": 163840,
"input_cost_per_token": 5e-07,
"output_cost_per_token": 1.5e-06,
"cache_read_input_token_cost": 2.5e-07,
"supports_prompt_caching": true,
"supports_reasoning": true,
"reasoning_effort_levels": [],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_native_structured_output": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": false,
"supports_image_input": false,
"supports_video_input": false,
"mode": "chat",
"comment": "DeepSeek chat model for instruction following, coding, and analysis",
"source": "https://api.friendli.ai/serverless/v1/models"
},
"friendliai/MiniMaxAI/MiniMax-M2.5": {
"litellm_provider": "friendliai",
"max_input_tokens": 196608,
"max_output_tokens": 196608,
"max_tokens": 196608,
"input_cost_per_token": 3e-07,
"output_cost_per_token": 1.2e-06,
"cache_read_input_token_cost": 6e-08,
"supports_prompt_caching": true,
"supports_reasoning": true,
"reasoning_effort_levels": [],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_native_structured_output": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": false,
"supports_image_input": false,
"supports_video_input": false,
"mode": "chat",
"comment": "Prior MiniMax coding model for agent workflows, office edits, and automation",
"source": "https://api.friendli.ai/serverless/v1/models"
},
"friendliai/zai-org/GLM-5.1": {
"litellm_provider": "friendliai",
"max_input_tokens": 202752,
"max_output_tokens": 202752,
"max_tokens": 202752,
"input_cost_per_token": 1.4e-06,
"output_cost_per_token": 4.4e-06,
"cache_read_input_token_cost": 2.6e-07,
"supports_prompt_caching": true,
"supports_reasoning": true,
"reasoning_effort_levels": [],
"supports_function_calling": true,
"supports_parallel_function_calling": true,
"supports_response_schema": true,
"supports_native_structured_output": true,
"supports_system_messages": true,
"supports_tool_choice": true,
"supports_vision": false,
"supports_image_input": false,
"supports_video_input": false,
"mode": "chat",
"comment": "Strong GLM coding model for agentic engineering, terminals, and repository generation",
"source": "https://api.friendli.ai/serverless/v1/models"
},
"ft:babbage-002": {
"deprecation_date": "2026-10-23",
@ -32510,6 +32765,7 @@
},
"gpt-realtime": {
"cache_creation_input_audio_token_cost": 4e-07,
"cache_read_input_audio_token_cost": 4e-07,
"cache_read_input_token_cost": 4e-07,
"deprecation_date": "2027-01-20",
"input_cost_per_audio_token": 3.2e-05,
@ -32543,6 +32799,7 @@
},
"gpt-realtime-1.5": {
"cache_creation_input_audio_token_cost": 4e-07,
"cache_read_input_audio_token_cost": 4e-07,
"cache_read_input_token_cost": 4e-07,
"input_cost_per_audio_token": 3.2e-05,
"input_cost_per_image_token": 5e-06,
@ -32679,6 +32936,7 @@
"gpt-realtime-mini": {
"cache_creation_input_audio_token_cost": 3e-07,
"cache_read_input_audio_token_cost": 3e-07,
"cache_read_input_token_cost": 6e-08,
"deprecation_date": "2027-01-20",
"input_cost_per_audio_token": 1e-05,
"input_cost_per_token": 6e-07,
@ -32710,6 +32968,7 @@
},
"gpt-realtime-2025-08-28": {
"cache_creation_input_audio_token_cost": 4e-07,
"cache_read_input_audio_token_cost": 4e-07,
"cache_read_input_token_cost": 4e-07,
"deprecation_date": "2027-01-20",
"input_cost_per_audio_token": 3.2e-05,
@ -34717,6 +34976,22 @@
"supports_web_search": true,
"supports_xhigh_reasoning_effort": true
},
"meta/muse-voice-transcribe-1.0": {
"input_cost_per_second": 0.00005,
"litellm_provider": "meta",
"mode": "audio_transcription",
"source": "https://dev.meta.ai/docs/speech-to-text",
"supported_endpoints": [
"/v1/realtime"
],
"supported_modalities": [
"audio"
],
"supported_output_modalities": [
"text"
],
"supports_audio_input": true
},
"meta_llama/Llama-3.3-70B-Instruct": {
"litellm_provider": "meta_llama",
"max_input_tokens": 128000,
@ -39019,7 +39294,9 @@
"supports_max_reasoning_effort": true,
"supports_tool_choice": true,
"supports_vision": true,
"prompt_cache_min_tokens": 4096
"prompt_cache_min_tokens": 4096,
"supports_response_schema": true,
"source": "https://openrouter.ai/api/v1/models"
},
"openrouter/anthropic/claude-sonnet-4.5": {
"input_cost_per_image": 0.0048,
@ -39167,18 +39444,20 @@
},
"openrouter/deepseek/deepseek-v3.2": {
"input_cost_per_token": 2.69e-07,
"input_cost_per_token_cache_hit": 2.8e-08,
"input_cost_per_token_cache_hit": 1.345e-07,
"litellm_provider": "openrouter",
"max_input_tokens": 163840,
"max_output_tokens": 163840,
"max_tokens": 163840,
"max_output_tokens": 65536,
"max_tokens": 65536,
"mode": "chat",
"output_cost_per_token": 4e-07,
"supports_assistant_prefill": true,
"supports_function_calling": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_tool_choice": true
"supports_tool_choice": true,
"supports_response_schema": true,
"source": "https://openrouter.ai/api/v1/models"
},
"openrouter/deepseek/deepseek-v3.2-exp": {
"input_cost_per_token": 2.7e-07,
@ -43488,6 +43767,7 @@
"supports_tool_choice": true
},
"together_ai/openai/gpt-oss-20b": {
"deprecation_date": "2026-09-14",
"input_cost_per_token": 5e-08,
"litellm_provider": "together_ai",
"max_input_tokens": 131072,
@ -43780,6 +44060,7 @@
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/google/gemma-4-31B-it": {
"deprecation_date": "2026-09-14",
"input_cost_per_token": 3.9e-07,
"litellm_provider": "together_ai",
"max_input_tokens": 262144,
@ -43794,6 +44075,7 @@
"supports_vision": true
},
"together_ai/intfloat/multilingual-e5-large-instruct": {
"deprecation_date": "2026-09-14",
"input_cost_per_token": 2e-08,
"litellm_provider": "together_ai",
"max_input_tokens": 514,
@ -43906,6 +44188,7 @@
"supports_tool_choice": true
},
"together_ai/thinkingmachines/Inkling-Small": {
"deprecation_date": "2026-09-14",
"cache_read_input_token_cost": 1e-07,
"input_cost_per_token": 5e-07,
"litellm_provider": "together_ai",
@ -57470,6 +57753,14 @@
"model_info": {
"supports_reasoning": true
}
},
{
"name": "openai-reasoning-family-baseline",
"pattern": "^(?!.*search-api)(?:[a-z0-9_.-]+/)*(?:ft:)?(?:o[1-9]\\d*(?![a-z0-9])|gpt-[5-9](?:\\.\\d+)?(?![0-9.])|(?:gpt-\\d+(?:\\.\\d+)?(?:-[a-z0-9]+)*-)?(?:codex|deep-research|chat-latest)(?![a-z0-9]))",
"description": "OpenAI reasoning families by id shape, under any provider namespace and with an optional ft: prefix: the o-series (o1, o3-pro, o4-mini), gpt-5 through gpt-9 majors including dotted minors and suffixed variants (gpt-5.5-cyber, gpt-6-astra), and the codex, deep-research and chat-latest lines when standalone or on a gpt base. gpt-5-search-api is excluded because it is a search-only surface. Every model here is a reasoning model, and the Responses API drops the caller's reasoning param for any mapped OpenAI model whose info lacks supports_reasoning, so an id the registry has not named yet keeps its reasoning settings instead of silently losing them. Rules lose to exact entries. Carries no mode and no pricing, so cost stays on the standard unpriced behavior.",
"model_info": {
"supports_reasoning": true
}
}
]
},
@ -59098,9 +59389,9 @@
"litellm_provider": "wandb",
"mode": "chat",
"supports_reasoning": true,
"input_cost_per_token": 0.00000131,
"output_cost_per_token": 0.00000396,
"cache_read_input_token_cost": 0.000000044,
"input_cost_per_token": 1.31e-06,
"output_cost_per_token": 3.96e-06,
"cache_read_input_token_cost": 4.4e-08,
"supports_prompt_caching": true,
"source": "https://wandb.ai/site/pricing/tokens/"
},
@ -59108,9 +59399,9 @@
"litellm_provider": "wandb",
"mode": "chat",
"supports_reasoning": true,
"input_cost_per_token": 0.0000001,
"output_cost_per_token": 0.00000015,
"cache_read_input_token_cost": 0.00000005,
"input_cost_per_token": 1e-07,
"output_cost_per_token": 1.5e-07,
"cache_read_input_token_cost": 5e-08,
"supports_prompt_caching": true,
"source": "https://wandb.ai/site/pricing/tokens/"
},
@ -60832,6 +61123,7 @@
"source": "https://docs.together.ai/docs/serverless-models"
},
"together_ai/moonshotai/Kimi-K2.6": {
"deprecation_date": "2026-08-19",
"input_cost_per_token": 1.2e-06,
"output_cost_per_token": 4.5e-06,
"cache_read_input_token_cost": 2e-07,
@ -60858,6 +61150,7 @@
"source": "https://api.together.xyz/v1/models"
},
"together_ai/zai-org/GLM-5": {
"deprecation_date": "2026-06-22",
"input_cost_per_token": 1e-06,
"output_cost_per_token": 3.2e-06,
"litellm_provider": "together_ai",
@ -60866,6 +61159,7 @@
"source": "https://api.together.xyz/v1/models"
},
"together_ai/zai-org/GLM-5.1": {
"deprecation_date": "2026-07-10",
"input_cost_per_token": 1.4e-06,
"output_cost_per_token": 4.4e-06,
"cache_read_input_token_cost": 2.6e-07,
@ -60883,6 +61177,7 @@
"source": "https://api.together.xyz/v1/models"
},
"together_ai/Qwen/Qwen3-Coder-Next-FP8": {
"deprecation_date": "2026-05-14",
"input_cost_per_token": 5e-07,
"output_cost_per_token": 1.2e-06,
"litellm_provider": "together_ai",
@ -60891,6 +61186,7 @@
"source": "https://api.together.xyz/v1/models"
},
"together_ai/Qwen/Qwen3-VL-32B-Instruct": {
"deprecation_date": "2026-02-25",
"input_cost_per_token": 5e-07,
"output_cost_per_token": 1.5e-06,
"litellm_provider": "together_ai",
@ -60899,6 +61195,7 @@
"source": "https://api.together.xyz/v1/models"
},
"together_ai/Qwen/Qwen3-VL-8B-Instruct": {
"deprecation_date": "2026-04-16",
"input_cost_per_token": 1.8e-07,
"output_cost_per_token": 6.8e-07,
"litellm_provider": "together_ai",
@ -60931,6 +61228,7 @@
"source": "https://api.together.xyz/v1/models"
},
"together_ai/Qwen/QwQ-32B": {
"deprecation_date": "2025-11-13",
"input_cost_per_token": 1.2e-06,
"output_cost_per_token": 1.2e-06,
"litellm_provider": "together_ai",

View file

@ -28,6 +28,7 @@ from litellm.llms.base_llm.ocr.transformation import (
from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler
from litellm.ocr.input import FileReader
from litellm.types.router import GenericLiteLLMParams
from litellm.types.utils import CustomPricingLiteLLMParams
from litellm.utils import ProviderConfigManager, client
base_llm_http_handler: Final = BaseLLMHTTPHandler()
@ -149,6 +150,7 @@ def _prepare_ocr_request(
litellm_params={
"litellm_call_id": litellm_call_id,
"api_base": resolved_api_base,
**litellm_params.model_dump(include=frozenset(CustomPricingLiteLLMParams.model_fields), exclude_none=True),
},
custom_llm_provider=custom_llm_provider,
)

View file

@ -67,9 +67,8 @@ module materially harder to understand.
auth, SSE, streamable HTTP, and stdio as separate flows. Do not collapse them
behind a single generic branch unless tests prove every mode still behaves
correctly.
- Be especially careful with `available_on_public_internet: false` combined with
`delegate_auth_to_upstream: true`. The local `CLAUDE.md` explains the anonymous
upstream PKCE path that must remain intentional.
- Be especially careful with legacy `delegate_auth_to_upstream: true`. The local
`CLAUDE.md` explains its admitted replacement and public discovery contract.
- Keep database-backed fields in sync across migrations, typed models under
`litellm/types/mcp.py` or `litellm/types/mcp_server/`, config loading, this
package, and dashboard state when the field is user-visible.

View file

@ -1 +1 @@
MCP note: **`available_on_public_internet: false` with `delegate_auth_to_upstream: true` (oauth2, interactive - not `client_credentials`)** - LiteLLM still allows the anonymous upstream PKCE path (no proxy API key for `/authorize` and matching MCP routes). The internal-only flag mainly affects other surfaces (e.g. IP-based discovery). Rely on the upstream IdP and network policy; the dashboard shows a warning when both are set, and the proxy logs a warning when the server is loaded from config or the database
MCP note: **`auth_type: oauth2` with `delegate_auth_to_upstream: true` is deprecated** - LiteLLM admission is required for matching MCP routes. Use `auth_type: oauth_delegate` for client-forwarded OAuth. OAuth discovery endpoints stay public so clients can start the RFC 9728 flow

View file

@ -129,10 +129,9 @@ def _is_mcp_passthrough_cold_start(mcp_servers: list[str] | None, client_ip: str
spec-compliant WWW-Authenticate challenge instead of surfacing a generic
admission error.
Uses "all" semantics (mirrors
:meth:`MCPRequestHandler._target_servers_delegate_auth_to_upstream`): one
non-passthrough target in a co-targeted set must not flip the bypass open
for the others. Fails closed when any target cannot be resolved."""
Uses "all" semantics: one non-passthrough target in a co-targeted set must
not flip the bypass open for the others. Fails closed when any target
cannot be resolved."""
if not mcp_servers:
return False
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
@ -146,6 +145,27 @@ def _is_mcp_passthrough_cold_start(mcp_servers: list[str] | None, client_ip: str
return True
def _is_legacy_delegate_cold_start(mcp_servers: list[str] | None, client_ip: str | None) -> bool:
"""Allow only credential-free legacy delegates to reach the route's OAuth challenge."""
if not mcp_servers:
return False
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
for name in mcp_servers:
server = global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip)
if server is None or server.auth_type != MCPAuth.oauth2:
return False
if server.delegate_auth_to_upstream is not True:
return False
if MCPServerManager.effective_oauth2_flow(server) == "client_credentials":
return False
return True
def _is_litellm_auth_admission_error(exc: Exception) -> bool:
if isinstance(exc, HTTPException):
return exc.status_code == 401
@ -277,9 +297,18 @@ def _admission_failure_fallback(
mcp_servers_from_path is not None
and not _has_client_supplied_mcp_auth(mcp_auth_header, mcp_server_auth_headers)
and _is_litellm_auth_admission_error(exc)
and _is_mcp_passthrough_cold_start(
mcp_servers_from_path,
client_ip=IPAddressUtils.get_mcp_client_ip(request),
and (
_is_mcp_passthrough_cold_start(
mcp_servers_from_path,
client_ip=IPAddressUtils.get_mcp_client_ip(request),
)
or (
not bearer_presented
and _is_legacy_delegate_cold_start(
mcp_servers_from_path,
client_ip=IPAddressUtils.get_mcp_client_ip(request),
)
)
)
):
verbose_logger.debug("MCP pass-through cold start: deferring admission to route 401 emitter")
@ -434,22 +463,6 @@ class MCPRequestHandler:
api_key=f"Bearer {_get_bearer_token_or_received_api_key(litellm_api_key)}",
request=request,
)
elif MCPRequestHandler._target_servers_delegate_auth_to_upstream(
path=request_route,
mcp_servers=mcp_servers,
client_ip=IPAddressUtils.get_mcp_client_ip(request),
):
# Operator opted this oauth2 server into upstream-delegated auth: the
# client authenticates directly with the upstream MCP server, so any
# Authorization bearer is an upstream token, never a LiteLLM key. Skip
# LiteLLM validation entirely — covering both the no-credential
# discovery request and the authenticated call carrying the upstream
# bearer — so a tool call that succeeds never carries a phantom 401
# auth span; the bearer is forwarded upstream unchanged. Gated by
# _target_servers_delegate_auth_to_upstream, which returns True only
# when EVERY target is auth_type=oauth2 with delegate_auth_to_upstream
# set; fails closed otherwise.
validated_user_api_key_auth = UserAPIKeyAuth()
elif MCPRequestHandler._target_servers_are_true_passthrough(
path=request_route,
mcp_servers=mcp_servers,
@ -660,64 +673,6 @@ class MCPRequestHandler:
return [single_server_match.group(1)]
return [servers_and_path]
@staticmethod
def _target_servers_delegate_auth_to_upstream(
path: str, mcp_servers: list[str] | None, client_ip: str | None
) -> bool:
"""
True only when EVERY MCP server the request targets is configured for
``auth_type == oauth2`` AND has ``delegate_auth_to_upstream=True``.
Fails closed when any target does not opt in or cannot be resolved.
Used by :meth:`process_mcp_request` to skip LiteLLM API-key/SSO auth
entirely (PKCE passthrough) so the client authenticates directly with
the upstream MCP server. Mixed-target requests (e.g. one delegated +
one non-delegated server) fall back to normal LiteLLM auth.
"""
# Inline imports avoid a circular dependency: mcp_server_manager imports
# from this module.
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
MCPServerManager,
global_mcp_server_manager,
)
from litellm.types.mcp import MCPAuth
# Must mirror the downstream header-vs-path override
# (``extract_mcp_auth_context``) or an attacker could set
# ``x-mcp-servers`` to a delegate-enabled server while the URL path
# targets a non-delegate server, skipping LiteLLM auth for it.
target_names: Final = MCPRequestHandler._resolve_target_server_names(path=path, mcp_servers_header=mcp_servers)
if not target_names:
return False
for name in target_names:
server = global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip)
if server is None or server.auth_type != MCPAuth.oauth2:
return False
# `is True` is intentional: opt-in must be an explicit boolean
# True. A MagicMock attribute (in tests) or any other truthy
# non-bool must not silently enable the bypass.
if getattr(server, "delegate_auth_to_upstream", False) is not True:
return False
# Never delegate for M2M (client_credentials) servers: LiteLLM
# fetches the upstream token automatically using stored credentials,
# so allowing anonymous bypass would let any external caller invoke
# tools authenticated as LiteLLM's service account.
#
# Resolve the flow rather than reading has_client_credentials directly:
# this is a security gate, and a legacy row whose oauth2_flow was never
# stamped still carries the M2M credential shape (client_id/secret +
# token_url, no authorization_url). Treating an unstamped-but-M2M-shaped
# row as non-M2M here would reopen the anonymous bypass the explicit
# column no longer closes on its own. Shares the one resolution helper
# with the egress backstop and the anonymous-delegate allowlist; all fail
# closed on the ambiguous shape and are removed together once no null rows
# remain. A pure-PKCE delegate server (no stored credentials) resolves to a
# non-M2M flow and keeps its bypass.
if MCPServerManager.effective_oauth2_flow(server) == "client_credentials":
return False
return True
@staticmethod
def _target_servers_are_true_passthrough(path: str, mcp_servers: list[str] | None, client_ip: str | None) -> bool:
"""
@ -726,7 +681,7 @@ class MCPRequestHandler:
Used by :meth:`process_mcp_request` to skip LiteLLM admission auth entirely: the gateway is a
transparent proxy and the caller's ``Authorization`` is an upstream token, never a LiteLLM key.
Mirrors :meth:`_target_servers_delegate_auth_to_upstream`; a mixed-target request keeps normal auth.
A mixed-target request keeps normal auth.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,

View file

@ -1055,7 +1055,7 @@ def _should_strip_caller_authorization(
pass-through cold-start case (RFC 9728) the bearer in
``Authorization`` is the upstream OAuth token and must be
forwarded, so we keep it.
- **oauth_delegate servers**: admission always runs and there is no
- **Delegated OAuth servers**: admission always runs and there is no
anonymous path, so the caller's separate ``Authorization`` is
forwarded only when a distinct ``x-litellm-api-key`` carried
admission. Without that header the ``Authorization`` *was* the
@ -1075,12 +1075,17 @@ def _should_strip_caller_authorization(
# upstream — it would override another user's stored credential. Delegate and
# pass-through return None from to_server_spec and keep forwarding the bearer.
return True
if not (mcp_server.is_oauth_passthrough or mcp_server.is_oauth_delegate):
is_delegated_oauth: Final = mcp_server.is_oauth_delegate or (
mcp_server.auth_type == MCPAuth.oauth2 and mcp_server.delegate_auth_to_upstream
)
if not (mcp_server.is_oauth_passthrough or is_delegated_oauth):
return False
has_explicit_litellm_admission_header: Final = _has_explicit_litellm_admission_header(raw_headers)
if mcp_server.is_oauth_delegate:
return not has_explicit_litellm_admission_header
if is_delegated_oauth:
return not has_explicit_litellm_admission_header or _authorization_is_litellm_admission_credential(
raw_headers, user_api_key_auth
)
return _authorization_is_litellm_admission_credential(raw_headers, user_api_key_auth) or (
user_api_key_auth is None and not has_explicit_litellm_admission_header
)
@ -1107,15 +1112,11 @@ def _authorization_is_litellm_admission_credential(
That is the case when no usable ``x-litellm-api-key`` was sent, or when the client repeated the
same key in both headers.
"""
if user_api_key_auth is None or not user_api_key_auth.api_key:
return False
admission_header: Final = _raw_header_value(raw_headers, "x-litellm-api-key")
if not admission_header:
return True
authorization: Final = _raw_header_value(raw_headers, "authorization")
return authorization is not None and strip_auth_scheme(authorization, "Bearer") == strip_auth_scheme(
admission_header, "Bearer"
)
if admission_header and authorization:
return strip_auth_scheme(authorization, "Bearer") == strip_auth_scheme(admission_header, "Bearer")
return bool(user_api_key_auth and user_api_key_auth.api_key and not admission_header)
def _format_byok_openapi_auth_header(mcp_server: MCPServer, mcp_auth_header: str) -> str:
@ -1453,22 +1454,19 @@ def _warn_on_server_name_fields(
_warn("server_name", server_name)
def _warn_internal_delegate_pkce_if_applicable(server: MCPServer, *, source: str) -> None:
"""Surface internal + upstream PKCE delegate in logs for operators."""
def _warn_legacy_delegate_auth_if_applicable(server: MCPServer, *, source: str) -> None:
"""Direct legacy delegated OAuth configurations to the admitted replacement."""
if server.auth_type != MCPAuth.oauth2:
return
if getattr(server, "delegate_auth_to_upstream", False) is not True:
return
if getattr(server, "available_on_public_internet", True):
return
if server.has_client_credentials:
return
label: Final = get_server_prefix(server)
verbose_logger.warning(
"MCP server %r (id=%s, source=%s): internal-only (available_on_public_internet=false) "
"with delegate_auth_to_upstream=true. Anonymous callers can reach the upstream OAuth2 "
"/authorize flow and complete PKCE without a LiteLLM API key session; ensure the "
"upstream IdP and network enforce your access policy.",
"MCP server %r (id=%s, source=%s) uses deprecated auth_type=oauth2 with "
"delegate_auth_to_upstream=true. LiteLLM admission is now required; migrate to "
"auth_type=oauth_delegate for client-forwarded OAuth.",
label,
server.server_id,
source,
@ -2640,7 +2638,7 @@ class MCPServerManager:
oauth_identity_binding=server_config.get("oauth_identity_binding", None),
)
self._assign_unique_short_prefix(new_server)
_warn_internal_delegate_pkce_if_applicable(new_server, source="config")
_warn_legacy_delegate_auth_if_applicable(new_server, source="config")
_warn_config_id_jag_server_outruns_sso(new_server)
self._invalidate_discovery_lists(server_id)
self.config_mcp_servers[server_id] = new_server
@ -3185,7 +3183,7 @@ class MCPServerManager:
timeout=getattr(mcp_server, "timeout", None),
max_concurrent_requests=getattr(mcp_server, "max_concurrent_requests", None),
)
_warn_internal_delegate_pkce_if_applicable(new_server, source="database")
_warn_legacy_delegate_auth_if_applicable(new_server, source="database")
self._set_oauth_discovery_deferred(
new_server.server_id,
_requires_oauth_discovery(server_url, use_issuer_anchor, new_server),
@ -3479,10 +3477,6 @@ class MCPServerManager:
)
)
# For anonymous callers (no user_id, no role), also surface any
# servers the operator has opted into upstream-delegated auth.
# These servers handle their own auth at the upstream level, so
# LiteLLM granting access here does not bypass any security gate.
is_anonymous: Final = not (
user_api_key_auth
and (
@ -3492,23 +3486,12 @@ class MCPServerManager:
)
)
if is_anonymous:
delegate_server_ids: Final = [
passthrough_server_ids: Final = [
server.server_id
for server in self.get_registry().values()
if (
getattr(server, "auth_type", None) == MCPAuth.oauth2
and getattr(server, "delegate_auth_to_upstream", False) is True
# M2M servers must not be exposed anonymously: an
# unauthenticated caller would get LiteLLM to proxy tool
# calls using its stored client_credentials. Resolve the flow
# rather than reading has_client_credentials so an unstamped
# M2M-shape row (null column, verbatim-read as non-M2M) still
# fails closed here, matching the anonymous-delegate auth gate.
and MCPServerManager.effective_oauth2_flow(server) != "client_credentials"
)
or getattr(server, "auth_type", None) == MCPAuth.true_passthrough
if getattr(server, "auth_type", None) == MCPAuth.true_passthrough
]
combined_servers.update(delegate_server_ids)
combined_servers.update(passthrough_server_ids)
restrict_allow_all: Final = (
resolved_general_settings.get("mcp_allow_all_keys_respects_mcp_scope", False)

View file

@ -4257,20 +4257,6 @@ if MCP_AVAILABLE:
return None
return _get_authorization_header_from_scope(scope)
def _is_delegate_upstream_probe_target(server: MCPServer) -> bool:
"""Whether ``server`` is an interactive delegate-auth server whose client-supplied
token should be preflighted upstream.
Mirrors the anonymous-delegate gate in ``get_allowed_mcp_servers``: the flow is
resolved via ``effective_oauth2_flow`` so an unstamped M2M-shape row fails closed
(its stored client credentials drive egress; the caller's bearer is irrelevant).
"""
return (
server.auth_type == MCPAuth.oauth2
and server.delegate_auth_to_upstream is True
and MCPServerManager.effective_oauth2_flow(server) != "client_credentials"
)
async def _probe_upstream_auth(
url: str,
auth_header: str,
@ -4331,7 +4317,7 @@ if MCP_AVAILABLE:
mcp_servers: list[str] | None,
client_ip: str | None,
) -> None:
"""Probe pass-through and delegate-auth upstream servers in parallel before the MCP session starts.
"""Probe pass-through upstream servers in parallel before the MCP session starts.
Only servers the caller's key is already authorized to reach are probed —
the list is derived from _get_allowed_mcp_servers so that a user cannot
@ -4343,38 +4329,9 @@ if MCP_AVAILABLE:
if the upstream accepts it but forbids the caller.
Fails-open: network errors are logged and the request is allowed through.
Delegate-auth servers (``auth_type=oauth2`` + ``delegate_auth_to_upstream``)
are probed with the caller's bare ``Authorization`` bearer. That bearer is only
an upstream token (never a LiteLLM key) when admission took the delegate bypass,
so the delegate target is resolved through ``get_mcp_server_by_name`` -- the same
resolver admission used -- rather than the wider allowed-server prefix/access-group
matching. A name that only reaches a delegate server via server_id or an access
group would have been admitted as a real LiteLLM key, so probing it would leak that
key upstream; requiring the admission-resolver match closes that gap. Without the
probe a rejected token is absorbed by the tools/list handler and masked as an empty
tool list. Gated to single-server routes so one rejected token cannot 401 a
multi-server aggregate connect, matching the OBO preflight gating; the challenge
echoes the requested name so aliased routes get the same resource_metadata URL as
the tokenless preemptive challenge.
"""
forwarded_auth: Final = _get_forwarded_auth_from_scope(scope)
requested_single_target: Final = mcp_servers[0] if mcp_servers is not None and len(mcp_servers) == 1 else None
# The bare Authorization header (no x-litellm-api-key) is a valid upstream token
# only when admission classified it as one, i.e. the single requested name resolves
# to a delegate server under admission's own resolver. Resolve it the same way here
# so a server_id- or access-group-named delegate (which admission would have treated
# as a LiteLLM key) is never probed with that key.
delegate_server: Final = (
global_mcp_server_manager.get_mcp_server_by_name(requested_single_target, client_ip=client_ip)
if requested_single_target
else None
)
delegate_auth: Final = (
_get_authorization_header_from_scope(scope)
if delegate_server is not None and _is_delegate_upstream_probe_target(delegate_server)
else None
)
if not forwarded_auth and not delegate_auth:
if not forwarded_auth:
return
# Use the authorized server set, not the raw user-supplied names, so that
@ -4384,35 +4341,20 @@ if MCP_AVAILABLE:
mcp_servers=mcp_servers,
client_ip=client_ip,
)
passthrough_targets: Final[tuple[tuple[MCPServer, str, str], ...]] = (
tuple(
(srv, forwarded_auth, srv.name)
for srv in allowed_servers
# Restrict to genuine OAuth pass-through servers (auth_type none +
# Authorization in extra_headers). Gateway-managed OAuth2 servers
# must not receive the ``resource_metadata=`` challenge emitted
# below — they require ``authorization_uri=`` pointing at the
# gateway AS metadata. ``is_oauth_passthrough`` already requires
# ``auth_type in (None, MCPAuth.none)``, which is mutually
# exclusive with ``has_client_credentials`` (oauth2 + M2M flow),
# so M2M servers are implicitly excluded here.
if srv.is_oauth_passthrough
)
if forwarded_auth
else ()
passthrough_targets: Final[tuple[tuple[MCPServer, str, str], ...]] = tuple(
(srv, forwarded_auth, srv.name)
for srv in allowed_servers
# Restrict to genuine OAuth pass-through servers (auth_type none +
# Authorization in extra_headers). Gateway-managed OAuth2 servers
# must not receive the ``resource_metadata=`` challenge emitted
# below — they require ``authorization_uri=`` pointing at the
# gateway AS metadata. ``is_oauth_passthrough`` already requires
# ``auth_type in (None, MCPAuth.none)``, which is mutually
# exclusive with ``has_client_credentials`` (oauth2 + M2M flow),
# so M2M servers are implicitly excluded here.
if srv.is_oauth_passthrough
)
# Probe the admission-resolved delegate server only when the caller is actually
# authorized for it (present in the IP-filtered allowed set), keyed by server_id.
delegate_targets: Final[tuple[tuple[MCPServer, str, str], ...]] = (
tuple(
(srv, delegate_auth, requested_single_target)
for srv in allowed_servers
if delegate_server is not None and srv.server_id == delegate_server.server_id
)
if delegate_auth and requested_single_target
else ()
)
probe_targets: Final = passthrough_targets + delegate_targets
probe_targets: Final = passthrough_targets
if not probe_targets:
return

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,9 +1,35 @@
1:"$Sreact.fragment"
2:I[347257,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"ClientPageRoot"]
3:I[871135,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js","/litellm-asset-prefix/_next/static/chunks/2k6lzy5s7rafp.js","/litellm-asset-prefix/_next/static/chunks/2yqxc2yxa1-go.js","/litellm-asset-prefix/_next/static/chunks/2mo45qar55a-z.js","/litellm-asset-prefix/_next/static/chunks/0aoel7yrv88fp.js","/litellm-asset-prefix/_next/static/chunks/3ytz29phknzsy.js","/litellm-asset-prefix/_next/static/chunks/2i0218zvrsasm.js","/litellm-asset-prefix/_next/static/chunks/257-u3v7vdxzj.js","/litellm-asset-prefix/_next/static/chunks/1nfnjvxf_0-3n.js","/litellm-asset-prefix/_next/static/chunks/0t8t3-_8y1jh9.js","/litellm-asset-prefix/_next/static/chunks/0di-9qm-8ex8r.js","/litellm-asset-prefix/_next/static/chunks/3dy-3uqjux30s.js","/litellm-asset-prefix/_next/static/chunks/3155srena77mb.js","/litellm-asset-prefix/_next/static/chunks/0limvbttcca8i.js","/litellm-asset-prefix/_next/static/chunks/2ty4asibief-4.js","/litellm-asset-prefix/_next/static/chunks/07cqsb7poupf9.js","/litellm-asset-prefix/_next/static/chunks/2dvjnwfxzyldc.js","/litellm-asset-prefix/_next/static/chunks/0ab_ntohf1wik.js","/litellm-asset-prefix/_next/static/chunks/0esaql-j_8-p2.js","/litellm-asset-prefix/_next/static/chunks/0dylouuq8ak8p.js","/litellm-asset-prefix/_next/static/chunks/2bl93j-9lt0zm.js","/litellm-asset-prefix/_next/static/chunks/2eonl4rcemkdj.js","/litellm-asset-prefix/_next/static/chunks/2yrtzeoze9bgu.js"],"default"]
6:I[897367,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"OutletBoundary"]
2:I[347257,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"ClientPageRoot"]
3:I[871135,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js","/litellm-asset-prefix/_next/static/chunks/1kabwy23ggmhn.js","/litellm-asset-prefix/_next/static/chunks/03ljmgnmrvuxw.js","/litellm-asset-prefix/_next/static/chunks/1fl3r3enx76vk.js","/litellm-asset-prefix/_next/static/chunks/2wi6ubg_xifzg.js","/litellm-asset-prefix/_next/static/chunks/14hu41xvpzmuj.js","/litellm-asset-prefix/_next/static/chunks/0eh6yvm8qswse.js","/litellm-asset-prefix/_next/static/chunks/0uo3fyy_3adzw.js","/litellm-asset-prefix/_next/static/chunks/0aoel7yrv88fp.js","/litellm-asset-prefix/_next/static/chunks/1e5rsi2izekus.js","/litellm-asset-prefix/_next/static/chunks/1mxx3pzc7v4_x.js","/litellm-asset-prefix/_next/static/chunks/1fcix1vz8h1c8.js","/litellm-asset-prefix/_next/static/chunks/05vpfvve3-xds.js","/litellm-asset-prefix/_next/static/chunks/02erlqrgtvdvz.js","/litellm-asset-prefix/_next/static/chunks/3fj8wylwf-stx.js","/litellm-asset-prefix/_next/static/chunks/27u46a0m025he.js","/litellm-asset-prefix/_next/static/chunks/3c013ns4vt0zs.js","/litellm-asset-prefix/_next/static/chunks/1gvvrnrpw-7_u.js","/litellm-asset-prefix/_next/static/chunks/0fg9nx_731nkm.js","/litellm-asset-prefix/_next/static/chunks/1t7m5lyrljbza.js","/litellm-asset-prefix/_next/static/chunks/371aylk03p56q.js","/litellm-asset-prefix/_next/static/chunks/0n3xjld_n2chp.js","/litellm-asset-prefix/_next/static/chunks/2oyhu8rllo9v-.js","/litellm-asset-prefix/_next/static/chunks/3mwt8ofux_ic-.js","/litellm-asset-prefix/_next/static/chunks/0d17ojhl52r4k.js","/litellm-asset-prefix/_next/static/chunks/0xl3regan_n7s.js","/litellm-asset-prefix/_next/static/chunks/0ixfd4seits4-.js"],"default"]
6:I[897367,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"OutletBoundary"]
7:"$Sreact.suspense"
0:{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/0di-9qm-8ex8r.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/3dy-3uqjux30s.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/3155srena77mb.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/0limvbttcca8i.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/2ty4asibief-4.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/07cqsb7poupf9.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/2dvjnwfxzyldc.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0ab_ntohf1wik.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/0esaql-j_8-p2.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/0dylouuq8ak8p.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/2bl93j-9lt0zm.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/2eonl4rcemkdj.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/2yrtzeoze9bgu.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"912qRXFjlEYHK3EAPXXTc"}
b:I[897367,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"ViewportBoundary"]
c:I[897367,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"MetadataBoundary"]
d:I[27201,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"IconMark"]
f:I[92825,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"ClientSegmentRoot"]
10:I[216370,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js","/litellm-asset-prefix/_next/static/chunks/1kabwy23ggmhn.js","/litellm-asset-prefix/_next/static/chunks/03ljmgnmrvuxw.js","/litellm-asset-prefix/_next/static/chunks/1fl3r3enx76vk.js","/litellm-asset-prefix/_next/static/chunks/2wi6ubg_xifzg.js","/litellm-asset-prefix/_next/static/chunks/14hu41xvpzmuj.js","/litellm-asset-prefix/_next/static/chunks/0eh6yvm8qswse.js","/litellm-asset-prefix/_next/static/chunks/0uo3fyy_3adzw.js","/litellm-asset-prefix/_next/static/chunks/0aoel7yrv88fp.js","/litellm-asset-prefix/_next/static/chunks/1e5rsi2izekus.js"],"default"]
11:I[339756,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"default"]
12:I[837457,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"default"]
a:X
0:{"buildId":"N8M8GUEWcUrwZCaluei8R","data":[{"rsc":["$","$1","c",{"children":[["$","$L2",null,{"Component":"$3","serverProvidedParams":{"searchParams":{},"params":{},"promises":["$@4","$@5"]}}],[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/1mxx3pzc7v4_x.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/1fcix1vz8h1c8.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/05vpfvve3-xds.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/02erlqrgtvdvz.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/3fj8wylwf-stx.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/27u46a0m025he.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/3c013ns4vt0zs.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/1gvvrnrpw-7_u.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/0fg9nx_731nkm.js","async":true}],["$","script","script-9",{"src":"/litellm-asset-prefix/_next/static/chunks/1t7m5lyrljbza.js","async":true}],["$","script","script-10",{"src":"/litellm-asset-prefix/_next/static/chunks/371aylk03p56q.js","async":true}],["$","script","script-11",{"src":"/litellm-asset-prefix/_next/static/chunks/0n3xjld_n2chp.js","async":true}],["$","script","script-12",{"src":"/litellm-asset-prefix/_next/static/chunks/2oyhu8rllo9v-.js","async":true}],["$","script","script-13",{"src":"/litellm-asset-prefix/_next/static/chunks/3mwt8ofux_ic-.js","async":true}],["$","script","script-14",{"src":"/litellm-asset-prefix/_next/static/chunks/0d17ojhl52r4k.js","async":true}],["$","script","script-15",{"src":"/litellm-asset-prefix/_next/static/chunks/0xl3regan_n7s.js","async":true}],["$","script","script-16",{"src":"/litellm-asset-prefix/_next/static/chunks/0ixfd4seits4-.js","async":true}]],["$","$L6",null,{"children":["$","$7",null,{"name":"Next.MetadataOutlet","children":"$@8"}]}]]}],"isPartial":"$@9","staleTime":"$a","varyParams":null},{"rsc":["$","$1","h",{"children":[null,["$","$Lb",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$Lc",null,{"children":["$","$7",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.3arlap5n8tyzg.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"/get_favicon"}],["$","$Ld","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":"$@e","staleTime":"$a","varyParams":null},{"rsc":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/1kabwy23ggmhn.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/03ljmgnmrvuxw.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/1fl3r3enx76vk.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/2wi6ubg_xifzg.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/14hu41xvpzmuj.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/0eh6yvm8qswse.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/0uo3fyy_3adzw.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/0aoel7yrv88fp.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/1e5rsi2izekus.js","async":true}]],["$","$Lf",null,{"Component":"$10","slots":{"children":["$","$L11",null,{"parallelRouterKey":"children","template":["$","$L12",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":"$L13"}]],[]]}]},"serverProvidedParams":{"params":"$0:data:0:rsc:props:children:0:props:serverProvidedParams:params","promises":["$@14"]}}]]}],"isPartial":"$@15","staleTime":"$a","varyParams":null},{"rsc":"$L16","isPartial":"$@17","staleTime":"$a","varyParams":null}],"isUpgradeableISRFallback":false,"a":"$@18","rootVaryParams":null,"needsRuntimeRequest":"$@19"}
1a:I[363178,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"ThemeProvider"]
1b:I[12985,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"NuqsAdapter"]
1c:I[867271,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"default"]
1d:I[557951,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"AuthProvider"]
1e:I[713354,["/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js"],"Toaster"]
:HL["/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","style"]
:HL["/litellm-asset-prefix/_next/static/chunks/3rynlyl14avb-.css","style"]
4:{}
5:"$0:rsc:props:children:0:props:serverProvidedParams:params"
5:"$0:data:0:rsc:props:children:0:props:serverProvidedParams:params"
8:null
13:["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]
14:"$0:data:0:rsc:props:children:0:props:serverProvidedParams:params"
16:["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/3rynlyl14avb-.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/33t46atd3n2zd.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/3doe-1fpykdw3.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/1m-a1t8oh1ed0.js","async":true}]],["$","html",null,{"lang":"en","suppressHydrationWarning":true,"children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L1a",null,{"attribute":"class","defaultTheme":"light","enableSystem":true,"disableTransitionOnChange":true,"children":["$","$L1b",null,{"children":["$","$L1c",null,{"children":[["$","$L1d",null,{"children":["$","$L11",null,{"parallelRouterKey":"children","template":["$","$L12",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":"$0:data:2:rsc:props:children:1:props:slots:children:props:notFound:0:1:props:style","children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":"$13:props:children:1:props:style","children":404}],["$","div",null,{"style":"$13:props:children:2:props:style","children":["$","h2",null,{"style":"$13:props:children:2:props:children:props:style","children":"This page could not be found."}]}]]}]}]],[]]}]}],["$","$L1e",null,{}]]}]}]}]}]}]]}]
a:300
19:true
a:C
18:0
e:"$undefined"
17:"$undefined"
9:"$undefined"
15:"$undefined"

View file

@ -1,7 +0,0 @@
1:"$Sreact.fragment"
2:I[92825,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"ClientSegmentRoot"]
3:I[216370,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js","/litellm-asset-prefix/_next/static/chunks/2k6lzy5s7rafp.js","/litellm-asset-prefix/_next/static/chunks/2yqxc2yxa1-go.js","/litellm-asset-prefix/_next/static/chunks/2mo45qar55a-z.js","/litellm-asset-prefix/_next/static/chunks/0aoel7yrv88fp.js","/litellm-asset-prefix/_next/static/chunks/3ytz29phknzsy.js","/litellm-asset-prefix/_next/static/chunks/2i0218zvrsasm.js","/litellm-asset-prefix/_next/static/chunks/257-u3v7vdxzj.js","/litellm-asset-prefix/_next/static/chunks/1nfnjvxf_0-3n.js","/litellm-asset-prefix/_next/static/chunks/0t8t3-_8y1jh9.js"],"default"]
4:I[339756,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"default"]
5:I[837457,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"default"]
0:{"rsc":["$","$1","c",{"children":[[["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/2k6lzy5s7rafp.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/2yqxc2yxa1-go.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/2mo45qar55a-z.js","async":true}],["$","script","script-3",{"src":"/litellm-asset-prefix/_next/static/chunks/0aoel7yrv88fp.js","async":true}],["$","script","script-4",{"src":"/litellm-asset-prefix/_next/static/chunks/3ytz29phknzsy.js","async":true}],["$","script","script-5",{"src":"/litellm-asset-prefix/_next/static/chunks/2i0218zvrsasm.js","async":true}],["$","script","script-6",{"src":"/litellm-asset-prefix/_next/static/chunks/257-u3v7vdxzj.js","async":true}],["$","script","script-7",{"src":"/litellm-asset-prefix/_next/static/chunks/1nfnjvxf_0-3n.js","async":true}],["$","script","script-8",{"src":"/litellm-asset-prefix/_next/static/chunks/0t8t3-_8y1jh9.js","async":true}]],["$","$L2",null,{"Component":"$3","slots":{"children":["$","$L4",null,{"parallelRouterKey":"children","template":["$","$L5",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]},"serverProvidedParams":{"params":{},"promises":["$@6"]}}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"912qRXFjlEYHK3EAPXXTc"}
6:"$0:rsc:props:children:1:props:serverProvidedParams:params"

File diff suppressed because one or more lines are too long

View file

@ -1,6 +0,0 @@
1:"$Sreact.fragment"
2:I[897367,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"ViewportBoundary"]
3:I[897367,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"MetadataBoundary"]
4:"$Sreact.suspense"
5:I[27201,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"IconMark"]
0:{"rsc":["$","$1","h",{"children":[null,["$","$L2",null,{"children":[["$","meta","0",{"charSet":"utf-8"}],["$","meta","1",{"name":"viewport","content":"width=device-width, initial-scale=1"}]]}],["$","div",null,{"hidden":true,"children":["$","$L3",null,{"children":["$","$4",null,{"name":"Next.Metadata","children":[["$","title","0",{"children":"LiteLLM Dashboard"}],["$","meta","1",{"name":"description","content":"LiteLLM Proxy Admin UI"}],["$","link","2",{"rel":"icon","href":"/favicon.ico?favicon.3arlap5n8tyzg.ico","sizes":"48x48","type":"image/x-icon"}],["$","link","3",{"rel":"icon","href":"/get_favicon"}],["$","$L5","4",{}]]}]}]}],["$","meta",null,{"name":"next-size-adjust","content":""}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"912qRXFjlEYHK3EAPXXTc"}

View file

@ -1,11 +0,0 @@
1:"$Sreact.fragment"
2:I[363178,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"ThemeProvider"]
3:I[12985,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"NuqsAdapter"]
4:I[867271,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"default"]
5:I[557951,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"AuthProvider"]
6:I[339756,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"default"]
7:I[837457,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"default"]
8:I[713354,["/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js"],"Toaster"]
:HL["/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","style"]
:HL["/litellm-asset-prefix/_next/static/chunks/3lvwyc5xjv11f.css","style"]
0:{"rsc":["$","$1","c",{"children":[[["$","link","0",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","precedence":"next"}],["$","link","1",{"rel":"stylesheet","href":"/litellm-asset-prefix/_next/static/chunks/3lvwyc5xjv11f.css","precedence":"next"}],["$","script","script-0",{"src":"/litellm-asset-prefix/_next/static/chunks/03pu_dx0gqja9.js","async":true}],["$","script","script-1",{"src":"/litellm-asset-prefix/_next/static/chunks/3fn8zqlfrwowr.js","async":true}],["$","script","script-2",{"src":"/litellm-asset-prefix/_next/static/chunks/1ntn7efqc-iiw.js","async":true}]],["$","html",null,{"lang":"en","suppressHydrationWarning":true,"children":["$","body",null,{"className":"inter_5972bc34-module__OU16Qa__className","children":["$","$L2",null,{"attribute":"class","defaultTheme":"light","enableSystem":true,"disableTransitionOnChange":true,"children":["$","$L3",null,{"children":["$","$L4",null,{"children":[["$","$L5",null,{"children":["$","$L6",null,{"parallelRouterKey":"children","template":["$","$L7",null,{}],"notFound":[[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":404}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],[]]}]}],["$","$L8",null,{}]]}]}]}]}]}]]}],"isPartial":false,"staleTime":300,"varyParams":null,"buildId":"912qRXFjlEYHK3EAPXXTc"}

View file

@ -1,4 +1,4 @@
:HL["/litellm-asset-prefix/_next/static/chunks/1kid9zr1--h6y.css","style"]
:HL["/litellm-asset-prefix/_next/static/chunks/3lvwyc5xjv11f.css","style"]
:HL["/litellm-asset-prefix/_next/static/chunks/3rynlyl14avb-.css","style"]
:HL["/litellm-asset-prefix/_next/static/media/83afe278b6a6bb3c-s.p.2bn3s6zvc0dyp.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
0:{"tree":{"name":"","param":null,"prefetchHints":16,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":0,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":0,"slots":null}}}}},"staleTime":300,"buildId":"912qRXFjlEYHK3EAPXXTc"}
0:{"tree":{"name":"","param":null,"prefetchHints":4176,"slots":{"children":{"name":"(dashboard)","param":null,"prefetchHints":4192,"slots":{"children":{"name":"__PAGE__","param":null,"prefetchHints":4256,"slots":null}}}}},"staleTime":300,"buildId":"N8M8GUEWcUrwZCaluei8R"}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1 @@
(globalThis.TURBOPACK||(globalThis.TURBOPACK=[])).push(["object"==typeof document?document.currentScript:void 0,178971,e=>{"use strict";var t=e.i(843476),s=e.i(271645),u=e.i(135214),l=e.i(227409);function n(){let{accessToken:e}=(0,u.default)(),[n,c]=(0,s.useState)([]);return(0,t.jsx)("div",{className:"mx-auto w-full max-w-5xl px-8 py-8",children:(0,t.jsx)(l.default,{accessToken:e??"",selectedServers:n,onChange:c})})}e.s(["default",0,function(){return(0,t.jsx)(s.Suspense,{children:(0,t.jsx)(n,{})})}])}]);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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