Merge pull request #37439 from BerriAI/litellm_decrease_anys_fable_round3

chore(typing): drop 1.3k basedpyright errors across 42 Any hotspot files
This commit is contained in:
Mateo Wang 2026-08-19 12:07:26 -07:00 committed by GitHub
commit 4d100bdd89
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1660 additions and 651 deletions

View file

@ -1,12 +1,12 @@
{
"reportAny": {
"limit": 22343
"limit": 19955
},
"reportArgumentType": {
"limit": 2578
"limit": 2566
},
"reportAssignmentType": {
"limit": 323
"limit": 320
},
"reportAttributeAccessIssue": {
"limit": 488
@ -24,7 +24,7 @@
"limit": 19
},
"reportExplicitAny": {
"limit": 6991
"limit": 6049
},
"reportFunctionMemberAccess": {
"limit": 7
@ -54,10 +54,10 @@
"limit": 0
},
"reportMissingParameterType": {
"limit": 5681
"limit": 5663
},
"reportMissingTypeArgument": {
"limit": 15605
"limit": 15557
},
"reportMissingTypeStubs": {
"limit": 40
@ -99,19 +99,19 @@
"limit": 0
},
"reportUnknownArgumentType": {
"limit": 44709
"limit": 44655
},
"reportUnknownLambdaType": {
"limit": 112
"limit": 109
},
"reportUnknownMemberType": {
"limit": 39154
"limit": 39043
},
"reportUnknownParameterType": {
"limit": 19944
"limit": 19887
},
"reportUnknownVariableType": {
"limit": 30772
"limit": 30574
},
"reportUnnecessaryCast": {
"limit": 117
@ -123,7 +123,7 @@
"limit": 5
},
"reportUnnecessaryIsInstance": {
"limit": 851
"limit": 836
},
"reportUntypedBaseClass": {
"limit": 0

View file

@ -10,7 +10,8 @@ All /vector_store management endpoints
import copy
import json
from typing import List, Optional
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final, List, Optional, Protocol
from fastapi import APIRouter, Depends, HTTPException
@ -32,9 +33,35 @@ from litellm.types.vector_stores import (
)
from litellm.vector_stores.vector_store_registry import VectorStoreRegistry
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
router = APIRouter()
class ManagedVectorStoreRow(Protocol):
"""A ``litellm_managedvectorstorestable`` row as returned by Prisma."""
def model_dump(self) -> LiteLLM_ManagedVectorStore: ...
class ManagedVectorStoreTable(Protocol):
"""The Prisma actions namespace for ``litellm_managedvectorstorestable``."""
async def find_unique(self, where: Mapping[str, str | None]) -> ManagedVectorStoreRow | None: ...
async def create(self, data: Mapping[str, object]) -> ManagedVectorStoreRow: ...
async def delete(self, where: Mapping[str, str | None]) -> ManagedVectorStoreRow | None: ...
async def update(self, where: Mapping[str, str | None], data: Mapping[str, object]) -> ManagedVectorStoreRow: ...
def managed_vector_store_table(prisma_client: "PrismaClient") -> ManagedVectorStoreTable:
"""The Prisma table actions for managed vector stores, behind a typed surface."""
return prisma_client.db.litellm_managedvectorstorestable
########################################################
# Management Endpoints
########################################################
@ -66,7 +93,7 @@ async def new_vector_store(
try:
# Check if vector store already exists
existing_vector_store = (
await prisma_client.db.litellm_managedvectorstorestable.find_unique(
await managed_vector_store_table(prisma_client).find_unique(
where={"vector_store_id": vector_store.get("vector_store_id")}
)
)
@ -92,7 +119,7 @@ async def new_vector_store(
del vector_store["litellm_params"]
_new_vector_store = (
await prisma_client.db.litellm_managedvectorstorestable.create(
await managed_vector_store_table(prisma_client).create(
data={
**vector_store,
"litellm_params": litellm_params_json,
@ -213,7 +240,7 @@ async def delete_vector_store(
try:
# Check if vector store exists
existing_vector_store = (
await prisma_client.db.litellm_managedvectorstorestable.find_unique(
await managed_vector_store_table(prisma_client).find_unique(
where={"vector_store_id": data.vector_store_id}
)
)
@ -224,7 +251,7 @@ async def delete_vector_store(
)
# Delete vector store
await prisma_client.db.litellm_managedvectorstorestable.delete(
await managed_vector_store_table(prisma_client).delete(
where={"vector_store_id": data.vector_store_id}
)
@ -288,7 +315,7 @@ async def get_vector_store_info(
return {"vector_store": vector_store_pydantic_obj}
vector_store = (
await prisma_client.db.litellm_managedvectorstorestable.find_unique(
await managed_vector_store_table(prisma_client).find_unique(
where={"vector_store_id": data.vector_store_id}
)
)
@ -298,7 +325,7 @@ async def get_vector_store_info(
detail=f"Vector store with ID {data.vector_store_id} not found",
)
vector_store_dict = vector_store.model_dump() # type: ignore[attr-defined]
vector_store_dict = vector_store.model_dump()
return {"vector_store": vector_store_dict}
except Exception as e:
verbose_proxy_logger.exception(f"Error getting vector store info: {str(e)}")
@ -322,13 +349,13 @@ async def update_vector_store(
try:
update_data = data.model_dump(exclude_unset=True)
vector_store_id = update_data.pop("vector_store_id")
vector_store_id: Final[str] = update_data.pop("vector_store_id")
if update_data.get("vector_store_metadata") is not None:
update_data["vector_store_metadata"] = safe_dumps(
update_data["vector_store_metadata"]
)
updated = await prisma_client.db.litellm_managedvectorstorestable.update(
updated = await managed_vector_store_table(prisma_client).update(
where={"vector_store_id": vector_store_id},
data=update_data,
)

View file

@ -7,9 +7,10 @@ import hashlib
import json
import time
from collections.abc import AsyncIterator
from typing import Any, Final, NamedTuple, cast
from typing import Any, Final, NamedTuple, Protocol
import httpx
from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm._logging import verbose_logger
from litellm.a2a_protocol.providers.watsonx_orchestrate.transformation import (
@ -38,11 +39,59 @@ class WXORequestParams(NamedTuple):
thread_id: str | None
class WXOLitellmParams(TypedDict, total=False):
"""litellm_params keys read when routing an A2A request to watsonx Orchestrate."""
cp4d_host: ReadOnly[str]
instance_id: ReadOnly[str]
wxo_agent_id: ReadOnly[str]
api_key: ReadOnly[str]
username: ReadOnly[str | None]
auth_mode: ReadOnly[str]
thread_id: ReadOnly[str | None]
class _IBMCloudTokenBody(TypedDict):
"""Fields read from the IBM Cloud IAM token response."""
access_token: ReadOnly[str]
expires_in: ReadOnly[NotRequired[int]]
class _CP4DTokenBody(TypedDict):
"""Fields read from the CP4D authorize response."""
token: ReadOnly[str]
expiration: ReadOnly[NotRequired[float]]
class _WXORun(TypedDict, total=False):
"""Fields the handler reads from a WXO run object or run event."""
status: ReadOnly[str]
run_id: ReadOnly[str]
id: ReadOnly[str]
class _SSELineSource(Protocol):
def aiter_lines(self) -> AsyncIterator[str]: ...
class _WXOView(TypedDict, total=False):
"""Typed reads of otherwise untyped watsonx Orchestrate and httpx values."""
ibm_cloud_token: ReadOnly[_IBMCloudTokenBody]
cp4d_token: ReadOnly[_CP4DTokenBody]
run: ReadOnly[_WXORun]
content_type: ReadOnly[str]
sse_source: ReadOnly[_SSELineSource]
class WatsonxOrchestrateHandler:
@staticmethod
def _http_client(timeout: float = 90.0) -> AsyncHTTPHandler:
return get_async_httpx_client(
llm_provider=cast(Any, httpxSpecialProvider.A2AProvider),
llm_provider=httpxSpecialProvider.A2AProvider,
params={"timeout": timeout},
)
@ -57,7 +106,7 @@ class WatsonxOrchestrateHandler:
return hashlib.sha256(material.encode()).hexdigest()
@staticmethod
def _cp4d_token_ttl_seconds(expiration: Any, now_wall: float | None = None) -> int:
def _cp4d_token_ttl_seconds(expiration: float, now_wall: float | None = None) -> int:
# CP4D returns expiration as absolute Unix epoch seconds, not a duration.
expires_at: Final = int(expiration)
wall: Final = now_wall if now_wall is not None else time.time()
@ -90,9 +139,9 @@ class WatsonxOrchestrateHandler:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
response.raise_for_status()
payload = response.json()
token = str(payload["access_token"])
ttl_s = int(payload.get("expires_in", 3600))
iam_payload: Final[_WXOView] = {"ibm_cloud_token": response.json()}
token = str(iam_payload["ibm_cloud_token"]["access_token"])
ttl_s = int(iam_payload["ibm_cloud_token"].get("expires_in", 3600))
else:
if not username:
raise ValueError("'username' is required in litellm_params when auth_mode='cp4d'")
@ -103,9 +152,9 @@ class WatsonxOrchestrateHandler:
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
payload = response.json()
token = str(payload["token"])
expiration: Final = payload.get("expiration")
cp4d_payload: Final[_WXOView] = {"cp4d_token": response.json()}
token = str(cp4d_payload["cp4d_token"]["token"])
expiration: Final = cp4d_payload["cp4d_token"].get("expiration")
if expiration is None:
ttl_s = 3600
else:
@ -118,6 +167,16 @@ class WatsonxOrchestrateHandler:
del _token_cache[stale_key]
return token
@staticmethod
def _run_body(response: httpx.Response) -> _WXORun:
view: Final[_WXOView] = {"run": response.json()}
return view["run"]
@staticmethod
def _decode_run_event(payload: str | bytes) -> _WXORun:
view: Final[_WXOView] = {"run": json.loads(payload)}
return view["run"]
@staticmethod
async def _poll_run(
base_url: str,
@ -126,14 +185,14 @@ class WatsonxOrchestrateHandler:
client: AsyncHTTPHandler,
max_attempts: int = _MAX_POLL_ATTEMPTS,
interval_s: float = _POLL_INTERVAL_S,
) -> dict[str, Any]:
) -> _WXORun:
url: Final = f"{base_url}/v1/orchestrate/runs/{run_id}"
for attempt in range(max_attempts):
await asyncio.sleep(interval_s)
response = await client.get(url, headers=auth_headers)
response.raise_for_status()
result: dict[str, Any] = response.json()
result = WatsonxOrchestrateHandler._run_body(response)
status = result.get("status", "")
verbose_logger.debug("WXO: Poll %s/%s run='%s' status='%s'", attempt + 1, max_attempts, run_id, status)
if status in WatsonxOrchestrateTransformation.TERMINAL_STATES:
@ -145,11 +204,11 @@ class WatsonxOrchestrateHandler:
@staticmethod
async def _get_successful_run_data(
run_data: dict[str, Any],
run_data: _WXORun,
base_url: str,
auth_headers: dict[str, str],
client: AsyncHTTPHandler,
) -> dict[str, Any]:
) -> _WXORun:
status = run_data.get("status", "")
if status not in WatsonxOrchestrateTransformation.TERMINAL_STATES:
run_id: Final = run_data.get("run_id") or run_data.get("id") or ""
@ -170,15 +229,16 @@ class WatsonxOrchestrateHandler:
@staticmethod
async def _accumulate_wxo_sse_text(response: Any) -> str:
source: Final[_WXOView] = {"sse_source": response}
accumulated_text = ""
async for line in response.aiter_lines():
async for line in source["sse_source"].aiter_lines():
if not line.startswith("data:"):
continue
data_str = line[5:].strip()
if not data_str or data_str == "[DONE]":
continue
try:
event = json.loads(data_str)
event = WatsonxOrchestrateHandler._decode_run_event(data_str)
except json.JSONDecodeError:
continue
chunk_text = WatsonxOrchestrateTransformation.extract_text_from_wxo_result(event)
@ -187,7 +247,7 @@ class WatsonxOrchestrateHandler:
return accumulated_text
@staticmethod
def _extract_litellm_params(litellm_params: dict[str, Any]) -> WXORequestParams:
def _extract_litellm_params(litellm_params: WXOLitellmParams) -> WXORequestParams:
cp4d_host: Final = litellm_params.get("cp4d_host") or ""
instance_id: Final = litellm_params.get("instance_id") or ""
wxo_agent_id: Final = litellm_params.get("wxo_agent_id") or ""
@ -215,9 +275,9 @@ class WatsonxOrchestrateHandler:
@staticmethod
async def handle_non_streaming(
request_id: str,
params: dict[str, Any],
litellm_params: dict[str, Any],
) -> dict[str, Any]:
params: dict[str, object],
litellm_params: WXOLitellmParams,
) -> dict[str, object]:
wxo: Final = WatsonxOrchestrateHandler._extract_litellm_params(litellm_params)
client: Final = WatsonxOrchestrateHandler._http_client(timeout=90.0)
@ -246,7 +306,8 @@ class WatsonxOrchestrateHandler:
headers=auth_headers,
)
run_response.raise_for_status()
run_data: dict[str, Any] = run_response.json()
started: Final[_WXOView] = {"run": run_response.json()}
run_data: _WXORun = started["run"]
run_data = await WatsonxOrchestrateHandler._get_successful_run_data(
run_data=run_data,
@ -261,11 +322,11 @@ class WatsonxOrchestrateHandler:
@staticmethod
async def handle_streaming(
request_id: str,
params: dict[str, Any],
litellm_params: dict[str, Any],
params: dict[str, object],
litellm_params: WXOLitellmParams,
chunk_size: int = 50,
delay_ms: int = 10,
) -> AsyncIterator[dict[str, Any]]:
) -> AsyncIterator[dict[str, object]]:
wxo: Final = WatsonxOrchestrateHandler._extract_litellm_params(litellm_params)
client: Final = WatsonxOrchestrateHandler._http_client(timeout=120.0)
@ -316,10 +377,11 @@ class WatsonxOrchestrateHandler:
yield chunk
return
content_type: Final = response.headers.get("content-type", "").lower()
header_view: Final[_WXOView] = {"content_type": response.headers.get("content-type", "")}
content_type: Final = header_view["content_type"].lower()
if "text/event-stream" not in content_type:
response_body: Final = await response.aread()
result = json.loads(response_body)
result = WatsonxOrchestrateHandler._decode_run_event(response_body)
result = await WatsonxOrchestrateHandler._get_successful_run_data(
run_data=result,
base_url=base_url,

View file

@ -18,7 +18,7 @@ import asyncio
import datetime
import inspect
import time
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator
from collections.abc import AsyncGenerator, AsyncIterator, Callable, Generator, Mapping
from typing import TYPE_CHECKING, Any, Final, Optional, TypeVar
from pydantic import BaseModel
@ -106,7 +106,7 @@ def _is_chat_completion_cached_dict(cached_result: dict) -> bool:
return "choices" in cached_result
def _should_defer_streaming_cache_hit_callbacks(*, kwargs: dict[str, Any]) -> bool:
def _should_defer_streaming_cache_hit_callbacks(*, kwargs: dict[str, object]) -> bool:
"""
When stream=True, do not run success callbacks at cache-hit time.
@ -119,11 +119,21 @@ def _should_defer_streaming_cache_hit_callbacks(*, kwargs: dict[str, Any]) -> bo
return kwargs.get("stream", False) is True
def _prompt_tokens_details_as_mapping(details: "PromptTokensDetailsWrapper") -> Mapping[str, object]:
"""Dump prompt token details to an opaque field mapping, tolerating non-pydantic stand-ins."""
return details.model_dump(exclude_none=True) if hasattr(details, "model_dump") else {}
def _request_cache_key(request_kwargs: Mapping[str, Any]) -> str | None:
"""Read the caller-supplied ``cache_key`` off the request kwargs."""
return request_kwargs.get("cache_key", None)
class LLMCachingHandler:
def __init__(
self,
original_function: Callable,
request_kwargs: dict[str, Any],
request_kwargs: dict[str, object],
start_time: datetime.datetime,
):
from litellm.caching import DualCache, RedisCache
@ -150,7 +160,7 @@ class LLMCachingHandler:
start_time: datetime.datetime,
call_type: str,
kwargs: dict[str, Any],
args: tuple[Any, ...] | None = None,
args: tuple[object, ...] | None = None,
) -> CachingHandlerResponse | None:
"""
Internal method to get from the cache.
@ -289,7 +299,7 @@ class LLMCachingHandler:
start_time: datetime.datetime,
call_type: str,
kwargs: dict[str, Any],
args: tuple[Any, ...] | None = None,
args: tuple[object, ...] | None = None,
) -> CachingHandlerResponse:
cached_result: Any | None = None
@ -366,7 +376,7 @@ class LLMCachingHandler:
return CachingHandlerResponse(cached_result=cached_result)
return CachingHandlerResponse(cached_result=cached_result)
def handle_kwargs_input_list_or_str(self, kwargs: dict[str, Any]) -> list[str]:
def handle_kwargs_input_list_or_str(self, kwargs: dict[str, object]) -> list[str]:
"""
Handles the input of kwargs['input'] being a list or a string
"""
@ -548,8 +558,8 @@ class LLMCachingHandler:
if details2 is None:
return details1
dict1: Final = details1.model_dump(exclude_none=True) if hasattr(details1, "model_dump") else {}
dict2: Final = details2.model_dump(exclude_none=True) if hasattr(details2, "model_dump") else {}
dict1: Final = _prompt_tokens_details_as_mapping(details1)
dict2: Final = _prompt_tokens_details_as_mapping(details2)
merged: Final[dict] = {}
for key in set(dict1.keys()) | set(dict2.keys()):
@ -671,7 +681,9 @@ class LLMCachingHandler:
cache_hit=cache_hit,
)
async def _retrieve_from_cache(self, call_type: str, kwargs: dict[str, Any], args: tuple[Any, ...]) -> Any | None:
async def _retrieve_from_cache(
self, call_type: str, kwargs: dict[str, object], args: tuple[object, ...]
) -> Any | None:
"""
Internal method to
- get cache key
@ -727,7 +739,8 @@ class LLMCachingHandler:
cached_result = None
else:
request_kwargs: Final = new_kwargs.copy()
request_cache_key: Final = request_kwargs.pop("cache_key", None)
request_cache_key: Final = _request_cache_key(request_kwargs)
request_kwargs.pop("cache_key", None)
if litellm.cache._supports_async() is True:
## check if dual cache is supported ##
self.preset_cache_key = request_cache_key or litellm.cache.get_cache_key(**request_kwargs)
@ -749,10 +762,10 @@ class LLMCachingHandler:
self,
cached_result: Any,
call_type: str,
kwargs: dict[str, Any],
kwargs: dict[str, object],
logging_obj: LiteLLMLoggingObj,
model: str,
args: tuple[Any, ...],
args: tuple[object, ...],
custom_llm_provider: str | None = None,
) -> (
ModelResponse
@ -948,7 +961,7 @@ class LLMCachingHandler:
result: Any,
original_function: Callable,
kwargs: dict[str, Any],
args: tuple[Any, ...] | None = None,
args: tuple[object, ...] | None = None,
):
"""
Internal method to check the type of the result & cache used and adds the result to the cache accordingly
@ -1013,8 +1026,8 @@ class LLMCachingHandler:
def sync_set_cache(
self,
result: Any,
kwargs: dict[str, Any],
args: tuple[Any, ...] | None = None,
kwargs: dict[str, object],
args: tuple[object, ...] | None = None,
):
"""
Sync internal method to add the result to the cache
@ -1204,8 +1217,8 @@ class LLMCachingHandler:
def convert_args_to_kwargs(
original_function: Callable,
args: tuple[Any, ...] | None = None,
) -> dict[str, Any]:
args: tuple[object, ...] | None = None,
) -> dict[str, object]:
# Get the signature of the original function
signature: Final = inspect.signature(original_function)

View file

@ -102,6 +102,7 @@ from litellm.types.utils import (
LlmProviders,
LlmProvidersSet,
ModelInfo,
PromptTokensDetailsWrapper,
ServiceTier,
StandardBuiltInToolsParams,
TranscriptionUsageDurationObject,
@ -286,7 +287,7 @@ def _transcription_usage_has_token_details(
prompt_tokens_val: Final = getattr(usage_block, "prompt_tokens", 0) or 0
completion_tokens_val: Final = getattr(usage_block, "completion_tokens", 0) or 0
prompt_details: Final = getattr(usage_block, "prompt_tokens_details", None)
prompt_details: Final[PromptTokensDetailsWrapper | None] = getattr(usage_block, "prompt_tokens_details", None)
if prompt_details is not None:
audio_token_count: Final = getattr(prompt_details, "audio_tokens", 0) or 0
@ -375,7 +376,7 @@ def cost_per_token(
_is_anthropic_style = False
if usage_object is not None:
_pt_details: Final = getattr(usage_object, "prompt_tokens_details", None)
_pt_details: Final[PromptTokensDetailsWrapper | None] = getattr(usage_object, "prompt_tokens_details", None)
if _pt_details is not None:
_cache_read_tokens = float(getattr(_pt_details, "cached_tokens", 0) or 0)
# OpenAI-compatible providers report cache-write tokens under
@ -385,8 +386,8 @@ def cost_per_token(
getattr(_pt_details, "cache_write_tokens", 0) or getattr(_pt_details, "cache_creation_tokens", 0) or 0
)
_anthropic_read: Final = getattr(usage_object, "cache_read_input_tokens", None)
_anthropic_create: Final = getattr(usage_object, "cache_creation_input_tokens", None)
_anthropic_read: Final[int | None] = getattr(usage_object, "cache_read_input_tokens", None)
_anthropic_create: Final[int | None] = getattr(usage_object, "cache_creation_input_tokens", None)
if _anthropic_read is not None or _anthropic_create is not None:
_is_anthropic_style = True
if _anthropic_read is not None:
@ -703,7 +704,7 @@ def get_replicate_completion_pricing(completion_response: dict, total_time=0.0):
return a100_80gb_price_per_second_public * total_time / 1000
def has_hidden_params(obj: Any) -> bool:
def has_hidden_params(obj: object) -> bool:
return hasattr(obj, "_hidden_params")
@ -728,7 +729,7 @@ def _get_provider_for_cost_calc(
def _select_model_name_for_cost_calc(
model: str | None,
completion_response: Any | None,
completion_response: object | None,
base_model: str | None = None,
custom_pricing: bool | None = None,
custom_llm_provider: str | None = None,
@ -804,7 +805,7 @@ def _model_contains_known_llm_provider(model: str) -> bool:
return _provider_prefix in LlmProvidersSet
def _get_response_model(completion_response: Any) -> str | None:
def _get_response_model(completion_response: object) -> str | None:
"""
Extract the model name from a completion response object.
@ -866,8 +867,18 @@ def _normalize_service_tier(service_tier: object) -> str | None:
return service_tier
def _extract_service_tier(source: object) -> str | None:
"""Read a raw ``service_tier`` off a response body or usage object, dict or pydantic model alike."""
if isinstance(source, BaseModel):
return getattr(source, "service_tier", None)
elif isinstance(source, dict):
return source.get("service_tier")
return None
def _get_usage_object(
completion_response: Any,
completion_response: object,
) -> Usage | None:
usage_obj: Final = cast(
Usage | ResponseAPIUsage | dict | BaseModel,
@ -1110,7 +1121,7 @@ def _store_cost_breakdown_in_logging_obj(
def completion_cost(
completion_response=None,
completion_response: object | None = None,
model: str | None = None,
prompt="",
messages: list = [],
@ -1197,19 +1208,13 @@ def completion_cost(
# Extract service_tier from completion_response if not provided
if service_tier is None and completion_response is not None:
if isinstance(completion_response, BaseModel):
service_tier = getattr(completion_response, "service_tier", None)
elif isinstance(completion_response, dict):
service_tier = completion_response.get("service_tier")
service_tier = _extract_service_tier(completion_response)
service_tier = _normalize_service_tier(service_tier)
# Extract service_tier from usage object if not provided
if service_tier is None and cost_per_token_usage_object is not None:
if isinstance(cost_per_token_usage_object, BaseModel):
service_tier = getattr(cost_per_token_usage_object, "service_tier", None)
elif isinstance(cost_per_token_usage_object, dict):
service_tier = cost_per_token_usage_object.get("service_tier")
service_tier = _extract_service_tier(cost_per_token_usage_object)
service_tier = _normalize_service_tier(service_tier)
@ -1412,7 +1417,7 @@ def completion_cost(
if completion_response is not None and isinstance(completion_response, RerankResponse):
meta_obj = completion_response.meta
if meta_obj is not None:
billed_units = meta_obj.get("billed_units", {}) or {}
billed_units: RerankBilledUnits = meta_obj.get("billed_units") or {}
else:
billed_units = {}
@ -1801,7 +1806,7 @@ def response_cost_calculator(
def ocr_cost(
model: str,
custom_llm_provider: str | None,
response: Any | None = None,
response: object | None = None,
) -> tuple[float, float]:
"""
Args:

View file

@ -1,5 +1,5 @@
import json
from collections.abc import AsyncIterator, Iterator
from collections.abc import AsyncIterator, Iterator, Sequence
from typing import Any, Final, TypedDict, cast
from typing_extensions import ReadOnly
@ -27,6 +27,7 @@ from litellm.types.utils import (
ModelResponse,
ModelResponseStream,
StreamingChoices,
Usage,
)
@ -43,6 +44,29 @@ class _GenAIPart(TypedDict, total=False):
functionCall: ReadOnly[dict[str, object]]
class _GenAIFunctionDeclaration(TypedDict, total=False):
name: ReadOnly[str]
description: ReadOnly[str]
parametersJsonSchema: ReadOnly[dict[str, object]]
class _GenAITool(TypedDict, total=False):
functionDeclarations: ReadOnly[list[_GenAIFunctionDeclaration]]
class _GenAIFunctionCallingConfig(TypedDict, total=False):
mode: ReadOnly[str]
class _GenAIToolConfig(TypedDict, total=False):
functionCallingConfig: ReadOnly[_GenAIFunctionCallingConfig]
def _decode_tool_call_arguments(raw_arguments: str) -> object:
"""Decode a tool call's JSON-encoded arguments into the value Google GenAI expects."""
return json.loads(raw_arguments)
class GoogleGenAIStreamWrapper(AdapterCompletionStreamWrapper):
"""
Wrapper for streaming Google GenAI generate_content responses.
@ -51,7 +75,7 @@ class GoogleGenAIStreamWrapper(AdapterCompletionStreamWrapper):
sent_first_chunk: bool = False
# State tracking for accumulating partial tool calls
accumulated_tool_calls: dict[str, dict[str, str]]
accumulated_tool_calls: dict[int, dict[str, str]]
def __init__(self, completion_stream: object):
self.sent_first_chunk = False
@ -108,7 +132,7 @@ class GoogleGenAIStreamWrapper(AdapterCompletionStreamWrapper):
try:
# For tool calls with no arguments, accumulated_args will be "", which is not valid JSON.
# We default to an empty JSON object in this case.
parsed_args = json.loads(tool_call_data["arguments"] or "{}")
parsed_args = _decode_tool_call_arguments(tool_call_data["arguments"] or "{}")
function_call_part: _GenAIPart = {
"functionCall": {
"name": tool_call_data["name"] or "undefined_tool_name",
@ -319,7 +343,7 @@ class GoogleGenAIAdapter:
def _transform_google_genai_tools_to_openai(
self,
tools: list[dict[str, Any]],
tools: Sequence[_GenAITool],
) -> list[ChatCompletionToolParam]:
"""Transform Google GenAI tools to OpenAI tools format"""
openai_tools: Final[list[dict[str, object]]] = []
@ -346,7 +370,7 @@ class GoogleGenAIAdapter:
def _transform_google_genai_tool_config_to_openai(
self,
tool_config: dict[str, Any],
tool_config: _GenAIToolConfig,
) -> ChatCompletionToolChoiceValues | None:
"""Transform Google GenAI tool_config to OpenAI tool_choice"""
function_calling_config: Final = tool_config.get("functionCallingConfig", {})
@ -563,7 +587,7 @@ class GoogleGenAIAdapter:
parts = self._transform_openai_delta_to_google_genai_parts_with_accumulation(choice.delta, wrapper)
else:
parts = []
finish_reason = getattr(choice, "finish_reason", None)
finish_reason: str | None = getattr(choice, "finish_reason", None)
else:
# Fallback for generic choice objects
message_content: Final = getattr(choice, "delta", {}).get("content", "")
@ -625,7 +649,11 @@ class GoogleGenAIAdapter:
for tool_call in message.tool_calls:
if hasattr(tool_call, "function") and tool_call.function:
try:
args = json.loads(tool_call.function.arguments) if tool_call.function.arguments else {}
args = (
_decode_tool_call_arguments(tool_call.function.arguments)
if tool_call.function.arguments
else {}
)
except json.JSONDecodeError:
args = {}
@ -661,7 +689,7 @@ class GoogleGenAIAdapter:
continue
# 3. Use `index` as the primary key for accumulation
tool_call_index = getattr(tool_call, "index", None)
tool_call_index: int | None = getattr(tool_call, "index", None)
if tool_call_index is None:
continue # Index is essential for tracking streaming tool calls
@ -695,7 +723,7 @@ class GoogleGenAIAdapter:
# 5. Attempt to parse arguments even if name hasn't arrived.
try:
# Attempt to parse the accumulated arguments string
parsed_args = json.loads(accumulated_args)
parsed_args = _decode_tool_call_arguments(accumulated_args)
# If parsing succeeds, but we don't have a name yet, wait.
# The part will be created by a later chunk that brings the name.
@ -729,7 +757,7 @@ class GoogleGenAIAdapter:
return mapping.get(finish_reason, "STOP")
def _map_usage(self, usage: Any) -> dict[str, int]:
def _map_usage(self, usage: Usage | None) -> dict[str, int]:
"""Map OpenAI usage to Google GenAI usage format"""
return {
"promptTokenCount": getattr(usage, "prompt_tokens", 0) or 0,

View file

@ -60,13 +60,13 @@ class LLMResponse(BaseModel):
default=None,
description="Total cost of the LLM call in USD as computed by LiteLLM.",
)
output_logprobs: dict[str, Any] | None = Field(
output_logprobs: dict[str, object] | None = Field(
default=None,
description="Optional. When available, logprobs are used to compute Uncertainty.",
)
created_at: str = Field(..., description='timestamp constructed in "%Y-%m-%dT%H:%M:%S" format')
tags: list[str] | None = None
user_metadata: dict[str, Any] | None = None
user_metadata: dict[str, object] | None = None
class GalileoObserve(CustomLogger):
@ -238,13 +238,13 @@ class GalileoObserve(CustomLogger):
return created_at
@staticmethod
def _token_metrics_from_record(record: Mapping[str, Any]) -> dict[str, Any]:
def _token_metrics_from_record(record: Mapping[str, Any]) -> dict[str, object]:
num_input_tokens: Final = int(record.get("num_input_tokens") or 0)
num_output_tokens: Final = int(record.get("num_output_tokens") or 0)
num_total_tokens = int(record.get("num_total_tokens") or 0)
if num_total_tokens == 0 and (num_input_tokens or num_output_tokens):
num_total_tokens = num_input_tokens + num_output_tokens
metrics: Final[dict[str, Any]] = {
metrics: Final[dict[str, object]] = {
"num_input_tokens": num_input_tokens,
"num_output_tokens": num_output_tokens,
"num_total_tokens": num_total_tokens,
@ -260,10 +260,10 @@ class GalileoObserve(CustomLogger):
*,
trace_id: str,
span_id: str,
) -> dict[str, Any]:
) -> dict[str, object]:
created_at: Final = GalileoObserve._normalize_created_at(record.get("created_at", ""))
span: Final[dict[str, Any]] = {
span: Final[dict[str, object]] = {
"type": "llm",
"id": span_id,
"trace_id": trace_id,
@ -287,7 +287,7 @@ class GalileoObserve(CustomLogger):
return span
@staticmethod
def _record_to_v2_trace(record: Mapping[str, Any]) -> dict[str, Any]:
def _record_to_v2_trace(record: Mapping[str, Any]) -> dict[str, object]:
trace_id: Final = str(uuid.uuid4())
span_id: Final = str(uuid.uuid4())
created_at: Final = GalileoObserve._normalize_created_at(record.get("created_at", ""))
@ -307,8 +307,8 @@ class GalileoObserve(CustomLogger):
"spans": [GalileoObserve._record_to_v2_span(record, trace_id=trace_id, span_id=span_id)],
}
def _build_traces_payload(self, records: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
payload: Final[dict[str, Any]] = {
def _build_traces_payload(self, records: Sequence[Mapping[str, object]]) -> dict[str, object]:
payload: Final[dict[str, object]] = {
"traces": [self._record_to_v2_trace(record) for record in records],
"logging_method": "api_direct",
"reliable": False,
@ -318,7 +318,7 @@ class GalileoObserve(CustomLogger):
payload["log_stream_id"] = self.log_stream_id
return payload
def _get_ingest_request(self) -> tuple[str, dict[str, Any]] | None:
def _get_ingest_request(self) -> tuple[str, dict[str, object]] | None:
if not self.base_url or not self.project_id:
return None
@ -427,9 +427,9 @@ class GalileoObserve(CustomLogger):
pass
@staticmethod
def _build_prompt(kwargs: Mapping[str, Any]) -> dict[str, Any]:
def _build_prompt(kwargs: Mapping[str, Any]) -> dict[str, object]:
optional_params: Final[Mapping[str, object]] = kwargs.get("optional_params", {}) or {}
prompt: Final[dict[str, Any]] = {"messages": kwargs.get("messages")}
prompt: Final[dict[str, object]] = {"messages": kwargs.get("messages")}
if optional_params.get("functions") is not None:
prompt["functions"] = optional_params["functions"]
if optional_params.get("tools") is not None:
@ -451,7 +451,7 @@ class GalileoObserve(CustomLogger):
return json.dumps(value, default=_json_default)
@staticmethod
def _prompt_to_input_text(prompt: Mapping[str, Any]) -> str:
def _prompt_to_input_text(prompt: Mapping[str, object]) -> str:
messages: Final[object] = prompt.get("messages")
if messages is not None:
text: Final = GalileoObserve._input_text_from_messages(messages)
@ -464,7 +464,7 @@ class GalileoObserve(CustomLogger):
if response_obj.choices and len(response_obj.choices) > 0:
message: Final = response_obj["choices"][0]["message"]
if hasattr(message, "json"):
message_json: Final = message.json()
message_json: Final[object] = message.json()
if isinstance(message_json, str):
return json.loads(message_json)
return message_json
@ -488,7 +488,7 @@ class GalileoObserve(CustomLogger):
return None
@staticmethod
def _langfuse_style_rerank_prompt(kwargs: Mapping[str, object]) -> dict[str, Any]:
def _langfuse_style_rerank_prompt(kwargs: Mapping[str, object]) -> dict[str, object]:
"""Match Langfuse rerank input: prompt = {"messages": kwargs.get("messages")}."""
return {"messages": kwargs.get("messages")}

View file

@ -5,7 +5,7 @@ import traceback
from collections.abc import Callable, Iterable, Mapping
from datetime import datetime
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, cast
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast
from packaging.version import Version
@ -49,10 +49,21 @@ else:
_DENIED_STEERING_KEYS: Final = frozenset({"headers", "endpoint", "caching_groups", "previous_models"})
_NO_METADATA: Final[Mapping[str, Any]] = MappingProxyType({})
_NO_METADATA: Final[Mapping[str, object]] = MappingProxyType({})
_REDACTED_PROXY_HEADERS: Final[frozenset[str]] = frozenset({"authorization", "cookie", "referer"})
def _object_mapping(value: object) -> Mapping[str, object] | None:
"""Return ``value`` as an opaque mapping when it is a dict."""
return value if isinstance(value, dict) else None
class _UsageObject(Protocol):
"""Token-count surface the Langfuse logger reads off a response usage payload."""
def get(self, key: Literal["cache_creation_input_tokens", "cache_read_input_tokens"], /) -> int | None: ...
def _extract_cache_read_input_tokens(usage_obj) -> int:
"""
Extract cache_read_input_tokens from usage object.
@ -82,6 +93,11 @@ def _extract_cache_read_input_tokens(usage_obj) -> int:
return cache_read_input_tokens
def _logging_id(start_time: datetime | None, response_obj: object) -> str | None:
"""Typed view of the timestamped response id Langfuse uses as the generation id."""
return litellm.utils.get_logging_id(start_time, response_obj)
def _as_steering_flag(value: object) -> bool:
"""A string ``str_to_bool`` does not recognise falls back to its truthiness."""
if isinstance(value, str):
@ -222,7 +238,7 @@ class LangFuseLogger:
return langfuse_client
@staticmethod
def add_metadata_from_header(litellm_params: dict, metadata: dict) -> dict:
def add_metadata_from_header(litellm_params: dict, metadata: dict) -> dict[str, object]:
"""
Adds metadata from proxy request headers to Langfuse logging if keys start with "langfuse_"
and overwrites litellm_params.metadata if already included.
@ -494,7 +510,7 @@ class LangFuseLogger:
def _log_langfuse_v2(
self,
user_id: str | None,
metadata: dict,
metadata: dict[str, object],
litellm_params: dict,
output: str | dict | list | None,
start_time: datetime | None,
@ -519,7 +535,7 @@ class LangFuseLogger:
else []
)
allowlisted_metadata: Final[StandardLoggingMetadata | dict[str, Any]] = (
allowlisted_metadata: Final[StandardLoggingMetadata | Mapping[str, object]] = (
standard_logging_object["metadata"] if standard_logging_object is not None else _NO_METADATA
)
end_user_id: Final = allowlisted_metadata.get("user_api_key_end_user_id", None)
@ -531,11 +547,12 @@ class LangFuseLogger:
# Clean Metadata before logging - never log raw metadata
# the raw metadata can contain circular references which leads to infinite recursion
# we clean out all extra litellm metadata params before logging
clean_metadata: dict[str, Any] = {}
clean_metadata: dict[str, object] = {}
if prompt_management_metadata is not None:
clean_metadata["prompt_management_metadata"] = prompt_management_metadata
if isinstance(metadata, dict):
for key, value in metadata.items():
metadata_entries: Final = _object_mapping(metadata)
if metadata_entries is not None:
for key, value in metadata_entries.items():
# generate langfuse tags - Default Tags sent to Langfuse from LiteLLM Proxy
if (
litellm.langfuse_default_tags is not None
@ -705,8 +722,8 @@ class LangFuseLogger:
usage_details = None
if response_obj is not None:
if hasattr(response_obj, "id") and response_obj.get("id", None) is not None:
generation_id = litellm.utils.get_logging_id(start_time, response_obj)
_usage_obj: Final = getattr(response_obj, "usage", None)
generation_id = _logging_id(start_time, response_obj)
_usage_obj: Final[_UsageObject | None] = getattr(response_obj, "usage", None)
if _usage_obj:
# Safely get usage values, defaulting None to 0 for Langfuse compatibility.
@ -811,7 +828,7 @@ class LangFuseLogger:
@staticmethod
def _get_chat_content_for_langfuse(
response_obj: ModelResponse,
):
) -> str | None:
"""
Get the chat content for Langfuse logging
"""
@ -1078,7 +1095,7 @@ def log_provider_specific_information_as_span(
None
"""
_hidden_params: Final = clean_metadata.get("hidden_params", None)
_hidden_params: Final[Mapping[str, object] | None] = clean_metadata.get("hidden_params", None)
if _hidden_params is None:
return

View file

@ -62,8 +62,13 @@ from litellm.integrations.otel.plumbing.providers import (
from litellm.integrations.otel.plumbing.routing import TenantTracerCache
if TYPE_CHECKING:
from opentelemetry.metrics import MeterProvider
from litellm.caching.dual_cache import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.services import ServiceLoggerPayload
from litellm.types.utils import (
CallTypesLiteral,
StandardLoggingGuardrailInformation,
StandardLoggingPayload,
)
@ -140,7 +145,7 @@ class OpenTelemetryV2(CustomLogger):
callback_name: str | None = None,
tracer_provider: TracerProvider | None = None,
logger_provider: LoggerProvider | None = None,
meter_provider: Any | None = None,
meter_provider: "MeterProvider | None" = None,
**kwargs: Any,
) -> None:
super().__init__(**kwargs)
@ -162,7 +167,7 @@ class OpenTelemetryV2(CustomLogger):
self._open_llm_calls: OrderedDict[str, _LLMCallSpan] = OrderedDict()
self._init_otel_logger_on_litellm_proxy()
def _init_metrics(self, meter_provider: Any | None) -> "GenAIMetricRecorder | None":
def _init_metrics(self, meter_provider: "MeterProvider | None") -> "GenAIMetricRecorder | None":
"""Create the six GenAI histograms when metrics are enabled, else ``None``.
``meter_provider`` is an explicit override (tests inject one); otherwise the
@ -340,7 +345,7 @@ class OpenTelemetryV2(CustomLogger):
def _emit_mcp_tool_call(
self,
kwargs: Mapping[str, Any],
kwargs: Mapping[str, object],
start_time: datetime | float | None,
end_time: datetime | float | None,
) -> bool:
@ -417,7 +422,7 @@ class OpenTelemetryV2(CustomLogger):
def _close_llm_call(
self,
kwargs: Mapping[str, Any],
kwargs: Mapping[str, object],
start_time: datetime | float | None,
end_time: datetime | float | None,
) -> Span | None:
@ -474,7 +479,7 @@ class OpenTelemetryV2(CustomLogger):
async def async_service_success_hook(
self,
payload: Any,
payload: "ServiceLoggerPayload",
parent_otel_span: Span | None = None,
start_time: datetime | float | None = None,
end_time: datetime | float | None = None,
@ -491,7 +496,7 @@ class OpenTelemetryV2(CustomLogger):
async def async_service_failure_hook(
self,
payload: Any,
payload: "ServiceLoggerPayload",
error: str | None = "",
parent_otel_span: Span | None = None,
start_time: datetime | float | None = None,
@ -509,7 +514,7 @@ class OpenTelemetryV2(CustomLogger):
def _emit_service(
self,
payload: Any,
payload: "ServiceLoggerPayload",
*,
parent_otel_span: Span | None,
start_time: datetime | float | None,
@ -559,7 +564,7 @@ class OpenTelemetryV2(CustomLogger):
# / errors are the FastAPI instrumentor's job, so we don't touch it here.
# ====================================================================== #
def seed_request_identity(self, user_api_key_dict: Any, model: Any = None) -> None:
def seed_request_identity(self, user_api_key_dict: object, model: str | None = None) -> None:
"""Attach request-identity Baggage to the current context + server span.
Seeding identity into Baggage makes **every** span emitted afterwards for
@ -615,10 +620,10 @@ class OpenTelemetryV2(CustomLogger):
async def async_pre_call_hook(
self,
user_api_key_dict: Any,
cache: Any,
user_api_key_dict: "UserAPIKeyAuth",
cache: "DualCache",
data: dict,
call_type: Any,
call_type: "CallTypesLiteral",
) -> dict:
self.seed_request_identity(
user_api_key_dict,
@ -790,7 +795,7 @@ def emit_guardrail_span(entry: "StandardLoggingGuardrailInformation") -> None:
pass
def seed_request_identity(user_api_key_dict: Any, model: Any = None) -> None:
def seed_request_identity(user_api_key_dict: object, model: str | None = None) -> None:
logger: Final = _registered_v2_logger()
if logger is not None:
logger.seed_request_identity(user_api_key_dict, model=model)

View file

@ -9,7 +9,9 @@ import os
import sys
from collections.abc import Awaitable, Callable, Mapping, Sequence
from datetime import datetime, timedelta
from typing import TYPE_CHECKING, Any, Final, Literal, cast
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, cast
from pydantic import BaseModel
import litellm
from litellm._logging import print_verbose, verbose_logger
@ -38,6 +40,7 @@ from litellm.proxy._types import (
LiteLLM_UserTable,
UserAPIKeyAuth,
)
from litellm.repositories.base_repository import BaseRepository
from litellm.repositories.organization_repository import OrganizationRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
@ -58,6 +61,9 @@ if TYPE_CHECKING:
else:
AsyncIOScheduler = Any
_BudgetRowT: Final = TypeVar("_BudgetRowT")
_TableRowT: Final = TypeVar("_TableRowT", bound=BaseModel)
_DEFAULT_BUDGET_METRICS_PER_REQUEST_TIMEOUT: Final = 5.0
_NON_ENUM_METRIC_LABELS: Final[frozenset[str]] = frozenset(
@ -73,6 +79,36 @@ _NON_ENUM_METRIC_LABELS: Final[frozenset[str]] = frozenset(
)
class _PaginatedPrismaTable(Protocol[_TableRowT]):
"""The slice of a prisma table action surface used for budget-metric pagination."""
async def find_many(
self,
*,
skip: int,
take: int,
order: Mapping[str, str],
include: Mapping[str, bool] | None = None,
) -> list[_TableRowT]: ...
async def count(self) -> int: ...
def _paginated_table(repository: BaseRepository[_TableRowT]) -> _PaginatedPrismaTable[_TableRowT]:
"""View a repository's prisma table through the pagination surface budget metrics need."""
return repository.table
class _OrgBudgetRow(Protocol):
"""The budget columns joined onto an organization row."""
@property
def max_budget(self) -> float | None: ...
@property
def budget_reset_at(self) -> datetime | None: ...
class _ExcludedLabelMetric:
"""Proxies a prometheus metric whose declared ``labelnames`` had globally
excluded labels removed, dropping those labels from every ``labels(...)``
@ -1531,7 +1567,7 @@ class PrometheusLogger(CustomLogger):
cache_creation_detail_tokens: Final = PrometheusLogger._resolve_cache_write_tokens(prompt_details)
detail_metrics: Final[list[tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]]] = [
detail_metrics: Final[list[tuple[Any, DEFINED_PROMETHEUS_METRICS, object]]] = [
(
self.litellm_input_cached_tokens_metric,
"litellm_input_cached_tokens_metric",
@ -1584,7 +1620,7 @@ class PrometheusLogger(CustomLogger):
if not isinstance(usage_object, dict):
return
media_metrics: Final[list[tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]]] = [
media_metrics: Final[list[tuple[Any, DEFINED_PROMETHEUS_METRICS, object]]] = [
(
self.litellm_video_duration_seconds_metric,
"litellm_video_duration_seconds_metric",
@ -1606,7 +1642,7 @@ class PrometheusLogger(CustomLogger):
def _inc_sparse_usage_counters(
self,
counters_with_values: list[tuple[Any, DEFINED_PROMETHEUS_METRICS, Any]],
counters_with_values: Sequence[tuple[Any, DEFINED_PROMETHEUS_METRICS, object]],
enum_values: UserAPIKeyLabelValues,
label_context: PrometheusLabelFactoryContext | None = None,
) -> None:
@ -2133,7 +2169,7 @@ class PrometheusLogger(CustomLogger):
def _extract_status_code(
self,
kwargs: dict | None = None,
enum_values: Any | None = None,
enum_values: UserAPIKeyLabelValues | None = None,
exception: Exception | None = None,
) -> int | None:
"""
@ -2151,7 +2187,7 @@ class PrometheusLogger(CustomLogger):
Returns:
Status code as integer if found, None otherwise
"""
status_code = None
status_code: int | None = None
# Try from enum_values first (most common in our callbacks)
if enum_values and hasattr(enum_values, "status_code") and enum_values.status_code:
@ -2225,8 +2261,8 @@ class PrometheusLogger(CustomLogger):
def _should_skip_metrics_for_invalid_key(
self,
kwargs: dict | None = None,
user_api_key_dict: Any | None = None,
enum_values: Any | None = None,
user_api_key_dict: UserAPIKeyAuth | None = None,
enum_values: UserAPIKeyLabelValues | None = None,
standard_logging_payload: dict | StandardLoggingPayload | None = None,
exception: Exception | None = None,
) -> bool:
@ -2391,7 +2427,7 @@ class PrometheusLogger(CustomLogger):
for all successful requests (both streaming and non-streaming).
"""
def _safe_get(self, obj: Any, key: str, default: Any = None) -> Any:
def _safe_get(self, obj: Any, key: str, default: object = None) -> Any:
"""Get value from dict or Pydantic model."""
if obj is None:
return default
@ -3273,8 +3309,8 @@ class PrometheusLogger(CustomLogger):
async def _initialize_budget_metrics(
self,
data_fetch_function: Callable[..., Awaitable[tuple[list[Any], int | None]]],
set_metrics_function: Callable[[list[Any]], Awaitable[None]],
data_fetch_function: Callable[..., Awaitable[tuple[list[_BudgetRowT], int | None]]],
set_metrics_function: Callable[[list[_BudgetRowT]], Awaitable[None]],
data_type: Literal["teams", "keys", "users", "orgs"],
):
"""
@ -3393,12 +3429,12 @@ class PrometheusLogger(CustomLogger):
async def fetch_users(page_size: int, page: int) -> tuple[list[LiteLLM_UserTable], int | None]:
skip: Final = (page - 1) * page_size
users: Final = await UserRepository(prisma_client).table.find_many(
users: Final = await _paginated_table(UserRepository(prisma_client)).find_many(
skip=skip,
take=page_size,
order={"created_at": "desc"},
)
total_count: Final = await UserRepository(prisma_client).table.count()
total_count: Final = await _paginated_table(UserRepository(prisma_client)).count()
return users, total_count
await self._initialize_budget_metrics(
@ -3419,13 +3455,13 @@ class PrometheusLogger(CustomLogger):
async def fetch_orgs(page_size: int, page: int) -> tuple[list, int | None]:
skip: Final = (page - 1) * page_size
orgs: Final = await OrganizationRepository(prisma_client).table.find_many(
orgs: Final = await _paginated_table(OrganizationRepository(prisma_client)).find_many(
skip=skip,
take=page_size,
order={"created_at": "desc"},
include={"litellm_budget_table": True},
)
total_count: Final = await OrganizationRepository(prisma_client).table.count()
total_count: Final = await _paginated_table(OrganizationRepository(prisma_client)).count()
return orgs, total_count
await self._initialize_budget_metrics(
@ -3488,7 +3524,7 @@ class PrometheusLogger(CustomLogger):
try:
# Get total user count
total_users: Final = await UserRepository(prisma_client).table.count()
total_users: Final = await _paginated_table(UserRepository(prisma_client)).count()
self.litellm_total_users_metric.set(total_users)
verbose_logger.debug("Prometheus: set litellm_total_users to %s", total_users)
@ -3497,13 +3533,13 @@ class PrometheusLogger(CustomLogger):
verbose_logger.debug("Prometheus: set litellm_active_users to %s", billable_users)
# Get total team count
total_teams: Final = await TeamRepository(prisma_client).table.count()
total_teams: Final = await _paginated_table(TeamRepository(prisma_client)).count()
self.litellm_teams_count_metric.set(total_teams)
verbose_logger.debug("Prometheus: set litellm_teams_count to %s", total_teams)
except Exception as e:
verbose_logger.exception("Error initializing user/team count metrics: %s", e)
async def _set_key_list_budget_metrics(self, keys: list[str | UserAPIKeyAuth]):
async def _set_key_list_budget_metrics(self, keys: list[str | UserAPIKeyAuth | LiteLLM_DeletedVerificationToken]):
"""Helper function to set budget metrics for a list of keys"""
for key in keys:
if isinstance(key, UserAPIKeyAuth):
@ -3522,7 +3558,7 @@ class PrometheusLogger(CustomLogger):
async def _set_org_list_budget_metrics(self, orgs: list):
"""Helper function to set budget metrics for a list of orgs"""
for org in orgs:
budget_table = getattr(org, "litellm_budget_table", None)
budget_table: _OrgBudgetRow | None = getattr(org, "litellm_budget_table", None)
self._set_org_budget_metrics(
org_id=org.organization_id or "",
org_alias=org.organization_alias or "",
@ -4051,6 +4087,11 @@ class PrometheusLogger(CustomLogger):
verbose_proxy_logger.debug("Starting Prometheus Metrics on /metrics (no authentication)")
def _label_source(enum_values: UserAPIKeyLabelValues) -> Mapping[str, object]:
"""Flatten the label values into the opaque name/value mapping the label filters read."""
return enum_values.model_dump()
def _prometheus_labels_from_context(
supported_enum_labels: list[str],
ctx: PrometheusLabelFactoryContext,
@ -4098,7 +4139,7 @@ def prometheus_label_factory(
return _prometheus_labels_from_context(supported_enum_labels, label_context)
# Extract dictionary from Pydantic object
enum_dict: Final = enum_values.model_dump()
enum_dict: Final = _label_source(enum_values)
# Filter supported labels and sanitize values to prevent breaking
# the Prometheus text format (e.g. U+2028 Line Separator in label values)
@ -4154,7 +4195,7 @@ def get_custom_labels_from_metadata(metadata: dict) -> dict[str, str]:
keys_parts = key.split(".")
# Traverse through the dictionary using the parts
value: Any = metadata
value: object = metadata
for part in keys_parts:
if isinstance(value, dict):
value = value.get(part, None) # Get the value, return None if not found
@ -4171,7 +4212,7 @@ def get_custom_labels_from_metadata(metadata: dict) -> dict[str, str]:
def _get_combined_custom_metadata_from_standard_logging_payload(
standard_logging_payload: dict | None,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Combine the metadata sources that can supply custom Prometheus labels.

View file

@ -12,6 +12,8 @@ import uuid
from collections.abc import AsyncIterator, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, TypedDict, cast
from typing_extensions import ReadOnly
import litellm
from litellm._logging import verbose_logger
from litellm.anthropic_interface import messages as anthropic_messages
@ -90,6 +92,20 @@ class _SearchToolConfig(TypedDict, total=False):
litellm_params: Mapping[str, object] | None
class _DeploymentKwargsView(TypedDict):
"""Typed reads of the untyped request kwargs seen by the deployment hook."""
custom_llm_provider: ReadOnly[str]
litellm_params: ReadOnly[Mapping[str, object]]
model: ReadOnly[str]
class _UserAuthView(TypedDict):
"""Typed read of the optional team attached to the caller's auth object."""
team_id: ReadOnly[str | None]
class WebSearchInterceptionLogger(CustomLogger):
"""
CustomLogger that intercepts WebSearch tool calls for models that don't
@ -265,7 +281,9 @@ class WebSearchInterceptionLogger(CustomLogger):
)
return response
async def async_pre_call_deployment_hook(self, kwargs: dict[str, Any], call_type: CallTypes | None) -> dict | None:
async def async_pre_call_deployment_hook(
self, kwargs: dict[str, Any], call_type: CallTypes | None
) -> dict[str, object] | None:
"""
Pre-call hook to convert native Anthropic web_search tools to regular tools.
@ -275,12 +293,17 @@ class WebSearchInterceptionLogger(CustomLogger):
"""
# Check if this is for an enabled provider
# Try top-level kwargs first, then nested litellm_params, then derive from model name
custom_llm_provider = kwargs.get("custom_llm_provider", "") or kwargs.get("litellm_params", {}).get(
kwargs_view: Final[_DeploymentKwargsView] = {
"custom_llm_provider": kwargs.get("custom_llm_provider", ""),
"litellm_params": kwargs.get("litellm_params", {}),
"model": kwargs.get("model", ""),
}
custom_llm_provider = kwargs_view["custom_llm_provider"] or kwargs_view["litellm_params"].get(
"custom_llm_provider", ""
)
if not custom_llm_provider:
try:
_, custom_llm_provider, _, _ = litellm.get_llm_provider(model=kwargs.get("model", ""))
_, custom_llm_provider, _, _ = litellm.get_llm_provider(model=kwargs_view["model"])
except Exception:
custom_llm_provider = ""
if custom_llm_provider not in self.enabled_providers:
@ -1422,7 +1445,8 @@ class WebSearchInterceptionLogger(CustomLogger):
valid_token=user_api_key_auth,
)
team_id: Final = getattr(user_api_key_auth, "team_id", None)
auth_view: Final[_UserAuthView] = {"team_id": getattr(user_api_key_auth, "team_id", None)}
team_id: Final = auth_view["team_id"]
if team_id:
from litellm.proxy.proxy_server import (
prisma_client,

View file

@ -1,7 +1,9 @@
import asyncio
import json
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Protocol, cast
from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict, cast
from typing_extensions import ReadOnly
import litellm
from litellm._logging import verbose_logger
@ -32,13 +34,52 @@ class _ClientWebSocketExceptions(Protocol):
ConnectionClosed: type[Exception]
class _ClientWebSocket(Protocol):
class _ASGIScope(TypedDict, total=False):
"""The part of an ASGI connection scope this module reads."""
headers: ReadOnly[Sequence[tuple[bytes | str, bytes | str]]]
class _ClientEventItem(TypedDict, total=False):
"""The ``item`` payload of a client ``conversation.item.create`` frame."""
type: ReadOnly[str]
role: ReadOnly[str]
output: ReadOnly[object]
content: ReadOnly[Sequence[object]]
class _ClientEventFrame(TypedDict, total=False):
"""The fields the proxy reads from a client realtime frame."""
type: ReadOnly[str]
item: ReadOnly[_ClientEventItem]
session: ReadOnly[Mapping[str, object]]
class _ResponseDoneBody(TypedDict, total=False):
"""The ``response`` body of a ``response.done`` event, as read for spend logging."""
output: ReadOnly[Sequence[Mapping[str, object]]]
class _ScopedWebSocket(Protocol):
@property
def scope(self) -> _ASGIScope: ...
class _ClientWebSocket(_ScopedWebSocket, Protocol):
exceptions: _ClientWebSocketExceptions
async def send_text(self, data: str) -> None: ...
async def receive_text(self) -> str: ...
def _decode_json_object(payload: str) -> Mapping[str, object]:
"""Decode a realtime frame into its top-level field mapping."""
return json.loads(payload)
class RealtimeEventNormalizer(Protocol):
def should_drop(self, event: object) -> bool: ...
def normalize(self, event: dict) -> dict: ...
@ -294,7 +335,7 @@ class RealTimeStreaming:
try:
if event_obj.get("type") != "response.done":
return
response: Final = cast(dict[str, Any], event_obj.get("response", {}))
response: Final = cast(_ResponseDoneBody, event_obj.get("response", {}))
item: Mapping[str, object]
for item in response.get("output", []):
if item.get("type") == "function_call":
@ -353,7 +394,7 @@ class RealTimeStreaming:
sent = False
for msg in transformed:
try:
msg_obj = json.loads(msg)
msg_obj = _decode_json_object(msg)
except (json.JSONDecodeError, TypeError):
msg_obj = None
if isinstance(msg_obj, dict) and self.provider_config.is_setup_message(msg_obj):
@ -399,7 +440,7 @@ class RealTimeStreaming:
return message
try:
message_obj: Final[Mapping[str, object]] = json.loads(message)
message_obj: Final = _decode_json_object(message)
except (json.JSONDecodeError, TypeError):
return message
@ -468,7 +509,7 @@ class RealTimeStreaming:
for message in messages:
try:
msg_type = json.loads(message).get("type")
msg_type = _decode_json_object(message).get("type")
except (json.JSONDecodeError, TypeError):
collapsed.extend(pending_appends)
pending_appends = []
@ -502,14 +543,14 @@ class RealTimeStreaming:
if self._backend_setup_complete and not self._flushing_pending_messages_until_setup:
return False
try:
msg_obj: Final[Mapping[str, object]] = json.loads(message)
msg_obj: Final = _decode_json_object(message)
except (json.JSONDecodeError, TypeError):
return False
return msg_obj.get("type") in RealTimeStreaming._CLIENT_AUDIO_BUFFER_TYPES
def _buffer_pending_message_until_setup(self, message: str) -> None:
try:
msg_type = json.loads(message).get("type")
msg_type = _decode_json_object(message).get("type")
except (json.JSONDecodeError, TypeError):
msg_type = None
@ -602,7 +643,7 @@ class RealTimeStreaming:
``return_new_content_delta_events`` modality lookup, ...).
"""
try:
message_obj: Final = json.loads(transformed_message)
message_obj: Final = _decode_json_object(transformed_message)
if "setup" in message_obj:
self.session_configuration_request = transformed_message
except (json.JSONDecodeError, TypeError):
@ -930,7 +971,7 @@ class RealTimeStreaming:
def _parse_backend_event(raw_response: str) -> dict[str, object] | None:
"""Parse a backend frame once. Returns None for non-JSON or non-object frames."""
try:
event: Final = json.loads(raw_response)
event: Final = _decode_json_object(raw_response)
except (json.JSONDecodeError, TypeError):
return None
return event if isinstance(event, dict) else None
@ -1030,14 +1071,14 @@ class RealTimeStreaming:
await self.log_messages()
@staticmethod
def _detect_beta_header(websocket: Any) -> bool:
def _detect_beta_header(websocket: _ScopedWebSocket) -> bool:
"""Return True if the client sent 'OpenAI-Beta: realtime=v1'.
Checks the raw ASGI scope headers so it works for both FastAPI WebSocket
objects and any test doubles that expose a .scope dict.
"""
try:
headers: Final[Sequence[tuple[bytes | str, bytes | str]]] = websocket.scope.get("headers", [])
headers: Final = websocket.scope.get("headers", [])
for name, value in headers:
if isinstance(name, bytes):
name = name.decode("latin-1")
@ -1183,6 +1224,7 @@ class RealTimeStreaming:
return item
async def client_ack_messages(self):
client_event: _ClientEventFrame
try:
while True:
message = await self.websocket.receive_text()
@ -1194,11 +1236,12 @@ class RealTimeStreaming:
from litellm.types.guardrails import GuardrailEventHooks
msg_obj = json.loads(message)
msg_type = msg_obj.get("type")
client_event = msg_obj
msg_type = client_event.get("type")
if msg_type == "conversation.item.create":
# Check user text messages for prompt injection
item = msg_obj.get("item", {})
item = client_event.get("item", {})
# Check function_call_output first so a client cannot
# bypass the tool-result guardrail by also setting
# role="user" on a function_call_output item.
@ -1297,7 +1340,7 @@ class RealTimeStreaming:
and not self._guardrail_turn_detection_update_sent
and self._has_audio_transcription_guardrails()
):
session: object = msg_obj.setdefault("session", {})
session: Mapping[str, object] | None = msg_obj.setdefault("session", {})
if isinstance(session, dict):
existing_td = session.get("turn_detection")
if not isinstance(existing_td, dict):
@ -1324,7 +1367,7 @@ class RealTimeStreaming:
and not guardrail_turn_detection_injected
and self._has_audio_transcription_guardrails()
):
session = msg_obj.get("session")
session = client_event.get("session")
if isinstance(session, dict):
td_overridden = False
flat_td = session.get("turn_detection")
@ -1367,14 +1410,14 @@ class RealTimeStreaming:
# the upstream is in GA mode. Beta upstreams expect the flat
# session shape unchanged.
if msg_type == "session.update" and not self._backend_uses_beta_protocol:
session = msg_obj.get("session", {})
session = client_event.get("session", {})
if isinstance(session, dict):
session = self._remap_beta_session_to_ga(session)
msg_obj["session"] = session
message = json.dumps(msg_obj)
if msg_type == "session.update" and self._event_normalizer:
session = msg_obj.get("session")
session = client_event.get("session")
if isinstance(session, dict):
msg_obj["session"] = self._event_normalizer.patch_outgoing_session(session)
message = json.dumps(msg_obj)

View file

@ -4,7 +4,7 @@ Handler for the Anthropic v1/messages -> OpenAI Responses API path.
Used when the target model is an OpenAI or Azure model.
"""
from collections.abc import AsyncIterator, Coroutine
from collections.abc import AsyncIterator, Coroutine, Mapping
from typing import Any, Final
import litellm
@ -25,6 +25,11 @@ from .transformation import LiteLLMAnthropicToResponsesAPIAdapter
_ADAPTER: Final = LiteLLMAnthropicToResponsesAPIAdapter()
def _forwarded_kwargs(extra_kwargs: Mapping[str, object] | None) -> Mapping[str, object]:
"""The litellm-specific kwargs forwarded verbatim onto the Responses API request."""
return extra_kwargs or {}
def _build_responses_kwargs(
*,
max_tokens: int,
@ -100,7 +105,7 @@ def _build_responses_kwargs(
# Forward litellm-specific kwargs (api_key, api_base, logging obj, etc.)
excluded: Final = {"anthropic_messages"}
for key, value in (extra_kwargs or {}).items():
for key, value in _forwarded_kwargs(extra_kwargs).items():
if key == "litellm_logging_obj" and value is not None:
from litellm.litellm_core_utils.litellm_logging import (
Logging as LiteLLMLoggingObject,

View file

@ -1,4 +1,7 @@
from typing import TYPE_CHECKING, Any, Final, Optional
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Optional, Protocol, TypeAlias
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
@ -40,7 +43,7 @@ def _generic_passthrough_handler() -> BaseTranslation:
_StringHolder = tuple[Any, str | int]
def _collect_strings(node: Any, holders: list[_StringHolder]) -> None:
def _collect_strings(node: object, holders: list[_StringHolder]) -> None:
"""
Record a (container, key) holder for every non-empty string value nested
under an arbitrary JSON node, so prompt content a caller hides in fields
@ -48,7 +51,7 @@ def _collect_strings(node: Any, holders: list[_StringHolder]) -> None:
and can be written back in place. Iterative to avoid unbounded recursion
on deeply nested payloads.
"""
stack: Final[list[Any]] = [node]
stack: Final[list[object]] = [node]
while stack:
current = stack.pop()
if isinstance(current, dict):
@ -129,7 +132,7 @@ def _extract_converse_texts(
def _extract_converse_output_texts(
content_blocks: list[Any],
content_blocks: Sequence[object],
) -> tuple[list[str], list[_StringHolder]]:
"""
Collect user-visible text from Bedrock Converse output content blocks.
@ -178,10 +181,34 @@ def _write_back_texts(
container[key] = guardrailed_texts[idx]
_DeltaHolder = tuple[Any, Any, str | int]
_GroupKey: TypeAlias = str | tuple[str, int]
def _collect_stream_delta_text_holders(delta: Any) -> list[_DeltaHolder]:
class _TextContainer(Protocol):
"""JSON object whose ``key`` entry holds a guardrailable text string."""
def __getitem__(self, key: str, /) -> str: ...
def __setitem__(self, key: str, value: str, /) -> None: ...
_DeltaHolder = tuple[_GroupKey, _TextContainer, str]
class _StreamFrame(TypedDict):
"""One raw event-stream frame plus the guardrailable texts it carries."""
raw: ReadOnly[bytes]
texts: ReadOnly[Sequence[tuple[_GroupKey, str]]]
def _unpack_uint32(buffer: bytes) -> int:
import struct
return struct.unpack("!I", buffer)[0]
def _collect_stream_delta_text_holders(delta: object) -> list[_DeltaHolder]:
"""
Collect the user-visible text strings a Bedrock Converse ``contentBlockDelta``
can carry, matching the coverage of the non-streaming output handler.
@ -238,11 +265,11 @@ class BedrockPassthroughGuardrailHandler(BaseTranslation):
from botocore.eventstream import EventStreamBuffer
frames: Final[list[dict]] = []
frames: Final[list[_StreamFrame]] = []
offset = 0
while offset + 16 <= len(body_bytes):
total_length = struct.unpack("!I", body_bytes[offset : offset + 4])[0]
total_length = _unpack_uint32(body_bytes[offset : offset + 4])
if total_length < 16 or offset + total_length > len(body_bytes):
break
frame_raw = body_bytes[offset : offset + total_length]
@ -263,10 +290,10 @@ class BedrockPassthroughGuardrailHandler(BaseTranslation):
frames.append({"raw": frame_raw, "texts": []})
continue
texts: list[tuple[Any, str]] = []
texts: list[tuple[_GroupKey, str]] = []
if event_type == "contentBlockDelta":
try:
payload_dict = _json.loads(payload_bytes)
payload_dict: dict[str, object] = _json.loads(payload_bytes)
texts = [
(group_key, container[key])
for group_key, container, key in _collect_stream_delta_text_holders(payload_dict.get("delta"))
@ -282,9 +309,9 @@ class BedrockPassthroughGuardrailHandler(BaseTranslation):
trailing_bytes: Final = body_bytes[offset:]
group_order: Final[list[Any]] = []
group_members: Final[dict[Any, list[tuple[int, int]]]] = {}
group_texts: Final[dict[Any, list[str]]] = {}
group_order: Final[list[_GroupKey]] = []
group_members: Final[dict[_GroupKey, list[tuple[int, int]]]] = {}
group_texts: Final[dict[_GroupKey, list[str]]] = {}
for frame_idx, frame in enumerate(frames):
for local_idx, (group_key, text) in enumerate(frame["texts"]):
if group_key not in group_members:
@ -351,8 +378,8 @@ class BedrockPassthroughGuardrailHandler(BaseTranslation):
continue
frame_raw = frame["raw"]
orig_total = struct.unpack("!I", frame_raw[0:4])[0]
orig_hdrs_len = struct.unpack("!I", frame_raw[4:8])[0]
orig_total = _unpack_uint32(frame_raw[0:4])
orig_hdrs_len = _unpack_uint32(frame_raw[4:8])
headers_bytes = frame_raw[12 : 12 + orig_hdrs_len]
try:
@ -386,7 +413,7 @@ class BedrockPassthroughGuardrailHandler(BaseTranslation):
data: dict,
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> Any:
) -> Mapping[str, object]:
endpoint: Final = data.get("endpoint", "")
body: Final = data.get("data")
@ -428,12 +455,12 @@ class BedrockPassthroughGuardrailHandler(BaseTranslation):
async def process_output_response(
self,
response: Any,
response: object,
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None,
user_api_key_dict: Any | None = None,
user_api_key_dict: Optional["UserAPIKeyAuth"] = None,
request_data: dict | None = None,
) -> Any:
) -> object:
endpoint: Final = (request_data or {}).get("endpoint", "")
if endpoint and not _is_converse_endpoint(endpoint):
return await _generic_passthrough_handler().process_output_response(

View file

@ -5,9 +5,11 @@ Implements the transformation between LiteLLM's unified vector store API
and Google Gemini's File Search API.
"""
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final
import httpx
from typing_extensions import ReadOnly, TypedDict
from litellm.llms.base_llm.vector_store.transformation import BaseVectorStoreConfig
from litellm.llms.gemini.common_utils import (
@ -35,6 +37,61 @@ else:
LiteLLMLoggingObj = Any
class GeminiRetrievedContext(TypedDict, total=False):
"""Passage Gemini retrieved from a File Search store."""
text: ReadOnly[str]
uri: ReadOnly[str]
title: ReadOnly[str]
class GeminiGroundingChunk(TypedDict, total=False):
"""One source Gemini grounded its answer on."""
retrievedContext: ReadOnly[GeminiRetrievedContext]
class GeminiGroundingSegment(TypedDict, total=False):
"""Span of the generated answer a grounding support refers to."""
text: ReadOnly[str]
class GeminiGroundingSupport(TypedDict, total=False):
"""Citation linking an answer span to the grounding chunks that back it."""
segment: ReadOnly[GeminiGroundingSegment]
groundingChunkIndices: ReadOnly[Sequence[int]]
confidenceScores: ReadOnly[Sequence[float]]
class GeminiFileSearchGroundingMetadata(TypedDict, total=False):
"""Grounding metadata Gemini returns for a File Search candidate."""
groundingChunks: ReadOnly[Sequence[GeminiGroundingChunk]]
groundingSupports: ReadOnly[Sequence[GeminiGroundingSupport]]
class GeminiFileSearchCandidate(TypedDict, total=False):
"""One candidate of a Gemini File Search ``generateContent`` response."""
groundingMetadata: ReadOnly[GeminiFileSearchGroundingMetadata]
class GeminiFileSearchResponse(TypedDict, total=False):
"""Body of a ``generateContent`` call made with the File Search tool."""
candidates: ReadOnly[Sequence[GeminiFileSearchCandidate]]
class GeminiFileSearchStore(TypedDict, total=False):
"""Body of a Gemini ``fileSearchStores`` create response."""
name: ReadOnly[str]
displayName: ReadOnly[str]
createTime: ReadOnly[str]
class GeminiVectorStoreConfig(BaseVectorStoreConfig):
"""
Vector store configuration for Google Gemini File Search.
@ -110,7 +167,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
api_base: str,
litellm_logging_obj: LiteLLMLoggingObj,
litellm_params: dict,
extra_body: dict[str, Any] | None = None,
extra_body: Mapping[str, object] | None = None,
) -> tuple[str, dict]:
"""
Transform search request to Gemini's generateContent format.
@ -133,7 +190,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
url: Final = f"{api_base}/models/{model}:generateContent"
# Build file_search tool configuration (using snake_case as per Gemini docs)
file_search_config: Final[dict[str, Any]] = {"file_search_store_names": [vector_store_id]}
file_search_config: Final[dict[str, object]] = {"file_search_store_names": [vector_store_id]}
# Add metadata filter if provided
metadata_filter: Final = vector_store_search_optional_params.get("filters")
@ -178,7 +235,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
Extracts grounding metadata and citations from the response.
"""
try:
response_data: Final = response.json()
response_data: Final[GeminiFileSearchResponse] = response.json()
results: Final[list[VectorStoreSearchResult]] = []
# Extract candidates and grounding metadata
@ -246,7 +303,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
)
)
query: Final = litellm_logging_obj.model_call_details.get("query", "")
query: Final[str] = litellm_logging_obj.model_call_details.get("query", "")
return VectorStoreSearchResponse(
object="vector_store.search_results.page",
@ -273,7 +330,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
# API key is passed via x-goog-api-key header (set in validate_environment)
request_body: Final[dict[str, Any]] = {}
request_body: Final[dict[str, object]] = {}
# Add display name if provided
name: Final = vector_store_create_optional_params.get("name")
@ -287,7 +344,7 @@ class GeminiVectorStoreConfig(BaseVectorStoreConfig):
Transform Gemini's fileSearchStore response to standard format.
"""
try:
response_data: Final = response.json()
response_data: Final[GeminiFileSearchStore] = response.json()
# Extract store name (format: fileSearchStores/xxxxxxx)
store_name: Final = response_data.get("name", "")

View file

@ -26,7 +26,9 @@ without the optional STT extras installed.
import asyncio
import inspect
from typing import TYPE_CHECKING, Any, Final
from collections.abc import Callable, Iterable
from types import ModuleType
from typing import TYPE_CHECKING, Any, Final, Protocol
from litellm.litellm_core_utils.audio_utils.utils import (
get_audio_file_name,
@ -62,6 +64,45 @@ _DEFAULT_CHUNK_BYTES: Final = _DEFAULT_CHUNK_SAMPLES * 2 # int16 = 2 bytes/samp
_RIVA_INSTALL_HINT = "NVIDIA Riva client is not installed. Install with `pip install 'litellm[stt-nvidia-riva]'`."
class _RivaAuth(Protocol):
"""Opaque ``riva.client.Auth`` handle."""
class _AsrService(Protocol):
@property
def streaming_response_generator(self) -> Callable[..., Iterable[object]]: ...
class _EndpointingConfig(Protocol):
"""Opaque ``EndpointingConfig`` protobuf message."""
class _EndpointingConfigField(Protocol):
CopyFrom: Callable[[_EndpointingConfig], None]
class _RecognitionConfig(Protocol):
@property
def endpointing_config(self) -> _EndpointingConfigField: ...
class _StreamingRecognitionConfig(Protocol):
"""Opaque ``StreamingRecognitionConfig`` protobuf message."""
class _AudioEncoding(Protocol):
@property
def LINEAR_PCM(self) -> object: ...
def _auth_factory(riva_module: ModuleType) -> Callable[..., _RivaAuth]:
return riva_module.Auth
def _audio_encoding(riva_asr_module: ModuleType) -> _AudioEncoding:
return riva_asr_module.AudioEncoding
class NvidiaRivaAudioTranscription:
"""Sync + async entry point for Riva ASR."""
@ -206,7 +247,9 @@ class NvidiaRivaAudioTranscription:
riva_asr_module=riva_asr_module,
recognition_config_dict=recognition_config_dict,
)
streaming_config = riva_asr_module.StreamingRecognitionConfig(config=recognition_config, interim_results=False)
streaming_config: Final[_StreamingRecognitionConfig] = riva_asr_module.StreamingRecognitionConfig(
config=recognition_config, interim_results=False
)
logging_obj.pre_call(
input=None,
@ -223,9 +266,9 @@ class NvidiaRivaAudioTranscription:
)
try:
asr_service: Final = riva_module.ASRService(auth_obj)
asr_service: Final[_AsrService] = riva_module.ASRService(auth_obj)
audio_chunks: Final = self._iter_audio_chunks(resampled.pcm_bytes)
stream_kwargs: Final[dict[str, Any]] = {
stream_kwargs: Final[dict[str, object]] = {
"audio_chunks": audio_chunks,
"streaming_config": streaming_config,
}
@ -274,11 +317,11 @@ class NvidiaRivaAudioTranscription:
def _construct_auth(
self,
riva_module: Any,
riva_module: ModuleType,
api_base: str,
api_key: str | None,
optional_params: dict,
) -> Any:
) -> _RivaAuth:
"""
Build a ``riva.client.Auth`` object.
@ -300,20 +343,22 @@ class NvidiaRivaAudioTranscription:
metadata.append(("authorization", f"Bearer {api_key}"))
try:
return riva_module.Auth(uri=api_base, use_ssl=use_ssl, metadata_args=metadata)
return _auth_factory(riva_module)(uri=api_base, use_ssl=use_ssl, metadata_args=metadata)
except TypeError:
# Older riva-client signatures used positional-only args.
return riva_module.Auth(None, use_ssl, api_base, metadata)
return _auth_factory(riva_module)(None, use_ssl, api_base, metadata)
def _build_recognition_config_proto(self, riva_asr_module: Any, recognition_config_dict: dict[str, Any]):
def _build_recognition_config_proto(
self, riva_asr_module: ModuleType, recognition_config_dict: dict[str, Any]
) -> _RecognitionConfig:
encoding_name: Final = (recognition_config_dict.get("encoding") or "LINEAR_PCM").upper()
encoding_enum: Final = getattr(
riva_asr_module.AudioEncoding,
encoding_enum: Final[object] = getattr(
_audio_encoding(riva_asr_module),
encoding_name,
riva_asr_module.AudioEncoding.LINEAR_PCM,
_audio_encoding(riva_asr_module).LINEAR_PCM,
)
config: Final = riva_asr_module.RecognitionConfig(
config: Final[_RecognitionConfig] = riva_asr_module.RecognitionConfig(
encoding=encoding_enum,
sample_rate_hertz=int(recognition_config_dict["sample_rate_hertz"]),
language_code=recognition_config_dict["language_code"],
@ -329,7 +374,7 @@ class NvidiaRivaAudioTranscription:
endpointing: Final = recognition_config_dict.get("endpointing_config")
if isinstance(endpointing, dict) and endpointing:
try:
ep: Final = riva_asr_module.EndpointingConfig(**endpointing)
ep: Final[_EndpointingConfig] = riva_asr_module.EndpointingConfig(**endpointing)
config.endpointing_config.CopyFrom(ep)
except Exception:
# If the user supplied an unknown EndpointingConfig field
@ -340,7 +385,7 @@ class NvidiaRivaAudioTranscription:
return config
@staticmethod
def _supports_timeout_kwarg(callable_obj: Any) -> bool:
def _supports_timeout_kwarg(callable_obj: Callable[..., object]) -> bool:
try:
sig: Final = inspect.signature(callable_obj)
except (TypeError, ValueError):
@ -359,14 +404,14 @@ class NvidiaRivaAudioTranscription:
yield chunk
@staticmethod
def _collect_final_results(stream) -> list[dict[str, Any]]:
def _collect_final_results(stream) -> list[dict[str, object]]:
"""
Walk the gRPC stream, ignore empty / non-final chunks, and return a
list of normalized final-result dicts. Matching the user's note: the
``id`` blocks with no ``results`` are streaming heartbeats and must
be skipped.
"""
final_results: Final[list[dict[str, Any]]] = []
final_results: Final[list[dict[str, object]]] = []
for response in stream:
results = getattr(response, "results", None) or []
for result in results:
@ -391,7 +436,7 @@ class NvidiaRivaAudioTranscription:
return final_results
def _import_riva():
def _import_riva() -> tuple[ModuleType, ModuleType]:
"""
Lazy import of ``riva.client`` and ``riva.client.proto.riva_asr_pb2``.

View file

@ -84,9 +84,9 @@ def adapt_messages_to_cohere_standard(
tool_calls_raw: Any = msg.get("tool_calls") or []
for tc in tool_calls_raw:
tc_id = tc.get("id", "")
raw_args: Any = tc.get("function", {}).get("arguments", "{}")
raw_args = tc.get("function", {}).get("arguments", "{}")
try:
params: dict[str, Any] = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
params: dict[str, object] = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
except json.JSONDecodeError:
params = {}
tool_call_lookup[tc_id] = CohereToolCall(
@ -111,10 +111,10 @@ def adapt_messages_to_cohere_standard(
if role == "assistant" and msg.get("tool_calls"):
tool_calls = []
for tc in msg["tool_calls"]: # pyright: ignore[reportOptionalIterable] # truthiness check above rules out None
raw_arguments: Any = tc.get("function", {}).get("arguments", {})
raw_arguments = tc.get("function", {}).get("arguments", {})
if isinstance(raw_arguments, str):
try:
arguments: dict[str, Any] = json.loads(raw_arguments)
arguments: dict[str, object] = json.loads(raw_arguments)
except json.JSONDecodeError:
arguments = {}
else:
@ -211,7 +211,7 @@ def handle_cohere_response(
response_text: Final = cohere_response.chatResponse.text
finish_reason: Final = _normalize_oci_finish_reason(cohere_response.chatResponse.finishReason)
tool_calls: list[dict[str, Any]] | None = None
tool_calls: list[dict[str, object]] | None = None
if cohere_response.chatResponse.toolCalls:
tool_calls = [
{
@ -232,7 +232,7 @@ def handle_cohere_response(
# ``"tool_calls" in message`` (rather than truthiness) incorrectly conclude
# that tool calls were attempted. Matches the generic handler's behaviour,
# which only sets ``message.tool_calls`` when tool calls are present.
message: Final[dict[str, Any]] = {"role": "assistant", "content": content}
message: Final[dict[str, object]] = {"role": "assistant", "content": content}
if tool_calls is not None:
message["tool_calls"] = tool_calls
@ -317,7 +317,7 @@ def handle_cohere_stream_chunk(
# passing them through is the only chance to surface them.
cohere_tool_calls = None if (is_terminal_consolidation and prior_tool_calls_emitted) else typed_chunk.toolCalls
tool_calls: list[dict[str, Any]] | None = None
tool_calls: list[dict[str, object]] | None = None
if cohere_tool_calls:
tool_calls = [
{

View file

@ -28,10 +28,13 @@ Output: response.output is List[GenericResponseOutputItem] where each has:
- text: str
"""
from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, Final, Union, cast
from openai.types.responses.response_function_tool_call import ResponseFunctionToolCall
from openai.types.responses.tool_param import FunctionToolParam
from pydantic import BaseModel
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.completion_extras.litellm_responses_transformation.transformation import (
@ -45,6 +48,7 @@ from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionToolCallChunk,
ChatCompletionToolParam,
OpenAIMcpServerTool,
ResponsesAPIStreamEvents,
)
from litellm.types.responses.main import (
@ -56,10 +60,26 @@ from litellm.types.utils import GenericGuardrailAPIInputs
if TYPE_CHECKING:
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.llms.openai import ResponseInputParam
from litellm.types.utils import ResponsesAPIResponse
class ResponseOutputEnvelope(TypedDict, total=False):
"""Dict form of a Responses API response, as far as guardrail write-back reads it."""
output: ReadOnly[Sequence[object]]
model: ReadOnly[str | None]
class ResponsesStreamChunk(TypedDict, total=False):
"""Responses API streaming event, as far as the accumulated-stream helpers read it."""
type: ReadOnly[str]
text: ReadOnly[str]
class OpenAIResponsesHandler(BaseTranslation):
"""
Handler for processing OpenAI Responses API with guardrails.
@ -91,8 +111,8 @@ class OpenAIResponsesHandler(BaseTranslation):
self,
data: dict,
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Any | None = None,
) -> Any:
litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
) -> dict[str, object]:
"""
Process input by applying guardrails to text content.
@ -108,7 +128,7 @@ class OpenAIResponsesHandler(BaseTranslation):
# Handle simple string input
if isinstance(input_data, str):
inputs = GenericGuardrailAPIInputs(texts=[input_data])
original_tools: list[dict[str, Any]] = []
original_tools: list[dict[str, object]] = []
# Extract and transform tools if present
if "tools" in data and data["tools"]:
@ -142,7 +162,7 @@ class OpenAIResponsesHandler(BaseTranslation):
texts_to_check: Final[list[str]] = []
images_to_check: Final[list[str]] = []
task_mappings: Final[list[tuple[int, int | None]]] = []
original_tools_list: Final[list[dict[str, Any]]] = list(data.get("tools") or [])
original_tools_list: Final[list[dict[str, object]]] = list(data.get("tools") or [])
# Step 1: Extract all text content, images, and tools
for msg_idx, message in enumerate(input_data):
@ -211,7 +231,7 @@ class OpenAIResponsesHandler(BaseTranslation):
def _extract_and_transform_tools(
self,
tools: list[dict[str, Any]],
tools: list[FunctionToolParam | OpenAIMcpServerTool],
tools_to_check: list[ChatCompletionToolParam],
) -> None:
"""
@ -228,7 +248,7 @@ class OpenAIResponsesHandler(BaseTranslation):
) = LiteLLMCompletionResponsesConfig.transform_responses_api_tools_to_chat_completion_tools(tools)
tools_to_check.extend(cast(list[ChatCompletionToolParam], transformed_tools))
def _remap_tools_to_responses_api_format(self, guardrailed_tools: list[Any]) -> list[dict[str, Any]]:
def _remap_tools_to_responses_api_format(self, guardrailed_tools: list[Any]) -> list[dict[str, object]]:
"""
Remap guardrail-returned tools (Chat Completion format) back to
Responses API request tool format.
@ -239,9 +259,9 @@ class OpenAIResponsesHandler(BaseTranslation):
def _merge_tools_after_guardrail(
self,
original_tools: list[dict[str, Any]],
remapped: list[dict[str, Any]],
) -> list[dict[str, Any]]:
original_tools: list[dict[str, object]],
remapped: list[dict[str, object]],
) -> list[dict[str, object]]:
"""
Merge remapped guardrailed tools with original tools that were not sent
to the guardrail (e.g. web_search, web_search_preview), preserving order.
@ -250,7 +270,7 @@ class OpenAIResponsesHandler(BaseTranslation):
"""
if not original_tools:
return remapped
result: Final[list[dict[str, Any]]] = []
result: Final[list[dict[str, object]]] = []
j = 0
for tool in original_tools:
if isinstance(tool, dict) and tool.get("type") in (
@ -269,8 +289,8 @@ class OpenAIResponsesHandler(BaseTranslation):
def _apply_guardrailed_tools_to_data(
self,
data: dict,
original_tools: list[dict[str, Any]],
guardrailed_tools: list[Any] | None,
original_tools: list[dict[str, object]],
guardrailed_tools: list[ChatCompletionToolParam] | None,
) -> None:
"""Remap guardrailed tools to Responses API format and merge with original, then set data['tools']."""
if guardrailed_tools is not None:
@ -279,7 +299,7 @@ class OpenAIResponsesHandler(BaseTranslation):
def _extract_input_text_and_images(
self,
message: Any, # Can be Dict[str, Any] or ResponseInputParam
message: Any,
msg_idx: int,
texts_to_check: list[str],
images_to_check: list[str],
@ -348,12 +368,12 @@ class OpenAIResponsesHandler(BaseTranslation):
async def process_output_response(
self,
response: "ResponsesAPIResponse",
response: Union["ResponsesAPIResponse", ResponseOutputEnvelope],
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Any | None = None,
user_api_key_dict: Any | None = None,
litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
user_api_key_dict: "UserAPIKeyAuth | None" = None,
request_data: dict | None = None,
) -> Any:
) -> Union["ResponsesAPIResponse", ResponseOutputEnvelope]:
"""
Process output response by applying guardrails to text content and tool calls.
@ -381,6 +401,7 @@ class OpenAIResponsesHandler(BaseTranslation):
# Track (output_item_index, content_index) for each text
# Handle both dict and Pydantic object responses
response_output: Sequence[object]
if isinstance(response, dict):
response_output = response.get("output", [])
elif hasattr(response, "output"):
@ -426,7 +447,7 @@ class OpenAIResponsesHandler(BaseTranslation):
if tool_calls_to_check:
inputs["tool_calls"] = tool_calls_to_check
# Include model information from the response if available
response_model = None
response_model: str | None = None
if isinstance(response, dict):
response_model = response.get("model")
elif hasattr(response, "model"):
@ -458,8 +479,8 @@ class OpenAIResponsesHandler(BaseTranslation):
self,
responses_so_far: list[Any],
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Any | None = None,
user_api_key_dict: Any | None = None,
litellm_logging_obj: "LiteLLMLoggingObj | None" = None,
user_api_key_dict: "UserAPIKeyAuth | None" = None,
request_data: dict | None = None,
) -> list[Any]:
"""
@ -488,10 +509,10 @@ class OpenAIResponsesHandler(BaseTranslation):
# final chunk; iterate output items, apply guardrail, write back. #
# ------------------------------------------------------------------ #
if final_chunk.get("type") == "response.completed":
response_obj: Final = final_chunk.get("response") or {}
response_obj: Final[ResponseOutputEnvelope] = final_chunk.get("response") or {}
if not hasattr(response_obj, "get"):
return responses_so_far
outputs: Final[list[Any]] = response_obj.get("output") or []
outputs: Final[Sequence[object]] = response_obj.get("output") or []
texts_to_check: Final[list[str]] = []
tool_calls_to_check: Final[list[ChatCompletionToolCallChunk]] = []
@ -586,7 +607,7 @@ class OpenAIResponsesHandler(BaseTranslation):
)
return responses_so_far
def _check_streaming_has_ended(self, responses_so_far: list[Any]) -> bool:
def _check_streaming_has_ended(self, responses_so_far: Sequence[ResponsesStreamChunk]) -> bool:
"""
Check if the streaming has ended.
"""
@ -599,7 +620,7 @@ class OpenAIResponsesHandler(BaseTranslation):
}
return responses_so_far[-1].get("type") in terminal_types
def get_streaming_string_so_far(self, responses_so_far: list[Any]) -> str:
def get_streaming_string_so_far(self, responses_so_far: Sequence[ResponsesStreamChunk]) -> str:
"""
Get the string so far from the responses so far.
"""
@ -641,7 +662,7 @@ class OpenAIResponsesHandler(BaseTranslation):
def _extract_output_text_and_images(
self,
output_item: Any,
output_item: object,
output_idx: int,
texts_to_check: list[str],
images_to_check: list[str],
@ -724,7 +745,7 @@ class OpenAIResponsesHandler(BaseTranslation):
async def _apply_guardrail_responses_to_output(
self,
response: Union["ResponsesAPIResponse", dict[Any, Any]],
response: Union["ResponsesAPIResponse", ResponseOutputEnvelope],
responses: list[str],
task_mappings: list[tuple[int, int]],
) -> None:

View file

@ -9,9 +9,11 @@ Ref: https://docs.snowflake.com/en/user-guide/snowflake-cortex/cortex-rest-api
"""
import json
from typing import TYPE_CHECKING, Any, Final
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict
import httpx
from typing_extensions import ReadOnly
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolCallChunk
from litellm.types.utils import (
@ -44,6 +46,47 @@ _CLAUDE_MODEL_PREFIXES: Final = (
)
class _AnthropicContentBlock(TypedDict, total=False):
type: ReadOnly[str]
text: ReadOnly[str]
id: ReadOnly[str]
name: ReadOnly[str]
input: ReadOnly[Mapping[str, object]]
class _AnthropicUsageBlock(TypedDict, total=False):
input_tokens: ReadOnly[int]
output_tokens: ReadOnly[int]
class _AnthropicMessagesResponse(TypedDict, total=False):
id: ReadOnly[str]
model: ReadOnly[str]
stop_reason: ReadOnly[str]
content: ReadOnly[Sequence[_AnthropicContentBlock]]
usage: ReadOnly[_AnthropicUsageBlock]
class _ChatCompletionsResponse(Protocol):
"""Response view that decodes the Cortex chat-completions body as a field mapping."""
def json(self) -> Mapping[str, object]: ...
class _MessagesResponse(Protocol):
"""Response view that decodes the Cortex messages body in Anthropic shape."""
def json(self) -> _AnthropicMessagesResponse: ...
def _decoded_chat_completions(response: _ChatCompletionsResponse) -> Mapping[str, object]:
return response.json()
def _decoded_messages(response: _MessagesResponse) -> _AnthropicMessagesResponse:
return response.json()
def _is_claude_model(model: str) -> bool:
"""Return True if model name (after stripping snowflake/ prefix) is a Claude model."""
name: Final = model.lower().removeprefix("snowflake/")
@ -129,7 +172,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig):
for tool in tools:
if tool.get("type") == "function" and "function" in tool:
func = tool["function"]
anthropic_tool: dict[str, Any] = {
anthropic_tool: dict[str, object] = {
"name": func.get("name", ""),
}
if "description" in func:
@ -173,7 +216,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig):
elif role == "assistant":
tool_calls = msg.get("tool_calls") if isinstance(msg, dict) else getattr(msg, "tool_calls", None)
if tool_calls:
content_blocks: list[dict[str, Any]] = []
content_blocks: list[dict[str, object]] = []
if content:
content_blocks.append({"type": "text", "text": content})
for tc in tool_calls:
@ -310,7 +353,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig):
model_name: Final = model.removeprefix("snowflake/")
body: Final[dict[str, Any]] = {
body: Final[dict[str, object]] = {
"model": model_name,
"messages": conversation,
"stream": stream,
@ -336,7 +379,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig):
messages: list[AllMessageValues],
optional_params: dict,
litellm_params: dict,
encoding: Any,
encoding: object,
api_key: str | None = None,
json_mode: bool | None = None,
) -> ModelResponse:
@ -356,7 +399,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig):
messages: list[AllMessageValues],
) -> ModelResponse:
"""Parse standard OpenAI chat completions response."""
response_json: Final = raw_response.json()
response_json: Final = _decoded_chat_completions(raw_response)
logging_obj.post_call(
input=messages,
@ -383,7 +426,7 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig):
messages: list[AllMessageValues],
) -> ModelResponse:
"""Parse Anthropic Messages response into OpenAI format."""
response_json: Final = raw_response.json()
response_json: Final = _decoded_messages(raw_response)
logging_obj.post_call(
input=messages,
@ -447,10 +490,10 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig):
def get_model_response_iterator(
self,
streaming_response: Any,
streaming_response: object,
sync_stream: bool,
json_mode: bool | None = False,
) -> Any:
) -> "SnowflakeStreamingHandler":
return SnowflakeStreamingHandler(
streaming_response=streaming_response,
sync_stream=sync_stream,
@ -468,7 +511,7 @@ class SnowflakeStreamingHandler(BaseModelResponseIterator):
def __init__(
self,
streaming_response: Any,
streaming_response: object,
sync_stream: bool,
json_mode: bool | None = False,
):

View file

@ -18,10 +18,11 @@ handler (analogous to the OpenAI / Azure transcription handlers).
import asyncio
import math
import time
from collections.abc import Coroutine
from collections.abc import Coroutine, Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final
import httpx
from typing_extensions import ReadOnly, TypedDict
from litellm.litellm_core_utils.audio_utils.utils import (
get_audio_file_name,
@ -57,6 +58,49 @@ else:
LiteLLMLoggingObj = Any
class _TranscriptionMeta(TypedDict, total=False):
"""Fields the handler reads from a Soniox transcription object."""
status: ReadOnly[str]
error_message: ReadOnly[str]
error_type: ReadOnly[str]
audio_duration_ms: ReadOnly[float]
class _IdentifiedResource(TypedDict):
"""Soniox create/upload response, carrying the new resource id."""
id: ReadOnly[str]
class _SonioxErrorBody(TypedDict, total=False):
"""Fields the handler reads from a Soniox error response body."""
error_message: ReadOnly[object]
error: ReadOnly[object]
class _SonioxJsonView(TypedDict, total=False):
"""Typed reads of decoded Soniox JSON response bodies."""
resource: ReadOnly[_IdentifiedResource]
transcription: ReadOnly[_TranscriptionMeta]
transcript: ReadOnly[Mapping[str, object]]
error: ReadOnly[_SonioxErrorBody]
class _HandlerOptions(TypedDict):
"""Handler-only options pulled out of ``optional_params``."""
poll_interval: ReadOnly[float]
max_attempts: ReadOnly[int]
cleanup: ReadOnly[Sequence[str]]
filename_override: ReadOnly[str | None]
audio_url: ReadOnly[str | None]
file_id: ReadOnly[str | None]
response_format: ReadOnly[str | None]
class SonioxAudioTranscriptionHandler:
"""Orchestrates the Soniox async transcription flow."""
@ -78,9 +122,9 @@ class SonioxAudioTranscriptionHandler:
api_base: str | None,
client: HTTPHandler | AsyncHTTPHandler | None = None,
atranscription: bool = False,
headers: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
provider_config: SonioxAudioTranscriptionConfig | None = None,
) -> TranscriptionResponse | Coroutine[Any, Any, TranscriptionResponse]:
) -> TranscriptionResponse | Coroutine[object, object, TranscriptionResponse]:
"""Sync/async dispatch for Soniox transcription requests.
Note: ``max_retries`` is accepted for signature compatibility with
@ -134,12 +178,12 @@ class SonioxAudioTranscriptionHandler:
api_key: str | None,
api_base: str | None,
provider_config: SonioxAudioTranscriptionConfig,
headers: dict[str, Any],
headers: dict[str, str],
) -> tuple[
dict[str, str], # auth headers
str, # api_base (no trailing slash)
dict[str, Any], # body for POST /v1/transcriptions (without file_id/audio_url)
dict[str, Any], # handler-only options (poll interval, cleanup, ...)
dict[str, object], # body for POST /v1/transcriptions (without file_id/audio_url)
_HandlerOptions, # handler-only options (poll interval, cleanup, ...)
]:
# Validate env -> auth headers.
auth_headers: Final = provider_config.validate_environment(
@ -184,32 +228,31 @@ class SonioxAudioTranscriptionHandler:
clamped_poll_interval: Final = max(SONIOX_MIN_POLL_INTERVAL, min(poll_interval, SONIOX_MAX_POLL_INTERVAL))
clamped_max_attempts: Final = max(1, min(max_attempts, SONIOX_MAX_POLL_ATTEMPTS))
handler_opts: Final[dict[str, Any]] = {
# response_format is handled by LiteLLM post-processing, not Soniox.
handler_opts: Final[_HandlerOptions] = {
"poll_interval": clamped_poll_interval,
"max_attempts": clamped_max_attempts,
"cleanup": cleanup,
"filename_override": filename_override,
"audio_url": params.pop("audio_url", None),
"file_id": params.pop("file_id", None),
"response_format": params.pop("response_format", None),
}
# Soniox does not accept `language` directly; map_openai_params should
# already have translated it, but drop any leftover to be safe.
params.pop("language", None)
# response_format is handled by LiteLLM post-processing, not Soniox.
handler_opts["response_format"] = params.pop("response_format", None)
return auth_headers, base_url, params, handler_opts
def _build_create_body(
self,
model: str,
optional_params: dict,
handler_opts: dict[str, Any],
optional_params: Mapping[str, object],
handler_opts: _HandlerOptions,
file_id: str | None,
) -> dict[str, Any]:
body: Final[dict[str, Any]] = {"model": model}
) -> dict[str, object]:
body: Final[dict[str, object]] = {"model": model}
# Soniox-native passthrough fields
for key, value in optional_params.items():
if value is None:
@ -224,7 +267,7 @@ class SonioxAudioTranscriptionHandler:
return body
@staticmethod
def _redact_body_for_logging(body: dict[str, Any]) -> dict[str, Any]:
def _redact_body_for_logging(body: dict[str, object]) -> dict[str, object]:
"""Return a shallow copy of ``body`` with secret fields redacted.
Soniox's create-transcription body can include
@ -248,7 +291,7 @@ class SonioxAudioTranscriptionHandler:
logging_obj: LiteLLMLoggingObj,
api_key: str | None,
api_base: str,
body: dict[str, Any],
body: dict[str, object],
) -> None:
try:
logging_obj.pre_call(
@ -270,8 +313,8 @@ class SonioxAudioTranscriptionHandler:
logging_obj: LiteLLMLoggingObj,
audio_file: FileTypes | None,
api_key: str | None,
body: dict[str, Any],
original_response: Any,
body: dict[str, object],
original_response: Mapping[str, object],
) -> None:
try:
logging_obj.post_call(
@ -285,6 +328,11 @@ class SonioxAudioTranscriptionHandler:
# observability integration must never break a real Soniox call.
pass
@staticmethod
def _transcription_meta(response: httpx.Response) -> _TranscriptionMeta:
polled: Final[_SonioxJsonView] = {"transcription": response.json()}
return polled["transcription"]
@staticmethod
def _raise_for_response(
response: httpx.Response,
@ -293,8 +341,8 @@ class SonioxAudioTranscriptionHandler:
) -> None:
if response.status_code >= 400:
try:
payload: Final = response.json()
message = payload.get("error_message") or payload.get("error") or response.text
payload: Final[_SonioxJsonView] = {"error": response.json()}
message = payload["error"].get("error_message") or payload["error"].get("error") or response.text
except Exception:
message = response.text
raise provider_config.get_error_class(
@ -319,7 +367,7 @@ class SonioxAudioTranscriptionHandler:
api_key: str | None,
api_base: str | None,
client: HTTPHandler | None,
headers: dict[str, Any],
headers: dict[str, str],
provider_config: SonioxAudioTranscriptionConfig,
) -> TranscriptionResponse:
auth_headers, base_url, opt_params, handler_opts = self._prepare(
@ -378,7 +426,8 @@ class SonioxAudioTranscriptionHandler:
timeout=timeout,
)
self._raise_for_response(create_resp, provider_config, "create transcription")
transcription_id = create_resp.json()["id"]
created: Final[_SonioxJsonView] = {"resource": create_resp.json()}
transcription_id = created["resource"]["id"]
transcription_meta: Final = self._sync_poll_until_completed(
http_client=http_client,
@ -397,9 +446,9 @@ class SonioxAudioTranscriptionHandler:
timeout=timeout,
)
self._raise_for_response(transcript_resp, provider_config, "fetch transcript")
transcript: Final = transcript_resp.json()
fetched: Final[_SonioxJsonView] = {"transcript": transcript_resp.json()}
payload: Final = {"transcription": transcription_meta, "transcript": transcript}
payload: Final = {"transcription": transcription_meta, "transcript": fetched["transcript"]}
response: Final = provider_config._build_response_from_payload(
payload,
model_response=model_response,
@ -454,7 +503,8 @@ class SonioxAudioTranscriptionHandler:
timeout=timeout,
)
self._raise_for_response(resp, provider_config, "upload file")
return resp.json()["id"]
uploaded: Final[_SonioxJsonView] = {"resource": resp.json()}
return uploaded["resource"]["id"]
def _sync_poll_until_completed(
self,
@ -466,7 +516,7 @@ class SonioxAudioTranscriptionHandler:
max_attempts: int,
timeout: float,
provider_config: SonioxAudioTranscriptionConfig,
) -> dict[str, Any]:
) -> _TranscriptionMeta:
for _ in range(max_attempts):
resp = http_client.get(
url=f"{base_url}/v1/transcriptions/{transcription_id}",
@ -474,7 +524,7 @@ class SonioxAudioTranscriptionHandler:
timeout=timeout,
)
self._raise_for_response(resp, provider_config, "poll transcription")
data = resp.json()
data = self._transcription_meta(resp)
status = data.get("status")
if status == "completed":
return data
@ -502,7 +552,7 @@ class SonioxAudioTranscriptionHandler:
http_client: HTTPHandler,
base_url: str,
auth_headers: dict[str, str],
cleanup: list[str],
cleanup: Sequence[str],
file_id_to_cleanup: str | None,
transcription_id: str | None,
timeout: float,
@ -548,7 +598,7 @@ class SonioxAudioTranscriptionHandler:
api_key: str | None,
api_base: str | None,
client: AsyncHTTPHandler | None,
headers: dict[str, Any],
headers: dict[str, str],
provider_config: SonioxAudioTranscriptionConfig,
) -> TranscriptionResponse:
import litellm
@ -610,7 +660,8 @@ class SonioxAudioTranscriptionHandler:
timeout=timeout,
)
self._raise_for_response(create_resp, provider_config, "create transcription")
transcription_id = create_resp.json()["id"]
created: Final[_SonioxJsonView] = {"resource": create_resp.json()}
transcription_id = created["resource"]["id"]
transcription_meta: Final = await self._async_poll_until_completed(
http_client=http_client,
@ -629,9 +680,9 @@ class SonioxAudioTranscriptionHandler:
timeout=timeout,
)
self._raise_for_response(transcript_resp, provider_config, "fetch transcript")
transcript: Final = transcript_resp.json()
fetched: Final[_SonioxJsonView] = {"transcript": transcript_resp.json()}
payload: Final = {"transcription": transcription_meta, "transcript": transcript}
payload: Final = {"transcription": transcription_meta, "transcript": fetched["transcript"]}
response: Final = provider_config._build_response_from_payload(
payload,
model_response=model_response,
@ -685,7 +736,8 @@ class SonioxAudioTranscriptionHandler:
timeout=timeout,
)
self._raise_for_response(resp, provider_config, "upload file")
return resp.json()["id"]
uploaded: Final[_SonioxJsonView] = {"resource": resp.json()}
return uploaded["resource"]["id"]
async def _async_poll_until_completed(
self,
@ -697,7 +749,7 @@ class SonioxAudioTranscriptionHandler:
max_attempts: int,
timeout: float,
provider_config: SonioxAudioTranscriptionConfig,
) -> dict[str, Any]:
) -> _TranscriptionMeta:
for _ in range(max_attempts):
resp = await http_client.get(
url=f"{base_url}/v1/transcriptions/{transcription_id}",
@ -705,7 +757,7 @@ class SonioxAudioTranscriptionHandler:
timeout=timeout,
)
self._raise_for_response(resp, provider_config, "poll transcription")
data = resp.json()
data = self._transcription_meta(resp)
status = data.get("status")
if status == "completed":
return data
@ -733,7 +785,7 @@ class SonioxAudioTranscriptionHandler:
http_client: AsyncHTTPHandler,
base_url: str,
auth_headers: dict[str, str],
cleanup: list[str],
cleanup: Sequence[str],
file_id_to_cleanup: str | None,
transcription_id: str | None,
timeout: float,

View file

@ -9,10 +9,11 @@ import os
import re
from collections.abc import Mapping, Sequence
from pathlib import PurePosixPath
from typing import Any, Final, TypeAlias, TypedDict
from typing import Any, Final, TypedDict
from urllib.parse import quote
import httpx
from typing_extensions import ReadOnly, Required
# Tool names emitted from OpenAPI specs must work across all major LLM providers.
# OpenAI/Anthropic/Bedrock all enforce a character class roughly equivalent to
@ -47,11 +48,17 @@ from litellm.proxy._experimental.mcp_server.tool_registry import (
global_mcp_tool_registry,
)
_OpenAPIParameter: TypeAlias = Mapping[str, Any]
class _OpenAPIJSONSchema(TypedDict, total=False):
properties: Mapping[str, object]
type: ReadOnly[str]
class _OpenAPIParameter(TypedDict, total=False):
name: Required[ReadOnly[str]]
description: ReadOnly[str]
required: ReadOnly[bool]
schema: ReadOnly[_OpenAPIJSONSchema]
class _OpenAPIMediaType(TypedDict, total=False):
@ -241,7 +248,7 @@ def resolve_operation_params(
operation: _OpenAPIOperation,
path_item: _OpenAPIPathItem,
components: _OpenAPIComponents,
) -> dict[str, Any]:
) -> _OpenAPIOperation:
"""Return a copy of *operation* with fully-resolved, merged parameters.
Handles two common patterns in real-world OpenAPI specs:
@ -261,12 +268,11 @@ def resolve_operation_params(
op_level: Final = _resolve_param_list(operation.get("parameters", []), component_params)
op_keys: Final = {(p["name"], p.get("in")) for p in op_level}
merged: Final = [p for p in path_level if (p["name"], p.get("in")) not in op_keys] + op_level
result: Final = dict(operation)
result["parameters"] = merged
result: Final[_OpenAPIOperation] = {**operation, "parameters": merged}
return result
def extract_parameters(operation: Mapping[str, Any]) -> tuple[Sequence[str], Sequence[str], Sequence[str]]:
def extract_parameters(operation: _OpenAPIOperation) -> tuple[Sequence[str], Sequence[str], Sequence[str]]:
"""Extract parameter names from OpenAPI operation."""
path_params: Final = []
query_params: Final = []
@ -292,7 +298,7 @@ def extract_parameters(operation: Mapping[str, Any]) -> tuple[Sequence[str], Seq
return path_params, query_params, body_params
def build_input_schema(operation: Mapping[str, Any]) -> dict[str, Any]:
def build_input_schema(operation: _OpenAPIOperation) -> dict[str, object]:
"""Build MCP input schema from OpenAPI operation."""
properties: Final = {}
required: Final = []
@ -389,7 +395,7 @@ def _merge_openapi_tool_request_headers(
def create_tool_function(
path: str,
method: str,
operation: Mapping[str, Any],
operation: _OpenAPIOperation,
base_url: str,
headers: dict[str, str] | None = None,
):
@ -443,7 +449,7 @@ def create_tool_function(
url = url.replace("{{" + param_name + "}}", safe_value)
# Build query params using original parameter names
params: Final[dict[str, Any]] = {}
params: Final[dict[str, object]] = {}
for param_name in query_params:
param_value = kwargs.get(param_name, "")
if param_value:
@ -451,7 +457,7 @@ def create_tool_function(
params[param_name] = param_value
# Build request body
json_body: dict[str, Any] | None = None
json_body: dict[str, object] | None = None
if body_params:
# Try "body" first (most common), then check all body param names
body_value = kwargs.get("body", {})
@ -492,7 +498,7 @@ def create_tool_function(
def register_tools_from_openapi(spec: Mapping[str, Any], base_url: str) -> None:
"""Register MCP tools from OpenAPI specification."""
paths: Final[Mapping[str, Mapping[str, Any]]] = spec.get("paths", {})
paths: Final[Mapping[str, Mapping[str, _OpenAPIOperation]]] = spec.get("paths", {})
used_names: Final = set()
for path, path_item in paths.items():

View file

@ -41,6 +41,7 @@ from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
if TYPE_CHECKING:
from mcp.types import CallToolResult
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._experimental.mcp_server.db import OAuthCredentialPayload
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
from litellm.types.mcp import MCPAuth
@ -108,7 +109,7 @@ if MCP_AVAILABLE:
########################################################
############ MCP Server REST API Routes #################
async def _safe_fire_mcp_tool_call_logging(
logging_obj: Any | None,
logging_obj: "LiteLLMLoggingObj | None",
result: "CallToolResult",
start_time: datetime,
end_time: datetime,
@ -158,7 +159,7 @@ if MCP_AVAILABLE:
data: dict[str, Any],
tool_name: str,
user_api_key_dict: UserAPIKeyAuth,
) -> Any:
) -> "CallToolResult":
"""Handle the virtual ``mcp_tool_search`` / ``mcp_tool_call`` REST tools (gated on
``mcp_tool_search_enabled``). Kept out of ``call_tool_rest_api`` so that endpoint stays a single
dispatch. An upstream 401 raised by the virtual ``mcp_tool_call`` propagates unhandled to the
@ -298,8 +299,8 @@ if MCP_AVAILABLE:
"""
if not _is_v1_resolved_oauth2_server(server):
return None
user_id: Final = getattr(user_api_key_dict, "user_id", None)
server_id: Final = getattr(server, "server_id", None)
user_id: Final[str | None] = getattr(user_api_key_dict, "user_id", None)
server_id: Final[str | None] = getattr(server, "server_id", None)
if not user_id or not server_id:
return None
try:
@ -343,7 +344,7 @@ if MCP_AVAILABLE:
Returns a dict keyed by server_id. Used to avoid N+1 DB queries when
iterating over multiple OAuth2 MCP servers.
"""
user_id: Final = getattr(user_api_key_dict, "user_id", None)
user_id: Final[str | None] = getattr(user_api_key_dict, "user_id", None)
if not user_id:
return {}
try:
@ -664,7 +665,7 @@ if MCP_AVAILABLE:
"message": "Successfully retrieved tools",
}
def _as_query_str(value: Any) -> str | None:
def _as_query_str(value: object) -> str | None:
"""Coerce an Optional[str] Query param to str|None, dropping unresolved FastAPI defaults."""
return value if isinstance(value, str) else None
@ -935,8 +936,8 @@ if MCP_AVAILABLE:
user_api_key_dict = await acting_user_auth(user_api_key_dict)
data = await request.json()
tool_name: Final = data.get("name")
tool_arguments: Final = data.get("arguments") or {}
tool_name: Final[str | None] = data.get("name")
tool_arguments: Final[dict[str, object]] = data.get("arguments") or {}
from litellm.proxy._experimental.mcp_server.tool_search import (
MCP_TOOL_CALL_TOOL_NAME,
@ -947,7 +948,7 @@ if MCP_AVAILABLE:
return await _handle_virtual_mcp_tool(request, data, tool_name, user_api_key_dict)
# Validate required parameters early
server_id: Final = data.get("server_id")
server_id: Final[str | None] = data.get("server_id")
if not server_id:
raise HTTPException(
status_code=400,
@ -1123,11 +1124,11 @@ if MCP_AVAILABLE:
async def _execute_with_mcp_client(
request: NewMCPServerRequest,
operation: Callable[..., Awaitable[Any]],
operation: Callable[..., Awaitable[Mapping[str, object]]],
mcp_auth_header: str | dict[str, str] | None = None,
oauth2_headers: dict[str, str] | None = None,
raw_headers: dict[str, str] | None = None,
) -> dict:
) -> Mapping[str, object]:
"""
Create a temporary MCP client from *request*, run *operation*, and return the result.

View file

@ -1,5 +1,6 @@
import builtins
import json
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import Any, Final, Literal
@ -7,10 +8,30 @@ import click
import requests
import rich
from rich.table import Table
from typing_extensions import ReadOnly, TypedDict
from ...keys import KeysManagementClient
class _CliContext(TypedDict):
"""Values the top-level CLI group stores on the click context."""
base_url: ReadOnly[str]
api_key: ReadOnly[str | None]
class _CliContextView(TypedDict):
obj: ReadOnly[_CliContext]
class _KeyRowsView(TypedDict):
rows: ReadOnly[Sequence[Mapping[str, object]]]
class _JsonBodyView(TypedDict):
body: ReadOnly[object]
@click.group()
def keys():
"""Manage API keys for the LiteLLM proxy server"""
@ -53,7 +74,8 @@ def list(
return_full_object: bool,
):
"""List all API keys"""
client: Final = KeysManagementClient(ctx.obj["base_url"], ctx.obj["api_key"])
context: Final[_CliContextView] = {"obj": ctx.obj}
client: Final = KeysManagementClient(context["obj"]["base_url"], context["obj"]["api_key"])
response: Final = client.list(
page=page,
size=size,
@ -70,14 +92,16 @@ def list(
if output_format == "json":
rich.print_json(data=response)
else:
rich.print(f"Showing {len(response.get('keys', []))} keys out of {response.get('total_count', 0)}")
listed: Final[_KeyRowsView] = {"rows": response.get("keys", [])}
rich.print(f"Showing {len(listed['rows'])} keys out of {response.get('total_count', 0)}")
table: Final = Table(title="API Keys")
table.add_column("Key Hash", style="cyan")
table.add_column("Alias", style="green")
table.add_column("User ID", style="magenta")
table.add_column("Team ID", style="yellow")
table.add_column("Spend", style="red")
for key in response.get("keys", []):
key_rows: Final[_KeyRowsView] = {"rows": response.get("keys", [])}
for key in key_rows["rows"]:
table.add_row(
str(key.get("token", "")),
str(key.get("key_alias", "")),
@ -116,7 +140,8 @@ def generate(
config: str | None,
):
"""Generate a new API key"""
client: Final = KeysManagementClient(ctx.obj["base_url"], ctx.obj["api_key"])
context: Final[_CliContextView] = {"obj": ctx.obj}
client: Final = KeysManagementClient(context["obj"]["base_url"], context["obj"]["api_key"])
try:
models_list: Final = [m.strip() for m in models.split(",")] if models else None
aliases_dict: Final = json.loads(aliases) if aliases else None
@ -139,8 +164,8 @@ def generate(
except requests.exceptions.HTTPError as e:
click.echo(f"Error: HTTP {e.response.status_code}", err=True)
try:
error_body: Final = e.response.json()
rich.print_json(data=error_body)
error_body: Final[_JsonBodyView] = {"body": e.response.json()}
rich.print_json(data=error_body["body"])
except json.JSONDecodeError:
click.echo(e.response.text, err=True)
raise click.Abort()
@ -152,7 +177,8 @@ def generate(
@click.pass_context
def delete(ctx: click.Context, keys: str | None, key_aliases: str | None):
"""Delete API keys by key or alias"""
client: Final = KeysManagementClient(ctx.obj["base_url"], ctx.obj["api_key"])
context: Final[_CliContextView] = {"obj": ctx.obj}
client: Final = KeysManagementClient(context["obj"]["base_url"], context["obj"]["api_key"])
keys_list: Final = [k.strip() for k in keys.split(",")] if keys else None
aliases_list: Final = [a.strip() for a in key_aliases.split(",")] if key_aliases else None
try:
@ -161,8 +187,8 @@ def delete(ctx: click.Context, keys: str | None, key_aliases: str | None):
except requests.exceptions.HTTPError as e:
click.echo(f"Error: HTTP {e.response.status_code}", err=True)
try:
error_body: Final = e.response.json()
rich.print_json(data=error_body)
error_body: Final[_JsonBodyView] = {"body": e.response.json()}
rich.print_json(data=error_body["body"])
except json.JSONDecodeError:
click.echo(e.response.text, err=True)
raise click.Abort()
@ -189,10 +215,10 @@ def _parse_created_since_filter(created_since: str | None) -> datetime | None:
def _fetch_all_keys_with_pagination(
source_client: KeysManagementClient, source_base_url: str
) -> builtins.list[dict[str, Any]]:
) -> Sequence[Mapping[str, object]]:
"""Fetch all keys from source instance using pagination."""
click.echo(f"Fetching keys from source server: {source_base_url}")
source_keys: Final = []
source_keys: Final[builtins.list[Mapping[str, object]]] = []
page = 1
page_size: Final = 100 # Use a larger page size to minimize API calls
@ -200,7 +226,7 @@ def _fetch_all_keys_with_pagination(
source_response = source_client.list(return_full_object=True, page=page, size=page_size)
# source_client.list() returns Dict[str, Any] when return_request is False (default)
assert isinstance(source_response, dict), "Expected dict response from list API"
page_keys = source_response.get("keys", [])
page_keys: Sequence[Mapping[str, object]] = source_response.get("keys", [])
if not page_keys:
break
@ -218,15 +244,15 @@ def _fetch_all_keys_with_pagination(
def _filter_keys_by_created_since(
source_keys: builtins.list[dict[str, Any]],
source_keys: Sequence[Mapping[str, object]],
created_since_dt: datetime | None,
created_since: str,
) -> builtins.list[dict[str, Any]]:
) -> Sequence[Mapping[str, object]]:
"""Filter keys by created_since date if specified."""
if not created_since_dt:
return source_keys
filtered_keys: Final = []
filtered_keys: Final[builtins.list[Mapping[str, object]]] = []
for key in source_keys:
key_created_at = key.get("created_at")
if key_created_at:
@ -248,7 +274,7 @@ def _filter_keys_by_created_since(
return filtered_keys
def _display_dry_run_table(source_keys: builtins.list[dict[str, Any]]) -> None:
def _display_dry_run_table(source_keys: Sequence[Mapping[str, object]]) -> None:
"""Display a table of keys that would be imported in dry-run mode."""
click.echo("\n--- DRY RUN MODE ---")
table: Final = Table(title="Keys that would be imported")
@ -271,7 +297,7 @@ def _display_dry_run_table(source_keys: builtins.list[dict[str, Any]]) -> None:
rich.print(table)
def _prepare_key_import_data(key: dict[str, Any]) -> dict[str, Any]:
def _prepare_key_import_data(key: Mapping[str, object]) -> dict[str, Any]:
"""Prepare key data for import by extracting relevant fields."""
import_data: Final = {}
@ -293,7 +319,7 @@ def _prepare_key_import_data(key: dict[str, Any]) -> dict[str, Any]:
def _import_keys_to_destination(
source_keys: builtins.list[dict[str, Any]], dest_client: KeysManagementClient
source_keys: Sequence[Mapping[str, object]], dest_client: KeysManagementClient
) -> tuple[int, int]:
"""Import each key to the destination instance and return counts."""
imported_count = 0
@ -351,7 +377,8 @@ def import_keys(
# Create clients for both source and destination
source_client: Final = KeysManagementClient(source_base_url, source_api_key)
dest_client: Final = KeysManagementClient(ctx.obj["base_url"], ctx.obj["api_key"])
context: Final[_CliContextView] = {"obj": ctx.obj}
dest_client: Final = KeysManagementClient(context["obj"]["base_url"], context["obj"]["api_key"])
try:
# Get all keys from source instance with pagination
@ -383,8 +410,8 @@ def import_keys(
except requests.exceptions.HTTPError as e:
click.echo(f"Error: HTTP {e.response.status_code}", err=True)
try:
error_body: Final = e.response.json()
rich.print_json(data=error_body)
error_body: Final[_JsonBodyView] = {"body": e.response.json()}
rich.print_json(data=error_body["body"])
except json.JSONDecodeError:
click.echo(e.response.text, err=True)
raise click.Abort()

View file

@ -7,10 +7,11 @@
import asyncio
import json
import os
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING, Any, Final
from collections.abc import AsyncGenerator, AsyncIterator, Mapping, Sequence
from typing import TYPE_CHECKING, Final, TypeAlias
from pydantic import BaseModel
from typing_extensions import NotRequired, ReadOnly, TypedDict
from websockets.asyncio.client import ClientConnection, connect
from litellm import DualCache
@ -31,8 +32,7 @@ from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import (
CallTypesLiteral,
Choices,
EmbeddingResponse,
ImageResponse,
LLMResponseTypes,
ModelResponse,
ModelResponseStream,
)
@ -45,6 +45,58 @@ class AimGuardrailMissingSecrets(Exception):
pass
class AimRequiredAction(TypedDict):
"""The ``required_action`` block of an Aim ``/fw/v1/analyze`` response."""
action_type: ReadOnly[NotRequired[str]]
detection_message: ReadOnly[str]
class AimAnalysisResult(TypedDict):
"""The ``analysis_result`` block of an Aim ``/fw/v1/analyze`` response."""
policy_drill_down: ReadOnly[Mapping[str, object]]
class AimRedactedMessage(TypedDict):
"""One entry of Aim's ``redacted_chat.all_redacted_messages``."""
role: ReadOnly[str]
content: ReadOnly[str]
class AimRedactedChat(TypedDict):
"""The ``redacted_chat`` block of an Aim ``/fw/v1/analyze`` response."""
all_redacted_messages: ReadOnly[Sequence[AimRedactedMessage]]
class AimAnalyzeResponse(TypedDict):
"""Body returned by Aim's ``POST /fw/v1/analyze``."""
required_action: ReadOnly[AimRequiredAction]
analysis_result: ReadOnly[AimAnalysisResult]
redacted_chat: ReadOnly[NotRequired[AimRedactedChat]]
class AimOutputGuardrailResult(TypedDict, total=False):
"""Outcome of inspecting one model completion with Aim."""
detection_message: ReadOnly[str]
redacted_output: ReadOnly[str]
class AimStreamMessage(TypedDict, total=False):
"""One frame of Aim's ``/fw/v1/analyze/stream`` websocket protocol."""
verified_chunk: ReadOnly[Mapping[str, object]]
done: ReadOnly[bool]
blocking_message: ReadOnly[str]
AimStreamChunk: TypeAlias = BaseModel | Mapping[str, object] | str | bytes
class AimGuardrail(CustomGuardrail):
@classmethod
def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]:
@ -110,7 +162,7 @@ class AimGuardrail(CustomGuardrail):
json={"messages": self._build_aim_inspection_messages(data)},
)
response.raise_for_status()
res: Final = response.json()
res: Final[AimAnalyzeResponse] = response.json()
required_action: Final = res.get("required_action")
action_type: Final = required_action and required_action.get("action_type", None)
if action_type is None:
@ -145,7 +197,7 @@ class AimGuardrail(CustomGuardrail):
openai_code=openai_code,
)
def _handle_block_action(self, analysis_result: Any, required_action: Any) -> None:
def _handle_block_action(self, analysis_result: AimAnalysisResult, required_action: AimRequiredAction) -> None:
detection_message: Final = required_action.get("detection_message", None)
verbose_proxy_logger.info(
"Aim: Violation detected enabled policies: {policies}".format(
@ -154,7 +206,7 @@ class AimGuardrail(CustomGuardrail):
)
raise self._rejection(detection_message, openai_code="content_policy_violation")
def _anonymize_request(self, res: Any, data: dict) -> dict:
def _anonymize_request(self, res: AimAnalyzeResponse, data: dict) -> dict:
verbose_proxy_logger.info("Aim: anonymize action")
redacted_chat: Final = res.get("redacted_chat")
if not redacted_chat:
@ -185,7 +237,7 @@ class AimGuardrail(CustomGuardrail):
async def call_aim_guardrail_on_output(
self, request_data: dict, output: str, hook: str, key_alias: str | None
) -> dict | None:
) -> AimOutputGuardrailResult | None:
user_email: Final = request_data.get("metadata", {}).get("headers", {}).get("x-aim-user-email")
call_id: Final = request_data.get("litellm_call_id")
response: Final = await self.async_handler.post(
@ -202,7 +254,7 @@ class AimGuardrail(CustomGuardrail):
},
)
response.raise_for_status()
res: Final = response.json()
res: Final[AimAnalyzeResponse] = response.json()
required_action: Final = res.get("required_action")
action_type: Final = required_action and required_action.get("action_type", None)
if action_type and action_type == "block_action":
@ -213,7 +265,9 @@ class AimGuardrail(CustomGuardrail):
return {"redacted_output": redacted_chat["all_redacted_messages"][-1]["content"]}
return {"redacted_output": output}
def _handle_block_action_on_output(self, analysis_result: Any, required_action: Any) -> dict | None:
def _handle_block_action_on_output(
self, analysis_result: AimAnalysisResult, required_action: AimRequiredAction
) -> AimOutputGuardrailResult | None:
detection_message: Final = required_action.get("detection_message", None)
verbose_proxy_logger.info(
"Aim: detected: {detected}, enabled policies: {policies}".format(
@ -260,8 +314,8 @@ class AimGuardrail(CustomGuardrail):
self,
data: dict,
user_api_key_dict: UserAPIKeyAuth,
response: Any | ModelResponse | EmbeddingResponse | ImageResponse,
) -> Any:
response: LLMResponseTypes,
) -> LLMResponseTypes:
if not (isinstance(response, ModelResponse) and response.choices):
return response
# Inspect every choice — when ``n>1`` the additional completions
@ -289,9 +343,11 @@ class AimGuardrail(CustomGuardrail):
for choice, aim_output_guardrail_result in zip(choices_to_inspect, results):
if isinstance(aim_output_guardrail_result, BaseException):
raise aim_output_guardrail_result
if aim_output_guardrail_result and aim_output_guardrail_result.get("detection_message"):
if aim_output_guardrail_result and (
detection_message := aim_output_guardrail_result.get("detection_message")
):
raise self._rejection(
aim_output_guardrail_result.get("detection_message"),
detection_message,
openai_code="content_policy_violation",
)
if aim_output_guardrail_result and aim_output_guardrail_result.get("redacted_output"):
@ -301,7 +357,7 @@ class AimGuardrail(CustomGuardrail):
async def async_post_call_streaming_iterator_hook(
self,
user_api_key_dict: UserAPIKeyAuth,
response,
response: AsyncIterator[AimStreamChunk],
request_data: dict,
) -> AsyncGenerator[ModelResponseStream, None]:
user_email: Final = request_data.get("metadata", {}).get("headers", {}).get("x-aim-user-email")
@ -317,7 +373,7 @@ class AimGuardrail(CustomGuardrail):
) as websocket:
sender: Final = asyncio.create_task(self.forward_the_stream_to_aim(websocket, response))
while True:
result = json.loads(await websocket.recv())
result: AimStreamMessage = json.loads(await websocket.recv())
if verified_chunk := result.get("verified_chunk"):
yield ModelResponseStream.model_validate(verified_chunk)
else:
@ -334,7 +390,7 @@ class AimGuardrail(CustomGuardrail):
async def forward_the_stream_to_aim(
self,
websocket: ClientConnection,
response_iter,
response_iter: AsyncIterator[AimStreamChunk],
) -> None:
async for chunk in response_iter:
if isinstance(chunk, BaseModel):

View file

@ -7,10 +7,13 @@ and provide safe, sandboxed functionality for common guardrail operations.
import json
import re
from collections.abc import Mapping, Sequence
from typing import Any, Final
from urllib.parse import urlparse
import httpx
from pydantic import JsonValue
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
@ -21,7 +24,7 @@ from litellm.types.llms.custom_http import httpxSpecialProvider
# =============================================================================
def allow() -> dict[str, Any]:
def allow() -> dict[str, object]:
"""
Allow the request/response to proceed unchanged.
@ -31,7 +34,7 @@ def allow() -> dict[str, Any]:
return {"action": "allow"}
def block(reason: str, detection_info: dict[str, Any] | None = None) -> dict[str, Any]:
def block(reason: str, detection_info: Mapping[str, object] | None = None) -> dict[str, object]:
"""
Block the request/response with a reason.
@ -42,17 +45,17 @@ def block(reason: str, detection_info: dict[str, Any] | None = None) -> dict[str
Returns:
Dict indicating the request should be blocked
"""
result: Final[dict[str, Any]] = {"action": "block", "reason": reason}
result: Final[dict[str, object]] = {"action": "block", "reason": reason}
if detection_info:
result["detection_info"] = detection_info
return result
def modify(
texts: list[str] | None = None,
images: list[Any] | None = None,
tool_calls: list[Any] | None = None,
) -> dict[str, Any]:
texts: Sequence[str] | None = None,
images: Sequence[object] | None = None,
tool_calls: Sequence[object] | None = None,
) -> dict[str, object]:
"""
Modify the request/response content.
@ -64,7 +67,7 @@ def modify(
Returns:
Dict indicating the content should be modified
"""
result: Final[dict[str, Any]] = {"action": "modify"}
result: Final[dict[str, object]] = {"action": "modify"}
if texts is not None:
result["texts"] = texts
if images is not None:
@ -161,7 +164,15 @@ def regex_find_all(text: str, pattern: str, flags: int = 0) -> list[str]:
# =============================================================================
def json_parse(text: str) -> Any | None:
class JsonSchemaNode(TypedDict, total=False):
"""Subset of JSON Schema keywords understood by the built-in validator."""
type: ReadOnly[str]
required: ReadOnly[Sequence[str]]
properties: ReadOnly[Mapping[str, "JsonSchemaNode"]]
def json_parse(text: str) -> JsonValue:
"""
Parse a JSON string into a Python object.
@ -178,7 +189,7 @@ def json_parse(text: str) -> Any | None:
return None
def json_stringify(obj: Any) -> str:
def json_stringify(obj: object) -> str:
"""
Convert a Python object to a JSON string.
@ -195,7 +206,7 @@ def json_stringify(obj: Any) -> str:
return ""
def json_schema_valid(obj: Any, schema: dict[str, Any]) -> bool:
def json_schema_valid(obj: JsonValue, schema: JsonSchemaNode) -> bool:
"""
Validate an object against a JSON schema.
@ -226,7 +237,7 @@ def json_schema_valid(obj: Any, schema: dict[str, Any]) -> bool:
return False
def _basic_json_schema_validate(obj: Any, schema: dict[str, Any], max_depth: int = 50) -> bool:
def _basic_json_schema_validate(obj: JsonValue, schema: JsonSchemaNode, max_depth: int = 50) -> bool:
"""
Basic JSON schema validation without external library.
Handles: type, required, properties
@ -234,7 +245,7 @@ def _basic_json_schema_validate(obj: Any, schema: dict[str, Any], max_depth: int
Uses an iterative approach with a stack to avoid recursion limits.
max_depth limits nesting to prevent infinite loops from circular schemas.
"""
type_map: Final[dict[str, type | tuple[type, ...]]] = {
type_map: Final[Mapping[str, type | tuple[type, ...]]] = {
"object": dict,
"array": list,
"string": str,
@ -245,7 +256,7 @@ def _basic_json_schema_validate(obj: Any, schema: dict[str, Any], max_depth: int
}
# Stack of (obj, schema, depth) tuples to process
stack: Final[list[tuple[Any, dict[str, Any], int]]] = [(obj, schema, 0)]
stack: Final[list[tuple[JsonValue, JsonSchemaNode, int]]] = [(obj, schema, 0)]
while stack:
current_obj, current_schema, depth = stack.pop()
@ -257,19 +268,19 @@ def _basic_json_schema_validate(obj: Any, schema: dict[str, Any], max_depth: int
# Check type
schema_type = current_schema.get("type")
if schema_type:
expected_type = type_map.get(schema_type)
expected_type: type | tuple[type, ...] | None = type_map.get(schema_type)
if expected_type is not None and not isinstance(current_obj, expected_type):
return False
# Check required fields and properties for dicts
if isinstance(current_obj, dict):
required = current_schema.get("required", [])
required: Sequence[str] = current_schema.get("required", [])
for field in required:
if field not in current_obj:
return False
# Queue property validations
properties = current_schema.get("properties", {})
properties: Mapping[str, JsonSchemaNode] = current_schema.get("properties", {})
for prop_name, prop_schema in properties.items():
if prop_name in current_obj:
stack.append((current_obj[prop_name], prop_schema, depth + 1))
@ -358,7 +369,17 @@ _HTTP_DEFAULT_TIMEOUT: Final = 30.0
_HTTP_MAX_TIMEOUT: Final = 60.0
def _http_error_response(error: str) -> dict[str, Any]:
class HttpResponseResult(TypedDict):
"""Outcome of an HTTP primitive call, as handed back to custom code."""
status_code: ReadOnly[int]
body: ReadOnly[JsonValue]
headers: ReadOnly[Mapping[str, str]]
success: ReadOnly[bool]
error: ReadOnly[str | None]
def _http_error_response(error: str) -> HttpResponseResult:
"""Create a standardized error response for HTTP requests."""
return {
"status_code": 0,
@ -369,9 +390,9 @@ def _http_error_response(error: str) -> dict[str, Any]:
}
def _http_success_response(response: httpx.Response) -> dict[str, Any]:
def _http_success_response(response: httpx.Response) -> HttpResponseResult:
"""Create a standardized success response from an httpx Response."""
parsed_body: Any
parsed_body: JsonValue
try:
parsed_body = response.json()
except (json.JSONDecodeError, ValueError):
@ -387,8 +408,8 @@ def _http_success_response(response: httpx.Response) -> dict[str, Any]:
def _prepare_http_body(
body: Any | None,
) -> tuple[dict[str, Any] | None, str | None]:
body: JsonValue,
) -> tuple[dict[str, JsonValue] | None, str | None]:
"""Prepare body arguments for HTTP request - returns (json_body, data_body)."""
if body is None:
return None, None
@ -405,9 +426,9 @@ async def http_request(
url: str,
method: str = "GET",
headers: dict[str, str] | None = None,
body: Any | None = None,
body: JsonValue = None,
timeout: float | None = None,
) -> dict[str, Any]:
) -> HttpResponseResult:
"""
Make an async HTTP request to an external service.
@ -491,7 +512,7 @@ async def _execute_http_request(
method: str,
url: str,
headers: dict[str, str] | None,
body: Any | None,
body: JsonValue,
timeout: float,
) -> httpx.Response:
"""Execute the HTTP request using the appropriate client method."""
@ -515,7 +536,7 @@ async def http_get(
url: str,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> dict[str, Any]:
) -> HttpResponseResult:
"""
Make an async HTTP GET request.
@ -534,10 +555,10 @@ async def http_get(
async def http_post(
url: str,
body: Any | None = None,
body: JsonValue = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> dict[str, Any]:
) -> HttpResponseResult:
"""
Make an async HTTP POST request.
@ -755,7 +776,7 @@ def trim(text: str) -> str:
# =============================================================================
def get_custom_code_primitives() -> dict[str, Any]:
def get_custom_code_primitives() -> dict[str, object]:
"""
Get all primitives to inject into the custom code environment.

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import os
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final, Literal, TypedDict
from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypedDict
from urllib.parse import urlparse
from uuid import uuid4
@ -76,6 +76,36 @@ class _HiddenlayerChoice(TypedDict, total=False):
message: ReadOnly[_HiddenlayerChoiceMessage]
class _HiddenlayerV2Output(TypedDict, total=False):
messages: ReadOnly[Sequence[_HiddenlayerOutputMessage]]
choices: ReadOnly[Sequence[_HiddenlayerChoice]]
class _LoggedCallDetails(Protocol):
"""Logging object view that exposes its untyped call details with the shape this guardrail reads."""
@property
def model_call_details(self) -> Mapping[str, _LoggedCallLitellmParams]: ...
class _TokenPayloadSource(Protocol):
"""Response view that decodes the HiddenLayer OAuth token body as a string mapping."""
def json(self) -> Mapping[str, str]: ...
def _logged_request_headers(logging_obj: _LoggedCallDetails) -> Mapping[str, str]:
return logging_obj.model_call_details.get("litellm_params", {}).get("metadata", {}).get("headers", {})
def _token_payload(response: _TokenPayloadSource) -> Mapping[str, str]:
return response.json()
def _header_value(headers: Mapping[str, str], key: str, default: str) -> str:
return headers.get(key, default)
def is_saas(host: str) -> bool:
"""Checks whether the connection is to the SaaS platform"""
@ -102,7 +132,7 @@ def _get_jwt(auth_url, api_id, api_key) -> str:
f"Unable to get authentication credentials for the HiddenLayer API - invalid response: {resp.json()}"
)
return resp.json()["access_token"]
return _token_payload(resp)["access_token"]
class HiddenlayerGuardrail(CustomGuardrail):
@ -176,10 +206,7 @@ class HiddenlayerGuardrail(CustomGuardrail):
# from the logger object on the response from the model.
headers = request_data.get("proxy_server_request", {}).get("headers", {})
if not headers and logging_obj and logging_obj.model_call_details:
logged_litellm_params: Final[_LoggedCallLitellmParams] = logging_obj.model_call_details.get(
"litellm_params", {}
)
headers = logged_litellm_params.get("metadata", {}).get("headers", {})
headers = _logged_request_headers(logging_obj)
hl_request_metadata["requester_id"] = headers.get("hl-requester-id") or "LiteLLM"
project_id: Final = headers.get("hl-project-id")
@ -418,8 +445,9 @@ class HiddenlayerGuardrailV2(CustomGuardrail):
response: Final = await self._call_hiddenlayer(payload, input_type, hl_headers)
output: Final = response.json()
evaluated_output: Final[_HiddenlayerV2Output] = output
if response.headers.get("hl-runtime-action", "").lower() == "block":
if _header_value(response.headers, "hl-runtime-action", "").lower() == "block":
raise HTTPException(
status_code=400,
detail={
@ -432,7 +460,7 @@ class HiddenlayerGuardrailV2(CustomGuardrail):
if input_type == "request":
inputs["structured_messages"] = output
modified_messages: Final[Sequence[_HiddenlayerOutputMessage]] = output.get("messages", [])
modified_messages: Final[Sequence[_HiddenlayerOutputMessage]] = evaluated_output.get("messages", [])
for message in modified_messages:
content = message.get("content", "")
if isinstance(content, list):
@ -447,7 +475,7 @@ class HiddenlayerGuardrailV2(CustomGuardrail):
inputs["texts"] = new_texts
elif input_type == "response" and inputs.get("texts"):
redacted_choices: Final[Sequence[_HiddenlayerChoice]] = output.get("choices", [{}])
redacted_choices: Final[Sequence[_HiddenlayerChoice]] = evaluated_output.get("choices", [{}])
inputs["texts"] = [redacted_choices[-1].get("message", {}).get("content", "")]
elif input_type == "response" and inputs.get("tool_calls"):
inputs["tool_calls"] = output

View file

@ -10,9 +10,9 @@ Supports three modes:
import asyncio
import threading
import uuid
from collections.abc import AsyncGenerator
from collections.abc import AsyncGenerator, AsyncIterable, Mapping, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Any, Final, Union, cast
from typing import TYPE_CHECKING, Any, Final, cast
import httpx
from fastapi import HTTPException
@ -36,14 +36,15 @@ from litellm.types.utils import (
from .base import PurviewGuardrailBase
if TYPE_CHECKING:
from litellm.caching.dual_cache import DualCache
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.llms.openai import AllMessageValues
from litellm.types.proxy.guardrails.guardrail_hooks.base import (
GuardrailConfigModel,
)
from litellm.types.utils import (
CallTypesLiteral,
EmbeddingResponse,
ImageResponse,
LLMResponseTypes,
)
@ -63,7 +64,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
client_secret: str,
purview_app_name: str = "LiteLLM",
user_id_field: str = "user_id",
**kwargs: Any,
**kwargs: object,
):
super().__init__(
tenant_id=tenant_id,
@ -104,7 +105,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
activity: str,
request_data: dict[str, Any],
block_on_violation: bool = True,
) -> dict[str, Any]:
) -> dict[str, object]:
"""Evaluate content against Purview DLP policies.
Args:
@ -119,7 +120,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
"""
start_time: Final = datetime.now()
status: GuardrailStatus = "success"
response: dict[str, Any] = {}
response: dict[str, object] = {}
try:
etag, _ = await self._compute_protection_scopes(user_id)
@ -149,7 +150,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
upstream_status: Final = exc.response.status_code
client_status: Final = 502 if upstream_status in (401, 403) else upstream_status
headers: dict[str, str] | None = None
retry_after: Final = exc.response.headers.get("retry-after")
retry_after: Final[str | None] = exc.response.headers.get("retry-after")
if retry_after:
headers = {"Retry-After": retry_after}
raise HTTPException(
@ -205,7 +206,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
return response
@staticmethod
def _extract_responses_api_function_call_args(result: Any) -> list[str]:
def _extract_responses_api_function_call_args(result: object) -> list[str]:
"""Return tool-call argument strings from a ``ResponsesAPIResponse.output``.
``ResponsesAPIResponse.output_text`` only aggregates ``output_text``
@ -215,7 +216,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
chat (``ModelResponse``) path.
"""
args: Final[list[str]] = []
output: Final = getattr(result, "output", None)
output: Final[Sequence[object] | None] = getattr(result, "output", None)
if not output:
return args
for item in output:
@ -230,7 +231,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
args.append(arguments)
return args
def _completion_response_text_parts(self, result: Any) -> list[str]:
def _completion_response_text_parts(self, result: object) -> list[str]:
"""Collect non-empty text segments from chat, text completions, or responses API.
Includes assistant message content *and* model-generated tool-call
@ -266,7 +267,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
parts.extend(self._extract_tool_call_args_from_message(msg))
return parts
def _assemble_responses_api_from_chunks(self, chunks: list[Any]) -> tuple[bool, ResponsesAPIResponse | None]:
def _assemble_responses_api_from_chunks(self, chunks: Sequence[object]) -> tuple[bool, ResponsesAPIResponse | None]:
"""Extract the final ``ResponsesAPIResponse`` from a buffered Responses API stream.
Returns a ``(is_responses_api_stream, assembled)`` tuple so the caller
@ -314,7 +315,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
input=input_data if input_data is not None else "",
responses_api_request=data,
)
return self.get_prompt_text_for_dlp(cast(list[Any], messages))
return self.get_prompt_text_for_dlp(cast(list["AllMessageValues"], messages))
except Exception:
verbose_proxy_logger.warning(
"Purview DLP: failed to transform responses API input",
@ -338,8 +339,8 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
def _resolve_user_id_for_blocking(
self,
data: dict[str, Any],
user_api_key_dict: Any,
data: Mapping[str, object],
user_api_key_dict: "UserAPIKeyAuth",
) -> str:
"""Resolve user ID for blocking (pre_call / post_call) DLP hooks.
@ -386,10 +387,10 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
async def async_pre_call_hook(
self,
user_api_key_dict: "UserAPIKeyAuth",
cache: Any,
cache: "DualCache",
data: dict[str, Any],
call_type: "CallTypesLiteral",
) -> dict[str, Any] | None:
) -> dict[str, object] | None:
"""Check user prompt against Purview DLP policies before LLM call."""
user_id: Final = self._resolve_user_id_for_blocking(data, user_api_key_dict)
@ -423,7 +424,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
else:
messages: Final[list | None] = data.get("messages")
if messages:
prompt_text = self.get_prompt_text_for_dlp(cast(list[Any], messages))
prompt_text = self.get_prompt_text_for_dlp(cast(list["AllMessageValues"], messages))
if not prompt_text:
return data
@ -446,8 +447,8 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
self,
data: dict,
user_api_key_dict: "UserAPIKeyAuth",
response: Union[Any, ModelResponse, "EmbeddingResponse", "ImageResponse"],
) -> Any:
response: "LLMResponseTypes",
) -> "LLMResponseTypes":
"""Check LLM response against Purview DLP policies (non-streaming only).
Streaming responses are handled by ``async_post_call_streaming_iterator_hook``
@ -472,7 +473,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
async def async_post_call_streaming_iterator_hook(
self,
user_api_key_dict: "UserAPIKeyAuth",
response: Any,
response: AsyncIterable[ModelResponseStream],
request_data: dict,
) -> AsyncGenerator[ModelResponseStream, None]:
"""Check streaming LLM responses against Purview DLP policies.
@ -592,7 +593,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
# Logging-only hook — audit without blocking
# ------------------------------------------------------------------
def logging_hook(self, kwargs: dict, result: Any, call_type: str) -> tuple[dict, Any]:
def logging_hook(self, kwargs: dict, result: object, call_type: str) -> tuple[dict, object]:
"""Fire-and-forget async audit logging; returns original (kwargs, result) immediately.
In the proxy's async success path, litellm independently calls both
@ -640,7 +641,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
return kwargs, result
async def async_logging_hook(self, kwargs: dict, result: Any, call_type: str) -> tuple[dict, Any]:
async def async_logging_hook(self, kwargs: dict, result: object, call_type: str) -> tuple[dict, object]:
"""Send both prompt and response to Purview for audit logging.
Errors are logged but never raised this mode is non-blocking.
@ -670,7 +671,7 @@ class MicrosoftPurviewDLPGuardrail(PurviewGuardrailBase, CustomGuardrail):
else:
messages: Final = kwargs.get("messages")
if messages:
prompt_text = self.get_prompt_text_for_dlp(cast(list[Any], messages))
prompt_text = self.get_prompt_text_for_dlp(cast(list["AllMessageValues"], messages))
if prompt_text:
await self._check_content(

View file

@ -1,9 +1,11 @@
import asyncio
import base64
import os
from typing import TYPE_CHECKING, Any, Final, Literal, Optional
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Final, Literal, Optional
from fastapi import HTTPException
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.integrations.custom_guardrail import (
@ -26,6 +28,41 @@ class PromptSecurityGuardrailMissingSecrets(Exception):
pass
class _ProtectVerdict(TypedDict, total=False):
"""One side (``prompt`` or ``response``) of an ``/api/protect`` verdict."""
action: ReadOnly[str]
violations: ReadOnly[Sequence[str]]
modified_messages: ReadOnly[Sequence[Mapping[str, object]]]
modified_text: ReadOnly[str]
class _ProtectResult(TypedDict, total=False):
prompt: ReadOnly[_ProtectVerdict | None]
response: ReadOnly[_ProtectVerdict | None]
class _ProtectResponse(TypedDict, total=False):
result: ReadOnly[_ProtectResult]
class _SanitizeUploadResponse(TypedDict, total=False):
jobId: ReadOnly[str]
class _SanitizeMetadata(TypedDict, total=False):
action: ReadOnly[str]
violations: ReadOnly[Sequence[str]]
class _SanitizeStatusResponse(TypedDict, total=False):
"""One poll of ``/api/sanitizeFile``."""
status: ReadOnly[str]
content: ReadOnly[str]
metadata: ReadOnly[_SanitizeMetadata]
class PromptSecurityGuardrail(CustomGuardrail):
@classmethod
def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]:
@ -199,7 +236,7 @@ class PromptSecurityGuardrail(CustomGuardrail):
json=payload,
)
response.raise_for_status()
res: Final = response.json()
res: Final[_ProtectResponse] = response.json()
self._log_api_response(
url=f"{self.api_base}/api/protect",
@ -261,7 +298,7 @@ class PromptSecurityGuardrail(CustomGuardrail):
json=payload,
)
response.raise_for_status()
res: Final = response.json()
res: Final[_ProtectResponse] = response.json()
self._log_api_response(
url=f"{self.api_base}/api/protect",
@ -290,7 +327,7 @@ class PromptSecurityGuardrail(CustomGuardrail):
return inputs
def _extract_texts_from_messages(self, messages: list) -> list[str]:
def _extract_texts_from_messages(self, messages: Sequence[Mapping[str, object]]) -> list[str]:
"""Extract text content from messages."""
texts: Final = []
for message in messages:
@ -379,7 +416,7 @@ class PromptSecurityGuardrail(CustomGuardrail):
files=files,
)
upload_response.raise_for_status()
upload_result: Final = upload_response.json()
upload_result: Final[_SanitizeUploadResponse] = upload_response.json()
job_id: Final = upload_result.get("jobId")
self._log_api_response(
@ -409,7 +446,7 @@ class PromptSecurityGuardrail(CustomGuardrail):
params={"jobId": job_id},
)
poll_response.raise_for_status()
result = poll_response.json()
result: _SanitizeStatusResponse = poll_response.json()
self._log_api_response(
url=f"{self.api_base}/api/sanitizeFile",
@ -656,7 +693,7 @@ class PromptSecurityGuardrail(CustomGuardrail):
method: str,
url: str,
headers: dict,
payload: Any,
payload: object,
) -> None:
verbose_proxy_logger.debug(
"Prompt Security request %s %s headers=%s payload=%s",
@ -670,7 +707,7 @@ class PromptSecurityGuardrail(CustomGuardrail):
self,
url: str,
status_code: int,
payload: Any,
payload: object,
) -> None:
verbose_proxy_logger.debug(
"Prompt Security response %s status=%s payload=%s",

View file

@ -1,6 +1,6 @@
import json
import re
from collections.abc import AsyncGenerator, Sequence
from collections.abc import AsyncGenerator, AsyncIterable, Mapping, Sequence
from typing import Any, Final, Literal
from fastapi import HTTPException
@ -41,6 +41,16 @@ from litellm.types.utils import (
GUARDRAIL_NAME: Final = "tool_permission"
def _object_mapping(value: object) -> Mapping[str, object] | None:
"""Return ``value`` as an opaque mapping when it is a dict."""
return value if isinstance(value, dict) else None
def _object_list(value: object) -> Sequence[object] | None:
"""Return ``value`` as an opaque sequence when it is a list."""
return value if isinstance(value, list) else None
class ToolPermissionGuardrail(CustomGuardrail):
def __init__(
self,
@ -274,12 +284,12 @@ class ToolPermissionGuardrail(CustomGuardrail):
def _parse_tool_call_arguments(
self, tool_call: ChatCompletionMessageToolCall
) -> tuple[dict[str, Any] | None, str | None]:
) -> tuple[Mapping[str, object] | None, str | None]:
arguments: Final = getattr(tool_call.function, "arguments", None)
if not arguments:
return None, "missing arguments"
parsed_arguments: Any = {}
parsed_arguments: object = {}
try:
if isinstance(arguments, str):
parsed_arguments = json.loads(arguments)
@ -306,9 +316,9 @@ class ToolPermissionGuardrail(CustomGuardrail):
def _collect_argument_paths(
self,
value: Any,
value: object,
current_path: str,
collected: dict[str, list[Any]],
collected: dict[str, list[object]],
depth: int = 0,
) -> None:
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
@ -316,13 +326,15 @@ class ToolPermissionGuardrail(CustomGuardrail):
if depth > DEFAULT_MAX_RECURSE_DEPTH:
return
if isinstance(value, dict):
for key, sub_value in value.items():
mapping_value: Final = _object_mapping(value)
list_value: Final = _object_list(value)
if mapping_value is not None:
for key, sub_value in mapping_value.items():
next_path = f"{current_path}.{key}" if current_path else key
self._collect_argument_paths(sub_value, next_path, collected, depth + 1)
elif isinstance(value, list):
elif list_value is not None:
list_path: Final = f"{current_path}[]" if current_path else "[]"
for item in value:
for item in list_value:
self._collect_argument_paths(item, list_path, collected, depth + 1)
else:
if not current_path:
@ -332,7 +344,7 @@ class ToolPermissionGuardrail(CustomGuardrail):
def _patterns_match_for_rule(
self,
*,
arguments: dict[str, Any],
arguments: Mapping[str, object],
rule: ToolPermissionRule,
tool_name: str | None,
) -> tuple[bool, str | None]:
@ -340,7 +352,7 @@ class ToolPermissionGuardrail(CustomGuardrail):
if not compiled_patterns:
return True, None
path_value_map: Final[dict[str, list[Any]]] = {}
path_value_map: Final[dict[str, list[object]]] = {}
self._collect_argument_paths(arguments, "", path_value_map)
for path, compiled_pattern in compiled_patterns.items():
@ -493,14 +505,14 @@ class ToolPermissionGuardrail(CustomGuardrail):
)
@staticmethod
def _get_anthropic_content_blocks(response: object) -> tuple[Any, ...] | None:
def _get_anthropic_content_blocks(response: object) -> tuple[object, ...] | None:
if not isinstance(response, dict):
return None
content: Final[object] = response.get("content")
return tuple(content) if isinstance(content, list) else None
def _extract_tool_calls_from_anthropic_content(
self, content: tuple[Any, ...]
self, content: tuple[object, ...]
) -> tuple[ChatCompletionMessageToolCall, ...]:
return tuple(
tool_call for block in content if (tool_call := self._anthropic_tool_use_to_tool_call(block)) is not None
@ -852,7 +864,7 @@ class ToolPermissionGuardrail(CustomGuardrail):
async def async_post_call_streaming_iterator_hook(
self,
user_api_key_dict: UserAPIKeyAuth,
response: Any,
response: AsyncIterable[ModelResponseStream],
request_data: dict,
) -> AsyncGenerator[ModelResponseStream, None]:
"""

View file

@ -1,8 +1,9 @@
from collections.abc import Awaitable
from collections.abc import Awaitable, Mapping, Sequence
from json import JSONDecodeError
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, cast
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, TypeAlias, cast
import httpx
from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.exceptions import GuardrailRaisedException
@ -50,7 +51,23 @@ _METADATA_ALLOWLIST: Final = (
"org_id",
)
_FallbackMode = Literal["fail_closed", "fail_open"]
_FallbackMode: TypeAlias = Literal["fail_closed", "fail_open"]
_MetadataValue: TypeAlias = str | int | float | Sequence[str | int | float]
class _AnalyzePayload(TypedDict):
"""Request body posted to the Vigil Guard analyze endpoint."""
text: ReadOnly[str]
source: ReadOnly[str]
mode: ReadOnly[str]
metadata: ReadOnly[Mapping[str, _MetadataValue]]
class _AnalysisView(TypedDict):
"""Typed read of the analyze endpoint's decoded JSON body."""
analysis: ReadOnly[Mapping[str, object]]
class _AsyncPostHandler(Protocol):
@ -59,7 +76,7 @@ class _AsyncPostHandler(Protocol):
*,
url: str,
headers: dict[str, str],
json: dict[str, Any],
json: _AnalyzePayload,
timeout: httpx.Timeout,
) -> Awaitable[httpx.Response]: ...
@ -244,7 +261,7 @@ class VigilGuardGuardrail(CustomGuardrail):
exc: Exception,
inputs: GenericGuardrailAPIInputs,
source: str,
final_texts: list[Any],
final_texts: list[str],
final_tool_calls: Any,
) -> GenericGuardrailAPIInputs:
if self.unreachable_fallback == "fail_open":
@ -271,7 +288,7 @@ class VigilGuardGuardrail(CustomGuardrail):
@staticmethod
def _build_output(
inputs: GenericGuardrailAPIInputs,
final_texts: list[Any],
final_texts: list[str],
final_tool_calls: Any,
) -> GenericGuardrailAPIInputs:
# When nothing was changed, return the input shape verbatim so the guardrail
@ -292,7 +309,7 @@ class VigilGuardGuardrail(CustomGuardrail):
return guardrailed
@staticmethod
def _tool_call_arguments(tool_calls: Any) -> list[tuple[int, str]]:
def _tool_call_arguments(tool_calls: Sequence[object] | None) -> list[tuple[int, str]]:
pairs: Final[list[tuple[int, str]]] = []
if isinstance(tool_calls, list):
for index, tool_call in enumerate(tool_calls):
@ -312,8 +329,8 @@ class VigilGuardGuardrail(CustomGuardrail):
updated[index] = tool_call
return updated
async def _analyze(self, text: str, source: str, metadata: dict[str, Any]) -> dict[str, Any]:
payload: Final = {
async def _analyze(self, text: str, source: str, metadata: Mapping[str, _MetadataValue]) -> Mapping[str, object]:
payload: Final[_AnalyzePayload] = {
"text": text,
"source": source,
"mode": "full",
@ -325,9 +342,12 @@ class VigilGuardGuardrail(CustomGuardrail):
"Content-Type": "application/json",
}
response: Final = await self._post_with_retry(endpoint, headers, payload)
return response.json()
decoded: Final[_AnalysisView] = {"analysis": response.json()}
return decoded["analysis"]
async def _post_with_retry(self, endpoint: str, headers: dict[str, str], payload: dict[str, Any]) -> httpx.Response:
async def _post_with_retry(
self, endpoint: str, headers: dict[str, str], payload: _AnalyzePayload
) -> httpx.Response:
for attempt in range(2):
try:
response = await self.async_handler.post(
@ -364,7 +384,7 @@ class VigilGuardGuardrail(CustomGuardrail):
)
@staticmethod
def _build_block_reason(analysis: dict[str, Any]) -> str:
def _build_block_reason(analysis: Mapping[str, object]) -> str:
for key in ("blockMessage", "decisionReason"):
value = analysis.get(key)
if isinstance(value, str) and value.strip():
@ -377,14 +397,16 @@ class VigilGuardGuardrail(CustomGuardrail):
return "Blocked by policy"
@staticmethod
def _resolve_sanitized_text(original: str, analysis: dict[str, Any]) -> str:
def _resolve_sanitized_text(original: str, analysis: Mapping[str, object]) -> str:
for key in ("sanitizedText", "outputText"):
value = analysis.get(key)
if isinstance(value, str):
return value
return original
def _collect_metadata(self, request_data: dict, logging_obj: Optional["LiteLLMLoggingObj"]) -> dict[str, Any]:
def _collect_metadata(
self, request_data: dict, logging_obj: Optional["LiteLLMLoggingObj"]
) -> Mapping[str, _MetadataValue]:
sources: Final[list[dict]] = []
if isinstance(request_data, dict):
sources.append(request_data)
@ -393,7 +415,7 @@ class VigilGuardGuardrail(CustomGuardrail):
if isinstance(nested, dict):
sources.append(nested)
collected: Final[dict[str, Any]] = {}
collected: Final[dict[str, _MetadataValue]] = {}
for field in _METADATA_ALLOWLIST:
for source in sources:
if field in source and source[field] is not None:
@ -409,7 +431,7 @@ class VigilGuardGuardrail(CustomGuardrail):
return collected
@staticmethod
def _clamp_metadata_value(value: Any) -> Any:
def _clamp_metadata_value(value: Any) -> _MetadataValue | None:
if isinstance(value, bool):
return None
if isinstance(value, str):
@ -417,7 +439,7 @@ class VigilGuardGuardrail(CustomGuardrail):
if isinstance(value, (int, float)):
return value
if isinstance(value, list):
clamped: Final[list[Any]] = []
clamped: Final[list[str | int | float]] = []
for item in value[:_METADATA_ARRAY_MAX_ITEMS]:
if isinstance(item, bool):
continue

View file

@ -3,12 +3,12 @@
import asyncio
import importlib
import os
from collections.abc import Callable, Iterator, Mapping
from collections.abc import Callable, Iterator, Mapping, Sequence
from datetime import datetime, timezone
from itertools import chain, count
from typing import Any, Final, Literal, Optional, Protocol, cast
from typing import Final, Literal, Optional, Protocol, cast
from pydantic import ValidationError
from pydantic import BaseModel, ValidationError
import litellm
from litellm import Router
@ -68,6 +68,19 @@ class _GuardrailRowLike(Protocol):
def __iter__(self) -> Iterator[tuple[str, object]]: ...
class _GuardrailTableActions(Protocol):
async def create(self, *, data: Mapping[str, object]) -> _GuardrailRowLike: ...
async def delete(self, *, where: Mapping[str, str]) -> object: ...
async def update(self, *, where: Mapping[str, str], data: Mapping[str, object]) -> _GuardrailRowLike: ...
async def find_many(self, *, where: Mapping[str, str], order: Mapping[str, str]) -> Sequence[BaseModel]: ...
async def find_unique(self, *, where: Mapping[str, str]) -> BaseModel | None: ...
def _guardrail_table(prisma_client: PrismaClient) -> _GuardrailTableActions:
"""Typed view of the guardrails table actions exposed by the Prisma repository."""
return GuardrailsRepository(prisma_client).table
guardrail_initializer_registry: Final = {
SupportedGuardrailIntegrations.BEDROCK.value: initialize_bedrock,
SupportedGuardrailIntegrations.LAKERA.value: initialize_lakera,
@ -279,7 +292,7 @@ class GuardrailRegistry:
try:
guardrail_name: Final = guardrail.get("guardrail_name")
# Properly serialize LitellmParams Pydantic model to dict
litellm_params_obj: Final[Any] = guardrail.get("litellm_params", {})
litellm_params_obj: Final = guardrail.get("litellm_params", {})
if hasattr(litellm_params_obj, "model_dump"):
litellm_params_dict = litellm_params_obj.model_dump()
else:
@ -288,7 +301,7 @@ class GuardrailRegistry:
guardrail_info: Final[str] = safe_dumps(guardrail.get("guardrail_info", {}))
# Create guardrail in DB
created_guardrail: Final[_GuardrailRowLike] = await GuardrailsRepository(prisma_client).table.create(
created_guardrail: Final[_GuardrailRowLike] = await _guardrail_table(prisma_client).create(
data={
"guardrail_name": guardrail_name,
"litellm_params": litellm_params,
@ -312,7 +325,7 @@ class GuardrailRegistry:
"""
try:
# Delete from DB
await GuardrailsRepository(prisma_client).table.delete(where={"guardrail_id": guardrail_id})
await _guardrail_table(prisma_client).delete(where={"guardrail_id": guardrail_id})
return {"message": f"Guardrail {guardrail_id} deleted successfully"}
except Exception as e:
@ -325,7 +338,7 @@ class GuardrailRegistry:
try:
guardrail_name: Final = guardrail.get("guardrail_name")
# Properly serialize LitellmParams Pydantic model to dict
litellm_params_obj: Final[Any] = guardrail.get("litellm_params", {})
litellm_params_obj: Final = guardrail.get("litellm_params", {})
if hasattr(litellm_params_obj, "model_dump"):
litellm_params_dict = litellm_params_obj.model_dump()
else:
@ -334,7 +347,7 @@ class GuardrailRegistry:
guardrail_info: Final[str] = safe_dumps(guardrail.get("guardrail_info", {}))
# Update in DB
updated_guardrail: Final[_GuardrailRowLike] = await GuardrailsRepository(prisma_client).table.update(
updated_guardrail: Final[_GuardrailRowLike] = await _guardrail_table(prisma_client).update(
where={"guardrail_id": guardrail_id},
data={
"guardrail_name": guardrail_name,
@ -358,7 +371,7 @@ class GuardrailRegistry:
Only rows with status == "active" are returned (pending_review and rejected are excluded).
"""
try:
guardrails_from_db: Final = await GuardrailsRepository(prisma_client).table.find_many(
guardrails_from_db: Final = await _guardrail_table(prisma_client).find_many(
where={"status": "active"},
order={"created_at": "desc"},
)
@ -376,9 +389,7 @@ class GuardrailRegistry:
Get a guardrail by its ID from the database
"""
try:
guardrail: Final = await GuardrailsRepository(prisma_client).table.find_unique(
where={"guardrail_id": guardrail_id}
)
guardrail: Final = await _guardrail_table(prisma_client).find_unique(where={"guardrail_id": guardrail_id})
if not guardrail:
return None
@ -392,7 +403,7 @@ class GuardrailRegistry:
Get a guardrail by its name from the database
"""
try:
guardrail: Final = await GuardrailsRepository(prisma_client).table.find_unique(
guardrail: Final = await _guardrail_table(prisma_client).find_unique(
where={"guardrail_name": guardrail_name}
)

View file

@ -27,7 +27,7 @@ Usage:
import base64
import json
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Final
from typing import TYPE_CHECKING, Any, Final, Protocol
from litellm._logging import verbose_proxy_logger
from litellm.caching.caching import DualCache
@ -43,6 +43,30 @@ if TYPE_CHECKING:
from litellm.llms.litellm_proxy.skills.sandbox_executor import SkillsSandboxExecutor
class _ToolCallFunction(Protocol):
@property
def name(self) -> str: ...
@property
def arguments(self) -> str: ...
class _ChatToolCall(Protocol):
@property
def id(self) -> str: ...
@property
def function(self) -> _ToolCallFunction: ...
class _ChatMessage(Protocol):
@property
def content(self) -> str | None: ...
@property
def tool_calls(self) -> Sequence[_ChatToolCall] | None: ...
class SkillsInjectionHook(CustomLogger):
"""
Pre/Post-call hook that processes skills from container.skills parameter.
@ -443,7 +467,7 @@ class SkillsInjectionHook(CustomLogger):
async def _execute_code_loop_messages_api(
self,
data: dict,
response: Any,
response: object,
skill_files: dict[str, bytes],
) -> LLMResponseTypes | None:
"""
@ -673,7 +697,7 @@ print('No executable skill module found')
async def _execute_code_loop(
self,
data: dict,
response: Any,
response: object,
skill_files: dict[str, bytes],
) -> LLMResponseTypes:
"""
@ -714,8 +738,8 @@ print('No executable skill module found')
for iteration in range(self.max_iterations):
# OpenAI format response has choices[0].message
assistant_message = current_response.choices[0].message
stop_reason = current_response.choices[0].finish_reason
assistant_message: _ChatMessage = current_response.choices[0].message
stop_reason: str | None = current_response.choices[0].finish_reason
# Build assistant message for conversation history
assistant_msg_dict: dict[str, object] = {
@ -784,14 +808,14 @@ print('No executable skill module found')
async def _execute_code_tool(
self,
tool_call: Any,
tool_call: _ChatToolCall,
skill_files: dict[str, bytes],
executor: "SkillsSandboxExecutor",
generated_files: list[dict[str, object]],
) -> str:
"""Execute a litellm_code_execution tool call and return result string."""
try:
args: Final = json.loads(tool_call.function.arguments)
args: Final[Mapping[str, str]] = json.loads(tool_call.function.arguments)
code: Final[str] = args.get("code", "")
verbose_proxy_logger.debug("SkillsInjectionHook: Executing code (%s chars)", len(code))

View file

@ -7,7 +7,7 @@ POST /auto_router/test_routing - Route one prompt through an unsaved complexity-
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
from types import MappingProxyType
from typing import TYPE_CHECKING, Annotated, Final
from typing import TYPE_CHECKING, Annotated, Final, Protocol
from pydantic import BaseModel, TypeAdapter
@ -29,6 +29,7 @@ from litellm.proxy.auth.auth_checks import (
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.db.autorouter_session_rollup import AUTOROUTER_BENCHMARKS_SQL
from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup
from litellm.repositories.base_repository import SupportsModelDump
from litellm.repositories.team_repository import TeamRepository
from litellm.router_strategy.complexity_router import ComplexityRouter
from litellm.types.management_endpoints.auto_router_endpoints import (
@ -61,6 +62,77 @@ else:
router: Final = APIRouter()
class _TeamTable(Protocol):
async def find_unique(self, *, where: Mapping[str, object]) -> SupportsModelDump | None: ...
class _VerificationTokenRow(Protocol):
@property
def token(self) -> str: ...
@property
def key_alias(self) -> str | None: ...
@property
def key_name(self) -> str | None: ...
class _VerificationTokenTable(Protocol):
async def find_unique(self, *, where: Mapping[str, object]) -> _VerificationTokenRow | None: ...
async def find_many(self, *, where: Mapping[str, object]) -> Sequence[_VerificationTokenRow]: ...
class _ShadowEvalJobRow(Protocol):
@property
def id(self) -> str: ...
class _ShadowEvalJobTable(Protocol):
async def find_unique(self, *, where: Mapping[str, object]) -> _ShadowEvalJobRow | None: ...
async def find_first(self, *, where: Mapping[str, object]) -> _ShadowEvalJobRow | None: ...
async def find_many(
self, *, where: Mapping[str, object], order: Mapping[str, str], take: int
) -> Sequence[_ShadowEvalJobRow]: ...
async def create(self, data: Mapping[str, object]) -> _ShadowEvalJobRow: ...
async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> _ShadowEvalJobRow | None: ...
class _ShadowEvalAttemptRow(Protocol):
@property
def error(self) -> str | None: ...
class _ShadowEvalAttemptTable(Protocol):
async def find_first(
self, *, where: Mapping[str, object], order: Mapping[str, str]
) -> _ShadowEvalAttemptRow | None: ...
def _team_table(prisma_client: "PrismaClient") -> _TeamTable:
return TeamRepository(prisma_client).table
def _verification_tokens(prisma_client: "PrismaClient") -> _VerificationTokenTable:
return prisma_client.db.litellm_verificationtoken
def _shadow_eval_jobs(prisma_client: "PrismaClient") -> _ShadowEvalJobTable:
return prisma_client.db.litellm_shadowevaljob
def _shadow_eval_attempts(prisma_client: "PrismaClient") -> _ShadowEvalAttemptTable:
return prisma_client.db.litellm_shadowevalattempt
async def _query_raw(prisma_client: "PrismaClient", query: str, *args: object) -> Sequence[Mapping[str, object]]:
return await prisma_client.db.query_raw(query, *args)
async def _authorize_routing_test(user_api_key_dict: UserAPIKeyAuth, team_id: str | None) -> None:
"""Allow exactly the callers who could create this router.
@ -92,7 +164,7 @@ async def _authorize_routing_test(user_api_key_dict: UserAPIKeyAuth, team_id: st
},
)
team_row: Final = await TeamRepository(prisma_client).table.find_unique(
team_row: Final = await _team_table(prisma_client).find_unique(
where={"team_id": team_id}, # mutable-ok: Prisma query filters are dict-shaped
)
if team_row is None:
@ -342,6 +414,26 @@ def _benchmark_totals(row: _SessionAggRow) -> AutoRouterBenchmarkTotals:
)
def _benchmark_group(row: _SessionAggRow) -> AutoRouterBenchmarkGroup:
totals: Final = _benchmark_totals(row)
return AutoRouterBenchmarkGroup(
router_name=row.router_name,
router_type=row.router_type,
tier_turns=row.tier_turns,
sessions=totals.sessions,
turns=totals.turns,
avg_turns_per_session=totals.avg_turns_per_session,
avg_session_seconds=totals.avg_session_seconds,
avg_tokens_per_session=totals.avg_tokens_per_session,
spend=totals.spend,
saved_spend=totals.saved_spend,
baseline_spend=totals.baseline_spend,
saved_pct=totals.saved_pct,
saved_per_session=totals.saved_per_session,
cache=totals.cache,
)
def _summed_agg_row(rows: Sequence[_SessionAggRow]) -> _SessionAggRow:
return _SessionAggRow(
router_name="",
@ -407,21 +499,14 @@ async def get_auto_router_benchmarks(
if end_day < start_day:
raise HTTPException(status_code=400, detail="end_date must not be earlier than start_date")
raw_rows: Final = await prisma_client.db.query_raw(
raw_rows: Final = await _query_raw(
prisma_client,
AUTOROUTER_BENCHMARKS_SQL,
start_day.isoformat(),
(end_day + timedelta(days=1)).isoformat(),
)
rows: Final = _SESSION_AGG_ROWS.validate_python(raw_rows or ())
groups: Final = tuple(
AutoRouterBenchmarkGroup(
router_name=row.router_name,
router_type=row.router_type,
tier_turns=row.tier_turns,
**_benchmark_totals(row).model_dump(),
)
for row in rows
)
groups: Final = tuple(_benchmark_group(row) for row in rows)
return AutoRouterBenchmarksResponse(
start_date=start_day.strftime("%Y-%m-%d"),
end_date=end_day.strftime("%Y-%m-%d"),
@ -584,7 +669,7 @@ async def _with_key_labels(
so the UI can say whose traffic a job shadows. Deleted keys resolve to None."""
if not responses:
return ()
key_rows: Final = await prisma_client.db.litellm_verificationtoken.find_many(
key_rows: Final = await _verification_tokens(prisma_client).find_many(
where={"token": {"in": sorted({response.api_key_id for response in responses})}} # mutable-ok: Prisma filter
)
labels: Final[Mapping[str, tuple[str | None, str | None]]] = {
@ -608,12 +693,12 @@ async def _shadow_eval_results(prisma_client: "PrismaClient", job_id: str) -> Sh
"for the turns the router sent to X, did X beat the baseline" in reverse. Reads are
bounded by the job's own attempts (<= max_turns) via the job_id index."""
by_tier: Final = _ATTEMPT_AGG_ROWS.validate_python(
await prisma_client.db.query_raw(_ATTEMPT_AGG_BY_TIER_SQL, job_id) or ()
await _query_raw(prisma_client, _ATTEMPT_AGG_BY_TIER_SQL, job_id) or ()
)
if not by_tier:
return None
by_model: Final = _ATTEMPT_AGG_ROWS.validate_python(
await prisma_client.db.query_raw(_ATTEMPT_AGG_BY_MODEL_SQL, job_id) or ()
await _query_raw(prisma_client, _ATTEMPT_AGG_BY_MODEL_SQL, job_id) or ()
)
total_turns: Final = sum(r.turn_count for r in by_tier)
return ShadowEvalResult(
@ -661,7 +746,7 @@ async def start_shadow_eval(
_validate_plain_model(llm_router, data.judge_model, "judge_model")
if data.baseline_model is not None:
_validate_plain_model(llm_router, data.baseline_model, "baseline_model")
key_row: Final = await prisma_client.db.litellm_verificationtoken.find_unique(
key_row: Final = await _verification_tokens(prisma_client).find_unique(
where={"token": data.api_key_id} # mutable-ok: Prisma filter
)
if key_row is None:
@ -677,7 +762,7 @@ async def start_shadow_eval(
# still holds its slot in the per-key, per-direction partial unique index until
# stamped; free it so a new eval can start. Sweeping both directions is deliberate.
await prisma_client.db.execute_raw(_SWEEP_FINISHED_JOBS_SQL, data.api_key_id)
active: Final = await prisma_client.db.litellm_shadowevaljob.find_first(
active: Final = await _shadow_eval_jobs(prisma_client).find_first(
where={ # mutable-ok: Prisma filter
"api_key_id": data.api_key_id,
"direction": data.direction,
@ -691,7 +776,7 @@ async def start_shadow_eval(
)
now: Final = datetime.now(timezone.utc)
try:
job: Final = await prisma_client.db.litellm_shadowevaljob.create(
job: Final = await _shadow_eval_jobs(prisma_client).create(
data={ # mutable-ok: Prisma payload
"api_key_id": data.api_key_id,
"router_name": data.router_name,
@ -735,7 +820,7 @@ async def list_shadow_eval_jobs(
_require_admin_viewer(user_api_key_dict, "view shadow evals")
if prisma_client is None:
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
records: Final = await prisma_client.db.litellm_shadowevaljob.find_many(
records: Final = await _shadow_eval_jobs(prisma_client).find_many(
where={"api_key_id": api_key_id} if api_key_id else {}, # mutable-ok: Prisma filter
order={"created_at": "desc"}, # mutable-ok: Prisma order
take=limit,
@ -762,15 +847,15 @@ async def get_shadow_eval_job(
_require_admin_viewer(user_api_key_dict, "view shadow evals")
if prisma_client is None:
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
record: Final = await prisma_client.db.litellm_shadowevaljob.find_unique(
record: Final = await _shadow_eval_jobs(prisma_client).find_unique(
where={"id": job_id} # mutable-ok: Prisma filter
)
if record is None:
raise HTTPException(status_code=404, detail=f"No shadow eval job {job_id}")
totals: Final = _ATTEMPT_TOTALS_ROWS.validate_python(
await prisma_client.db.query_raw(_ATTEMPT_TOTALS_SQL, job_id) or ()
await _query_raw(prisma_client, _ATTEMPT_TOTALS_SQL, job_id) or ()
)
latest_error: Final = await prisma_client.db.litellm_shadowevalattempt.find_first(
latest_error: Final = await _shadow_eval_attempts(prisma_client).find_first(
where={"job_id": job_id, "outcome": "error"}, # mutable-ok: Prisma filter
order={"created_at": "desc"}, # mutable-ok: Prisma order
)
@ -804,7 +889,7 @@ async def stop_shadow_eval_job(
_require_admin_writer(user_api_key_dict, "stop a shadow eval")
if prisma_client is None:
raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value)
record: Final = await prisma_client.db.litellm_shadowevaljob.find_unique(
record: Final = await _shadow_eval_jobs(prisma_client).find_unique(
where={"id": job_id} # mutable-ok: Prisma filter
)
if record is None:
@ -812,7 +897,7 @@ async def stop_shadow_eval_job(
current: Final = ShadowEvalJobResponse.model_validate(record, from_attributes=True)
if current.status != "running":
raise HTTPException(status_code=400, detail=f"Job {job_id} is already {current.status}")
updated: Final = await prisma_client.db.litellm_shadowevaljob.update(
updated: Final = await _shadow_eval_jobs(prisma_client).update(
where={"id": job_id}, # mutable-ok: Prisma filter
data={"stopped_at": datetime.now(timezone.utc)}, # mutable-ok: Prisma payload
)

View file

@ -194,6 +194,13 @@ class _PrismaTableActions(Protocol[_PrismaRowT]):
data: Mapping[str, object],
) -> _PrismaRowT | None: ...
async def upsert(
self,
*,
where: Mapping[str, object],
data: Mapping[str, object],
) -> _PrismaRowT: ...
class _UserRowLike(Protocol):
user_id: str | None
@ -209,24 +216,43 @@ class _TxTables(Protocol):
litellm_proxymodeltable: _PrismaTableActions[object]
class _TableSource(Protocol[_PrismaRowT]):
"""Repository view that exposes its untyped Prisma ``table`` with a concrete row type."""
@property
def table(self) -> _PrismaTableActions[_PrismaRowT]: ...
def _table_of(source: _TableSource[_PrismaRowT]) -> _PrismaTableActions[_PrismaRowT]:
return source.table
def _prisma_table(
repository: BaseRepository[_RepositoryModelT],
) -> _PrismaTableActions[_RepositoryModelT]:
return repository.table
return _table_of(repository)
def _deleted_verification_token_table(
prisma_client: PrismaClient,
) -> _PrismaTableActions[LiteLLM_DeletedVerificationToken]:
return DeletedVerificationTokenRepository(prisma_client).table
return _table_of(DeletedVerificationTokenRepository(prisma_client))
def _deprecated_verification_token_table(prisma_client: PrismaClient) -> _PrismaTableActions[object]:
return _table_of(DeprecatedVerificationTokenRepository(prisma_client))
def _user_table(prisma_client: PrismaClient) -> _PrismaTableActions[_UserRowLike]:
return _table_of(UserRepository(prisma_client))
def _credentials_table(prisma_client: PrismaClient) -> _PrismaTableActions[CredentialItem]:
return CredentialsRepository(prisma_client).table
return _table_of(CredentialsRepository(prisma_client))
def _config_table(prisma_client: PrismaClient) -> _PrismaTableActions[ConfigParam]:
return ConfigRepository(prisma_client).table
return _table_of(ConfigRepository(prisma_client))
async def _check_custom_key_allowed(custom_key_value: str | None) -> None:
@ -4656,7 +4682,7 @@ async def _insert_deprecated_key(
try:
revoke_at: Final = datetime.now(timezone.utc) + timedelta(seconds=grace_seconds)
await DeprecatedVerificationTokenRepository(prisma_client).table.upsert(
await _deprecated_verification_token_table(prisma_client).upsert(
where={"token": old_token_hash},
data={
"create": {
@ -6059,13 +6085,13 @@ async def _list_key_helper(
total_pages: Final = -(-total_count // size) # Ceiling division
# Fetch user information if expand includes "user"
user_map = {}
user_map = dict[str | None, _UserRowLike]()
if expand and "user" in expand:
user_ids: Final = [key.user_id for key in keys if key.user_id]
created_by_ids: Final = [key.created_by for key in keys if key.created_by]
all_ids: Final = list(set(user_ids + created_by_ids)) # Remove duplicates
if all_ids:
users: Final[Sequence[_UserRowLike]] = await UserRepository(prisma_client).table.find_many(
users: Final[Sequence[_UserRowLike]] = await _user_table(prisma_client).find_many(
where={"user_id": {"in": all_ids}}
)
user_map = {user.user_id: user for user in users}

View file

@ -114,13 +114,14 @@ class UpdatePublicModelGroupsRequest(BaseModel):
class _ProxyModelRow(Protocol):
model_id: str
model_name: str
litellm_params: Mapping[str, object]
model_info: Mapping[str, object] | None
def model_dump_json(self, *, exclude_none: bool = False) -> str: ...
class _ProxyModelTable(Protocol):
def find_unique(self, *, where: Mapping[str, object]) -> Awaitable[_ProxyModelRow | None]: ...
def find_unique(self, *, where: Mapping[str, object]) -> Awaitable[BaseModel | None]: ...
def find_many(self, *, where: Mapping[str, object]) -> Awaitable[Sequence[_ProxyModelRow]]: ...
@ -182,10 +183,7 @@ def _model_alias_table(prisma_client: PrismaClient) -> _ModelAliasTable:
async def get_db_model(model_id: str, prisma_client: PrismaClient) -> Deployment | None:
db_model: Final = cast(
BaseModel | None,
await _proxy_model_table(prisma_client).find_unique(where={"model_id": model_id}),
)
db_model: Final = await _proxy_model_table(prisma_client).find_unique(where={"model_id": model_id})
if not db_model:
return None
@ -1577,7 +1575,7 @@ async def delete_model(
},
)
model_in_db: Final = await ModelRepository(prisma_client).table.find_unique(where={"model_id": model_info.id})
model_in_db: Final = await _proxy_model_table(prisma_client).find_unique(where={"model_id": model_info.id})
if model_in_db is None:
raise HTTPException(
status_code=400,
@ -1914,7 +1912,7 @@ async def update_model(
)
_model_id: str | None = None
_model_info: Final = getattr(model_params, "model_info", None)
_model_info: Final[ModelInfo | None] = getattr(model_params, "model_info", None)
if _model_info is None:
raise Exception("model_info not provided")

View file

@ -1,9 +1,9 @@
# What is this?
## Helper utils for the management endpoints (keys/users/teams)
from collections.abc import Callable
from collections.abc import Callable, Mapping, MutableMapping, Sequence
from datetime import datetime
from functools import wraps
from typing import Any, Final
from typing import Any, Final, Protocol
from fastapi import HTTPException, Request
from pydantic import BaseModel
@ -23,6 +23,7 @@ from litellm.proxy._types import ( # key request types; user request types; tea
LiteLLM_UserTable,
ManagementEndpointLoggingPayload,
Member,
Span,
SSOUserDefinedValues,
UpdateCustomerRequest,
UpdateKeyRequest,
@ -39,7 +40,53 @@ from litellm.repositories.table_repositories import TeamMembershipRepository
from litellm.repositories.user_repository import UserRepository
def get_new_internal_user_defaults(user_id: str, user_email: str | None = None) -> dict:
class _PrismaRecord(Protocol):
"""Row surface the management helpers read back from Prisma."""
def model_dump(self) -> Mapping[str, object]: ...
class _PrismaUserRecord(Protocol):
"""User row surface the management helpers read back from Prisma."""
user_id: str
def model_dump(self) -> Mapping[str, object]: ...
class _PrismaBudgetRecord(Protocol):
"""Budget row surface the management helpers read back from Prisma."""
budget_id: str
def model_dump(self) -> Mapping[str, object]: ...
class _PrismaBudgetTable(Protocol):
"""Budget table actions the management helpers issue."""
async def create(self, *, data: Mapping[str, object]) -> _PrismaBudgetRecord: ...
async def find_unique(self, *, where: Mapping[str, object]) -> _PrismaBudgetRecord | None: ...
class _PrismaUserTable(Protocol):
"""User table actions the management helpers issue."""
async def update_many(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> int: ...
async def upsert(
self, *, where: Mapping[str, object], data: Mapping[str, Mapping[str, object]]
) -> _PrismaUserRecord | None: ...
class _PrismaTeamMembershipTable(Protocol):
"""Team membership table actions the management helpers issue."""
async def create(self, *, data: Mapping[str, object], include: Mapping[str, bool]) -> _PrismaRecord: ...
def get_new_internal_user_defaults(user_id: str, user_email: str | None = None) -> dict[str, object]:
user_info: Final = litellm.default_internal_user_params or {}
returned_dict: Final[SSOUserDefinedValues] = {
@ -95,7 +142,7 @@ async def handle_budget_for_entity(
_budget_data: Final = {k: v for k, v in _json_data.items() if k in budget_params}
# Check if budget_id is explicitly provided in the data
data_budget_id: Final = getattr(data, "budget_id", None)
data_budget_id: Final[str | None] = getattr(data, "budget_id", None)
# Case 1: Creating new entity - no existing budget_id
if existing_budget_id is None:
@ -107,7 +154,7 @@ async def handle_budget_for_entity(
budget_row: Final = LiteLLM_BudgetTable(**_budget_data)
new_budget_data: Final = prisma_client.jsonify_object(budget_row.model_dump(exclude_none=True))
_budget: Final = await BudgetRepository(prisma_client).table.create(
_budget: Final[_PrismaBudgetRecord] = await BudgetRepository(prisma_client).table.create(
data={
**new_budget_data,
"created_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
@ -173,9 +220,8 @@ async def _clone_team_default_budget_for_member(
member while keeping the default's other limits, so an admin can set a
member's reset cadence without discarding the team default's max_budget.
"""
default_budget: Final = await BudgetRepository(prisma_client).table.find_unique(
where={"budget_id": default_team_budget_id}
)
budget_table: Final[_PrismaBudgetTable] = BudgetRepository(prisma_client).table
default_budget: Final = await budget_table.find_unique(where={"budget_id": default_team_budget_id})
if default_budget is None:
return None
@ -202,7 +248,7 @@ async def _clone_team_default_budget_for_member(
if cloned_data.get("budget_duration"):
cloned_data["budget_reset_at"] = get_budget_reset_time(cloned_data["budget_duration"])
new_budget: Final = await BudgetRepository(prisma_client).table.create(data=cloned_data)
new_budget: Final[_PrismaBudgetRecord] = await BudgetRepository(prisma_client).table.create(data=cloned_data)
return new_budget.budget_id
@ -238,7 +284,7 @@ async def _resolve_member_budget_id(
if not has_explicit_limit and budget_duration is None:
return None
budget_data: Final[dict] = {
budget_data: Final[dict[str, object]] = {
"created_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
"updated_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
}
@ -249,7 +295,8 @@ async def _resolve_member_budget_id(
if budget_duration is not None:
budget_data["budget_duration"] = budget_duration
budget_data["budget_reset_at"] = get_budget_reset_time(budget_duration=budget_duration)
response: Final = await BudgetRepository(prisma_client).table.create(data=budget_data)
budget_table: Final[_PrismaBudgetTable] = BudgetRepository(prisma_client).table
response: Final = await budget_table.create(data=budget_data)
return response.budget_id
@ -262,7 +309,8 @@ async def _append_team_id_if_absent(prisma_client: PrismaClient, user_id: str, t
number of teams a user belongs to). Teams added concurrently for a different
team id are unaffected, since each update filters on its own team id.
"""
await UserRepository(prisma_client).table.update_many(
user_table: Final[_PrismaUserTable] = UserRepository(prisma_client).table
await user_table.update_many(
where={"user_id": user_id, "NOT": {"teams": {"has": team_id}}},
data={"teams": {"push": [team_id]}},
)
@ -300,7 +348,8 @@ async def add_new_member(
# Prisma only compiles an upsert down to INSERT ... ON CONFLICT when it
# is non-empty, and falls back to a racy SELECT-then-INSERT when it is
# not, so this re-states user_id as a no-op rather than being empty.
_returned_user = await UserRepository(prisma_client).table.upsert(
user_table: Final[_PrismaUserTable] = UserRepository(prisma_client).table
_returned_user: _PrismaUserRecord | None = await user_table.upsert(
where={"user_id": new_member.user_id},
data={
"create": {"teams": [team_id], **new_user_defaults},
@ -314,7 +363,7 @@ async def add_new_member(
new_user_defaults = get_new_internal_user_defaults(user_id=str(uuid.uuid4()), user_email=new_member.user_email)
## user email is not unique acc. to prisma schema -> future improvement
### for now: check if it exists in db, if not - insert it
existing_user_row: Final[list | None] = await prisma_client.get_data(
existing_user_row: Final[list[_PrismaUserRecord] | None] = await prisma_client.get_data(
key_val={"user_email": new_member.user_email},
table_name="user",
query_type="find_all",
@ -346,7 +395,8 @@ async def add_new_member(
)
if _budget_id and returned_user is not None and returned_user.user_id is not None:
_returned_team_membership: Final = await TeamMembershipRepository(prisma_client).table.create(
membership_table: Final[_PrismaTeamMembershipTable] = TeamMembershipRepository(prisma_client).table
_returned_team_membership: Final = await membership_table.create(
data={
"team_id": team_id,
"user_id": returned_user.user_id,
@ -469,8 +519,18 @@ async def send_management_endpoint_alert(
)
def _redacted_env_var(entry: Any) -> dict:
get: Final = entry.get if isinstance(entry, dict) else lambda k: getattr(entry, k, None)
def _object_mapping(value: object) -> Mapping[str, object] | None:
"""Return ``value`` as an opaque mapping when it is a dict."""
return value if isinstance(value, dict) else None
def _object_list(value: object) -> Sequence[object] | None:
"""Return ``value`` as an opaque sequence when it is a list."""
return value if isinstance(value, list) else None
def _redacted_env_var(entry: object) -> dict[str, object]:
get: Final[Callable[[str], object]] = entry.get if isinstance(entry, dict) else lambda k: getattr(entry, k, None)
return {
"name": get("name"),
"scope": get("scope"),
@ -479,25 +539,28 @@ def _redacted_env_var(entry: Any) -> dict:
}
def _redact_record_env_vars(record: Any) -> Any:
def _redact_record_env_vars(record: object) -> object:
"""Return ``record`` with its ``env_vars[].value`` blanked.
Copies rather than mutating, because the record aliases the live response
object that is also returned to the caller. Records without an ``env_vars``
list are returned unchanged.
"""
env_vars: Final = record.get("env_vars") if isinstance(record, dict) else getattr(record, "env_vars", None)
if not isinstance(env_vars, list):
record_map: Final = _object_mapping(record)
env_vars: Final = _object_list(
record_map.get("env_vars") if record_map is not None else getattr(record, "env_vars", None)
)
if env_vars is None:
return record
redacted: Final = [_redacted_env_var(entry) for entry in env_vars]
if isinstance(record, dict):
return {**record, "env_vars": redacted}
if record_map is not None:
return {**record_map, "env_vars": redacted}
if isinstance(record, BaseModel):
return record.model_copy(update={"env_vars": redacted})
return record
def _redact_env_var_values(response: dict) -> None:
def _redact_env_var_values(response: MutableMapping[str, object]) -> None:
"""Blank ``env_vars[].value`` in a management response before telemetry.
MCP endpoints return decrypted ``scope="global"`` env var values so the admin
@ -507,18 +570,19 @@ def _redact_env_var_values(response: dict) -> None:
create/update) and nested under ``items`` (the submissions queue), so both are
scrubbed. Names, scopes, and descriptions are kept so traces stay useful.
"""
if isinstance(response.get("env_vars"), list):
response["env_vars"] = [_redacted_env_var(entry) for entry in response["env_vars"]]
env_vars: Final = _object_list(response.get("env_vars"))
if env_vars is not None:
response["env_vars"] = [_redacted_env_var(entry) for entry in env_vars]
items: Final = response.get("items")
if isinstance(items, list):
items: Final = _object_list(response.get("items"))
if items is not None:
response["items"] = [_redact_record_env_vars(item) for item in items]
async def _emit_management_endpoint_otel_span(
func: Callable,
kwargs: dict,
parent_otel_span: Any,
parent_otel_span: Span | None,
start_time: datetime,
end_time: datetime,
result: Any = None,
@ -571,10 +635,10 @@ async def _emit_management_endpoint_otel_span(
}
)
_response: dict | None = None
_response: dict[str, object] | None = None
if exception is None and result is not None:
try:
raw: Final = dict(result)
raw: Final[Mapping[str, object]] = dict(result)
_response = {k: v for k, v in raw.items() if k not in _CREDENTIAL_FIELDS}
_redact_env_var_values(_response)
except Exception:
@ -623,7 +687,7 @@ def management_endpoint_wrapper(func):
user_api_key_dict=user_api_key_dict,
function_name=func.__name__,
)
parent_otel_span = getattr(user_api_key_dict, "parent_otel_span", None)
parent_otel_span: Span | None = getattr(user_api_key_dict, "parent_otel_span", None)
if parent_otel_span is not None:
await _emit_management_endpoint_otel_span(
func=func,

View file

@ -12992,9 +12992,9 @@ async def _filter_models_by_team_id(
async def _find_model_by_id(
model_id: str,
search: str | None,
llm_router,
prisma_client,
proxy_config,
llm_router: Router | None,
prisma_client: PrismaClient | None,
proxy_config: "ProxyConfig",
) -> tuple[list, int | None]:
"""Find a model by its ID and optionally filter by search term."""
found_model = None

View file

@ -105,8 +105,8 @@ def _key_reservation_should_release_for_throttle(counter_key: str, valid_token:
async def _apply_over_budget_reservation_policy(
counter: _BudgetCounter,
valid_token: UserAPIKeyAuth | None,
entry: dict[str, Any],
applied_entries: list[dict[str, Any]],
entry: dict[str, float | str],
applied_entries: list[dict[str, float | str]],
reservation_cost: float,
current_spend: float,
) -> float:
@ -156,7 +156,7 @@ async def reserve_budget_for_request(
user_api_key_cache: DualCache,
proxy_logging_obj: ProxyLogging,
end_user_id: str | None = None,
end_user_object: Any | None = None,
end_user_object: object = None,
apply_user_budget_to_team_keys: bool = False,
fail_closed_budget_enforcement: bool = False,
) -> dict | None:
@ -194,7 +194,7 @@ async def reserve_budget_for_request(
if reservation_cost is None or reservation_cost <= 0:
return None
applied_entries: Final[list[dict[str, Any]]] = []
applied_entries: Final[list[dict[str, float | str]]] = []
try:
for counter in counters:
entry = _counter_to_reservation_entry(
@ -334,7 +334,7 @@ async def _get_budget_counters(
user_api_key_cache: DualCache,
proxy_logging_obj: ProxyLogging,
end_user_id: str | None = None,
end_user_object: Any | None = None,
end_user_object: object = None,
apply_user_budget_to_team_keys: bool = False,
) -> list[_BudgetCounter]:
counters: Final[list[_BudgetCounter]] = []
@ -443,7 +443,7 @@ async def _get_budget_counters(
async def _get_end_user_budget_counter(
valid_token: UserAPIKeyAuth,
end_user_id: str | None,
end_user_object: Any | None,
end_user_object: object,
) -> _BudgetCounter | None:
end_user_id = end_user_id or valid_token.end_user_id
if end_user_id is None:
@ -608,7 +608,7 @@ def _get_budget_limit_counters(
entity_prefix: str,
entity_type: str,
entity_id: str,
budget_limits: Sequence[Any] | None,
budget_limits: Sequence[object] | None,
fallback_spend: float,
) -> list[_BudgetCounter]:
counters: Final[list[_BudgetCounter]] = []
@ -855,7 +855,7 @@ async def _resize_applied_reservation(
def _counter_to_reservation_entry(
counter: _BudgetCounter,
reserved_cost: float,
) -> dict[str, Any]:
) -> dict[str, float | str]:
return {
"counter_key": counter.counter_key,
"entity_type": counter.entity_type,
@ -983,7 +983,7 @@ def _input_cost_for_cost_info(
request_body: dict,
route: str,
model: str,
model_info: dict[str, Any],
model_info: Mapping[str, object],
) -> float | None:
input_tokens: Final = _estimate_input_tokens(
request_body=request_body,
@ -1027,7 +1027,7 @@ def _max_cost_for_cost_info(
request_body: dict,
route: str,
model: str,
model_info: dict[str, Any],
model_info: Mapping[str, object],
) -> float | None:
image_cost: Final = _estimate_image_generation_cost(
request_body=request_body,
@ -1086,7 +1086,7 @@ def _max_cost_for_cost_info(
def _estimate_image_generation_cost(
request_body: dict,
model_info: dict[str, Any],
model_info: Mapping[str, object],
) -> float | None:
"""
Reserve `n × per-image cost` for image-generation requests so concurrent
@ -1125,7 +1125,7 @@ def _estimate_image_generation_cost(
def _get_model_cost_info(
model: str,
llm_router: Router | None,
) -> dict[str, Any] | None:
) -> Mapping[str, object] | None:
if llm_router is not None:
model_group_info: Final = llm_router.get_model_group_info(model_group=model)
if model_group_info is not None:
@ -1136,7 +1136,7 @@ def _get_model_cost_info(
def _get_model_cost_infos(
model: str,
llm_router: Router | None,
) -> list[dict[str, Any]]:
) -> Sequence[Mapping[str, object]]:
"""Cost-info candidates to estimate a request against for one model group.
Reservation runs before routing, so the deployment that will serve the request
@ -1181,7 +1181,7 @@ def _deployment_tiered_pricing_table(
def _get_deployment_tiered_pricing_tables(
model: str,
llm_router: Router | None,
) -> list[list[dict]]:
) -> Sequence[Sequence[Mapping[str, object]]]:
if llm_router is None:
return []
deployments: Final = llm_router.get_model_list(model_name=model) or []
@ -1196,7 +1196,7 @@ def _estimate_input_tokens(
request_body: dict,
route: str,
model: str,
model_info: dict[str, Any],
model_info: Mapping[str, object],
) -> int | None:
try:
if "messages" in request_body:
@ -1233,7 +1233,7 @@ DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK: Final = 16384
def _estimate_output_tokens(
request_body: dict,
route: str,
model_info: dict[str, Any],
model_info: Mapping[str, object],
) -> int | None:
if _is_input_only_route(route=route):
return 0

View file

@ -10,12 +10,41 @@ import asyncio
import copy
import json
import os
from typing import Any, Final, Literal, cast
from collections.abc import Mapping, Sequence
from typing import Any, Final, Literal, Protocol, cast
from litellm._logging import verbose_proxy_logger
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
class _ConfigRow(Protocol):
@property
def param_name(self) -> str: ...
@property
def param_value(self) -> object: ...
class _ConfigTable(Protocol):
async def find_unique(self, *, where: Mapping[str, str]) -> _ConfigRow | None: ...
async def find_many(self) -> Sequence[_ConfigRow]: ...
async def upsert(self, *, where: Mapping[str, str], data: Mapping[str, Mapping[str, str]]) -> _ConfigRow: ...
async def delete(self, *, where: Mapping[str, str]) -> _ConfigRow | None: ...
class _ConfigDb(Protocol):
@property
def litellm_config(self) -> _ConfigTable: ...
class _PrismaHandle(Protocol):
@property
def db(self) -> _ConfigDb: ...
class ConfigParam:
"""Simple wrapper for config parameter from DB."""
@ -38,18 +67,22 @@ class ConfigRepository:
self._prisma_client = prisma_client
@property
def prisma_client(self) -> Any:
def prisma_client(self) -> _PrismaHandle:
if self._prisma_client is None:
raise RuntimeError("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
return self._prisma_client
@property
def table(self) -> Any:
def _config_table(self) -> _ConfigTable:
return self.prisma_client.db.litellm_config
@property
def table(self) -> Any:
return self._config_table
async def get_param(self, param_name: str) -> ConfigParam | None:
"""Get a config parameter from the database."""
record: Final = await self.table.find_unique(where={"param_name": param_name})
record: Final = await self._config_table.find_unique(where={"param_name": param_name})
if record is None:
return None
param_value = record.param_value
@ -60,7 +93,7 @@ class ConfigRepository:
async def set_param(self, param_name: str, param_value: Any) -> ConfigParam:
"""Set a config parameter in the database."""
value_json: Final = json.dumps(param_value) if not isinstance(param_value, str) else param_value
await self.table.upsert(
await self._config_table.upsert(
where={"param_name": param_name},
data={
"create": {"param_name": param_name, "param_value": value_json},
@ -72,15 +105,15 @@ class ConfigRepository:
async def delete_param(self, param_name: str) -> bool:
"""Delete a config parameter from the database."""
try:
await self.table.delete(where={"param_name": param_name})
await self._config_table.delete(where={"param_name": param_name})
return True
except Exception:
return False
async def get_all_params(self) -> dict[str, Any]:
async def get_all_params(self) -> dict[str, object]:
"""Get all config parameters from the database."""
records: Final = await self.table.find_many()
result: Final = {}
records: Final = await self._config_table.find_many()
result: Final[dict[str, object]] = {}
for record in records:
param_value = record.param_value
if isinstance(param_value, str):
@ -107,7 +140,9 @@ class ConfigRepository:
else:
d[k] = v
def _decrypt_env_variables(self, env_vars: dict[str, Any], return_original_value: bool = True) -> dict[str, str]:
def _decrypt_env_variables(
self, env_vars: Mapping[str, object], return_original_value: bool = True
) -> dict[str, str]:
"""Decrypt environment variables from database."""
decrypted: Final[dict[str, str]] = {}
for key, value in env_vars.items():

View file

@ -3,7 +3,8 @@ Model repository for database operations on LiteLLM_ProxyModelTable.
"""
import json
from typing import Any, Final
from collections.abc import Awaitable, Mapping, Sequence
from typing import Any, Final, Protocol
from litellm.models.model import LiteLLM_ProxyModelTable
from litellm.proxy.common_utils.config_sync_pubsub import wrap_table_actions_for_config_sync
@ -11,28 +12,51 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
from litellm.repositories.base_repository import BaseRepository
from litellm.repositories.base_repository import BaseRepository, DbRecord
class _PrismaModelDb(Protocol):
litellm_proxymodeltable: object
class _PrismaClientView(Protocol):
db: _PrismaModelDb
class _ProxyModelActions(Protocol):
"""Prisma table actions used by :class:`ModelRepository`."""
def find_many(self, *, where: Mapping[str, object] | None = None) -> Awaitable[Sequence[DbRecord]]: ...
def create(self, *, data: Mapping[str, object]) -> Awaitable[DbRecord]: ...
def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> Awaitable[DbRecord | None]: ...
class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
"""Repository for proxy model database operations with encryption support."""
def __init__(self, prisma_client: Any, encryption_key: str | None = None):
def __init__(self, prisma_client: object, encryption_key: str | None = None):
super().__init__(prisma_client)
self._encryption_key = encryption_key
@property
def table(self) -> Any:
client: Final[_PrismaClientView] = self.prisma_client
return wrap_table_actions_for_config_sync(
actions=self.prisma_client.db.litellm_proxymodeltable,
actions=client.db.litellm_proxymodeltable,
table_name="litellm_proxymodeltable",
)
@property
def _model_table(self) -> _ProxyModelActions:
return self.table
@property
def model_class(self) -> type[LiteLLM_ProxyModelTable]:
return LiteLLM_ProxyModelTable
def _encrypt_litellm_params(self, litellm_params: dict[str, Any]) -> dict[str, Any]:
def _encrypt_litellm_params(self, litellm_params: Mapping[str, object]) -> Mapping[str, object]:
"""Encrypt sensitive values in litellm_params."""
encrypted: Final = {}
for key, value in litellm_params.items():
@ -42,7 +66,7 @@ class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
encrypted[key] = value
return encrypted
def _decrypt_litellm_params(self, litellm_params: dict[str, Any]) -> dict[str, Any]:
def _decrypt_litellm_params(self, litellm_params: Mapping[str, object]) -> Mapping[str, object]:
"""Decrypt sensitive values in litellm_params."""
decrypted: Final = {}
for key, value in litellm_params.items():
@ -76,17 +100,17 @@ class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
async def find_by_name(self, model_name: str) -> list[LiteLLM_ProxyModelTable]:
"""Find models by name."""
records: Final = await self.table.find_many(where={"model_name": model_name})
records: Final = await self._model_table.find_many(where={"model_name": model_name})
return self._to_model_list(records)
async def find_all(self) -> list[LiteLLM_ProxyModelTable]:
"""Find all models."""
records: Final = await self.table.find_many()
records: Final = await self._model_table.find_many()
return self._to_model_list(records)
async def find_unblocked(self) -> list[LiteLLM_ProxyModelTable]:
"""Find all models that are not blocked."""
records: Final = await self.table.find_many(where={"blocked": False})
records: Final = await self._model_table.find_many(where={"blocked": False})
return self._to_model_list(records)
async def find_by_team_id(self, team_id: str) -> list[LiteLLM_ProxyModelTable]:
@ -102,16 +126,16 @@ class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
async def create_model(
self,
model_name: str,
litellm_params: dict[str, Any],
litellm_params: Mapping[str, object],
created_by: str,
model_id: str | None = None,
model_info: dict[str, Any] | None = None,
model_info: Mapping[str, object] | None = None,
blocked: bool = False,
) -> LiteLLM_ProxyModelTable:
"""Create a new model with encryption."""
encrypted_params: Final = self._encrypt_litellm_params(litellm_params)
data: Final[dict[str, Any]] = {
data: Final[dict[str, str | bool]] = {
"model_name": model_name,
"litellm_params": json.dumps(encrypted_params),
"created_by": created_by,
@ -123,7 +147,7 @@ class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
if model_info is not None:
data["model_info"] = json.dumps(model_info)
record: Final = await self.table.create(data=data)
record: Final = await self._model_table.create(data=data)
model: Final = self._to_model(record)
assert model is not None
return model
@ -133,12 +157,12 @@ class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
model_id: str,
updated_by: str,
model_name: str | None = None,
litellm_params: dict[str, Any] | None = None,
model_info: dict[str, Any] | None = None,
litellm_params: Mapping[str, object] | None = None,
model_info: Mapping[str, object] | None = None,
blocked: bool | None = None,
) -> LiteLLM_ProxyModelTable | None:
"""Update a model with encryption."""
data: Final[dict[str, Any]] = {"updated_by": updated_by}
data: Final[dict[str, str | bool]] = {"updated_by": updated_by}
if model_name is not None:
data["model_name"] = model_name
if litellm_params is not None:
@ -149,7 +173,7 @@ class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
if blocked is not None:
data["blocked"] = blocked
record: Final = await self.table.update(where={"model_id": model_id}, data=data)
record: Final = await self._model_table.update(where={"model_id": model_id}, data=data)
return self._to_model(record)
async def delete_model(self, model_id: str) -> LiteLLM_ProxyModelTable | None:

View file

@ -810,7 +810,7 @@ def _responses_try_dispatch_emulated_file_search(
extra_body: dict[str, object] | None,
timeout: float | httpx.Timeout | None,
custom_llm_provider: str | None,
kwargs: dict[str, Any],
kwargs: dict[str, object],
_is_async: bool,
) -> ResponsesAPIResponse | Coroutine[object, object, ResponsesAPIResponse] | None:
"""Return a response when emulated file_search handles the call; otherwise None."""

View file

@ -79,6 +79,11 @@ def _is_str_mapping(value: object) -> TypeIs[dict[str, str]]: # guard-ok: verif
return _is_json_object(value) and all(isinstance(item, str) for item in value.values())
def _load_json_object(payload: str | bytes) -> dict[str, object]:
"""Parse a JSON payload that the caller consumes as an object."""
return json.loads(payload)
def _model_id_from_metadata(litellm_metadata: dict[str, object] | None) -> str | None:
model_info: Final = litellm_metadata.get("model_info") if litellm_metadata else None
model_id: Final = model_info.get("id") if _is_json_object(model_info) else None
@ -1474,7 +1479,7 @@ class ResponsesWebSocketStreaming:
event = event.decode("utf-8")
if isinstance(event, str):
try:
event_obj = json.loads(event)
event_obj = _load_json_object(event)
except (json.JSONDecodeError, TypeError):
return
else:
@ -1487,7 +1492,7 @@ class ResponsesWebSocketStreaming:
"""Extract user input content from response.create for logging."""
try:
if isinstance(message, str):
msg_obj = json.loads(message)
msg_obj = _load_json_object(message)
elif _is_json_object(message):
msg_obj = message
else:
@ -1557,7 +1562,7 @@ class ResponsesWebSocketStreaming:
# masked response.completed.
if self.output_guardrail_callbacks:
try:
_evt_payload: Mapping[str, object] = json.loads(response_str)
_evt_payload: Mapping[str, object] = _load_json_object(response_str)
_evt_type = _evt_payload.get("type")
except (json.JSONDecodeError, TypeError):
_evt_type = None
@ -1622,7 +1627,7 @@ class ResponsesWebSocketStreaming:
Non-``response.create`` messages are returned unchanged.
"""
try:
msg_obj: Final[dict[str, object]] = json.loads(message)
msg_obj: Final = _load_json_object(message)
except (json.JSONDecodeError, TypeError):
return message
@ -1751,7 +1756,7 @@ class ResponsesWebSocketStreaming:
return response_str
try:
evt_obj: Final[dict[str, object]] = json.loads(response_str)
evt_obj: Final = _load_json_object(response_str)
except (json.JSONDecodeError, TypeError):
return response_str
@ -1807,7 +1812,7 @@ class ResponsesWebSocketStreaming:
return response_str
try:
evt_obj: Final[Mapping[str, object]] = json.loads(response_str)
evt_obj: Final[Mapping[str, object]] = _load_json_object(response_str)
except (json.JSONDecodeError, TypeError):
return response_str
@ -2141,7 +2146,7 @@ class ManagedResponsesWebSocketHandler:
async def _parse_message(self, raw_message: str) -> dict[str, object] | None:
"""Parse raw WS text; return the message dict or None (JSON error / ignored type)."""
try:
msg_obj: Final[dict[str, object]] = json.loads(raw_message)
msg_obj: Final = _load_json_object(raw_message)
except json.JSONDecodeError:
await self._send_error("Invalid JSON in response.create event", "invalid_request_error")
return None
@ -2345,7 +2350,7 @@ class ManagedResponsesWebSocketHandler:
continue
if chunk_type == "response.completed" and completed_event is None:
try:
completed_event = json.loads(serialized)
completed_event = _load_json_object(serialized)
except Exception:
pass
try:

View file

@ -12,6 +12,7 @@ from __future__ import annotations
import contextlib
import contextvars
from collections.abc import Mapping, MutableMapping
from typing import TYPE_CHECKING, Any, Final
import httpx
@ -26,13 +27,13 @@ from litellm.utils import get_utc_datetime
if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
Span = _Span | Any
Span = _Span
else:
Span = Any
RoutingArgsTTL: Final = 60
_io_token_rate_limit_request_kwargs: Final[contextvars.ContextVar[dict[str, Any] | None]] = contextvars.ContextVar(
_io_token_rate_limit_request_kwargs: Final[contextvars.ContextVar[dict[str, object] | None]] = contextvars.ContextVar(
"io_token_rate_limit_request_kwargs",
default=None,
)
@ -43,7 +44,7 @@ ITPM_CACHE_KEY: Final = "_litellm_itpm_cache_key"
OTPM_CACHE_KEY: Final = "_litellm_otpm_cache_key"
def set_io_token_rate_limit_request_kwargs(kwargs: dict[str, Any] | None, store_in_context: bool = True) -> None:
def set_io_token_rate_limit_request_kwargs(kwargs: dict[str, object] | None, store_in_context: bool = True) -> None:
# The reservation sentinels are server-only, but `metadata` is caller
# controlled on proxy requests. Strip any client-supplied copies here (this
# runs before the router stashes its own reservation) so a forged
@ -60,7 +61,7 @@ def set_io_token_rate_limit_request_kwargs(kwargs: dict[str, Any] | None, store_
_io_token_rate_limit_request_kwargs.set(kwargs if store_in_context else None)
def get_io_token_rate_limit_request_kwargs() -> dict[str, Any] | None:
def get_io_token_rate_limit_request_kwargs() -> dict[str, object] | None:
return _io_token_rate_limit_request_kwargs.get()
@ -151,14 +152,14 @@ def _resolve_max_tokens(request_kwargs: dict[str, Any] | None, deployment: dict)
return 4096
def _get_usage_tokens(usage: Any) -> tuple[int, int, int]:
def _get_usage_tokens(usage: object) -> tuple[int, int, int]:
if usage is None:
return 0, 0, 0
if hasattr(usage, "prompt_tokens") or hasattr(usage, "input_tokens"):
prompt = int(getattr(usage, "prompt_tokens", None) or getattr(usage, "input_tokens", 0) or 0)
completion = int(getattr(usage, "completion_tokens", None) or getattr(usage, "output_tokens", 0) or 0)
cached = 0
details = getattr(usage, "prompt_tokens_details", None)
details: object = getattr(usage, "prompt_tokens_details", None)
if details is not None:
cached = int(getattr(details, "cached_tokens", 0) or 0)
if not cached:
@ -175,13 +176,13 @@ def _get_usage_tokens(usage: Any) -> tuple[int, int, int]:
return 0, 0, 0
def _extract_response_usage(response_obj: Any) -> Any:
def _extract_response_usage(response_obj: object) -> object:
if isinstance(response_obj, dict):
return response_obj.get("usage")
return getattr(response_obj, "usage", None)
def _usage_is_present(usage: Any) -> bool:
def _usage_is_present(usage: object) -> bool:
"""
True only if usage carries an actual input/output breakdown.
@ -199,8 +200,8 @@ def _usage_is_present(usage: Any) -> bool:
def _resolve_reconcile_usage_tokens(
kwargs: Any,
response_obj: Any,
kwargs: Mapping[str, object] | None,
response_obj: object,
) -> tuple[int, int, bool]:
"""
Resolve billable input and output tokens for post-call reconcile.
@ -233,7 +234,7 @@ def _resolve_reconcile_usage_tokens(
def _stash_reservation_in_metadata(
request_kwargs: dict[str, Any] | None,
request_kwargs: dict[str, object] | None,
*,
itpm_reserved: int,
otpm_reserved: int,
@ -256,7 +257,7 @@ def _stash_reservation_in_metadata(
request_kwargs[channel] = dict(reservation)
def _extract_reservation(reservation: dict[str, Any]) -> tuple[int, int, str | None, str | None]:
def _extract_reservation(reservation: Mapping[str, int | str | None]) -> tuple[int, int, str | None, str | None]:
itpm_cache_key: Final = reservation.get(ITPM_CACHE_KEY)
otpm_cache_key: Final = reservation.get(OTPM_CACHE_KEY)
return (
@ -267,7 +268,12 @@ def _extract_reservation(reservation: dict[str, Any]) -> tuple[int, int, str | N
)
def _reservation_channels(kwargs: Any) -> tuple[Any, ...]:
def _as_mutable_mapping(value: object) -> MutableMapping[str, object] | None:
"""``value`` when it is a dict, else ``None``."""
return value if isinstance(value, dict) else None
def _reservation_channels(kwargs: Mapping[str, object] | None) -> tuple[object, ...]:
"""
Places a reservation may live, in priority order: the top-level metadata
channels win over litellm_params.metadata (so a top-level stash is never
@ -275,30 +281,29 @@ def _reservation_channels(kwargs: Any) -> tuple[Any, ...]:
"""
if not isinstance(kwargs, dict):
return ()
channels: Final = [kwargs.get("metadata"), kwargs.get("litellm_metadata")]
litellm_params: Final = kwargs.get("litellm_params")
if isinstance(litellm_params, dict):
channels.append(litellm_params.get("metadata"))
standard_logging_object: Final = kwargs.get("standard_logging_object")
if isinstance(standard_logging_object, dict):
channels.append(standard_logging_object.get("metadata"))
return tuple(channels)
top_level: Final = (kwargs.get("metadata"), kwargs.get("litellm_metadata"))
litellm_params: Final = _as_mutable_mapping(kwargs.get("litellm_params"))
from_params: Final = () if litellm_params is None else (litellm_params.get("metadata"),)
standard_logging_object: Final = _as_mutable_mapping(kwargs.get("standard_logging_object"))
from_logging_object: Final = () if standard_logging_object is None else (standard_logging_object.get("metadata"),)
return top_level + from_params + from_logging_object
def _read_reservation_from_kwargs(kwargs: Any) -> tuple[int, int, str | None, str | None]:
def _read_reservation_from_kwargs(kwargs: Mapping[str, object] | None) -> tuple[int, int, str | None, str | None]:
for channel_dict in _reservation_channels(kwargs):
if isinstance(channel_dict, dict) and ITPM_RESERVED_KEY in channel_dict:
return _extract_reservation(channel_dict)
return 0, 0, None, None
def _clear_reservation_from_kwargs(kwargs: Any) -> None:
def _clear_reservation_from_kwargs(kwargs: Mapping[str, object] | None) -> None:
"""
Remove the stashed reservation so a retry on a different (e.g. non-IO)
deployment does not re-process the already-reconciled/refunded reservation.
"""
for channel_dict in _reservation_channels(kwargs):
if isinstance(channel_dict, dict):
for channel in _reservation_channels(kwargs):
channel_dict = _as_mutable_mapping(channel)
if channel_dict is not None:
for key in (ITPM_RESERVED_KEY, OTPM_RESERVED_KEY, ITPM_CACHE_KEY, OTPM_CACHE_KEY):
channel_dict.pop(key, None)
@ -524,11 +529,13 @@ def io_token_reconcile_success(
kwargs: Any,
response_obj: Any,
) -> None:
itpm_reserved, otpm_reserved, itpm_key, otpm_key = _read_reservation_from_kwargs(kwargs)
request_kwargs: Final[Mapping[str, object] | None] = kwargs
response: Final[object] = response_obj
itpm_reserved, otpm_reserved, itpm_key, otpm_key = _read_reservation_from_kwargs(request_kwargs)
if itpm_key is None and otpm_key is None:
return
billable_input, completion_tokens, usage_resolved = _resolve_reconcile_usage_tokens(kwargs, response_obj)
billable_input, completion_tokens, usage_resolved = _resolve_reconcile_usage_tokens(request_kwargs, response)
try:
if usage_resolved:
@ -556,7 +563,7 @@ def io_token_reconcile_success(
otpm_reserved,
)
finally:
_clear_reservation_from_kwargs(kwargs)
_clear_reservation_from_kwargs(request_kwargs)
verbose_router_logger.debug(
"[IO TOKEN LIMIT] reconciled (usage_resolved=%s, itpm_reserved=%s, billable_input=%s, otpm_reserved=%s, output=%s)",
@ -575,11 +582,13 @@ async def async_io_token_reconcile_success(
*,
parent_otel_span: Span | None = None,
) -> None:
itpm_reserved, otpm_reserved, itpm_key, otpm_key = _read_reservation_from_kwargs(kwargs)
request_kwargs: Final[Mapping[str, object] | None] = kwargs
response: Final[object] = response_obj
itpm_reserved, otpm_reserved, itpm_key, otpm_key = _read_reservation_from_kwargs(request_kwargs)
if itpm_key is None and otpm_key is None:
return
billable_input, completion_tokens, usage_resolved = _resolve_reconcile_usage_tokens(kwargs, response_obj)
billable_input, completion_tokens, usage_resolved = _resolve_reconcile_usage_tokens(request_kwargs, response)
# Reconcile against the exact key that held the reservation (which encodes
# the reservation's minute), not a key recomputed at response time. This
@ -615,7 +624,7 @@ async def async_io_token_reconcile_success(
otpm_reserved,
)
finally:
_clear_reservation_from_kwargs(kwargs)
_clear_reservation_from_kwargs(request_kwargs)
verbose_router_logger.debug(
"[IO TOKEN LIMIT] reconciled (usage_resolved=%s, itpm_reserved=%s, billable_input=%s, otpm_reserved=%s, output=%s)",
@ -631,7 +640,8 @@ def io_token_refund_failure(
dual_cache: DualCache,
kwargs: Any,
) -> None:
itpm_reserved, otpm_reserved, itpm_key, otpm_key = _read_reservation_from_kwargs(kwargs)
request_kwargs: Final[Mapping[str, object] | None] = kwargs
itpm_reserved, otpm_reserved, itpm_key, otpm_key = _read_reservation_from_kwargs(request_kwargs)
if itpm_key is None and otpm_key is None:
return
if itpm_key is not None and itpm_reserved > 0:
@ -646,11 +656,11 @@ def io_token_refund_failure(
value=-otpm_reserved,
ttl=RoutingArgsTTL,
)
_clear_reservation_from_kwargs(kwargs)
_clear_reservation_from_kwargs(request_kwargs)
verbose_router_logger.debug("[IO TOKEN LIMIT] refunded ITPM=%s OTPM=%s", itpm_reserved, otpm_reserved)
def refund_stale_reservation_before_retry(dual_cache: DualCache, kwargs: dict[str, Any] | None) -> None:
def refund_stale_reservation_before_retry(dual_cache: DualCache, kwargs: Mapping[str, object] | None) -> None:
"""
Synchronously refund and clear any reservation a previous deployment
attempt stashed in ``kwargs``, before it's overwritten for the next
@ -683,7 +693,8 @@ async def async_io_token_refund_failure(
*,
parent_otel_span: Span | None = None,
) -> None:
itpm_reserved, otpm_reserved, itpm_key, otpm_key = _read_reservation_from_kwargs(kwargs)
request_kwargs: Final[Mapping[str, object] | None] = kwargs
itpm_reserved, otpm_reserved, itpm_key, otpm_key = _read_reservation_from_kwargs(request_kwargs)
if itpm_key is None and otpm_key is None:
return
if itpm_key is not None and itpm_reserved > 0:
@ -700,7 +711,7 @@ async def async_io_token_refund_failure(
ttl=RoutingArgsTTL,
parent_otel_span=parent_otel_span,
)
_clear_reservation_from_kwargs(kwargs)
_clear_reservation_from_kwargs(request_kwargs)
verbose_router_logger.debug("[IO TOKEN LIMIT] refunded ITPM=%s OTPM=%s", itpm_reserved, otpm_reserved)

View file

@ -1,6 +1,6 @@
{
"ANN001": {
"limit": 3026
"limit": 3020
},
"ANN002": {
"limit": 71
@ -12,19 +12,19 @@
"limit": 2017
},
"ANN202": {
"limit": 854
"limit": 852
},
"ANN204": {
"limit": 711
},
"ANN205": {
"limit": 114
"limit": 113
},
"ANN206": {
"limit": 133
},
"ANN401": {
"limit": 1290
"limit": 1188
},
"ASYNC230": {
"limit": 11
@ -234,7 +234,7 @@
"limit": 5
},
"TID251": {
"limit": 1216
"limit": 1212
},
"TRY002": {
"limit": 524

View file

@ -1,9 +1,9 @@
{
"LIT001": {
"limit": 22892
"limit": 22809
},
"LIT002": {
"limit": 26886
"limit": 26878
},
"LIT003": {
"limit": 269
@ -15,7 +15,7 @@
"limit": 0
},
"LIT006": {
"limit": 1071
"limit": 1069
},
"LIT007": {
"limit": 0
@ -27,10 +27,10 @@
"limit": 0
},
"LIT010": {
"limit": 16699
"limit": 16695
},
"LIT011": {
"limit": 5590
"limit": 5588
},
"LIT012": {
"limit": 4519