mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
refactor(types): replace Any with real types across 54 more backend files
Second pass over the highest-Any-density modules that the first pass left untouched: guardrail hooks, the gemini and anthropic transformation layers, the proxy spend-tracking and pass-through endpoints, and the caching clients. Untyped `response.json()` bodies and `dict[str, Any]` request payloads are described once at their boundary with a TypedDict or Protocol, so the fields read downstream resolve to real types instead of Any. No cast, no type: ignore, no noqa, and no new Any annotations.
This commit is contained in:
parent
abaaf8b210
commit
14484d67fd
55 changed files with 1630 additions and 940 deletions
|
|
@ -4,7 +4,7 @@ Polls LiteLLM_ManagedObjectTable to check if the batch job is complete, and if t
|
|||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Dict, Final, List, Optional, Tuple
|
||||
from typing import TYPE_CHECKING, Final, List, Optional, Tuple
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -86,7 +86,7 @@ class CheckBatchCost:
|
|||
return
|
||||
self.batch_processed_support_confirmed = True
|
||||
|
||||
async def _get_user_info(self, batch_id: str, user_id: Optional[str]) -> Dict[str, Any]:
|
||||
async def _get_user_info(self, batch_id: str, user_id: Optional[str]) -> dict[str, str | None]:
|
||||
"""
|
||||
Look up user email and key alias by user_id for enriching the S3 callback metadata.
|
||||
Returns a dict with user_api_key_user_email and user_api_key_alias (both may be None).
|
||||
|
|
@ -96,8 +96,10 @@ class CheckBatchCost:
|
|||
if not user_id:
|
||||
return {}
|
||||
try:
|
||||
user_row = await self.prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_id}
|
||||
user_row: prisma_models.LiteLLM_UserTable | None = (
|
||||
await self.prisma_client.db.litellm_usertable.find_unique(
|
||||
where={"user_id": user_id}
|
||||
)
|
||||
)
|
||||
if user_row is None:
|
||||
return {}
|
||||
|
|
@ -114,8 +116,10 @@ class CheckBatchCost:
|
|||
if not api_key:
|
||||
return None
|
||||
try:
|
||||
key_row = await self.prisma_client.db.litellm_verificationtoken.find_unique(
|
||||
where={"token": api_key}
|
||||
key_row: prisma_models.LiteLLM_VerificationToken | None = (
|
||||
await self.prisma_client.db.litellm_verificationtoken.find_unique(
|
||||
where={"token": api_key}
|
||||
)
|
||||
)
|
||||
return getattr(key_row, "key_alias", None) if key_row is not None else None
|
||||
except Exception as e:
|
||||
|
|
@ -127,8 +131,10 @@ class CheckBatchCost:
|
|||
if not team_id:
|
||||
return None
|
||||
try:
|
||||
team_row = await self.prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": team_id}
|
||||
team_row: prisma_models.LiteLLM_TeamTable | None = (
|
||||
await self.prisma_client.db.litellm_teamtable.find_unique(
|
||||
where={"team_id": team_id}
|
||||
)
|
||||
)
|
||||
return getattr(team_row, "team_alias", None) if team_row is not None else None
|
||||
except Exception as e:
|
||||
|
|
@ -137,7 +143,7 @@ class CheckBatchCost:
|
|||
|
||||
async def _build_creator_attribution_metadata(
|
||||
self, job: "LiteLLM_ManagedObjectTable", batch_id: str
|
||||
) -> Dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Rebuild the spend-tracking metadata for the key, team, and tags that created the
|
||||
batch so the batch-cost spend log is attributed the same way a non-batch request
|
||||
|
|
@ -151,7 +157,7 @@ class CheckBatchCost:
|
|||
team_id = getattr(job, "team_id", None)
|
||||
request_tags = getattr(job, "request_tags", None)
|
||||
|
||||
metadata: Dict[str, Any] = {
|
||||
metadata: dict[str, object] = {
|
||||
"user_api_key_user_id": job.created_by,
|
||||
"user_api_key": api_key,
|
||||
"user_api_key_team_id": team_id,
|
||||
|
|
|
|||
|
|
@ -181,6 +181,10 @@ class _ManagedObjectTableActions(Protocol):
|
|||
async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int: ...
|
||||
|
||||
|
||||
class _SchedulerWithJobLookup(Protocol):
|
||||
def get_job(self, job_id: str) -> object: ...
|
||||
|
||||
|
||||
class _CursorPageArgs(TypedDict, total=False):
|
||||
cursor: Mapping[str, str]
|
||||
skip: int
|
||||
|
|
@ -815,7 +819,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
file_ids.append(file_id)
|
||||
return file_ids
|
||||
|
||||
def get_file_ids_from_responses_input(self, input: Union[str, List[Dict[str, Any]]]) -> List[str]:
|
||||
def get_file_ids_from_responses_input(self, input: Union[str, List[Dict[str, object]]]) -> List[str]:
|
||||
"""
|
||||
Gets file ids from responses API input.
|
||||
|
||||
|
|
@ -840,7 +844,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
# Check for direct input_file type
|
||||
if item.get("type") == "input_file":
|
||||
file_id = item.get("file_id")
|
||||
if file_id:
|
||||
if isinstance(file_id, str) and file_id:
|
||||
file_ids.append(file_id)
|
||||
|
||||
# Check for input_file in content array
|
||||
|
|
@ -849,7 +853,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
for content_item in content:
|
||||
if isinstance(content_item, dict) and content_item.get("type") == "input_file":
|
||||
file_id = content_item.get("file_id")
|
||||
if file_id:
|
||||
if isinstance(file_id, str) and file_id:
|
||||
file_ids.append(file_id)
|
||||
|
||||
return file_ids
|
||||
|
|
@ -1189,7 +1193,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
|
||||
# Handle both output_file_id and error_file_id
|
||||
for file_attr in ["output_file_id", "error_file_id"]:
|
||||
file_id_value = getattr(response, file_attr, None)
|
||||
file_id_value: str | None = getattr(response, file_attr, None)
|
||||
if file_id_value and model_id:
|
||||
decoded_output_file_id = _is_base64_encoded_unified_file_id(file_id_value)
|
||||
if decoded_output_file_id and "llm_output_file_id," in decoded_output_file_id:
|
||||
|
|
@ -1458,7 +1462,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
import litellm.proxy.proxy_server as proxy_server_module
|
||||
|
||||
# Check if the scheduler has the batch cost checking job registered
|
||||
scheduler = getattr(proxy_server_module, "scheduler", None)
|
||||
scheduler: Final[_SchedulerWithJobLookup | None] = getattr(proxy_server_module, "scheduler", None)
|
||||
if scheduler is None:
|
||||
return False
|
||||
|
||||
|
|
@ -1504,7 +1508,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
)
|
||||
MAX_MATCHES_TO_RETURN = 10
|
||||
|
||||
batches = await self.prisma_client.db.litellm_managedobjecttable.find_many(
|
||||
batches = await _managed_object_table(self.prisma_client).find_many(
|
||||
where={
|
||||
"file_purpose": "batch",
|
||||
"batch_processed": False,
|
||||
|
|
@ -1514,11 +1518,14 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints):
|
|||
order={"created_at": "desc"},
|
||||
)
|
||||
|
||||
referencing_batches = []
|
||||
referencing_batches: Final[list[dict[str, object]]] = []
|
||||
for batch in batches:
|
||||
try:
|
||||
# Parse the batch file_object to check for file references
|
||||
batch_data = json.loads(batch.file_object) if isinstance(batch.file_object, str) else batch.file_object
|
||||
decoded_file_object = _decode_json_blob(batch.file_object)
|
||||
batch_data: Mapping[str, object] = (
|
||||
decoded_file_object if isinstance(decoded_file_object, Mapping) else {}
|
||||
)
|
||||
|
||||
# Extract file IDs from batch
|
||||
# Batches typically reference the unified file ID in input_file_id
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ import time
|
|||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from contextvars import ContextVar
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar, cast
|
||||
|
||||
import litellm
|
||||
from litellm._logging import print_verbose, verbose_logger
|
||||
|
|
@ -58,6 +58,26 @@ else:
|
|||
Span = Any
|
||||
|
||||
|
||||
class _AsyncRedisCommands(Protocol):
|
||||
"""Async redis commands this cache issues.
|
||||
|
||||
redis-py's type stubs omit these methods on RedisCluster, so the union returned by
|
||||
init_async_client() is untyped at every call site without this protocol.
|
||||
"""
|
||||
|
||||
def ping(self) -> Awaitable[bool]: ...
|
||||
|
||||
def delete(self, *names: str) -> Awaitable[int]: ...
|
||||
|
||||
def ttl(self, name: str) -> Awaitable[int]: ...
|
||||
|
||||
def rpush(self, name: str, *values: str | bytes | float) -> Awaitable[int]: ...
|
||||
|
||||
def lpop(self, name: str, count: int | None = None) -> Awaitable[object]: ...
|
||||
|
||||
def pipeline(self, transaction: bool = True) -> "Pipeline[bytes]": ...
|
||||
|
||||
|
||||
def _get_call_stack_info(num_frames: int = 2) -> str:
|
||||
"""
|
||||
Get the function names from the previous 1-2 functions in the call stack.
|
||||
|
|
@ -429,6 +449,9 @@ class RedisCache(BaseCache):
|
|||
self.redis_async_client = redis_async_client
|
||||
return redis_async_client
|
||||
|
||||
def _async_commands(self) -> _AsyncRedisCommands:
|
||||
return self.init_async_client()
|
||||
|
||||
def check_and_fix_namespace(self, key: str) -> str:
|
||||
"""
|
||||
Make sure each key starts with the given namespace
|
||||
|
|
@ -1055,19 +1078,17 @@ class RedisCache(BaseCache):
|
|||
await self.async_set_cache_pipeline(self.redis_batch_writing_buffer)
|
||||
self.redis_batch_writing_buffer = []
|
||||
|
||||
def _get_cache_logic(self, cached_response: Any):
|
||||
def _get_cache_logic(self, cached_response: bytes | str | None):
|
||||
"""
|
||||
Common 'get_cache_logic' across sync + async redis client implementations
|
||||
"""
|
||||
if cached_response is None:
|
||||
return cached_response
|
||||
# cached_response is in `b{} convert it to ModelResponse
|
||||
cached_response = cached_response.decode("utf-8") # Convert bytes to string
|
||||
return None
|
||||
decoded: Final = cached_response.decode("utf-8") if isinstance(cached_response, bytes) else cached_response
|
||||
try:
|
||||
cached_response = json.loads(cached_response) # Convert string to dictionary
|
||||
return json.loads(decoded)
|
||||
except Exception:
|
||||
cached_response = ast.literal_eval(cached_response)
|
||||
return cached_response
|
||||
return ast.literal_eval(decoded)
|
||||
|
||||
def get_cache(self, key, parent_otel_span: Span | None = None, **kwargs):
|
||||
try:
|
||||
|
|
@ -1314,8 +1335,7 @@ class RedisCache(BaseCache):
|
|||
raise e
|
||||
|
||||
async def ping(self) -> bool:
|
||||
# typed as Any, redis python lib has incomplete type stubs for RedisCluster and does not include `ping`
|
||||
_redis_client: Final[Any] = self.init_async_client()
|
||||
_redis_client: Final = self._async_commands()
|
||||
start_time: Final = time.time()
|
||||
print_verbose("Pinging Async Redis Cache")
|
||||
try:
|
||||
|
|
@ -1349,8 +1369,7 @@ class RedisCache(BaseCache):
|
|||
|
||||
@_redis_circuit_breaker_guard
|
||||
async def delete_cache_keys(self, keys):
|
||||
# typed as Any, redis python lib has incomplete type stubs for RedisCluster and does not include `delete`
|
||||
_redis_client: Final[Any] = self.init_async_client()
|
||||
_redis_client: Final = self._async_commands()
|
||||
keys = [self.check_and_fix_namespace(key=key) for key in keys]
|
||||
# keys is a list, unpack it so it gets passed as individual elements to delete
|
||||
await _redis_client.delete(*keys)
|
||||
|
|
@ -1415,8 +1434,7 @@ class RedisCache(BaseCache):
|
|||
|
||||
@_redis_circuit_breaker_guard
|
||||
async def async_delete_cache(self, key: str):
|
||||
# typed as Any, redis python lib has incomplete type stubs for RedisCluster and does not include `delete`
|
||||
_redis_client: Final[Any] = self.init_async_client()
|
||||
_redis_client: Final = self._async_commands()
|
||||
key = self.check_and_fix_namespace(key=key)
|
||||
# keys is str
|
||||
return await _redis_client.delete(key)
|
||||
|
|
@ -1523,8 +1541,7 @@ class RedisCache(BaseCache):
|
|||
Redis ref: https://redis.io/docs/latest/commands/ttl/
|
||||
"""
|
||||
try:
|
||||
# typed as Any, redis python lib has incomplete type stubs for RedisCluster and does not include `ttl`
|
||||
_redis_client: Final[Any] = self.init_async_client()
|
||||
_redis_client: Final = self._async_commands()
|
||||
key = self.check_and_fix_namespace(key=key)
|
||||
ttl: Final = await _redis_client.ttl(key)
|
||||
if ttl <= -1: # -1 means the key does not exist, -2 key does not exist
|
||||
|
|
@ -1554,7 +1571,7 @@ class RedisCache(BaseCache):
|
|||
Returns:
|
||||
int: The length of the list after the push operation
|
||||
"""
|
||||
_redis_client: Final[Any] = self.init_async_client()
|
||||
_redis_client: Final = self._async_commands()
|
||||
key = self.check_and_fix_namespace(key=key)
|
||||
start_time: Final = time.time()
|
||||
try:
|
||||
|
|
@ -1621,7 +1638,7 @@ class RedisCache(BaseCache):
|
|||
if len(rpush_list) == 0:
|
||||
return []
|
||||
|
||||
_redis_client: Final[Any] = self.init_async_client()
|
||||
_redis_client: Final = self._async_commands()
|
||||
start_time: Final = time.time()
|
||||
|
||||
try:
|
||||
|
|
@ -1678,7 +1695,7 @@ class RedisCache(BaseCache):
|
|||
parent_otel_span: Span | None = None,
|
||||
**kwargs,
|
||||
) -> Any | list[Any]:
|
||||
_redis_client: Final[Any] = self.init_async_client()
|
||||
_redis_client: Final = self._async_commands()
|
||||
key = self.check_and_fix_namespace(key=key)
|
||||
start_time: Final = time.time()
|
||||
print_verbose(f"LPOP from Redis list: key: {key}, count: {count}")
|
||||
|
|
@ -1810,7 +1827,7 @@ class RedisCache(BaseCache):
|
|||
if len(lpop_list) == 0:
|
||||
return []
|
||||
|
||||
_redis_client: Final[Any] = self.init_async_client()
|
||||
_redis_client: Final = self._async_commands()
|
||||
start_time: Final = time.time()
|
||||
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ RedisSemanticCache since those are backend agnostic.
|
|||
import asyncio
|
||||
import hashlib
|
||||
import os
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, Protocol
|
||||
|
||||
from redis import Redis
|
||||
from redis.asyncio import Redis as AsyncRedis
|
||||
|
|
@ -40,6 +41,19 @@ class _ValkeyCacheHit:
|
|||
distance: float
|
||||
|
||||
|
||||
class _SearchDocumentLike(Protocol):
|
||||
"""A valkey-search result document, whose fields are addressed by configurable name."""
|
||||
|
||||
def __getattr__(self, name: str, /) -> str | bytes | int | float: ...
|
||||
|
||||
|
||||
class _SearchResultLike(Protocol):
|
||||
"""The one field this backend reads off an ``FT.SEARCH`` reply."""
|
||||
|
||||
@property
|
||||
def docs(self) -> Sequence[_SearchDocumentLike]: ...
|
||||
|
||||
|
||||
class ValkeySemanticCache(RedisSemanticCache):
|
||||
"""Valkey-backed semantic cache for LLM responses."""
|
||||
|
||||
|
|
@ -64,7 +78,7 @@ class ValkeySemanticCache(RedisSemanticCache):
|
|||
async_client: AsyncRedis | None = None,
|
||||
embedding_max_input_tokens: int | None = None,
|
||||
embedding_timeout: float | None = None,
|
||||
**kwargs: Any,
|
||||
**kwargs: object,
|
||||
):
|
||||
if similarity_threshold is None:
|
||||
raise ValueError("similarity_threshold must be provided, passed None")
|
||||
|
|
@ -192,7 +206,9 @@ class ValkeySemanticCache(RedisSemanticCache):
|
|||
def _doc_key(self, key: str) -> str:
|
||||
return f"{self.key_prefix}{self._scope_tag(key)}:{uuid.uuid4()}"
|
||||
|
||||
def _doc_mapping(self, key: str, prompt: str, value_str: str, embedding: list[float]) -> dict:
|
||||
def _doc_mapping(
|
||||
self, key: str, prompt: str, value_str: str, embedding: list[float]
|
||||
) -> dict[str | bytes, str | bytes]:
|
||||
return {
|
||||
self.CACHE_KEY_FIELD_NAME: self._scope_tag(key),
|
||||
self.PROMPT_FIELD_NAME: prompt,
|
||||
|
|
@ -209,8 +225,8 @@ class ValkeySemanticCache(RedisSemanticCache):
|
|||
return Query(query_string).return_fields(self.RESPONSE_FIELD_NAME, self.DISTANCE_FIELD_NAME).dialect(2)
|
||||
|
||||
@classmethod
|
||||
def _first_hit(cls, search_result: Any) -> _ValkeyCacheHit | None:
|
||||
docs: Final = getattr(search_result, "docs", [])
|
||||
def _first_hit(cls, search_result: _SearchResultLike) -> _ValkeyCacheHit | None:
|
||||
docs: Final[Sequence[_SearchDocumentLike]] = getattr(search_result, "docs", ())
|
||||
if not docs:
|
||||
return None
|
||||
doc: Final = docs[0]
|
||||
|
|
@ -219,7 +235,7 @@ class ValkeySemanticCache(RedisSemanticCache):
|
|||
distance=float(getattr(doc, cls.DISTANCE_FIELD_NAME)),
|
||||
)
|
||||
|
||||
def _resolve_hit(self, hit: _ValkeyCacheHit | None, key: str, **kwargs: Any) -> Any:
|
||||
def _resolve_hit(self, hit: _ValkeyCacheHit | None, key: str, **kwargs: Any) -> object:
|
||||
if hit is None:
|
||||
kwargs.setdefault("metadata", {})["semantic-similarity"] = 0.0
|
||||
return None
|
||||
|
|
@ -231,7 +247,7 @@ class ValkeySemanticCache(RedisSemanticCache):
|
|||
return None
|
||||
return self._get_cache_logic(cached_response=hit.response)
|
||||
|
||||
def set_cache(self, key: str, value: Any, **kwargs: Any) -> None:
|
||||
def set_cache(self, key: str, value: object, **kwargs: object) -> None:
|
||||
print_verbose(f"Valkey semantic-cache set_cache, kwargs: {kwargs}")
|
||||
try:
|
||||
prompt: Final = self._get_prompt_from_kwargs(**kwargs)
|
||||
|
|
@ -250,7 +266,7 @@ class ValkeySemanticCache(RedisSemanticCache):
|
|||
except Exception as e:
|
||||
print_verbose(f"Error in Valkey semantic-cache set_cache: {e}")
|
||||
|
||||
def get_cache(self, key: str, **kwargs: Any) -> Any:
|
||||
def get_cache(self, key: str, **kwargs: Any) -> object:
|
||||
print_verbose(f"Valkey semantic-cache get_cache, kwargs: {kwargs}")
|
||||
try:
|
||||
prompt: Final = self._get_prompt_from_kwargs(**kwargs)
|
||||
|
|
@ -270,7 +286,7 @@ class ValkeySemanticCache(RedisSemanticCache):
|
|||
print_verbose(f"Error in Valkey semantic-cache get_cache: {e}")
|
||||
kwargs.setdefault("metadata", {})["semantic-similarity"] = 0.0
|
||||
|
||||
async def async_set_cache(self, key: str, value: Any, **kwargs: Any) -> None:
|
||||
async def async_set_cache(self, key: str, value: object, **kwargs: object) -> None:
|
||||
print_verbose(f"Async Valkey semantic-cache set_cache, kwargs: {kwargs}")
|
||||
try:
|
||||
prompt: Final = self._get_prompt_from_kwargs(**kwargs)
|
||||
|
|
@ -289,7 +305,7 @@ class ValkeySemanticCache(RedisSemanticCache):
|
|||
except Exception as e:
|
||||
print_verbose(f"Error in async Valkey semantic-cache set_cache: {e}")
|
||||
|
||||
async def async_get_cache(self, key: str, **kwargs: Any) -> Any:
|
||||
async def async_get_cache(self, key: str, **kwargs: Any) -> object:
|
||||
print_verbose(f"Async Valkey semantic-cache get_cache, kwargs: {kwargs}")
|
||||
try:
|
||||
prompt: Final = self._get_prompt_from_kwargs(**kwargs)
|
||||
|
|
@ -309,11 +325,11 @@ class ValkeySemanticCache(RedisSemanticCache):
|
|||
print_verbose(f"Error in async Valkey semantic-cache get_cache: {e}")
|
||||
kwargs.setdefault("metadata", {})["semantic-similarity"] = 0.0
|
||||
|
||||
async def async_set_cache_pipeline(self, cache_list: list[tuple[str, Any]], **kwargs: Any) -> None:
|
||||
async def async_set_cache_pipeline(self, cache_list: list[tuple[str, object]], **kwargs: object) -> None:
|
||||
try:
|
||||
await asyncio.gather(*[self.async_set_cache(key, value, **kwargs) for key, value in cache_list])
|
||||
except Exception as e:
|
||||
print_verbose(f"Error in Valkey semantic-cache async_set_cache_pipeline: {e}")
|
||||
|
||||
async def _index_info(self) -> dict:
|
||||
async def _index_info(self) -> Mapping[str, object]:
|
||||
return await self.async_client.ft(self.index_name).info()
|
||||
|
|
|
|||
|
|
@ -200,7 +200,8 @@ def _tool_call_dict_from_output_item(item: Mapping[str, Any], index: int) -> _Ch
|
|||
LiteLLMCompletionResponsesConfig,
|
||||
)
|
||||
|
||||
is_custom: Final = item.get("type") == "custom_tool_call"
|
||||
item_type: Final[object] = item.get("type")
|
||||
is_custom: Final = item_type == "custom_tool_call"
|
||||
arguments: Final = (item.get("input") if is_custom else item.get("arguments")) or ""
|
||||
name: Final = item.get("name") or ("custom_tool" if is_custom else "")
|
||||
function_chunk: Final = ChatCompletionToolCallFunctionChunk(name=name, arguments=arguments)
|
||||
|
|
@ -210,7 +211,7 @@ def _tool_call_dict_from_output_item(item: Mapping[str, Any], index: int) -> _Ch
|
|||
function=function_chunk,
|
||||
index=index,
|
||||
)
|
||||
raw_provider_fields: Final = item.get("provider_specific_fields")
|
||||
raw_provider_fields: Final[object] = item.get("provider_specific_fields")
|
||||
if isinstance(raw_provider_fields, dict):
|
||||
provider_specific_fields = raw_provider_fields
|
||||
elif raw_provider_fields and hasattr(raw_provider_fields, "__dict__"):
|
||||
|
|
@ -495,7 +496,7 @@ class LiteLLMResponsesTransformationHandler(CompletionTransformationBridge):
|
|||
|
||||
def _merge_responses_api_request_into_request_data(
|
||||
self,
|
||||
request_data: dict[str, Any],
|
||||
request_data: dict[str, object],
|
||||
responses_api_request: "ResponsesAPIOptionalRequestParams",
|
||||
instructions: str | None,
|
||||
) -> None:
|
||||
|
|
|
|||
|
|
@ -2253,6 +2253,10 @@ def batch_cost_calculator(
|
|||
return total_prompt_cost, total_completion_cost
|
||||
|
||||
|
||||
def _attribute_value(obj: object, name: str) -> object:
|
||||
return getattr(obj, name)
|
||||
|
||||
|
||||
def _summable_prompt_token_fields(prompt_tokens_details: BaseModel) -> list[str]:
|
||||
field_names: Final = list(type(prompt_tokens_details).model_fields)
|
||||
if getattr(prompt_tokens_details, "cache_write_tokens", None) is None:
|
||||
|
|
@ -2278,7 +2282,7 @@ class BaseTokenUsageProcessor:
|
|||
for usage in usage_objects:
|
||||
# Handle direct attributes by checking what exists in the model
|
||||
for attr in dir(usage):
|
||||
if not attr.startswith("_") and not callable(getattr(usage, attr)):
|
||||
if not attr.startswith("_") and not callable(_attribute_value(usage, attr)):
|
||||
current_val = getattr(combined, attr, 0)
|
||||
new_val = getattr(usage, attr, 0)
|
||||
if (
|
||||
|
|
@ -2298,7 +2302,7 @@ class BaseTokenUsageProcessor:
|
|||
if (
|
||||
hasattr(usage.prompt_tokens_details, attr)
|
||||
and not attr.startswith("_")
|
||||
and not callable(getattr(usage.prompt_tokens_details, attr))
|
||||
and not callable(_attribute_value(usage.prompt_tokens_details, attr))
|
||||
):
|
||||
current_val = getattr(combined.prompt_tokens_details, attr, 0) or 0
|
||||
new_val = getattr(usage.prompt_tokens_details, attr, 0) or 0
|
||||
|
|
@ -2317,7 +2321,9 @@ class BaseTokenUsageProcessor:
|
|||
# Check what keys exist in the model's completion_tokens_details
|
||||
# Access model_fields on the class, not the instance, to avoid Pydantic 2.11+ deprecation warnings
|
||||
for attr in type(usage.completion_tokens_details).model_fields:
|
||||
if not attr.startswith("_") and not callable(getattr(usage.completion_tokens_details, attr)):
|
||||
if not attr.startswith("_") and not callable(
|
||||
_attribute_value(usage.completion_tokens_details, attr)
|
||||
):
|
||||
current_val = getattr(combined.completion_tokens_details, attr, 0) or 0
|
||||
new_val = getattr(usage.completion_tokens_details, attr, 0) or 0
|
||||
if isinstance(new_val, (int, float)):
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ from litellm.types.llms.openai import (
|
|||
from litellm.types.router import GenericLiteLLMParams
|
||||
from litellm.types.utils import (
|
||||
AdapterCompletionStreamWrapper,
|
||||
ChatCompletionDeltaCustomToolCall,
|
||||
ChatCompletionMessageCustomToolCall,
|
||||
Choices,
|
||||
Delta,
|
||||
Message,
|
||||
ModelResponse,
|
||||
ModelResponseStream,
|
||||
StreamingChoices,
|
||||
|
|
@ -635,7 +639,7 @@ class GoogleGenAIAdapter:
|
|||
|
||||
def _transform_openai_message_to_google_genai_parts(
|
||||
self,
|
||||
message: Any,
|
||||
message: Message,
|
||||
) -> list[_GenAIPart]:
|
||||
"""Transform OpenAI message to Google GenAI parts format"""
|
||||
parts: Final[list[_GenAIPart]] = []
|
||||
|
|
@ -647,7 +651,11 @@ class GoogleGenAIAdapter:
|
|||
# Add tool calls if present
|
||||
if hasattr(message, "tool_calls") and message.tool_calls:
|
||||
for tool_call in message.tool_calls:
|
||||
if hasattr(tool_call, "function") and tool_call.function:
|
||||
if (
|
||||
hasattr(tool_call, "function")
|
||||
and not isinstance(tool_call, ChatCompletionMessageCustomToolCall)
|
||||
and tool_call.function
|
||||
):
|
||||
try:
|
||||
args = (
|
||||
_decode_tool_call_arguments(tool_call.function.arguments)
|
||||
|
|
@ -668,7 +676,7 @@ class GoogleGenAIAdapter:
|
|||
return parts if parts else [{"text": ""}]
|
||||
|
||||
def _transform_openai_delta_to_google_genai_parts_with_accumulation(
|
||||
self, delta: Any, wrapper: GoogleGenAIStreamWrapper
|
||||
self, delta: Delta, wrapper: GoogleGenAIStreamWrapper
|
||||
) -> list[_GenAIPart]:
|
||||
"""Transforms OpenAI delta to Google GenAI parts, accumulating streaming tool calls."""
|
||||
|
||||
|
|
@ -685,7 +693,7 @@ class GoogleGenAIAdapter:
|
|||
tool_calls: Final = delta.tool_calls or []
|
||||
|
||||
for tool_call in tool_calls:
|
||||
if not hasattr(tool_call, "function"):
|
||||
if not hasattr(tool_call, "function") or isinstance(tool_call, ChatCompletionDeltaCustomToolCall):
|
||||
continue
|
||||
|
||||
# 3. Use `index` as the primary key for accumulation
|
||||
|
|
|
|||
|
|
@ -3,10 +3,12 @@ Arize Phoenix prompt manager that integrates with LiteLLM's prompt management sy
|
|||
Fetches prompt versions from Arize Phoenix and provides workspace-based access control.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Final
|
||||
|
||||
from jinja2 import DictLoader, select_autoescape
|
||||
from jinja2.sandbox import ImmutableSandboxedEnvironment
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm.integrations.custom_prompt_management import CustomPromptManagement
|
||||
from litellm.integrations.prompt_management_base import (
|
||||
|
|
@ -20,6 +22,31 @@ from litellm.types.utils import StandardCallbackDynamicParams
|
|||
from .arize_phoenix_client import ArizePhoenixClient
|
||||
|
||||
|
||||
class ArizePhoenixContentPart(TypedDict, total=False):
|
||||
type: ReadOnly[str]
|
||||
text: ReadOnly[str]
|
||||
|
||||
|
||||
class ArizePhoenixTemplateMessage(TypedDict, total=False):
|
||||
role: ReadOnly[str]
|
||||
content: ReadOnly[Sequence[ArizePhoenixContentPart]]
|
||||
|
||||
|
||||
class ArizePhoenixTemplateBody(TypedDict, total=False):
|
||||
messages: ReadOnly[Sequence[ArizePhoenixTemplateMessage]]
|
||||
|
||||
|
||||
class ArizePhoenixPromptMetadata(TypedDict):
|
||||
model_name: ReadOnly[str | None]
|
||||
model_provider: ReadOnly[str | None]
|
||||
description: ReadOnly[str]
|
||||
template_type: ReadOnly[str | None]
|
||||
template_format: ReadOnly[str]
|
||||
invocation_parameters: ReadOnly[Mapping[str, Mapping[str, object]]]
|
||||
temperature: ReadOnly[float | None]
|
||||
max_tokens: ReadOnly[int | None]
|
||||
|
||||
|
||||
class ArizePhoenixPromptTemplate:
|
||||
"""
|
||||
Represents a prompt template loaded from Arize Phoenix.
|
||||
|
|
@ -28,10 +55,10 @@ class ArizePhoenixPromptTemplate:
|
|||
def __init__(
|
||||
self,
|
||||
template_id: str,
|
||||
messages: list[dict[str, Any]],
|
||||
metadata: dict[str, Any],
|
||||
messages: Sequence[ArizePhoenixTemplateMessage],
|
||||
metadata: ArizePhoenixPromptMetadata,
|
||||
model: str | None = None,
|
||||
):
|
||||
) -> None:
|
||||
self.template_id = template_id
|
||||
self.messages = messages
|
||||
self.metadata = metadata
|
||||
|
|
@ -43,7 +70,7 @@ class ArizePhoenixPromptTemplate:
|
|||
self.description = metadata.get("description", "")
|
||||
self.template_format = metadata.get("template_format", "MUSTACHE")
|
||||
|
||||
def __repr__(self):
|
||||
def __repr__(self) -> str:
|
||||
return f"ArizePhoenixPromptTemplate(id='{self.template_id}', model='{self.model}')"
|
||||
|
||||
|
||||
|
|
@ -109,7 +136,7 @@ class ArizePhoenixTemplateManager:
|
|||
|
||||
def _parse_prompt_data(self, data: dict[str, Any], prompt_version_id: str) -> ArizePhoenixPromptTemplate:
|
||||
"""Parse Arize Phoenix prompt data and extract messages and metadata."""
|
||||
template_data: Final = data.get("template", {})
|
||||
template_data: Final[ArizePhoenixTemplateBody] = data.get("template", {})
|
||||
messages: Final = template_data.get("messages", [])
|
||||
|
||||
# Extract invocation parameters
|
||||
|
|
@ -129,7 +156,7 @@ class ArizePhoenixTemplateManager:
|
|||
break
|
||||
|
||||
# Build metadata dictionary
|
||||
metadata: Final = {
|
||||
metadata: Final[ArizePhoenixPromptMetadata] = {
|
||||
"model_name": data.get("model_name"),
|
||||
"model_provider": data.get("model_provider"),
|
||||
"description": data.get("description", ""),
|
||||
|
|
@ -146,7 +173,9 @@ class ArizePhoenixTemplateManager:
|
|||
metadata=metadata,
|
||||
)
|
||||
|
||||
def render_template(self, template_id: str, variables: dict[str, Any] | None = None) -> list[AllMessageValues]:
|
||||
def render_template(
|
||||
self, template_id: str, variables: Mapping[str, object] | None = None
|
||||
) -> list[AllMessageValues]:
|
||||
"""Render a template with the given variables and return formatted messages."""
|
||||
if template_id not in self.prompts:
|
||||
raise ValueError(f"Template '{template_id}' not found")
|
||||
|
|
@ -243,8 +272,8 @@ class ArizePhoenixPromptManager(CustomPromptManagement):
|
|||
def get_prompt_template(
|
||||
self,
|
||||
prompt_id: str,
|
||||
prompt_variables: dict[str, Any] | None = None,
|
||||
) -> tuple[list[AllMessageValues], dict[str, Any]]:
|
||||
prompt_variables: Mapping[str, object] | None = None,
|
||||
) -> tuple[list[AllMessageValues], dict[str, object]]:
|
||||
"""
|
||||
Get a prompt template and render it with variables.
|
||||
|
||||
|
|
@ -263,7 +292,7 @@ class ArizePhoenixPromptManager(CustomPromptManagement):
|
|||
rendered_messages: Final = self.prompt_manager.render_template(prompt_id, prompt_variables or {})
|
||||
|
||||
# Extract metadata
|
||||
metadata: Final = {
|
||||
metadata: Final[dict[str, object]] = {
|
||||
"model": template.model,
|
||||
"temperature": template.temperature,
|
||||
"max_tokens": template.max_tokens,
|
||||
|
|
@ -271,7 +300,7 @@ class ArizePhoenixPromptManager(CustomPromptManagement):
|
|||
|
||||
# Add additional invocation parameters
|
||||
invocation_params: Final = template.invocation_parameters
|
||||
provider_params = {}
|
||||
provider_params: Mapping[str, object] = {}
|
||||
|
||||
if "openai" in invocation_params:
|
||||
provider_params = invocation_params["openai"]
|
||||
|
|
@ -289,12 +318,12 @@ class ArizePhoenixPromptManager(CustomPromptManagement):
|
|||
self,
|
||||
user_id: str | None,
|
||||
messages: list[AllMessageValues],
|
||||
function_call: dict[str, Any] | str | None = None,
|
||||
litellm_params: dict[str, Any] | None = None,
|
||||
function_call: dict[str, object] | str | None = None,
|
||||
litellm_params: dict[str, object] | None = None,
|
||||
prompt_id: str | None = None,
|
||||
prompt_variables: dict[str, Any] | None = None,
|
||||
prompt_variables: dict[str, object] | None = None,
|
||||
**kwargs,
|
||||
) -> tuple[list[AllMessageValues], dict[str, Any] | None]:
|
||||
) -> tuple[list[AllMessageValues], dict[str, object] | None]:
|
||||
"""
|
||||
Pre-call hook that processes the prompt template before making the LLM call.
|
||||
"""
|
||||
|
|
@ -335,9 +364,9 @@ class ArizePhoenixPromptManager(CustomPromptManagement):
|
|||
|
||||
except Exception as e:
|
||||
# Log error but don't fail the call
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
litellm._logging.verbose_proxy_logger.error("Error in Arize Phoenix prompt pre_call_hook: %s", e)
|
||||
verbose_proxy_logger.error("Error in Arize Phoenix prompt pre_call_hook: %s", e)
|
||||
return messages, litellm_params
|
||||
|
||||
def get_available_prompts(self) -> list[str]:
|
||||
|
|
@ -393,7 +422,8 @@ class ArizePhoenixPromptManager(CustomPromptManagement):
|
|||
rendered_messages, prompt_metadata = self.get_prompt_template(prompt_id, prompt_variables)
|
||||
|
||||
# Extract model from metadata (if specified)
|
||||
template_model: Final = prompt_metadata.get("model")
|
||||
raw_template_model: Final = prompt_metadata.get("model")
|
||||
template_model: Final = raw_template_model if isinstance(raw_template_model, str) else None
|
||||
|
||||
# Extract optional parameters from metadata
|
||||
optional_params: Final = {}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# On success, logs events to Promptlayer
|
||||
import re
|
||||
import traceback
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
from collections.abc import AsyncGenerator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, ClassVar, Final, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
|
@ -31,6 +31,9 @@ if TYPE_CHECKING:
|
|||
|
||||
from litellm.caching.caching import DualCache
|
||||
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
|
||||
from litellm.llms.base_llm.anthropic_messages.transformation import (
|
||||
BaseAnthropicMessagesConfig,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.mcp import (
|
||||
MCPPostCallResponseObject,
|
||||
|
|
@ -39,7 +42,7 @@ if TYPE_CHECKING:
|
|||
)
|
||||
from litellm.types.router import PreRoutingHookResponse
|
||||
|
||||
Span = _Span | Any
|
||||
Span = _Span
|
||||
else:
|
||||
Span = Any
|
||||
LiteLLMLoggingObj = Any
|
||||
|
|
@ -123,11 +126,11 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
return []
|
||||
|
||||
callbacks: Final = AllCallbacks()
|
||||
callback_info: Final = getattr(callbacks, lookup_name, None)
|
||||
callback_info: Final[object] = getattr(callbacks, lookup_name, None)
|
||||
if callback_info is None:
|
||||
return []
|
||||
|
||||
params: Final = getattr(callback_info, "litellm_callback_params", None)
|
||||
params: Final[list[str] | None] = getattr(callback_info, "litellm_callback_params", None)
|
||||
if not params:
|
||||
return []
|
||||
|
||||
|
|
@ -268,7 +271,9 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
) -> list[dict]:
|
||||
return healthy_deployments
|
||||
|
||||
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, object], call_type: CallTypes | None
|
||||
) -> dict | None:
|
||||
"""
|
||||
Allow modifying the request just before it's sent to the deployment.
|
||||
|
||||
|
|
@ -344,9 +349,9 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
async def async_post_call_streaming_deployment_hook(
|
||||
self,
|
||||
request_data: dict,
|
||||
response_chunk: Any,
|
||||
response_chunk: object,
|
||||
call_type: CallTypes | None,
|
||||
) -> Any | None:
|
||||
) -> object | None:
|
||||
"""
|
||||
Allow modifying streaming chunks just before they're returned to the user.
|
||||
|
||||
|
|
@ -378,7 +383,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
"""
|
||||
|
||||
def translate_completion_output_params_streaming(
|
||||
self, completion_stream: Any
|
||||
self, completion_stream: object
|
||||
) -> AdapterCompletionStreamWrapper | None:
|
||||
"""
|
||||
Translates the streaming chunk, from the OpenAI format to the custom format.
|
||||
|
|
@ -418,9 +423,9 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
self,
|
||||
data: dict,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
response: Any,
|
||||
response: object,
|
||||
request_headers: dict[str, str] | None = None,
|
||||
litellm_call_info: dict[str, Any] | None = None,
|
||||
litellm_call_info: dict[str, object] | None = None,
|
||||
) -> dict[str, str] | None:
|
||||
"""
|
||||
Called after an LLM API call (success or failure) to allow injecting custom HTTP response headers.
|
||||
|
|
@ -471,11 +476,11 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
) -> Any:
|
||||
pass
|
||||
|
||||
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]:
|
||||
"""For masking logged request/response. Return a modified version of the request/result."""
|
||||
return kwargs, result
|
||||
|
||||
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]:
|
||||
"""For masking logged request/response. Return a modified version of the request/result."""
|
||||
return kwargs, result
|
||||
|
||||
|
|
@ -581,7 +586,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
|
||||
async def async_should_run_agentic_loop(
|
||||
self,
|
||||
response: Any,
|
||||
response: object,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None,
|
||||
|
|
@ -642,8 +647,8 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
tools: dict,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
response: Any,
|
||||
anthropic_messages_provider_config: Any,
|
||||
response: object,
|
||||
anthropic_messages_provider_config: "BaseAnthropicMessagesConfig | None",
|
||||
anthropic_messages_optional_request_params: dict,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
stream: bool,
|
||||
|
|
@ -711,8 +716,8 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
tools: dict,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
response: Any,
|
||||
anthropic_messages_provider_config: Any,
|
||||
response: object,
|
||||
anthropic_messages_provider_config: "BaseAnthropicMessagesConfig | None",
|
||||
anthropic_messages_optional_request_params: dict,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
stream: bool,
|
||||
|
|
@ -728,7 +733,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
|
||||
async def async_post_agentic_loop_response_hook(
|
||||
self,
|
||||
response: Any,
|
||||
response: object,
|
||||
plan: AgenticLoopPlan,
|
||||
kwargs: dict,
|
||||
) -> Any:
|
||||
|
|
@ -767,7 +772,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
|
||||
async def async_should_run_chat_completion_agentic_loop(
|
||||
self,
|
||||
response: Any,
|
||||
response: object,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
tools: list[dict] | None,
|
||||
|
|
@ -785,12 +790,12 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
tools: dict,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
response: Any,
|
||||
response: object,
|
||||
optional_params: dict,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
stream: bool,
|
||||
kwargs: dict,
|
||||
) -> Any:
|
||||
) -> object:
|
||||
"""
|
||||
Hook to execute chat completion agentic loop based on context from should_run hook.
|
||||
"""
|
||||
|
|
@ -800,7 +805,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
tools: dict,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
response: Any,
|
||||
response: object,
|
||||
optional_params: dict,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
stream: bool,
|
||||
|
|
@ -851,7 +856,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
- Converting to string and then truncating the logged content catches this
|
||||
2. We want to avoid modifying the original `messages`, `response`, and `error_str` in the logging payload since these are in kwargs and could be returned to the user
|
||||
"""
|
||||
field_value: Final = standard_logging_object.get(field_name)
|
||||
field_value: Final[object] = standard_logging_object.get(field_name)
|
||||
if field_value:
|
||||
str_value: Final = str(field_value)
|
||||
if len(str_value) > max_length:
|
||||
|
|
@ -1005,8 +1010,8 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
• Keep untyped or text content.
|
||||
• Recursively redact inline base64 blobs in *any* string field, at any depth.
|
||||
"""
|
||||
raw_messages: Final[Any] = payload.get("messages", [])
|
||||
messages: Final[list[Any]] = raw_messages if isinstance(raw_messages, list) else []
|
||||
raw_messages: Final[object] = payload.get("messages", [])
|
||||
messages: Final[list[object]] = raw_messages if isinstance(raw_messages, list) else []
|
||||
verbose_logger.debug("[CustomLogger] Stripping base64 from %s messages", len(messages))
|
||||
|
||||
if messages:
|
||||
|
|
@ -1037,8 +1042,8 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
• Keep untyped or text content.
|
||||
• Recursively redact inline base64 blobs in *any* string field, at any depth.
|
||||
"""
|
||||
raw_messages: Final[Any] = payload.get("messages", [])
|
||||
messages: Final[list[Any]] = raw_messages if isinstance(raw_messages, list) else []
|
||||
raw_messages: Final[object] = payload.get("messages", [])
|
||||
messages: Final[list[object]] = raw_messages if isinstance(raw_messages, list) else []
|
||||
verbose_logger.debug("[CustomLogger] Stripping base64 from %s messages", len(messages))
|
||||
|
||||
if messages:
|
||||
|
|
@ -1056,10 +1061,10 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
|
||||
def _redact_base64(
|
||||
self,
|
||||
value: Any,
|
||||
value: object,
|
||||
depth: int = 0,
|
||||
max_depth: int = DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER,
|
||||
) -> Any:
|
||||
) -> object:
|
||||
"""Recursively redact inline base64 from any nested structure with a max recursion depth limit."""
|
||||
if depth > max_depth:
|
||||
verbose_logger.warning("[CustomLogger] Max recursion depth %s reached while redacting base64", max_depth)
|
||||
|
|
@ -1079,7 +1084,7 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
|
||||
return value
|
||||
|
||||
def _should_keep_content(self, content: Any) -> bool:
|
||||
def _should_keep_content(self, content: object) -> bool:
|
||||
"""Return True if this content item should be retained."""
|
||||
if not isinstance(content, dict):
|
||||
return True
|
||||
|
|
@ -1090,16 +1095,16 @@ class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callbac
|
|||
|
||||
def _process_messages(
|
||||
self,
|
||||
messages: list[Any],
|
||||
messages: Sequence[object],
|
||||
max_depth: int = DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER,
|
||||
) -> list[dict[str, Any]]:
|
||||
filtered_messages: Final[list[dict[str, Any]]] = []
|
||||
) -> list[dict[str, object]]:
|
||||
filtered_messages: Final[list[dict[str, object]]] = []
|
||||
for msg in messages:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
contents: Any = msg.get("content")
|
||||
contents: object = msg.get("content")
|
||||
if isinstance(contents, list):
|
||||
cleaned: list[Any] = []
|
||||
cleaned: list[object] = []
|
||||
for c in contents:
|
||||
if self._should_keep_content(content=c):
|
||||
cleaned.append(self._redact_base64(value=c, max_depth=max_depth))
|
||||
|
|
|
|||
|
|
@ -2,10 +2,12 @@
|
|||
GitLab prompt manager with configurable prompts folder.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final, TypeVar
|
||||
|
||||
from jinja2 import DictLoader, select_autoescape
|
||||
from jinja2.sandbox import ImmutableSandboxedEnvironment
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm.integrations.custom_prompt_management import CustomPromptManagement
|
||||
|
||||
|
|
@ -24,6 +26,19 @@ from litellm.types.utils import StandardCallbackDynamicParams
|
|||
|
||||
GITLAB_PREFIX: Final = "gitlab::"
|
||||
|
||||
_ResponseT = TypeVar("_ResponseT")
|
||||
|
||||
|
||||
class GitLabCachedPrompt(TypedDict):
|
||||
id: ReadOnly[str]
|
||||
path: ReadOnly[str]
|
||||
content: ReadOnly[str]
|
||||
metadata: ReadOnly[Mapping[str, object]]
|
||||
model: ReadOnly[str | None]
|
||||
temperature: ReadOnly[float | None]
|
||||
max_tokens: ReadOnly[int | None]
|
||||
optional_params: ReadOnly[Mapping[str, object]]
|
||||
|
||||
|
||||
def encode_prompt_id(raw_id: str) -> str:
|
||||
"""Convert GitLab path IDs like 'invoice/extract' → 'gitlab::invoice::extract'"""
|
||||
|
|
@ -206,7 +221,7 @@ class GitLabTemplateManager:
|
|||
result[key] = value.strip("\"'")
|
||||
return result
|
||||
|
||||
def render_template(self, template_id: str, variables: dict[str, Any] | None = None) -> str:
|
||||
def render_template(self, template_id: str, variables: Mapping[str, object] | None = None) -> str:
|
||||
if template_id not in self.prompts:
|
||||
raise ValueError(f"Template '{template_id}' not found")
|
||||
template: Final = self.prompts[template_id]
|
||||
|
|
@ -313,7 +328,7 @@ class GitLabPromptManager(CustomPromptManagement):
|
|||
def get_prompt_template(
|
||||
self,
|
||||
prompt_id: str,
|
||||
prompt_variables: dict[str, Any] | None = None,
|
||||
prompt_variables: Mapping[str, object] | None = None,
|
||||
*,
|
||||
ref: str | None = None,
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
|
|
@ -338,13 +353,13 @@ class GitLabPromptManager(CustomPromptManagement):
|
|||
self,
|
||||
user_id: str | None,
|
||||
messages: list[AllMessageValues],
|
||||
function_call: dict[str, Any] | str | None = None,
|
||||
litellm_params: dict[str, Any] | None = None,
|
||||
function_call: Mapping[str, object] | str | None = None,
|
||||
litellm_params: dict[str, object] | None = None,
|
||||
prompt_id: str | None = None,
|
||||
prompt_variables: dict[str, Any] | None = None,
|
||||
prompt_variables: Mapping[str, object] | None = None,
|
||||
prompt_version: str | None = None,
|
||||
**kwargs,
|
||||
) -> tuple[list[AllMessageValues], dict[str, Any] | None]:
|
||||
) -> tuple[list[AllMessageValues], dict[str, object] | None]:
|
||||
if not prompt_id:
|
||||
return messages, litellm_params
|
||||
try:
|
||||
|
|
@ -377,9 +392,9 @@ class GitLabPromptManager(CustomPromptManagement):
|
|||
|
||||
return final_messages, litellm_params
|
||||
except Exception as e:
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
litellm._logging.verbose_proxy_logger.error("Error in GitLab prompt pre_call_hook: %s", e)
|
||||
verbose_proxy_logger.error("Error in GitLab prompt pre_call_hook: %s", e)
|
||||
return messages, litellm_params
|
||||
|
||||
def _parse_prompt_to_messages(self, prompt_content: str) -> list[AllMessageValues]:
|
||||
|
|
@ -435,14 +450,14 @@ class GitLabPromptManager(CustomPromptManagement):
|
|||
def post_call_hook(
|
||||
self,
|
||||
user_id: str | None,
|
||||
response: Any,
|
||||
response: _ResponseT,
|
||||
input_messages: list[AllMessageValues],
|
||||
function_call: dict[str, Any] | str | None = None,
|
||||
litellm_params: dict[str, Any] | None = None,
|
||||
function_call: Mapping[str, object] | str | None = None,
|
||||
litellm_params: Mapping[str, object] | None = None,
|
||||
prompt_id: str | None = None,
|
||||
prompt_variables: dict[str, Any] | None = None,
|
||||
prompt_variables: Mapping[str, object] | None = None,
|
||||
**kwargs,
|
||||
) -> Any:
|
||||
) -> _ResponseT:
|
||||
return response
|
||||
|
||||
def get_available_prompts(self) -> list[str]:
|
||||
|
|
@ -498,7 +513,7 @@ class GitLabPromptManager(CustomPromptManagement):
|
|||
messages: Final = self._parse_prompt_to_messages(rendered_prompt)
|
||||
template_model: Final = prompt_metadata.get("model")
|
||||
|
||||
optional_params: Final[dict[str, Any]] = {}
|
||||
optional_params: Final[dict[str, object]] = {}
|
||||
for param in [
|
||||
"temperature",
|
||||
"max_tokens",
|
||||
|
|
@ -658,14 +673,14 @@ class GitLabPromptCache:
|
|||
self.template_manager: GitLabTemplateManager = self.prompt_manager.prompt_manager
|
||||
|
||||
# In-memory stores
|
||||
self._by_file: dict[str, dict[str, Any]] = {}
|
||||
self._by_id: dict[str, dict[str, Any]] = {}
|
||||
self._by_file: dict[str, GitLabCachedPrompt] = {}
|
||||
self._by_id: dict[str, GitLabCachedPrompt] = {}
|
||||
|
||||
# -------------------------
|
||||
# Public API
|
||||
# -------------------------
|
||||
|
||||
def load_all(self, *, recursive: bool = True) -> dict[str, dict[str, Any]]:
|
||||
def load_all(self, *, recursive: bool = True) -> dict[str, GitLabCachedPrompt]:
|
||||
"""
|
||||
Scan GitLab for all .prompt files under prompts_path, load and parse each,
|
||||
and return the mapping of repo file path -> JSON-like dict.
|
||||
|
|
@ -695,7 +710,7 @@ class GitLabPromptCache:
|
|||
|
||||
return self._by_id
|
||||
|
||||
def reload(self, *, recursive: bool = True) -> dict[str, dict[str, Any]]:
|
||||
def reload(self, *, recursive: bool = True) -> dict[str, GitLabCachedPrompt]:
|
||||
"""Clear the cache and re-load from GitLab."""
|
||||
self._by_file.clear()
|
||||
self._by_id.clear()
|
||||
|
|
@ -709,11 +724,11 @@ class GitLabPromptCache:
|
|||
"""Return the template IDs (relative to prompts_path, without extension) currently cached."""
|
||||
return list(self._by_id.keys())
|
||||
|
||||
def get_by_file(self, file_path: str) -> dict[str, Any] | None:
|
||||
def get_by_file(self, file_path: str) -> GitLabCachedPrompt | None:
|
||||
"""Get a cached prompt JSON by repo file path."""
|
||||
return self._by_file.get(file_path)
|
||||
|
||||
def get_by_id(self, prompt_id: str) -> dict[str, Any] | None:
|
||||
def get_by_id(self, prompt_id: str) -> GitLabCachedPrompt | None:
|
||||
"""Get a cached prompt JSON by prompt ID (relative to prompts_path)."""
|
||||
if prompt_id in self._by_id:
|
||||
return self._by_id[prompt_id]
|
||||
|
|
@ -728,7 +743,7 @@ class GitLabPromptCache:
|
|||
# Internals
|
||||
# -------------------------
|
||||
|
||||
def _template_to_json(self, prompt_id: str, tmpl: GitLabPromptTemplate) -> dict[str, Any]:
|
||||
def _template_to_json(self, prompt_id: str, tmpl: GitLabPromptTemplate) -> GitLabCachedPrompt:
|
||||
"""
|
||||
Normalize a GitLabPromptTemplate into a JSON-like dict that is easy to serialize.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -12,7 +12,10 @@ For batching specific details see CustomBatchLogger class
|
|||
import asyncio
|
||||
import atexit
|
||||
import os
|
||||
from typing import Any, Final
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Final
|
||||
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -34,6 +37,21 @@ from litellm.types.integrations.posthog import (
|
|||
from litellm.types.utils import StandardCallbackDynamicParams, StandardLoggingPayload
|
||||
|
||||
|
||||
class PostHogBatchPayload(TypedDict):
|
||||
api_key: ReadOnly[str]
|
||||
batch: ReadOnly[Sequence[PostHogEventPayload]]
|
||||
|
||||
|
||||
class PostHogLiteLLMParams(TypedDict, total=False):
|
||||
metadata: ReadOnly[Mapping[str, object]]
|
||||
|
||||
|
||||
class PostHogLogKwargs(TypedDict, total=False):
|
||||
standard_logging_object: ReadOnly[StandardLoggingPayload]
|
||||
standard_callback_dynamic_params: ReadOnly[StandardCallbackDynamicParams]
|
||||
litellm_params: ReadOnly[PostHogLiteLLMParams]
|
||||
|
||||
|
||||
class PostHogLogger(CustomBatchLogger):
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
|
|
@ -137,7 +155,7 @@ class PostHogLogger(CustomBatchLogger):
|
|||
if len(self.log_queue) >= self.batch_size:
|
||||
await self.flush_queue()
|
||||
|
||||
def create_posthog_event_payload(self, kwargs: dict[str, Any]) -> PostHogEventPayload:
|
||||
def create_posthog_event_payload(self, kwargs: PostHogLogKwargs) -> PostHogEventPayload:
|
||||
"""
|
||||
Helper function to create a PostHog event payload for logging
|
||||
|
||||
|
|
@ -171,11 +189,11 @@ class PostHogLogger(CustomBatchLogger):
|
|||
def _create_posthog_properties(
|
||||
self,
|
||||
standard_logging_object: StandardLoggingPayload,
|
||||
kwargs: dict[str, Any],
|
||||
kwargs: PostHogLogKwargs,
|
||||
event_name: str,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
"""Create PostHog properties following LLM Analytics spec"""
|
||||
properties: Final = {}
|
||||
properties: Final[dict[str, object]] = {}
|
||||
|
||||
# Core model information
|
||||
properties["$ai_model"] = self._safe_get(standard_logging_object, "model", "")
|
||||
|
|
@ -211,16 +229,19 @@ class PostHogLogger(CustomBatchLogger):
|
|||
properties["$ai_error"] = error_str
|
||||
|
||||
# Add trace properties
|
||||
self._add_trace_properties(properties, kwargs)
|
||||
self._add_trace_properties(properties, standard_logging_object, kwargs)
|
||||
|
||||
# Add custom metadata fields
|
||||
self._add_custom_metadata_properties(properties, kwargs)
|
||||
|
||||
return properties
|
||||
|
||||
def _add_trace_properties(self, properties: dict[str, Any], kwargs: dict[str, Any]):
|
||||
standard_logging_object: Final = self._safe_get(kwargs, "standard_logging_object", {})
|
||||
|
||||
def _add_trace_properties(
|
||||
self,
|
||||
properties: dict[str, object],
|
||||
standard_logging_object: StandardLoggingPayload,
|
||||
kwargs: PostHogLogKwargs,
|
||||
) -> None:
|
||||
trace_id: Final = self._safe_get(standard_logging_object, "trace_id", self._safe_uuid())
|
||||
properties["$ai_trace_id"] = trace_id
|
||||
|
||||
|
|
@ -232,7 +253,7 @@ class PostHogLogger(CustomBatchLogger):
|
|||
if parent_id:
|
||||
properties["$ai_parent_id"] = parent_id
|
||||
|
||||
def _add_custom_metadata_properties(self, properties: dict[str, Any], kwargs: dict[str, Any]):
|
||||
def _add_custom_metadata_properties(self, properties: dict[str, object], kwargs: PostHogLogKwargs) -> None:
|
||||
"""Add custom metadata fields to PostHog properties"""
|
||||
metadata: Final = self._extract_metadata(kwargs)
|
||||
if not isinstance(metadata, dict):
|
||||
|
|
@ -277,7 +298,7 @@ class PostHogLogger(CustomBatchLogger):
|
|||
if key not in litellm_internal_fields:
|
||||
properties[key] = value
|
||||
|
||||
def _get_distinct_id(self, standard_logging_object: StandardLoggingPayload, kwargs: dict[str, Any]) -> str:
|
||||
def _get_distinct_id(self, standard_logging_object: StandardLoggingPayload, kwargs: PostHogLogKwargs) -> str:
|
||||
metadata: Final = self._extract_metadata(kwargs)
|
||||
user_id: Final = self._safe_get(metadata, "user_id")
|
||||
if user_id:
|
||||
|
|
@ -291,7 +312,7 @@ class PostHogLogger(CustomBatchLogger):
|
|||
|
||||
return self._safe_uuid()
|
||||
|
||||
def _get_credentials_for_request(self, kwargs: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
def _get_credentials_for_request(self, kwargs: PostHogLogKwargs) -> tuple[str | None, str | None]:
|
||||
"""
|
||||
Get PostHog credentials for this request.
|
||||
|
||||
|
|
@ -334,7 +355,7 @@ class PostHogLogger(CustomBatchLogger):
|
|||
verbose_logger.debug("[POSTHOG MOCK] Mock mode enabled - API calls will be intercepted")
|
||||
|
||||
# Group events by credentials for batch sending
|
||||
batches_by_credentials: Final[dict[tuple[str, str], list]] = {}
|
||||
batches_by_credentials: Final[dict[tuple[str, str], list[PostHogEventPayload]]] = {}
|
||||
for item in self.log_queue:
|
||||
key = (item["api_key"], item["api_url"])
|
||||
if key not in batches_by_credentials:
|
||||
|
|
@ -380,18 +401,19 @@ class PostHogLogger(CustomBatchLogger):
|
|||
verbose_logger.error("PostHog: Failed to initialize async components: %s", e)
|
||||
raise
|
||||
|
||||
def _extract_metadata(self, kwargs: dict[str, Any]) -> dict[str, Any]:
|
||||
litellm_params: Final = kwargs.get("litellm_params", {}) or {}
|
||||
return litellm_params.get("metadata", {}) or {}
|
||||
def _extract_metadata(self, kwargs: PostHogLogKwargs) -> Mapping[str, object]:
|
||||
litellm_params: Final[PostHogLiteLLMParams] = kwargs.get("litellm_params", {}) or {}
|
||||
metadata: Final[Mapping[str, object]] = litellm_params.get("metadata", {}) or {}
|
||||
return metadata
|
||||
|
||||
def _safe_uuid(self) -> str:
|
||||
return str(uuid.uuid4())
|
||||
|
||||
def _create_posthog_payload(self, events: list, api_key: str) -> dict[str, Any]:
|
||||
def _create_posthog_payload(self, events: Sequence[PostHogEventPayload], api_key: str) -> PostHogBatchPayload:
|
||||
return {"api_key": api_key, "batch": events}
|
||||
|
||||
def _safe_get(self, obj: Any, key: str, default: Any = None) -> Any:
|
||||
if obj is None or not hasattr(obj, "get"):
|
||||
def _safe_get(self, obj: Mapping[str, object] | None, key: str, default: object = None) -> object:
|
||||
if not isinstance(obj, Mapping):
|
||||
return default
|
||||
return obj.get(key, default)
|
||||
|
||||
|
|
@ -412,7 +434,7 @@ class PostHogLogger(CustomBatchLogger):
|
|||
|
||||
try:
|
||||
# Group events by credentials (same logic as async_send_batch)
|
||||
batches_by_credentials: Final[dict[tuple[str, str], list]] = {}
|
||||
batches_by_credentials: Final[dict[tuple[str, str], list[PostHogEventPayload]]] = {}
|
||||
for item in self.log_queue:
|
||||
key = (item["api_key"], item["api_url"])
|
||||
if key not in batches_by_credentials:
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ Helper utilities for tracking the cost of built-in tools.
|
|||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Final, Literal
|
||||
from typing import Final, Literal
|
||||
|
||||
import litellm
|
||||
from litellm.constants import OPENAI_FILE_SEARCH_COST_PER_1K_CALLS
|
||||
|
|
@ -14,6 +14,7 @@ from litellm.types.llms.openai import (
|
|||
WebSearchOptions,
|
||||
)
|
||||
from litellm.types.utils import (
|
||||
ChatCompletionAnnotation,
|
||||
Message,
|
||||
ModelInfo,
|
||||
ModelResponse,
|
||||
|
|
@ -47,7 +48,7 @@ class StandardBuiltInToolCostTracking:
|
|||
@staticmethod
|
||||
def get_cost_for_built_in_tools(
|
||||
model: str,
|
||||
response_object: Any,
|
||||
response_object: object,
|
||||
usage: Usage | None = None,
|
||||
custom_llm_provider: str | None = None,
|
||||
standard_built_in_tools_params: StandardBuiltInToolsParams | None = None,
|
||||
|
|
@ -199,8 +200,7 @@ class StandardBuiltInToolCostTracking:
|
|||
model_info: Final = StandardBuiltInToolCostTracking._safe_get_model_info(
|
||||
model=model, custom_llm_provider=custom_llm_provider
|
||||
)
|
||||
file_search_raw: Final[Any] = standard_built_in_tools_params.get("file_search", {})
|
||||
file_search_usage: Final[FileSearchTool | None] = FileSearchTool(**file_search_raw) if file_search_raw else None
|
||||
file_search_usage: Final[FileSearchTool | None] = standard_built_in_tools_params.get("file_search") or None
|
||||
|
||||
# Convert model_info to dict and extract usage parameters
|
||||
model_info_dict: Final = dict(model_info) if model_info is not None else None
|
||||
|
|
@ -243,7 +243,7 @@ class StandardBuiltInToolCostTracking:
|
|||
|
||||
@staticmethod
|
||||
def _extract_file_search_params(
|
||||
file_search_usage: Any,
|
||||
file_search_usage: object,
|
||||
) -> tuple[float | None, float | None]:
|
||||
"""Extract and convert file search parameters safely."""
|
||||
storage_gb = None
|
||||
|
|
@ -333,7 +333,7 @@ class StandardBuiltInToolCostTracking:
|
|||
|
||||
@staticmethod
|
||||
def _extract_token_counts(
|
||||
computer_use_usage: Any,
|
||||
computer_use_usage: object,
|
||||
) -> tuple[int | None, int | None]:
|
||||
"""Extract and convert token counts safely."""
|
||||
input_tokens = None
|
||||
|
|
@ -349,9 +349,9 @@ class StandardBuiltInToolCostTracking:
|
|||
return input_tokens, output_tokens
|
||||
|
||||
@staticmethod
|
||||
def _safe_convert_to_int(value: Any) -> int | None:
|
||||
def _safe_convert_to_int(value: object) -> int | None:
|
||||
"""Safely convert a value to int."""
|
||||
if value is not None:
|
||||
if isinstance(value, (int, float, str)):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
|
|
@ -379,7 +379,7 @@ class StandardBuiltInToolCostTracking:
|
|||
return usage.model_copy(update={"server_tool_use": server_tool_use})
|
||||
|
||||
@staticmethod
|
||||
def response_object_includes_web_search_call(response_object: Any, usage: Usage | None = None) -> bool:
|
||||
def response_object_includes_web_search_call(response_object: object, usage: Usage | None = None) -> bool:
|
||||
"""
|
||||
Check if the response object includes a web search call.
|
||||
|
||||
|
|
@ -448,7 +448,7 @@ class StandardBuiltInToolCostTracking:
|
|||
|
||||
@staticmethod
|
||||
def response_object_includes_file_search_call(
|
||||
response_object: Any,
|
||||
response_object: object,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if the response object includes a file search call.
|
||||
|
|
@ -479,11 +479,11 @@ class StandardBuiltInToolCostTracking:
|
|||
message: Message | None = getattr(choice, "message", None)
|
||||
if message is None:
|
||||
continue
|
||||
if annotations := getattr(message, "annotations", None):
|
||||
if len(annotations) > 0:
|
||||
for annotation in annotations:
|
||||
if annotation.get("type", None) == annotation_type:
|
||||
return True
|
||||
annotations: list[ChatCompletionAnnotation] | None = getattr(message, "annotations", None)
|
||||
if annotations:
|
||||
for annotation in annotations:
|
||||
if annotation.get("type", None) == annotation_type:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -524,10 +524,8 @@ class StandardBuiltInToolCostTracking:
|
|||
if model_info is None:
|
||||
return 0.0
|
||||
|
||||
search_context_raw: Final[Any] = model_info.get("search_context_cost_per_query", {})
|
||||
search_context_pricing: Final[SearchContextCostPerQuery] = (
|
||||
SearchContextCostPerQuery(**search_context_raw) if search_context_raw else SearchContextCostPerQuery()
|
||||
)
|
||||
search_context_raw: Final = model_info.get("search_context_cost_per_query")
|
||||
search_context_pricing: Final[SearchContextCostPerQuery] = search_context_raw or SearchContextCostPerQuery()
|
||||
if web_search_options.get("search_context_size", None) == "low":
|
||||
return search_context_pricing.get("search_context_size_low", 0.0)
|
||||
elif web_search_options.get("search_context_size", None) == "medium":
|
||||
|
|
@ -547,10 +545,8 @@ class StandardBuiltInToolCostTracking:
|
|||
"""
|
||||
if model_info is None:
|
||||
return 0.0
|
||||
search_context_raw: Final[Any] = model_info.get("search_context_cost_per_query", {}) or {}
|
||||
search_context_pricing: Final[SearchContextCostPerQuery] = (
|
||||
SearchContextCostPerQuery(**search_context_raw) if search_context_raw else SearchContextCostPerQuery()
|
||||
)
|
||||
search_context_raw: Final = model_info.get("search_context_cost_per_query")
|
||||
search_context_pricing: Final[SearchContextCostPerQuery] = search_context_raw or SearchContextCostPerQuery()
|
||||
return search_context_pricing.get("search_context_size_medium", 0.0)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -716,7 +712,7 @@ class StandardBuiltInToolCostTracking:
|
|||
response_object: ModelResponse,
|
||||
) -> bool:
|
||||
for _choice in response_object.choices:
|
||||
message = getattr(_choice, "message", None)
|
||||
message: Message | None = getattr(_choice, "message", None)
|
||||
if (
|
||||
message is not None
|
||||
and hasattr(message, "annotations")
|
||||
|
|
|
|||
|
|
@ -519,10 +519,10 @@ def update_messages_with_model_file_ids(
|
|||
|
||||
|
||||
def update_responses_input_with_model_file_ids(
|
||||
input: Any,
|
||||
input: object,
|
||||
model_id: str | None = None,
|
||||
model_file_id_mapping: dict[str, dict[str, str]] | None = None,
|
||||
) -> str | list[dict[str, Any]]:
|
||||
) -> object:
|
||||
"""
|
||||
Updates responses API input with provider-specific file IDs.
|
||||
File IDs are always inside the content array, not as direct input_file items.
|
||||
|
|
@ -603,8 +603,8 @@ def update_responses_input_with_model_file_ids(
|
|||
|
||||
|
||||
def _decode_vector_store_ids_in_tools(
|
||||
tools: list[dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
tools: list[dict[str, object]] | None,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""
|
||||
Decodes unified (LiteLLM-managed) vector_store_ids in file_search tools to
|
||||
provider-native IDs. Non-unified IDs are passed through unchanged.
|
||||
|
|
@ -656,10 +656,10 @@ def _decode_vector_store_ids_in_tools(
|
|||
|
||||
|
||||
def update_responses_tools_with_model_file_ids(
|
||||
tools: list[dict[str, Any]] | None,
|
||||
tools: list[dict[str, object]] | None,
|
||||
model_id: str | None = None,
|
||||
model_file_id_mapping: dict[str, dict[str, str]] | None = None,
|
||||
) -> list[dict[str, Any]] | None:
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""
|
||||
Updates responses API tools with provider-specific file IDs.
|
||||
|
||||
|
|
@ -852,7 +852,7 @@ def extract_file_data(file_data: FileTypes) -> ExtractedFileData:
|
|||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _estimate_json_bytes(obj: Any) -> int:
|
||||
def _estimate_json_bytes(obj: object) -> int:
|
||||
"""Estimate the JSON-serialised byte size of ``obj`` without materialising
|
||||
JSON. Walks iteratively (no recursion stack risk).
|
||||
|
||||
|
|
@ -1747,7 +1747,7 @@ def hoist_images_from_tool_messages(
|
|||
]
|
||||
|
||||
|
||||
def _attempt_json_repair(s: str) -> Any | None:
|
||||
def _attempt_json_repair(s: str) -> object | None:
|
||||
"""
|
||||
Attempt to repair truncated JSON produced by LLM tool calls.
|
||||
|
||||
|
|
@ -1863,7 +1863,7 @@ def parse_tool_call_arguments(
|
|||
raise ValueError(error_message) from original_error
|
||||
|
||||
|
||||
def split_concatenated_json_objects(raw: str) -> list[dict[str, Any]]:
|
||||
def split_concatenated_json_objects(raw: str) -> list[dict[str, object]]:
|
||||
"""
|
||||
Split a string that contains one or more concatenated JSON objects into
|
||||
a list of parsed dicts.
|
||||
|
|
@ -1899,7 +1899,7 @@ def split_concatenated_json_objects(raw: str) -> list[dict[str, Any]]:
|
|||
return []
|
||||
|
||||
decoder: Final = json.JSONDecoder()
|
||||
results: Final[list[dict[str, Any]]] = []
|
||||
results: Final[list[dict[str, object]]] = []
|
||||
idx = 0
|
||||
length: Final = len(raw)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import base64
|
|||
import io
|
||||
import struct
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any, Final, Literal, cast
|
||||
from typing import Final, Literal, cast
|
||||
|
||||
import httpx
|
||||
import tiktoken
|
||||
|
||||
import litellm
|
||||
|
|
@ -164,6 +165,10 @@ def calculate_tiles_needed(
|
|||
return total_tiles
|
||||
|
||||
|
||||
def _unpack_ints(fmt: str, buffer: bytes) -> tuple[int, ...]:
|
||||
return struct.unpack(fmt, buffer)
|
||||
|
||||
|
||||
def get_image_type(image_data: bytes) -> str | None:
|
||||
"""take an image (really only the first ~100 bytes max are needed)
|
||||
and return 'png' 'gif' 'jpeg' 'webp' 'heic' or None. method added to
|
||||
|
|
@ -203,9 +208,9 @@ def get_image_dimensions(
|
|||
if data.startswith(("http://", "https://")):
|
||||
try:
|
||||
client: Final = _get_httpx_client()
|
||||
response: Final = safe_get(client, data)
|
||||
response: Final[httpx.Response] = safe_get(client, data)
|
||||
max_bytes: Final = int(MAX_IMAGE_URL_DOWNLOAD_SIZE_MB * 1024 * 1024)
|
||||
content_length: Final = response.headers.get("Content-Length")
|
||||
content_length: Final[str | None] = response.headers.get("Content-Length")
|
||||
if content_length is not None and int(content_length) > max_bytes:
|
||||
pass # skip download; img_data stays None
|
||||
else:
|
||||
|
|
@ -222,10 +227,10 @@ def get_image_dimensions(
|
|||
img_type: Final = get_image_type(img_data)
|
||||
|
||||
if img_type == "png":
|
||||
w, h = struct.unpack(">LL", img_data[16:24])
|
||||
w, h = _unpack_ints(">LL", img_data[16:24])
|
||||
return w, h
|
||||
elif img_type == "gif":
|
||||
w, h = struct.unpack("<HH", img_data[6:10])
|
||||
w, h = _unpack_ints("<HH", img_data[6:10])
|
||||
return w, h
|
||||
elif img_type == "jpeg":
|
||||
with io.BytesIO(img_data) as fhandle:
|
||||
|
|
@ -238,25 +243,25 @@ def get_image_dimensions(
|
|||
while ord(byte) == 0xFF:
|
||||
byte = fhandle.read(1)
|
||||
ftype = ord(byte)
|
||||
size = struct.unpack(">H", fhandle.read(2))[0] - 2
|
||||
size = _unpack_ints(">H", fhandle.read(2))[0] - 2
|
||||
fhandle.seek(1, 1)
|
||||
h, w = struct.unpack(">HH", fhandle.read(4))
|
||||
h, w = _unpack_ints(">HH", fhandle.read(4))
|
||||
return w, h
|
||||
elif img_type == "webp":
|
||||
# For WebP, the dimensions are stored at different offsets depending on the format
|
||||
# Check for VP8X (extended format)
|
||||
if img_data[12:16] == b"VP8X":
|
||||
w = struct.unpack("<I", img_data[24:27] + b"\x00")[0] + 1
|
||||
h = struct.unpack("<I", img_data[27:30] + b"\x00")[0] + 1
|
||||
w = _unpack_ints("<I", img_data[24:27] + b"\x00")[0] + 1
|
||||
h = _unpack_ints("<I", img_data[27:30] + b"\x00")[0] + 1
|
||||
return w, h
|
||||
# Check for VP8 (lossy format)
|
||||
elif img_data[12:16] == b"VP8 ":
|
||||
w = struct.unpack("<H", img_data[26:28])[0] & 0x3FFF
|
||||
h = struct.unpack("<H", img_data[28:30])[0] & 0x3FFF
|
||||
w = _unpack_ints("<H", img_data[26:28])[0] & 0x3FFF
|
||||
h = _unpack_ints("<H", img_data[28:30])[0] & 0x3FFF
|
||||
return w, h
|
||||
# Check for VP8L (lossless format)
|
||||
elif img_data[12:16] == b"VP8L":
|
||||
bits: Final = struct.unpack("<I", img_data[21:25])[0]
|
||||
bits: Final = _unpack_ints("<I", img_data[21:25])[0]
|
||||
w = (bits & 0x3FFF) + 1
|
||||
h = ((bits >> 14) & 0x3FFF) + 1
|
||||
return w, h
|
||||
|
|
@ -413,8 +418,8 @@ def token_counter(
|
|||
|
||||
def _count_function_call_tokens(
|
||||
key: str,
|
||||
value: Any,
|
||||
message: Mapping[str, Any],
|
||||
value: object,
|
||||
message: Mapping[str, object],
|
||||
count_function: TokenCounterFunction,
|
||||
) -> int:
|
||||
"""
|
||||
|
|
@ -580,7 +585,7 @@ def _fix_model_name(model: str) -> str:
|
|||
|
||||
|
||||
def _count_image_tokens(
|
||||
image_url: Any,
|
||||
image_url: object,
|
||||
use_default_image_token_count: bool,
|
||||
) -> int:
|
||||
"""
|
||||
|
|
@ -620,7 +625,7 @@ def _count_image_tokens(
|
|||
raise ValueError(f"Invalid image_url type: {type(image_url).__name__}. Expected str or dict with 'url' field.")
|
||||
|
||||
|
||||
def _validate_anthropic_content(content: Mapping[str, Any]) -> type:
|
||||
def _validate_anthropic_content(content: Mapping[str, object]) -> type:
|
||||
"""
|
||||
Validate and determine which Anthropic TypedDict applies.
|
||||
|
||||
|
|
@ -635,7 +640,7 @@ def _validate_anthropic_content(content: Mapping[str, Any]) -> type:
|
|||
"tool_result": AnthropicMessagesToolResultParam,
|
||||
}
|
||||
|
||||
expected_cls: Final = mapping.get(content_type)
|
||||
expected_cls: Final = mapping.get(content_type) if isinstance(content_type, str) else None
|
||||
if expected_cls is None:
|
||||
raise ValueError(f"Unknown Anthropic content type: '{content_type}'")
|
||||
|
||||
|
|
@ -647,7 +652,7 @@ def _validate_anthropic_content(content: Mapping[str, Any]) -> type:
|
|||
|
||||
|
||||
def _count_anthropic_content(
|
||||
content: Mapping[str, Any],
|
||||
content: Mapping[str, object],
|
||||
count_function: TokenCounterFunction,
|
||||
use_default_image_token_count: bool,
|
||||
default_token_count: int | None,
|
||||
|
|
@ -662,7 +667,7 @@ def _count_anthropic_content(
|
|||
avoiding hardcoded field names.
|
||||
"""
|
||||
typeddict_cls: Final = _validate_anthropic_content(content)
|
||||
type_hints: Final = getattr(typeddict_cls, "__annotations__", {})
|
||||
type_hints: Final[Mapping[str, object]] = getattr(typeddict_cls, "__annotations__", {})
|
||||
tokens = 0
|
||||
|
||||
# Fields to skip (metadata/identifiers that don't contribute to prompt tokens)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ from copy import deepcopy
|
|||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, cast
|
||||
|
||||
from typing_extensions import assert_never
|
||||
from typing_extensions import ReadOnly, TypedDict, assert_never
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.llms.anthropic.chat.transformation import AnthropicConfig
|
||||
|
|
@ -111,6 +111,16 @@ class ExtractedInput:
|
|||
EMPTY_EXTRACTED_INPUT: Final = ExtractedInput(scanned=(), images=())
|
||||
|
||||
|
||||
class _AnthropicSSEDelta(TypedDict, total=False):
|
||||
type: ReadOnly[str]
|
||||
text: ReadOnly[str]
|
||||
stop_reason: ReadOnly[str | None]
|
||||
|
||||
|
||||
class _AnthropicSSEEvent(TypedDict, total=False):
|
||||
delta: ReadOnly[_AnthropicSSEDelta]
|
||||
|
||||
|
||||
class AnthropicMessagesHandler(BaseTranslation):
|
||||
"""Process Anthropic messages with guardrails.
|
||||
|
||||
|
|
@ -747,7 +757,7 @@ class AnthropicMessagesHandler(BaseTranslation):
|
|||
if scan_only_tool_results:
|
||||
return EMPTY_EXTRACTED_INPUT
|
||||
|
||||
text_str: Final = content_item.get("text", None)
|
||||
text_str: Final[str | None] = content_item.get("text", None)
|
||||
return ExtractedInput(
|
||||
scanned=(
|
||||
() if text_str is None else (ScannedText(text_str, ContentBlockTextTarget(msg_idx, content_idx)),)
|
||||
|
|
@ -1156,8 +1166,8 @@ class AnthropicMessagesHandler(BaseTranslation):
|
|||
# Only process content_block_delta events
|
||||
if event_type == "content_block_delta" and data_line:
|
||||
try:
|
||||
data = json.loads(data_line)
|
||||
delta = data.get("delta", {})
|
||||
data: _AnthropicSSEEvent = json.loads(data_line)
|
||||
delta: _AnthropicSSEDelta = data.get("delta", {})
|
||||
if delta.get("type") == "text_delta":
|
||||
text += delta.get("text", "")
|
||||
except json.JSONDecodeError:
|
||||
|
|
@ -1219,9 +1229,9 @@ class AnthropicMessagesHandler(BaseTranslation):
|
|||
# Check for message_delta event with stop_reason
|
||||
if event_type == "message_delta" and data_line:
|
||||
try:
|
||||
data = json.loads(data_line)
|
||||
delta = data.get("delta", {})
|
||||
stop_reason = delta.get("stop_reason")
|
||||
data: _AnthropicSSEEvent = json.loads(data_line)
|
||||
delta: _AnthropicSSEDelta = data.get("delta", {})
|
||||
stop_reason: str | None = delta.get("stop_reason")
|
||||
if stop_reason is not None:
|
||||
return True
|
||||
except json.JSONDecodeError:
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@ if TYPE_CHECKING:
|
|||
from litellm.llms.base_llm.chat.transformation import BaseConfig
|
||||
|
||||
|
||||
def _loads_stream_chunk(payload: str) -> dict[str, object]:
|
||||
return json.loads(payload)
|
||||
|
||||
|
||||
async def make_call(
|
||||
client: AsyncHTTPHandler | None,
|
||||
api_base: str,
|
||||
|
|
@ -78,7 +82,7 @@ async def make_call(
|
|||
json_mode: bool,
|
||||
speed: str | None = None,
|
||||
tool_name_reverse_map: dict[str, str] | None = None,
|
||||
) -> tuple[Any, httpx.Headers]:
|
||||
) -> tuple["ModelResponseIterator", httpx.Headers]:
|
||||
if client is None:
|
||||
client = litellm.module_level_aclient
|
||||
|
||||
|
|
@ -93,7 +97,7 @@ async def make_call(
|
|||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_headers = getattr(e, "headers", None)
|
||||
error_response: Final = getattr(e, "response", None)
|
||||
error_response: Final[object] = getattr(e, "response", None)
|
||||
if error_headers is None and error_response:
|
||||
error_headers = getattr(error_response, "headers", None)
|
||||
raise AnthropicError(
|
||||
|
|
@ -138,7 +142,7 @@ def make_sync_call(
|
|||
json_mode: bool,
|
||||
speed: str | None = None,
|
||||
tool_name_reverse_map: dict[str, str] | None = None,
|
||||
) -> tuple[Any, httpx.Headers]:
|
||||
) -> tuple["ModelResponseIterator", httpx.Headers]:
|
||||
if client is None:
|
||||
client = litellm.module_level_client # re-use a module level client
|
||||
|
||||
|
|
@ -153,7 +157,7 @@ def make_sync_call(
|
|||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_headers = getattr(e, "headers", None)
|
||||
error_response: Final = getattr(e, "response", None)
|
||||
error_response: Final[object] = getattr(e, "response", None)
|
||||
if error_headers is None and error_response:
|
||||
error_headers = getattr(error_response, "headers", None)
|
||||
raise AnthropicError(
|
||||
|
|
@ -292,7 +296,7 @@ class AnthropicChatCompletion(BaseLLM):
|
|||
status_code: Final = getattr(e, "status_code", 500)
|
||||
error_headers = getattr(e, "headers", None)
|
||||
error_text = getattr(e, "text", str(e))
|
||||
error_response: Final = getattr(e, "response", None)
|
||||
error_response: Final[object] = getattr(e, "response", None)
|
||||
if error_headers is None and error_response:
|
||||
error_headers = getattr(error_response, "headers", None)
|
||||
if error_response and hasattr(error_response, "text"):
|
||||
|
|
@ -593,7 +597,7 @@ class AnthropicChatCompletion(BaseLLM):
|
|||
status_code: Final = getattr(e, "status_code", 500)
|
||||
error_headers = getattr(e, "headers", None)
|
||||
error_text = getattr(e, "text", str(e))
|
||||
error_response: Final = getattr(e, "response", None)
|
||||
error_response: Final[object] = getattr(e, "response", None)
|
||||
if error_headers is None and error_response:
|
||||
error_headers = getattr(error_response, "headers", None)
|
||||
if error_response and hasattr(error_response, "text"):
|
||||
|
|
@ -664,10 +668,10 @@ class ModelResponseIterator:
|
|||
|
||||
# Accumulate web_search_tool_result blocks for multi-turn reconstruction
|
||||
# See: https://github.com/BerriAI/litellm/issues/17737
|
||||
self.web_search_results: list[dict[str, Any]] = []
|
||||
self.web_search_results: list[dict[str, object]] = []
|
||||
|
||||
# Accumulate compaction blocks for multi-turn reconstruction
|
||||
self.compaction_blocks: list[dict[str, Any]] = []
|
||||
self.compaction_blocks: list[dict[str, object]] = []
|
||||
|
||||
# Accumulate streamed thinking text so final usage can split reasoning
|
||||
# tokens from regular output tokens.
|
||||
|
|
@ -727,7 +731,7 @@ class ModelResponseIterator:
|
|||
str,
|
||||
ChatCompletionToolCallChunk | None,
|
||||
list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock],
|
||||
dict[str, Any],
|
||||
dict[str, object],
|
||||
str | None,
|
||||
]:
|
||||
"""
|
||||
|
|
@ -735,7 +739,7 @@ class ModelResponseIterator:
|
|||
"""
|
||||
text = ""
|
||||
tool_use: ChatCompletionToolCallChunk | None = None
|
||||
provider_specific_fields: Final = {}
|
||||
provider_specific_fields: Final[dict[str, object]] = {}
|
||||
reasoning_content: str | None = None
|
||||
content_block: Final = ContentBlockDelta(**chunk)
|
||||
thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] = []
|
||||
|
|
@ -809,8 +813,8 @@ class ModelResponseIterator:
|
|||
def _handle_redacted_thinking_content(
|
||||
self,
|
||||
content_block_start: ContentBlockStart,
|
||||
provider_specific_fields: dict[str, Any],
|
||||
) -> tuple[list[ChatCompletionRedactedThinkingBlock], dict[str, Any]]:
|
||||
provider_specific_fields: dict[str, object],
|
||||
) -> tuple[list[ChatCompletionRedactedThinkingBlock], dict[str, object]]:
|
||||
"""
|
||||
Handle the redacted thinking content
|
||||
"""
|
||||
|
|
@ -878,7 +882,7 @@ class ModelResponseIterator:
|
|||
tool_use: ChatCompletionToolCallChunk | None = None
|
||||
finish_reason = ""
|
||||
usage: Usage | None = None
|
||||
provider_specific_fields: dict[str, Any] = {}
|
||||
provider_specific_fields: dict[str, object] = {}
|
||||
reasoning_content: str | None = None
|
||||
thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] | None = None
|
||||
|
||||
|
|
@ -1212,7 +1216,7 @@ class ModelResponseIterator:
|
|||
|
||||
# Try to parse as valid JSON first
|
||||
try:
|
||||
data_json: Final = json.loads(data_str)
|
||||
data_json: Final = _loads_stream_chunk(data_str)
|
||||
return self.chunk_parser(chunk=data_json)
|
||||
except json.JSONDecodeError:
|
||||
# Switch to accumulation mode and start accumulating
|
||||
|
|
@ -1330,7 +1334,7 @@ class ModelResponseIterator:
|
|||
str_line = str_line[index:]
|
||||
|
||||
if str_line.startswith("data:"):
|
||||
data_json: Final = json.loads(str_line[5:])
|
||||
data_json: Final = _loads_stream_chunk(str_line[5:])
|
||||
return self.chunk_parser(chunk=data_json)
|
||||
else:
|
||||
return ModelResponseStream(id=self.response_id)
|
||||
|
|
|
|||
|
|
@ -865,13 +865,9 @@ class AnthropicModelInfo(BaseLLMModelInfo):
|
|||
f"Failed to fetch models from Anthropic. Status code: {response.status_code}, Response: {response.text}"
|
||||
)
|
||||
|
||||
models: Final = response.json()["data"]
|
||||
models: Final[Sequence[Mapping[str, str]]] = response.json()["data"]
|
||||
|
||||
litellm_model_names: Final = []
|
||||
for model in models:
|
||||
stripped_model_name = model["id"]
|
||||
litellm_model_name = "anthropic/" + stripped_model_name
|
||||
litellm_model_names.append(litellm_model_name)
|
||||
litellm_model_names: Final = ["anthropic/" + model["id"] for model in models]
|
||||
return litellm_model_names
|
||||
|
||||
def get_token_counter(self) -> BaseTokenCounter | None:
|
||||
|
|
@ -1064,7 +1060,7 @@ def strip_empty_text_blocks_from_anthropic_messages(
|
|||
return out
|
||||
|
||||
|
||||
def _is_empty_text_block(block: Any) -> bool:
|
||||
def _is_empty_text_block(block: object) -> bool:
|
||||
if not isinstance(block, dict) or block.get("type") != "text":
|
||||
return False
|
||||
text: Final = block.get("text")
|
||||
|
|
@ -1084,7 +1080,7 @@ def normalize_anthropic_tool_use_id(raw_id: str) -> str:
|
|||
return sanitized or "tool_use_id"
|
||||
|
||||
|
||||
def _sanitize_tool_use_id_content_block(block: Any) -> Any:
|
||||
def _sanitize_tool_use_id_content_block(block: object) -> object:
|
||||
if not isinstance(block, dict):
|
||||
return block
|
||||
block_type: Final = block.get("type")
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping
|
||||
from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, cast
|
||||
|
||||
import litellm
|
||||
|
|
@ -18,6 +18,24 @@ TOOL_NAME_PREFIX_LENGTH: Final = OPENAI_MAX_TOOL_NAME_LENGTH - TOOL_NAME_HASH_LE
|
|||
PROVIDERS_PROXYING_AN_UNKNOWN_BACKEND: Final = frozenset({"litellm_proxy"})
|
||||
|
||||
|
||||
def _optional_attr(source: object, name: str) -> object:
|
||||
return getattr(source, name, None)
|
||||
|
||||
|
||||
def _as_string_mapping(value: object) -> Mapping[str, object] | None:
|
||||
if isinstance(value, Mapping):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _thought_signature(provider_specific_fields: object) -> str | None:
|
||||
fields: Final = _as_string_mapping(provider_specific_fields)
|
||||
if fields is None:
|
||||
return None
|
||||
signature: Final = fields.get("thought_signature")
|
||||
return signature if isinstance(signature, str) else None
|
||||
|
||||
|
||||
def truncate_tool_name(name: str) -> str:
|
||||
"""
|
||||
Truncate tool names that exceed OpenAI's 64-character limit.
|
||||
|
|
@ -40,7 +58,7 @@ def truncate_tool_name(name: str) -> str:
|
|||
|
||||
|
||||
def create_tool_name_mapping(
|
||||
tools: list[dict[str, Any]],
|
||||
tools: Sequence[Mapping[str, object]],
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Create a mapping of truncated tool names to original names.
|
||||
|
|
@ -54,6 +72,8 @@ def create_tool_name_mapping(
|
|||
mapping: Final[dict[str, str]] = {}
|
||||
for tool in tools:
|
||||
original_name = tool.get("name", "")
|
||||
if not isinstance(original_name, str):
|
||||
continue
|
||||
truncated_name = truncate_tool_name(original_name)
|
||||
if truncated_name != original_name:
|
||||
mapping[truncated_name] = original_name
|
||||
|
|
@ -263,44 +283,44 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
|
||||
### FOR [BETA] `/v1/messages` endpoint support
|
||||
|
||||
def _extract_signature_from_tool_call(self, tool_call: Any) -> str | None:
|
||||
def _extract_signature_from_tool_call(self, tool_call: object) -> str | None:
|
||||
"""
|
||||
Extract signature from a tool call's provider_specific_fields.
|
||||
Only checks provider_specific_fields, not thinking blocks.
|
||||
"""
|
||||
signature = None
|
||||
fields: Final = _optional_attr(tool_call, "provider_specific_fields")
|
||||
if fields:
|
||||
return _thought_signature(fields)
|
||||
|
||||
if hasattr(tool_call, "provider_specific_fields") and tool_call.provider_specific_fields:
|
||||
if "thought_signature" in tool_call.provider_specific_fields:
|
||||
signature = tool_call.provider_specific_fields["thought_signature"]
|
||||
elif hasattr(tool_call.function, "provider_specific_fields") and tool_call.function.provider_specific_fields:
|
||||
if "thought_signature" in tool_call.function.provider_specific_fields:
|
||||
signature = tool_call.function.provider_specific_fields["thought_signature"]
|
||||
function_fields: Final = _optional_attr(_optional_attr(tool_call, "function"), "provider_specific_fields")
|
||||
if function_fields:
|
||||
return _thought_signature(function_fields)
|
||||
|
||||
return signature
|
||||
return None
|
||||
|
||||
def _extract_signature_from_tool_use_content(self, content: dict[str, Any]) -> str | None:
|
||||
def _extract_signature_from_tool_use_content(self, content: Mapping[str, object]) -> str | None:
|
||||
"""
|
||||
Extract signature from a tool_use content block's provider_specific_fields.
|
||||
"""
|
||||
provider_specific_fields: Final = content.get("provider_specific_fields", {})
|
||||
provider_specific_fields: Final = _as_string_mapping(content.get("provider_specific_fields", {}))
|
||||
if provider_specific_fields:
|
||||
return provider_specific_fields.get("signature")
|
||||
signature: Final = provider_specific_fields.get("signature")
|
||||
return signature if isinstance(signature, str) else None
|
||||
return None
|
||||
|
||||
def _add_cache_control_if_applicable(
|
||||
self,
|
||||
source: Any,
|
||||
target: Any,
|
||||
source: object,
|
||||
target: object,
|
||||
model: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Extract cache_control from source and add to target if it should be preserved.
|
||||
|
||||
This method accepts Any type to support both regular dicts and TypedDict objects.
|
||||
TypedDict objects (like ChatCompletionTextObject, ChatCompletionImageObject, etc.)
|
||||
are dicts at runtime but have specific types at type-check time. Using Any allows
|
||||
this method to work with both while maintaining runtime correctness.
|
||||
This method accepts an unconstrained type to support both regular dicts and
|
||||
TypedDict objects. TypedDict objects (like ChatCompletionTextObject,
|
||||
ChatCompletionImageObject, etc.) are dicts at runtime but have specific types at
|
||||
type-check time, so the widest parameter type works with both.
|
||||
|
||||
Args:
|
||||
source: Dict or TypedDict containing potential cache_control field
|
||||
|
|
@ -801,7 +821,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
|
||||
return new_tools, tool_name_mapping
|
||||
|
||||
def translate_anthropic_output_format_to_openai(self, output_format: Any) -> dict[str, object] | None:
|
||||
def translate_anthropic_output_format_to_openai(self, output_format: object) -> dict[str, object] | None:
|
||||
"""
|
||||
Translate Anthropic's output_format to OpenAI's response_format.
|
||||
|
||||
|
|
@ -1326,7 +1346,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
|
||||
@classmethod
|
||||
def _first_positive_prompt_tokens_detail_value(cls, usage: Usage, field_names: tuple[str, ...]) -> int:
|
||||
prompt_tokens_details: Final = getattr(usage, "prompt_tokens_details", None)
|
||||
prompt_tokens_details: Final = _optional_attr(usage, "prompt_tokens_details")
|
||||
if prompt_tokens_details is None:
|
||||
return 0
|
||||
|
||||
|
|
@ -1334,7 +1354,7 @@ class LiteLLMAnthropicMessagesAdapter:
|
|||
if isinstance(prompt_tokens_details, dict):
|
||||
value = cls._positive_int(prompt_tokens_details.get(field_name))
|
||||
else:
|
||||
value = cls._positive_int(getattr(prompt_tokens_details, field_name, None))
|
||||
value = cls._positive_int(_optional_attr(prompt_tokens_details, field_name))
|
||||
if value > 0:
|
||||
return value
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -14,7 +14,18 @@ Mirrors Anthropic's native ``compact_20260112`` for non-Anthropic providers:
|
|||
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, NotRequired, Optional, TypedDict, Union, cast
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Final,
|
||||
Literal,
|
||||
NotRequired,
|
||||
Optional,
|
||||
Protocol,
|
||||
TypedDict,
|
||||
Union,
|
||||
cast,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
|
|
@ -159,11 +170,11 @@ async def _check_summary_model_access(
|
|||
return True
|
||||
|
||||
key_models: Final = list(getattr(user_api_key_auth, "models", None) or [])
|
||||
team_id: Final = getattr(user_api_key_auth, "team_id", None)
|
||||
team_model_aliases: Final = getattr(user_api_key_auth, "team_model_aliases", None)
|
||||
team_id: Final[str | None] = getattr(user_api_key_auth, "team_id", None)
|
||||
team_model_aliases: Final[dict[str, str] | None] = getattr(user_api_key_auth, "team_model_aliases", None)
|
||||
team_models: Final = list(getattr(user_api_key_auth, "team_models", None) or [])
|
||||
user_id: Final = getattr(user_api_key_auth, "user_id", None)
|
||||
project_id: Final = getattr(user_api_key_auth, "project_id", None)
|
||||
user_id: Final[str | None] = getattr(user_api_key_auth, "user_id", None)
|
||||
project_id: Final[str | None] = getattr(user_api_key_auth, "project_id", None)
|
||||
|
||||
checks: Final[tuple[tuple[Literal["key", "team"], list[str]], ...]] = (
|
||||
("key", key_models),
|
||||
|
|
@ -371,8 +382,10 @@ async def _check_summary_model_budget(
|
|||
)
|
||||
return False
|
||||
|
||||
end_user_model_max_budget: Final = getattr(user_api_key_auth, "end_user_model_max_budget", None)
|
||||
end_user_id: Final = getattr(user_api_key_auth, "end_user_id", None)
|
||||
end_user_model_max_budget: Final[dict[str, object] | None] = getattr(
|
||||
user_api_key_auth, "end_user_model_max_budget", None
|
||||
)
|
||||
end_user_id: Final[str | None] = getattr(user_api_key_auth, "end_user_id", None)
|
||||
if isinstance(end_user_model_max_budget, dict) and end_user_model_max_budget and end_user_id is not None:
|
||||
try:
|
||||
await model_max_budget_limiter.is_end_user_within_model_budget(
|
||||
|
|
@ -490,7 +503,7 @@ def _find_latest_compaction_index(
|
|||
|
||||
|
||||
def _slice_around_compaction_block(
|
||||
messages: list[dict[str, Any]],
|
||||
messages: list[dict[str, object]],
|
||||
) -> tuple[list[dict[str, object]], dict[str, object] | None]:
|
||||
"""Apply Anthropic's "drop everything before the compaction block" rule.
|
||||
|
||||
|
|
@ -505,7 +518,8 @@ def _slice_around_compaction_block(
|
|||
return messages, None
|
||||
|
||||
original_msg: Final = messages[msg_idx]
|
||||
original_content: Final = original_msg["content"]
|
||||
raw_content: Final = original_msg.get("content")
|
||||
original_content: Final[list[object]] = raw_content if isinstance(raw_content, list) else []
|
||||
compaction_block: Final = cast(dict[str, object], original_content[blk_idx])
|
||||
|
||||
# Per Anthropic's contract everything before the compaction block is
|
||||
|
|
@ -760,7 +774,7 @@ def _extract_summary_text(raw: str | None) -> str | None:
|
|||
|
||||
|
||||
def _system_to_openai_message(
|
||||
system: str | list[dict[str, Any]] | None,
|
||||
system: str | list[dict[str, object]] | None,
|
||||
) -> dict[str, object] | None:
|
||||
"""Translate Anthropic-shaped ``system`` to an OpenAI system message.
|
||||
|
||||
|
|
@ -772,8 +786,10 @@ def _system_to_openai_message(
|
|||
if isinstance(system, str):
|
||||
return {"role": "system", "content": system} if system else None
|
||||
if isinstance(system, list):
|
||||
parts = [block.get("text", "") for block in system if isinstance(block, dict) and block.get("type") == "text"]
|
||||
joined: Final = "\n\n".join(part for part in parts if part)
|
||||
parts: Final[list[object]] = [
|
||||
block.get("text", "") for block in system if isinstance(block, dict) and block.get("type") == "text"
|
||||
]
|
||||
joined: Final = "\n\n".join(part for part in parts if isinstance(part, str) and part)
|
||||
return {"role": "system", "content": joined} if joined else None
|
||||
return None
|
||||
|
||||
|
|
@ -873,7 +889,7 @@ async def _call_summary_model(
|
|||
summary_model: str,
|
||||
summary_messages: list[dict[str, object]],
|
||||
metadata: Mapping[str, object],
|
||||
llm_router: Any,
|
||||
llm_router: Optional["Router"],
|
||||
allowed_model_region: str | None = None,
|
||||
max_tokens: int = COMPACT_SUMMARY_MAX_TOKENS,
|
||||
) -> Union["ModelResponse", "CustomStreamWrapper"]:
|
||||
|
|
@ -927,11 +943,17 @@ async def _call_summary_model(
|
|||
return await litellm.acompletion(**call_kwargs)
|
||||
|
||||
|
||||
def _extract_response_text(response: Any) -> str | None:
|
||||
@runtime_checkable
|
||||
class _ResponseWithChoices(Protocol):
|
||||
choices: Sequence[object]
|
||||
|
||||
|
||||
def _extract_response_text(response: object) -> str | None:
|
||||
if not isinstance(response, _ResponseWithChoices) or not response.choices:
|
||||
return None
|
||||
try:
|
||||
choice: Final = response.choices[0]
|
||||
message: Final = choice.message
|
||||
content: Final = getattr(message, "content", None)
|
||||
message: Final[object] = getattr(response.choices[0], "message", None)
|
||||
content: Final[object] = getattr(message, "content", None)
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
# Some providers return a list of content parts.
|
||||
|
|
@ -946,13 +968,12 @@ def _extract_response_text(response: Any) -> str | None:
|
|||
|
||||
|
||||
def _extract_usage(response: object) -> tuple[int, int]:
|
||||
usage: Final = getattr(response, "usage", None)
|
||||
usage: Final[object] = getattr(response, "usage", None)
|
||||
if usage is None:
|
||||
return 0, 0
|
||||
return (
|
||||
int(getattr(usage, "prompt_tokens", 0) or 0),
|
||||
int(getattr(usage, "completion_tokens", 0) or 0),
|
||||
)
|
||||
prompt_tokens: Final[int | None] = getattr(usage, "prompt_tokens", 0)
|
||||
completion_tokens: Final[int | None] = getattr(usage, "completion_tokens", 0)
|
||||
return int(prompt_tokens or 0), int(completion_tokens or 0)
|
||||
|
||||
|
||||
def apply_client_compaction_block_history(
|
||||
|
|
|
|||
|
|
@ -179,14 +179,14 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
)
|
||||
|
||||
@staticmethod
|
||||
def _assistant_block_group_key(indexed_block: tuple[int, Mapping[str, Any]]) -> str:
|
||||
def _assistant_block_group_key(indexed_block: tuple[int, Mapping[str, object]]) -> str:
|
||||
"""Group a run of consecutive thinking blocks together; keep every other block alone."""
|
||||
index, block = indexed_block
|
||||
return "thinking" if block.get("type") == "thinking" else f"block:{index}"
|
||||
|
||||
@classmethod
|
||||
def _assistant_group_to_input_item(
|
||||
cls, group: tuple[Mapping[str, Any], ...]
|
||||
cls, group: tuple[Mapping[str, object], ...]
|
||||
) -> dict[str, Any] | None: # mutable-ok: API message payload
|
||||
first: Final = group[0]
|
||||
btype: Final = first.get("type")
|
||||
|
|
@ -206,7 +206,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
def translate_messages_to_responses_input(
|
||||
self,
|
||||
messages: list[AllAnthropicPassThroughMessageValues],
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> list[dict[str, object]]:
|
||||
"""
|
||||
Convert Anthropic messages list to Responses API `input` items.
|
||||
|
||||
|
|
@ -220,7 +220,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
assistant thinking -> reasoning
|
||||
assistant tool_use -> function_call
|
||||
"""
|
||||
input_items: Final[list[dict[str, Any]]] = []
|
||||
input_items: Final[list[dict[str, object]]] = []
|
||||
|
||||
for m in messages:
|
||||
if m["role"] == "system":
|
||||
|
|
@ -248,7 +248,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
}
|
||||
)
|
||||
elif isinstance(content, list):
|
||||
user_parts: list[dict[str, Any]] = []
|
||||
user_parts: list[Mapping[str, object]] = []
|
||||
tool_image_parts: list[dict[str, Any]] = [] # mutable-ok: json content parts
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
|
|
@ -379,9 +379,9 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
def translate_tools_to_responses_api(
|
||||
self,
|
||||
tools: list[AllAnthropicToolsValues],
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> list[dict[str, object]]:
|
||||
"""Convert Anthropic tool definitions to Responses API function tools."""
|
||||
result: Final[list[dict[str, Any]]] = []
|
||||
result: Final[list[dict[str, object]]] = []
|
||||
for tool in tools:
|
||||
tool_dict = cast(dict[str, Any], tool)
|
||||
tool_type = tool_dict.get("type", "")
|
||||
|
|
@ -392,7 +392,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
continue
|
||||
# Responses turns strict mode on when `strict` is omitted, silently rewriting
|
||||
# `required` to every property. Anthropic tools are non-strict unless asked.
|
||||
func_tool: dict[str, Any] = {
|
||||
func_tool: dict[str, object] = {
|
||||
"type": "function",
|
||||
"name": tool_name,
|
||||
"strict": bool(tool_dict.get("strict")),
|
||||
|
|
@ -407,7 +407,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
@staticmethod
|
||||
def translate_tool_choice_to_responses_api(
|
||||
tool_choice: AnthropicMessagesToolChoice,
|
||||
) -> str | dict[str, Any]:
|
||||
) -> str | dict[str, object]:
|
||||
"""Convert Anthropic tool_choice to Responses API tool_choice."""
|
||||
tc_type: Final = tool_choice.get("type")
|
||||
if tc_type == "any":
|
||||
|
|
@ -420,8 +420,8 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
|
||||
@staticmethod
|
||||
def translate_context_management_to_responses_api(
|
||||
context_management: dict[str, Any],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
context_management: dict[str, object],
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""
|
||||
Convert Anthropic context_management dict to OpenAI Responses API array format.
|
||||
|
||||
|
|
@ -435,13 +435,13 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
if not isinstance(edits, list):
|
||||
return None
|
||||
|
||||
result: Final[list[dict[str, Any]]] = []
|
||||
result: Final[list[dict[str, object]]] = []
|
||||
for edit in edits:
|
||||
if not isinstance(edit, dict):
|
||||
continue
|
||||
edit_type = edit.get("type", "")
|
||||
if edit_type == "compact_20260112":
|
||||
entry: dict[str, Any] = {"type": "compaction"}
|
||||
entry: dict[str, object] = {"type": "compaction"}
|
||||
trigger = edit.get("trigger")
|
||||
if isinstance(trigger, dict) and trigger.get("value") is not None:
|
||||
entry["compact_threshold"] = int(trigger["value"])
|
||||
|
|
@ -451,9 +451,9 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
|
||||
@staticmethod
|
||||
def translate_thinking_to_reasoning(
|
||||
thinking: dict[str, Any],
|
||||
output_config: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
thinking: dict[str, object],
|
||||
output_config: dict[str, object] | None = None,
|
||||
) -> dict[str, object] | None:
|
||||
"""
|
||||
Convert Anthropic thinking param to Responses API reasoning param.
|
||||
|
||||
|
|
@ -473,12 +473,14 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
if isinstance(output_config, dict) and output_config.get("effort"):
|
||||
effort = output_config["effort"]
|
||||
elif thinking_type == "enabled":
|
||||
effort = reasoning_effort_from_thinking_budget(thinking.get("budget_tokens", 0))
|
||||
raw_budget: Final = thinking.get("budget_tokens", 0)
|
||||
budget_tokens: Final = int(raw_budget) if isinstance(raw_budget, (int, float)) else 0
|
||||
effort = reasoning_effort_from_thinking_budget(budget_tokens)
|
||||
else:
|
||||
return None
|
||||
|
||||
auto_summary: Final = is_reasoning_auto_summary_enabled()
|
||||
result: Final[dict[str, Any]] = {"effort": effort}
|
||||
result: Final[dict[str, object]] = {"effort": effort}
|
||||
summary: Final = thinking.get("summary")
|
||||
if summary:
|
||||
result["summary"] = summary
|
||||
|
|
@ -570,7 +572,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
# output_format / output_config.format -> text format
|
||||
# output_format: {"type": "json_schema", "schema": {...}}
|
||||
# output_config: {"format": {"type": "json_schema", "schema": {...}}}
|
||||
output_format: Any = anthropic_request.get("output_format")
|
||||
output_format: object = anthropic_request.get("output_format")
|
||||
output_config = anthropic_request.get("output_config")
|
||||
if not isinstance(output_format, dict) and isinstance(output_config, dict):
|
||||
output_format = output_config.get("format")
|
||||
|
|
@ -620,7 +622,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter:
|
|||
ResponseReasoningItem,
|
||||
)
|
||||
|
||||
content: Final[list[dict[str, Any]]] = []
|
||||
content: Final[list[dict[str, object]]] = []
|
||||
stop_reason: AnthropicFinishReason = "end_turn"
|
||||
|
||||
for item in response.output:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@
|
|||
import base64
|
||||
import json
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar, cast
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final, Generic, Protocol, TypeVar, cast, runtime_checkable
|
||||
|
||||
from litellm import verbose_logger
|
||||
from litellm.llms.base_llm.managed_resources.isolation import (
|
||||
|
|
@ -37,6 +38,30 @@ else:
|
|||
ResourceObjectType = TypeVar("ResourceObjectType")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _HasIdentifier(Protocol):
|
||||
id: str
|
||||
|
||||
|
||||
class _ManagedResourceRecord(Protocol[ResourceObjectType]):
|
||||
unified_resource_id: str
|
||||
resource_object: ResourceObjectType
|
||||
|
||||
def model_dump(self) -> dict[str, object]: ...
|
||||
|
||||
|
||||
class _ManagedResourceTable(Protocol[ResourceObjectType]):
|
||||
async def create(self, *, data: Mapping[str, object]) -> object: ...
|
||||
|
||||
async def find_first(self, *, where: Mapping[str, object]) -> _ManagedResourceRecord[ResourceObjectType] | None: ...
|
||||
|
||||
async def find_many(
|
||||
self, *, where: Mapping[str, object], take: int, order: Mapping[str, str]
|
||||
) -> list[_ManagedResourceRecord[ResourceObjectType]]: ...
|
||||
|
||||
async def delete(self, *, where: Mapping[str, object]) -> object: ...
|
||||
|
||||
|
||||
class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
||||
"""
|
||||
Base class for managing resources with target_model_names support.
|
||||
|
|
@ -63,6 +88,9 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
self.internal_usage_cache = internal_usage_cache
|
||||
self.prisma_client = prisma_client
|
||||
|
||||
def _resource_table(self) -> _ManagedResourceTable[ResourceObjectType]:
|
||||
return getattr(self.prisma_client.db, self.table_name)
|
||||
|
||||
# ============================================================================
|
||||
# ABSTRACT METHODS
|
||||
# ============================================================================
|
||||
|
|
@ -136,7 +164,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
litellm_parent_otel_span: Span | None,
|
||||
model_mappings: dict[str, str],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
additional_db_fields: dict[str, Any] | None = None,
|
||||
additional_db_fields: Mapping[str, object] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Store unified resource ID with model mappings in cache and database.
|
||||
|
|
@ -152,7 +180,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
verbose_logger.info("Storing LiteLLM Managed %s with id=%s in cache", self.resource_type, unified_resource_id)
|
||||
|
||||
# Prepare cache data
|
||||
cache_data: Final = {
|
||||
cache_data: Final[dict[str, object]] = {
|
||||
"unified_resource_id": unified_resource_id,
|
||||
"resource_object": resource_object,
|
||||
"model_mappings": model_mappings,
|
||||
|
|
@ -175,7 +203,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
)
|
||||
|
||||
# Prepare database data
|
||||
db_data: Final = {
|
||||
db_data: Final[dict[str, object]] = {
|
||||
"unified_resource_id": unified_resource_id,
|
||||
"model_mappings": json.dumps(model_mappings),
|
||||
"flat_model_resource_ids": list(model_mappings.values()),
|
||||
|
|
@ -204,7 +232,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
db_data.update(additional_db_fields)
|
||||
|
||||
# Store in database
|
||||
table: Final = getattr(self.prisma_client.db, self.table_name)
|
||||
table: Final = self._resource_table()
|
||||
result: Final = await table.create(data=db_data)
|
||||
|
||||
verbose_logger.debug(
|
||||
|
|
@ -239,7 +267,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
return result
|
||||
|
||||
# Check database
|
||||
table: Final = getattr(self.prisma_client.db, self.table_name)
|
||||
table: Final = self._resource_table()
|
||||
db_object: Final = await table.find_first(where={"unified_resource_id": unified_resource_id})
|
||||
|
||||
if db_object:
|
||||
|
|
@ -263,7 +291,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
The deleted resource object or None if not found
|
||||
"""
|
||||
# Get old value from database
|
||||
table: Final = getattr(self.prisma_client.db, self.table_name)
|
||||
table: Final = self._resource_table()
|
||||
initial_value: Final = await table.find_first(where={"unified_resource_id": unified_resource_id})
|
||||
|
||||
if initial_value is None:
|
||||
|
|
@ -514,7 +542,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
limit: int | None = None,
|
||||
after: str | None = None,
|
||||
additional_filters: dict[str, Any] | None = None,
|
||||
additional_filters: Mapping[str, object] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
List resources created by a user.
|
||||
|
|
@ -532,7 +560,7 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
if owner_filter is None:
|
||||
return build_list_page([])
|
||||
|
||||
where_clause: Final[dict[str, Any]] = {**owner_filter}
|
||||
where_clause: Final[dict[str, object]] = {**owner_filter}
|
||||
|
||||
if after:
|
||||
where_clause["id"] = {"gt": after}
|
||||
|
|
@ -543,14 +571,14 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
|
||||
# Fetch resources
|
||||
fetch_limit: Final = limit or 20
|
||||
table: Final = getattr(self.prisma_client.db, self.table_name)
|
||||
table: Final = self._resource_table()
|
||||
resources: Final = await table.find_many(
|
||||
where=where_clause,
|
||||
take=fetch_limit,
|
||||
order={"created_at": "desc"},
|
||||
)
|
||||
|
||||
resource_objects: Final[list[Any]] = []
|
||||
resource_objects: Final[list[object]] = []
|
||||
for resource in resources:
|
||||
try:
|
||||
# Stop once we have enough
|
||||
|
|
@ -558,12 +586,13 @@ class BaseManagedResource(ABC, Generic[ResourceObjectType]):
|
|||
break
|
||||
|
||||
# Parse resource object
|
||||
resource_data = resource.resource_object
|
||||
if isinstance(resource_data, str):
|
||||
resource_data = json.loads(resource_data)
|
||||
stored_resource = resource.resource_object
|
||||
resource_data: object = (
|
||||
json.loads(stored_resource) if isinstance(stored_resource, str) else stored_resource
|
||||
)
|
||||
|
||||
# Set unified ID
|
||||
if hasattr(resource_data, "id"):
|
||||
if isinstance(resource_data, _HasIdentifier):
|
||||
resource_data.id = resource.unified_resource_id
|
||||
elif isinstance(resource_data, dict):
|
||||
resource_data["id"] = resource.unified_resource_id
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import base64
|
|||
import datetime
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Final
|
||||
|
||||
import httpx
|
||||
|
|
@ -128,24 +128,35 @@ def is_gemini_image_model(model: str) -> bool:
|
|||
return "gemini" in base_model
|
||||
|
||||
|
||||
def _parse_image_config_string(raw_image_config: str, model: str) -> object:
|
||||
try:
|
||||
return json.loads(raw_image_config)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise litellm.UnsupportedParamsError(
|
||||
model=model,
|
||||
message="`imageConfig` must be valid JSON when provided as a string.",
|
||||
) from exc
|
||||
|
||||
|
||||
def map_openai_image_params_to_gemini(
|
||||
params: dict[str, Any],
|
||||
params: Mapping[str, object],
|
||||
model: str,
|
||||
supported_params: Sequence[str],
|
||||
optional_params: dict[str, Any] | None = None,
|
||||
optional_params: Mapping[str, object] | None = None,
|
||||
parse_image_config_string: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
optional_params = optional_params or {}
|
||||
) -> dict[str, object]:
|
||||
already_mapped: Final[Mapping[str, object]] = optional_params or {}
|
||||
filtered_params: Final = {key: value for key, value in params.items() if key in supported_params}
|
||||
|
||||
mapped_params: Final[dict[str, Any]] = {}
|
||||
mapped_params: Final[dict[str, object]] = {}
|
||||
|
||||
if "n" in filtered_params and "n" not in optional_params:
|
||||
if "n" in filtered_params and "n" not in already_mapped:
|
||||
mapped_params["sampleCount"] = filtered_params["n"]
|
||||
|
||||
if "size" in filtered_params and "size" not in optional_params:
|
||||
size_param: Final = filtered_params.get("size")
|
||||
if isinstance(size_param, str) and "size" not in already_mapped:
|
||||
image_config: Final = map_openai_size_to_gemini_image_config(
|
||||
filtered_params["size"],
|
||||
size_param,
|
||||
model,
|
||||
)
|
||||
if image_config is not None:
|
||||
|
|
@ -156,33 +167,30 @@ def map_openai_image_params_to_gemini(
|
|||
if "imageSize" in image_config:
|
||||
mapped_params["imageSize"] = image_config["imageSize"]
|
||||
|
||||
image_config_param = filtered_params.get("imageConfig")
|
||||
if isinstance(image_config_param, str) and parse_image_config_string:
|
||||
try:
|
||||
image_config_param = json.loads(image_config_param)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise litellm.UnsupportedParamsError(
|
||||
model=model,
|
||||
message="`imageConfig` must be valid JSON when provided as a string.",
|
||||
) from exc
|
||||
raw_image_config: Final = filtered_params.get("imageConfig")
|
||||
image_config_param: Final[object] = (
|
||||
_parse_image_config_string(raw_image_config, model)
|
||||
if isinstance(raw_image_config, str) and parse_image_config_string
|
||||
else raw_image_config
|
||||
)
|
||||
if isinstance(image_config_param, dict):
|
||||
mapped_params["imageConfig"] = image_config_param
|
||||
|
||||
for key, value in filtered_params.items():
|
||||
if key not in ("n", "size", "imageConfig", "tools", "web_search_options") and key not in optional_params:
|
||||
if key not in ("n", "size", "imageConfig", "tools", "web_search_options") and key not in already_mapped:
|
||||
mapped_params[key] = value
|
||||
|
||||
return mapped_params
|
||||
|
||||
|
||||
def _dedupe_gemini_search_tools(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _dedupe_gemini_search_tools(tools: list[dict[str, object]]) -> list[dict[str, object]]:
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
VertexGeminiConfig,
|
||||
)
|
||||
|
||||
search_tool_keys: Final = VertexGeminiConfig._search_tool_keys()
|
||||
seen_search_keys: Final[set[str]] = set()
|
||||
deduped_tools: Final[list[dict[str, Any]]] = []
|
||||
deduped_tools: Final[list[dict[str, object]]] = []
|
||||
|
||||
for tool in tools:
|
||||
if not isinstance(tool, dict):
|
||||
|
|
@ -203,7 +211,7 @@ def _dedupe_gemini_search_tools(tools: list[dict[str, Any]]) -> list[dict[str, A
|
|||
return deduped_tools
|
||||
|
||||
|
||||
def _has_gemini_search_tool(tools: list[Any]) -> bool:
|
||||
def _has_gemini_search_tool(tools: list[object]) -> bool:
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
VertexGeminiConfig,
|
||||
)
|
||||
|
|
@ -213,9 +221,9 @@ def _has_gemini_search_tool(tools: list[Any]) -> bool:
|
|||
|
||||
|
||||
def map_gemini_image_tools_params(
|
||||
non_default_params: dict[str, Any],
|
||||
mapped_params: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
non_default_params: Mapping[str, object],
|
||||
mapped_params: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
VertexGeminiConfig,
|
||||
)
|
||||
|
|
@ -239,21 +247,24 @@ def map_gemini_image_tools_params(
|
|||
|
||||
gemini_config._drop_search_tools_mixed_with_functions(result)
|
||||
|
||||
if isinstance(result.get("tools"), list):
|
||||
result["tools"] = _dedupe_gemini_search_tools(result["tools"])
|
||||
resolved_tools: Final = result.get("tools")
|
||||
if isinstance(resolved_tools, list):
|
||||
result["tools"] = _dedupe_gemini_search_tools(resolved_tools)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_gemini_image_web_search_requests(
|
||||
response_data: dict[str, Any],
|
||||
response_data: Mapping[str, object],
|
||||
) -> int | None:
|
||||
from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import (
|
||||
VertexGeminiConfig,
|
||||
)
|
||||
|
||||
grounding_metadata: Final[list[dict[str, Any]]] = []
|
||||
for candidate in response_data.get("candidates", []):
|
||||
raw_candidates: Final = response_data.get("candidates")
|
||||
candidates: Final[list[object]] = raw_candidates if isinstance(raw_candidates, list) else []
|
||||
grounding_metadata: Final[list[dict[str, object]]] = []
|
||||
for candidate in candidates:
|
||||
if not isinstance(candidate, dict):
|
||||
continue
|
||||
candidate_grounding = candidate.get("groundingMetadata")
|
||||
|
|
@ -267,13 +278,14 @@ def get_gemini_image_web_search_requests(
|
|||
|
||||
def get_gemini_image_generation_config(
|
||||
model: str,
|
||||
optional_params: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
generation_config: Final[dict[str, Any]] = {"response_modalities": ["IMAGE", "TEXT"]}
|
||||
optional_params: Mapping[str, object],
|
||||
) -> dict[str, object]:
|
||||
generation_config: Final[dict[str, object]] = {"response_modalities": ["IMAGE", "TEXT"]}
|
||||
|
||||
image_config: Final[dict[str, Any]] = {}
|
||||
if isinstance(optional_params.get("imageConfig"), dict):
|
||||
image_config.update(optional_params["imageConfig"])
|
||||
raw_image_config: Final = optional_params.get("imageConfig")
|
||||
image_config: Final[dict[str, object]] = {}
|
||||
if isinstance(raw_image_config, dict):
|
||||
image_config.update(raw_image_config)
|
||||
|
||||
if not supports_gemini_image_size(model):
|
||||
image_config.pop("imageSize", None)
|
||||
|
|
@ -398,7 +410,7 @@ class GeminiModelInfo(BaseLLMModelInfo):
|
|||
f"Failed to fetch models from Gemini. Status code: {response.status_code}, Response: {response.json()}"
|
||||
)
|
||||
|
||||
models: Final = response.json()["models"]
|
||||
models: Final[list[dict[str, str]]] = response.json()["models"]
|
||||
|
||||
litellm_model_names: Final = self.process_model_name(models)
|
||||
return litellm_model_names
|
||||
|
|
@ -473,12 +485,12 @@ class GoogleAIStudioTokenCounter(BaseTokenCounter):
|
|||
async def count_tokens(
|
||||
self,
|
||||
model_to_use: str,
|
||||
messages: list[dict[str, Any]] | None,
|
||||
contents: list[dict[str, Any]] | None,
|
||||
messages: list[dict[str, object]] | None,
|
||||
contents: list[dict[str, object]] | None,
|
||||
deployment: dict[str, Any] | None = None,
|
||||
request_model: str = "",
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
system: Any | None = None,
|
||||
tools: list[dict[str, object]] | None = None,
|
||||
system: object | None = None,
|
||||
) -> TokenCountResponse | None:
|
||||
import copy
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ For vertex ai, check out the vertex_ai/files/handler.py file.
|
|||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Final, Literal
|
||||
from collections.abc import Mapping
|
||||
from typing import Final, Literal, TypedDict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from openai.types.file_deleted import FileDeleted
|
||||
from typing_extensions import ReadOnly, Required
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
from litellm.litellm_core_utils.prompt_templates.common_utils import extract_file_data
|
||||
|
|
@ -18,7 +20,6 @@ from litellm.llms.base_llm.files.transformation import (
|
|||
BaseFilesConfig,
|
||||
LiteLLMLoggingObj,
|
||||
)
|
||||
from litellm.types.llms.gemini import GeminiCreateFilesResponseObject
|
||||
from litellm.types.llms.openai import (
|
||||
AllMessageValues,
|
||||
CreateFileRequest,
|
||||
|
|
@ -31,6 +32,25 @@ from litellm.types.utils import LlmProviders
|
|||
from ..common_utils import GeminiModelInfo
|
||||
|
||||
|
||||
class _GeminiFileMetadata(TypedDict, total=False):
|
||||
name: ReadOnly[str]
|
||||
uri: ReadOnly[Required[str]]
|
||||
displayName: ReadOnly[Required[str]]
|
||||
mimeType: ReadOnly[str]
|
||||
sizeBytes: ReadOnly[Required[str]]
|
||||
createTime: ReadOnly[Required[str]]
|
||||
updateTime: ReadOnly[str]
|
||||
expirationTime: ReadOnly[str]
|
||||
sha256Hash: ReadOnly[str]
|
||||
state: ReadOnly[str]
|
||||
source: ReadOnly[str]
|
||||
error: ReadOnly[Mapping[str, object]]
|
||||
|
||||
|
||||
class _GeminiCreateFileResponse(TypedDict):
|
||||
file: ReadOnly[_GeminiFileMetadata]
|
||||
|
||||
|
||||
class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig):
|
||||
def __init__(self):
|
||||
pass
|
||||
|
|
@ -41,14 +61,14 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig):
|
|||
|
||||
def validate_environment(
|
||||
self,
|
||||
headers: dict[Any, Any],
|
||||
headers: dict[str, str],
|
||||
model: str,
|
||||
messages: list[AllMessageValues],
|
||||
optional_params: dict[Any, Any],
|
||||
litellm_params: dict[Any, Any],
|
||||
optional_params: dict[str, object],
|
||||
litellm_params: dict[str, object],
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
) -> dict[Any, Any]:
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Validate environment and add Gemini API key to headers.
|
||||
Google AI Studio uses x-goog-api-key header for authentication.
|
||||
|
|
@ -164,9 +184,9 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig):
|
|||
Transform Gemini's file upload response into OpenAI-style FileObject
|
||||
"""
|
||||
try:
|
||||
response_json: Final = raw_response.json()
|
||||
response_json: Final[_GeminiCreateFileResponse] = raw_response.json()
|
||||
|
||||
response_object: Final = GeminiCreateFilesResponseObject(**response_json.get("file", {}))
|
||||
response_object: Final = response_json["file"]
|
||||
|
||||
# Extract file information from Gemini response
|
||||
|
||||
|
|
@ -262,7 +282,7 @@ class GoogleAIStudioFilesHandler(GeminiModelInfo, BaseFilesConfig):
|
|||
"""
|
||||
try:
|
||||
verbose_logger.debug("Retrieve file response: %s", raw_response.text)
|
||||
response_json: Final = raw_response.json()
|
||||
response_json: Final[_GeminiFileMetadata] = raw_response.json()
|
||||
verbose_logger.debug("Response JSON: %s", response_json)
|
||||
# Map Gemini state to OpenAI status
|
||||
gemini_state: Final = response_json.get("state", "STATE_UNSPECIFIED")
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ from collections import OrderedDict
|
|||
from collections.abc import Mapping
|
||||
from typing import Any, Final, cast
|
||||
|
||||
from typing_extensions import ReadOnly, Required, TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm import verbose_logger
|
||||
from litellm._uuid import uuid
|
||||
|
|
@ -95,6 +97,23 @@ def _gemini_live_speech_config(voice: object) -> Mapping[str, object] | None:
|
|||
return VertexGeminiConfig()._map_audio_params({"voice": voice})
|
||||
|
||||
|
||||
class _GeminiLiveSetupEnvelope(TypedDict, total=False):
|
||||
setup: ReadOnly[BidiGenerateContentSetup]
|
||||
|
||||
|
||||
class _OpenAIRealtimeClientEvent(TypedDict, total=False):
|
||||
type: ReadOnly[str]
|
||||
audio: ReadOnly[Required[str]]
|
||||
session: ReadOnly[dict[str, object]]
|
||||
item: ReadOnly[dict[str, object]]
|
||||
|
||||
|
||||
def _parse_setup(session_configuration_request: str) -> BidiGenerateContentSetup:
|
||||
envelope: Final[_GeminiLiveSetupEnvelope] = json.loads(session_configuration_request)
|
||||
empty_setup: Final[BidiGenerateContentSetup] = {}
|
||||
return envelope.get("setup", empty_setup)
|
||||
|
||||
|
||||
class GeminiRealtimeConfig(BaseRealtimeConfig):
|
||||
_TOOL_CALL_ID_TO_NAME_MAX = 256 # LRU cap for call_id→name mapping
|
||||
|
||||
|
|
@ -116,7 +135,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
return True
|
||||
|
||||
@staticmethod
|
||||
def _usage_detail_alias(details: Any, defaults: dict[str, int]) -> dict[str, Any]:
|
||||
def _usage_detail_alias(details: Mapping[str, int | None] | None, defaults: dict[str, int]) -> dict[str, int]:
|
||||
if not isinstance(details, dict):
|
||||
return dict(defaults)
|
||||
return {
|
||||
|
|
@ -125,7 +144,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
}
|
||||
|
||||
@staticmethod
|
||||
def _add_pipecat_usage_detail_aliases(usage_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
def _add_pipecat_usage_detail_aliases(usage_dict: dict[str, Any]) -> dict[str, object]:
|
||||
usage_dict.setdefault(
|
||||
"input_token_details",
|
||||
GeminiRealtimeConfig._usage_detail_alias(
|
||||
|
|
@ -208,8 +227,10 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
if not session_configuration_request:
|
||||
return False
|
||||
try:
|
||||
setup: Final = json.loads(session_configuration_request).get("setup", {})
|
||||
automatic_detection: Final = setup.get("realtimeInputConfig", {}).get("automaticActivityDetection", {})
|
||||
setup: Final = _parse_setup(session_configuration_request)
|
||||
automatic_detection: Final[object] = setup.get("realtimeInputConfig", {}).get(
|
||||
"automaticActivityDetection", {}
|
||||
)
|
||||
return isinstance(automatic_detection, dict) and automatic_detection.get("disabled") is True
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
return False
|
||||
|
|
@ -384,7 +405,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
return bool(entry.get("gemini_native_audio") or entry.get("gemini_audio_only_live"))
|
||||
|
||||
@staticmethod
|
||||
def _coerce_response_modalities(model: str, modalities: list[Any]) -> list[str]:
|
||||
def _coerce_response_modalities(model: str, modalities: list[object]) -> list[str]:
|
||||
"""Map unsupported TEXT responseModalities to AUDIO for audio-only Live models."""
|
||||
normalized: Final = [
|
||||
modality.upper() if isinstance(modality, str) else str(modality).upper() for modality in modalities
|
||||
|
|
@ -409,7 +430,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
|
||||
def _handle_session_update(
|
||||
self,
|
||||
json_message: dict,
|
||||
json_message: _OpenAIRealtimeClientEvent,
|
||||
model: str,
|
||||
session_configuration_request: str | None,
|
||||
) -> list[str]:
|
||||
|
|
@ -423,7 +444,8 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
with a 1007, tearing the session down). To carry tools/instructions, send
|
||||
them on the first session.update before any conversation content.
|
||||
"""
|
||||
session_payload = json_message.get("session") or {}
|
||||
empty_session: Final[dict[str, object]] = {}
|
||||
session_payload = json_message.get("session") or empty_session
|
||||
# Normalize GA-remapped fields (``output_modalities``,
|
||||
# nested ``audio.input.transcription``,
|
||||
# ``audio.input.turn_detection``) back to their flat beta keys so
|
||||
|
|
@ -464,14 +486,15 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
verbose_logger.debug("Gemini Realtime: Ignoring session.update (setup already sent)")
|
||||
return []
|
||||
|
||||
def _handle_conversation_item(self, json_message: dict) -> list[str]:
|
||||
def _handle_conversation_item(self, json_message: _OpenAIRealtimeClientEvent) -> list[str]:
|
||||
"""
|
||||
Handle conversation.item.create for user text or function call output.
|
||||
|
||||
Converts OpenAI format to Gemini's clientContent (for user text) or
|
||||
toolResponse (for function outputs).
|
||||
"""
|
||||
item: Final = json_message.get("item", {})
|
||||
empty_item: Final[dict[str, object]] = {}
|
||||
item: Final = json_message.get("item", empty_item)
|
||||
item_type: Final = item.get("type")
|
||||
|
||||
if item_type == "function_call_output":
|
||||
|
|
@ -502,7 +525,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
call_id,
|
||||
)
|
||||
|
||||
function_response: Final[dict[str, Any]] = {"response": output_dict}
|
||||
function_response: Final[dict[str, object]] = {"response": output_dict}
|
||||
if self._include_function_response_id() and call_id:
|
||||
function_response["id"] = call_id
|
||||
if function_name:
|
||||
|
|
@ -537,7 +560,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
) -> list[str]:
|
||||
realtime_input_dict: BidiGenerateContentRealtimeInput = {}
|
||||
try:
|
||||
json_message: Final = json.loads(message)
|
||||
json_message: Final[_OpenAIRealtimeClientEvent] = json.loads(message)
|
||||
except json.JSONDecodeError:
|
||||
if isinstance(message, bytes):
|
||||
message_str = message.decode("utf-8", errors="replace")
|
||||
|
|
@ -587,9 +610,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
session_configuration_request: str | None = None,
|
||||
) -> OpenAIRealtimeStreamSessionEvents:
|
||||
if session_configuration_request:
|
||||
session_configuration_request_dict: BidiGenerateContentSetup = json.loads(
|
||||
session_configuration_request
|
||||
).get("setup", {})
|
||||
session_configuration_request_dict: BidiGenerateContentSetup = _parse_setup(session_configuration_request)
|
||||
else:
|
||||
session_configuration_request_dict = {}
|
||||
|
||||
|
|
@ -640,7 +661,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
session_configuration_request_dict: BidiGenerateContentSetup = {}
|
||||
if session_configuration_request is not None:
|
||||
try:
|
||||
session_configuration_request_dict = json.loads(session_configuration_request).get("setup", {})
|
||||
session_configuration_request_dict = _parse_setup(session_configuration_request)
|
||||
except json.JSONDecodeError:
|
||||
session_configuration_request_dict = {}
|
||||
generation_config: Final = session_configuration_request_dict.get("generationConfig", {})
|
||||
|
|
@ -908,9 +929,9 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
return events
|
||||
|
||||
@staticmethod
|
||||
def get_nested_value(obj: dict, path: str) -> Any:
|
||||
def get_nested_value(obj: dict, path: str) -> object | None:
|
||||
keys: Final = path.split(".")
|
||||
current = obj
|
||||
current: object = obj
|
||||
for key in keys:
|
||||
if isinstance(current, dict) and key in current:
|
||||
current = current[key]
|
||||
|
|
@ -988,9 +1009,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
current_response_id = f"resp_{uuid.uuid4()}"
|
||||
|
||||
if session_configuration_request:
|
||||
session_configuration_request_dict: BidiGenerateContentSetup = json.loads(
|
||||
session_configuration_request
|
||||
).get("setup", {})
|
||||
session_configuration_request_dict: BidiGenerateContentSetup = _parse_setup(session_configuration_request)
|
||||
else:
|
||||
session_configuration_request_dict = {}
|
||||
|
||||
|
|
@ -1286,7 +1305,7 @@ class GeminiRealtimeConfig(BaseRealtimeConfig):
|
|||
session_setup: BidiGenerateContentSetup = {}
|
||||
if session_configuration_request is not None:
|
||||
try:
|
||||
session_setup = json.loads(session_configuration_request).get("setup", {})
|
||||
session_setup = _parse_setup(session_configuration_request)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
session_setup = {}
|
||||
tool_call_generation_config = session_setup.get("generationConfig", {}) or {}
|
||||
|
|
|
|||
|
|
@ -13,12 +13,111 @@ Generated files are returned directly in the response - no separate storage need
|
|||
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from enum import Enum
|
||||
from typing import Any, Final
|
||||
from typing import Any, Final, Protocol, TypedDict
|
||||
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
from litellm._logging import verbose_logger
|
||||
|
||||
|
||||
class _ToolParameterSchema(TypedDict, total=False):
|
||||
type: ReadOnly[str]
|
||||
description: ReadOnly[str]
|
||||
|
||||
|
||||
class _ToolArgumentSchema(TypedDict, total=False):
|
||||
type: ReadOnly[str]
|
||||
properties: ReadOnly[Mapping[str, _ToolParameterSchema]]
|
||||
required: ReadOnly[Sequence[str]]
|
||||
|
||||
|
||||
class _OpenAIToolFunction(TypedDict, total=False):
|
||||
name: ReadOnly[str]
|
||||
description: ReadOnly[str]
|
||||
parameters: ReadOnly[_ToolArgumentSchema]
|
||||
|
||||
|
||||
class _OpenAIToolSpec(TypedDict, total=False):
|
||||
type: ReadOnly[str]
|
||||
function: ReadOnly[_OpenAIToolFunction]
|
||||
|
||||
|
||||
class _AnthropicToolSpec(TypedDict, total=False):
|
||||
name: ReadOnly[str]
|
||||
description: ReadOnly[str]
|
||||
input_schema: ReadOnly[_ToolArgumentSchema]
|
||||
|
||||
|
||||
class _CodeExecutionArguments(TypedDict, total=False):
|
||||
code: ReadOnly[str]
|
||||
|
||||
|
||||
class _GeneratedFile(TypedDict, total=False):
|
||||
name: ReadOnly[str]
|
||||
mime_type: ReadOnly[str]
|
||||
content_base64: ReadOnly[str]
|
||||
size: ReadOnly[int]
|
||||
|
||||
|
||||
class _SandboxGeneratedFile(TypedDict):
|
||||
name: ReadOnly[str]
|
||||
mime_type: ReadOnly[str]
|
||||
content_base64: ReadOnly[str]
|
||||
|
||||
|
||||
class _SandboxExecutionResult(TypedDict):
|
||||
success: ReadOnly[bool]
|
||||
output: ReadOnly[str]
|
||||
error: ReadOnly[str]
|
||||
files: ReadOnly[Sequence[_SandboxGeneratedFile]]
|
||||
|
||||
|
||||
class _ExecutionResult(TypedDict, total=False):
|
||||
iteration: ReadOnly[int]
|
||||
success: ReadOnly[bool]
|
||||
output: ReadOnly[str]
|
||||
error: ReadOnly[str]
|
||||
files: ReadOnly[Sequence[str]]
|
||||
|
||||
|
||||
class _ToolCallFunction(Protocol):
|
||||
name: str
|
||||
arguments: str
|
||||
|
||||
|
||||
class _ToolCall(Protocol):
|
||||
id: str
|
||||
function: _ToolCallFunction
|
||||
|
||||
|
||||
class _AssistantMessage(Protocol):
|
||||
content: str | None
|
||||
tool_calls: Sequence[_ToolCall] | None
|
||||
|
||||
|
||||
class _ResponseChoice(Protocol):
|
||||
message: _AssistantMessage
|
||||
finish_reason: str | None
|
||||
|
||||
|
||||
class _CompletionResponse(Protocol):
|
||||
choices: Sequence[_ResponseChoice]
|
||||
|
||||
|
||||
class _CodeExecutionOutcome(TypedDict, total=False):
|
||||
response: ReadOnly[_CompletionResponse | None]
|
||||
files: ReadOnly[Sequence[_GeneratedFile]]
|
||||
execution_results: ReadOnly[Sequence[_ExecutionResult]]
|
||||
messages: ReadOnly[Sequence[dict[str, object]]]
|
||||
max_iterations_reached: ReadOnly[bool]
|
||||
|
||||
|
||||
def _parse_code_execution_arguments(serialized_arguments: str) -> _CodeExecutionArguments:
|
||||
return json.loads(serialized_arguments)
|
||||
|
||||
|
||||
class LiteLLMInternalTools(str, Enum):
|
||||
"""
|
||||
Enum for internal LiteLLM tools that are injected into requests.
|
||||
|
|
@ -30,7 +129,7 @@ class LiteLLMInternalTools(str, Enum):
|
|||
CODE_EXECUTION = "litellm_code_execution"
|
||||
|
||||
|
||||
def get_litellm_code_execution_tool() -> dict[str, Any]:
|
||||
def get_litellm_code_execution_tool() -> _OpenAIToolSpec:
|
||||
"""
|
||||
Returns the litellm_code_execution tool definition in OpenAI format.
|
||||
|
||||
|
|
@ -51,7 +150,7 @@ def get_litellm_code_execution_tool() -> dict[str, Any]:
|
|||
}
|
||||
|
||||
|
||||
def get_litellm_code_execution_tool_anthropic() -> dict[str, Any]:
|
||||
def get_litellm_code_execution_tool_anthropic() -> _AnthropicToolSpec:
|
||||
"""
|
||||
Returns the litellm_code_execution tool definition in Anthropic/messages API format.
|
||||
|
||||
|
|
@ -98,12 +197,12 @@ class CodeExecutionHandler:
|
|||
async def execute_with_code_execution(
|
||||
self,
|
||||
model: str,
|
||||
messages: list[dict],
|
||||
tools: list[dict],
|
||||
messages: list[dict[str, object]],
|
||||
tools: list[_OpenAIToolSpec],
|
||||
skill_files: dict[str, bytes],
|
||||
skill_id: str | None = None,
|
||||
**kwargs,
|
||||
) -> dict[str, Any]:
|
||||
) -> _CodeExecutionOutcome:
|
||||
"""
|
||||
Execute an LLM call with automatic code execution handling.
|
||||
|
||||
|
|
@ -134,8 +233,8 @@ class CodeExecutionHandler:
|
|||
)
|
||||
|
||||
current_messages: Final = list(messages)
|
||||
generated_files: Final[list[dict[str, Any]]] = [] # Files returned directly
|
||||
execution_results: Final[list[dict]] = []
|
||||
generated_files: Final[list[_GeneratedFile]] = [] # Files returned directly
|
||||
execution_results: Final[list[_ExecutionResult]] = []
|
||||
|
||||
executor: Final = SkillsSandboxExecutor(timeout=self.sandbox_timeout)
|
||||
response: Any = None # Initialize to avoid possibly unbound error
|
||||
|
|
@ -151,11 +250,12 @@ class CodeExecutionHandler:
|
|||
**kwargs,
|
||||
)
|
||||
|
||||
assistant_message = response.choices[0].message
|
||||
stop_reason = response.choices[0].finish_reason
|
||||
choice: _ResponseChoice = response.choices[0]
|
||||
assistant_message = choice.message
|
||||
stop_reason = choice.finish_reason
|
||||
|
||||
# Build assistant message for conversation history
|
||||
assistant_msg_dict: dict[str, Any] = {
|
||||
assistant_msg_dict: dict[str, object] = {
|
||||
"role": "assistant",
|
||||
"content": assistant_message.content,
|
||||
}
|
||||
|
|
@ -190,12 +290,12 @@ class CodeExecutionHandler:
|
|||
if tool_name == LiteLLMInternalTools.CODE_EXECUTION.value:
|
||||
# Execute code in sandbox
|
||||
try:
|
||||
args = json.loads(tool_call.function.arguments)
|
||||
args = _parse_code_execution_arguments(tool_call.function.arguments)
|
||||
code = args.get("code", "")
|
||||
|
||||
verbose_logger.debug("CodeExecutionHandler: Executing code (%s chars)", len(code))
|
||||
|
||||
exec_result = executor.execute(
|
||||
exec_result: _SandboxExecutionResult = executor.execute(
|
||||
code=code,
|
||||
skill_files=skill_files,
|
||||
)
|
||||
|
|
@ -278,7 +378,7 @@ class CodeExecutionHandler:
|
|||
}
|
||||
|
||||
|
||||
def has_code_execution_tool(tools: list[dict] | None) -> bool:
|
||||
def has_code_execution_tool(tools: list[_OpenAIToolSpec] | None) -> bool:
|
||||
"""Check if litellm_code_execution tool is in the tools list."""
|
||||
if not tools:
|
||||
return False
|
||||
|
|
@ -289,7 +389,7 @@ def has_code_execution_tool(tools: list[dict] | None) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def add_code_execution_tool(tools: list[dict] | None) -> list[dict]:
|
||||
def add_code_execution_tool(tools: list[_OpenAIToolSpec] | None) -> list[_OpenAIToolSpec]:
|
||||
"""Add litellm_code_execution tool if not already present."""
|
||||
tools = tools or []
|
||||
if not has_code_execution_tool(tools):
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import io
|
|||
import os
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Final, cast
|
||||
from typing import Final, Protocol, cast
|
||||
|
||||
from litellm.llms.nvidia_riva.audio_transcription.transformation import (
|
||||
RIVA_TARGET_NUM_CHANNELS,
|
||||
|
|
@ -24,10 +24,30 @@ from litellm.llms.nvidia_riva.audio_transcription.transformation import (
|
|||
)
|
||||
from litellm.llms.nvidia_riva.common_utils import NvidiaRivaException
|
||||
|
||||
# Keep this as Any: the module intentionally avoids importing numpy at module
|
||||
# import time (optional dependency), and project-wide mypy config evaluates this
|
||||
# file in contexts where conditional type aliases can degrade to "FloatArray?".
|
||||
FloatArray = Any
|
||||
|
||||
class FloatArray(Protocol):
|
||||
"""Structural view of the ``numpy.ndarray`` surface this module relies on."""
|
||||
|
||||
@property
|
||||
def ndim(self) -> int: ...
|
||||
|
||||
@property
|
||||
def shape(self) -> tuple[int, ...]: ...
|
||||
|
||||
@property
|
||||
def size(self) -> int: ...
|
||||
|
||||
def mean(self, axis: int) -> "FloatArray": ...
|
||||
|
||||
def ravel(self) -> "FloatArray": ...
|
||||
|
||||
def astype(self, dtype: object) -> "FloatArray": ...
|
||||
|
||||
def tobytes(self) -> bytes: ...
|
||||
|
||||
def __getitem__(self, key: object) -> "FloatArray": ...
|
||||
|
||||
def __mul__(self, other: float) -> "FloatArray": ...
|
||||
|
||||
|
||||
_INSTALL_HINT = "Install Riva STT extras to enable automatic audio resampling: `pip install 'litellm[stt-nvidia-riva]'`"
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ import os
|
|||
import re
|
||||
from dataclasses import dataclass
|
||||
from email.utils import formatdate
|
||||
from typing import Any, Final, Protocol
|
||||
from typing import Final, Protocol
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from pydantic import JsonValue
|
||||
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
||||
|
|
@ -64,7 +65,7 @@ class OCISignerProtocol(Protocol):
|
|||
See: https://docs.oracle.com/en-us/iaas/tools/python/latest/api/signing.html
|
||||
"""
|
||||
|
||||
def do_request_sign(self, request: Any, *, enforce_content_headers: bool = False) -> None:
|
||||
def do_request_sign(self, request: "OCIRequestWrapper", *, enforce_content_headers: bool = False) -> None:
|
||||
pass
|
||||
|
||||
|
||||
|
|
@ -113,7 +114,7 @@ def build_signature_string(method: str, path: str, headers: dict, signed_headers
|
|||
return "\n".join(lines)
|
||||
|
||||
|
||||
def load_private_key_from_str(key_str: str) -> Any:
|
||||
def load_private_key_from_str(key_str: str) -> "rsa.RSAPrivateKey":
|
||||
_require_cryptography()
|
||||
key: Final = serialization.load_pem_private_key(
|
||||
key_str.encode("utf-8"),
|
||||
|
|
@ -124,7 +125,7 @@ def load_private_key_from_str(key_str: str) -> Any:
|
|||
return key
|
||||
|
||||
|
||||
def load_private_key_from_file(file_path: str) -> Any:
|
||||
def load_private_key_from_file(file_path: str) -> "rsa.RSAPrivateKey":
|
||||
"""Loads a private key from a file path."""
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
|
|
@ -421,16 +422,17 @@ OCI_JSON_TO_PYTHON_TYPES: Final[dict[str, str]] = {
|
|||
}
|
||||
|
||||
|
||||
def resolve_oci_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
|
||||
def resolve_oci_schema_refs(schema: JsonValue) -> JsonValue:
|
||||
"""Inline all ``$ref``/``$defs`` references — OCI does not support JSON Schema ``$ref``."""
|
||||
defs: Final = schema.get("$defs", {})
|
||||
resolving_stack: Final[set] = set()
|
||||
raw_defs: Final = schema.get("$defs") if isinstance(schema, dict) else None
|
||||
defs: Final[dict[str, JsonValue]] = raw_defs if isinstance(raw_defs, dict) else {}
|
||||
resolving_stack: Final[set[str]] = set()
|
||||
|
||||
def _resolve(obj: Any) -> Any:
|
||||
def _resolve(obj: JsonValue) -> JsonValue:
|
||||
if isinstance(obj, dict):
|
||||
if "$ref" in obj:
|
||||
ref: Final = obj["$ref"]
|
||||
if ref.startswith("#/$defs/"):
|
||||
ref: Final = obj.get("$ref")
|
||||
if ref is not None:
|
||||
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
||||
key: Final = ref.split("/")[-1]
|
||||
if key in resolving_stack:
|
||||
return {"type": "object"} # break cycles
|
||||
|
|
@ -451,7 +453,7 @@ def resolve_oci_schema_refs(schema: dict[str, Any]) -> dict[str, Any]:
|
|||
return resolved
|
||||
|
||||
|
||||
def resolve_oci_schema_anyof(obj: Any) -> Any:
|
||||
def resolve_oci_schema_anyof(obj: JsonValue) -> JsonValue:
|
||||
"""Resolve Pydantic v2 ``Optional[T]`` → ``anyOf`` patterns.
|
||||
|
||||
Pydantic v2 emits ``{"anyOf": [{"type": "T"}, {"type": "null"}]}`` for
|
||||
|
|
@ -459,10 +461,13 @@ def resolve_oci_schema_anyof(obj: Any) -> Any:
|
|||
first non-null branch and merge top-level metadata into it.
|
||||
"""
|
||||
if isinstance(obj, dict):
|
||||
if "anyOf" in obj and "type" not in obj:
|
||||
non_null: Final = [t for t in obj["anyOf"] if not (isinstance(t, dict) and t.get("type") == "null")]
|
||||
raw_any_of: Final = obj.get("anyOf")
|
||||
if raw_any_of is not None and "type" not in obj:
|
||||
branches: Final = raw_any_of if isinstance(raw_any_of, list) else []
|
||||
non_null: Final = [t for t in branches if not (isinstance(t, dict) and t.get("type") == "null")]
|
||||
if non_null:
|
||||
resolved: Final = {**obj, **non_null[0]}
|
||||
first: Final = non_null[0]
|
||||
resolved: Final[dict[str, JsonValue]] = {**obj, **first} if isinstance(first, dict) else {**obj}
|
||||
resolved.pop("anyOf", None)
|
||||
return resolve_oci_schema_anyof(resolved)
|
||||
return {k: resolve_oci_schema_anyof(v) for k, v in obj.items()}
|
||||
|
|
@ -471,7 +476,7 @@ def resolve_oci_schema_anyof(obj: Any) -> Any:
|
|||
return obj
|
||||
|
||||
|
||||
def sanitize_oci_schema(schema: Any) -> Any:
|
||||
def sanitize_oci_schema(schema: JsonValue) -> JsonValue:
|
||||
"""Recursively remove OCI-incompatible fields from a JSON schema.
|
||||
|
||||
Strips ``title`` keys, removes ``None``-valued ``default`` entries,
|
||||
|
|
@ -483,7 +488,7 @@ def sanitize_oci_schema(schema: Any) -> Any:
|
|||
if not isinstance(schema, dict):
|
||||
return schema
|
||||
|
||||
sanitized: Final[dict[str, Any]] = {}
|
||||
sanitized: Final[dict[str, JsonValue]] = {}
|
||||
for key, value in schema.items():
|
||||
if key == "title":
|
||||
continue
|
||||
|
|
@ -513,7 +518,7 @@ def sanitize_oci_schema(schema: Any) -> Any:
|
|||
return sanitized
|
||||
|
||||
|
||||
def enrich_cohere_param_description(description: str, param_schema: dict[str, Any]) -> str:
|
||||
def enrich_cohere_param_description(description: str, param_schema: dict[str, JsonValue]) -> str:
|
||||
"""Embed schema constraints into a Cohere parameter description.
|
||||
|
||||
``CohereParameterDefinition`` only has ``type``, ``description``, and
|
||||
|
|
|
|||
|
|
@ -111,10 +111,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ContainerObject:
|
||||
"""Transform the OpenAI container creation response."""
|
||||
response_data: Final = raw_response.json()
|
||||
|
||||
# Transform the response data
|
||||
container_obj: Final = ContainerObject(**response_data)
|
||||
container_obj: Final = ContainerObject.model_validate(raw_response.json())
|
||||
|
||||
# Add cost for container creation (OpenAI containers are code interpreter sessions)
|
||||
# https://platform.openai.com/docs/pricing
|
||||
|
|
@ -171,10 +168,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ContainerListResponse:
|
||||
"""Transform the OpenAI container list response."""
|
||||
response_data: Final = raw_response.json()
|
||||
|
||||
# Transform the response data
|
||||
container_list: Final = ContainerListResponse(**response_data)
|
||||
container_list: Final = ContainerListResponse.model_validate(raw_response.json())
|
||||
|
||||
return container_list
|
||||
|
||||
|
|
@ -191,7 +185,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
|
|||
url: Final = join_container_api_base_path(api_base, f"/{encoded_container_id}")
|
||||
|
||||
# No additional data needed for GET request
|
||||
data: Final[dict[str, Any]] = {}
|
||||
data: Final[dict[str, str]] = {}
|
||||
|
||||
return url, data
|
||||
|
||||
|
|
@ -201,9 +195,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ContainerObject:
|
||||
"""Transform the OpenAI container retrieve response."""
|
||||
response_data: Final = raw_response.json()
|
||||
# Transform the response data
|
||||
container_obj: Final = ContainerObject(**response_data)
|
||||
container_obj: Final = ContainerObject.model_validate(raw_response.json())
|
||||
|
||||
return container_obj
|
||||
|
||||
|
|
@ -224,7 +216,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
|
|||
url: Final = join_container_api_base_path(api_base, f"/{encoded_container_id}")
|
||||
|
||||
# No data needed for DELETE request
|
||||
data: Final[dict[str, Any]] = {}
|
||||
data: Final[dict[str, str]] = {}
|
||||
|
||||
return url, data
|
||||
|
||||
|
|
@ -234,10 +226,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> DeleteContainerResult:
|
||||
"""Transform the OpenAI container delete response."""
|
||||
response_data: Final = raw_response.json()
|
||||
|
||||
# Transform the response data
|
||||
delete_result: Final = DeleteContainerResult(**response_data)
|
||||
delete_result: Final = DeleteContainerResult.model_validate(raw_response.json())
|
||||
|
||||
return delete_result
|
||||
|
||||
|
|
@ -262,7 +251,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
|
|||
url: Final = join_container_api_base_path(api_base, f"/{encoded_container_id}/files")
|
||||
|
||||
# Prepare query parameters
|
||||
params: Final[dict[str, Any]] = {}
|
||||
params: Final[dict[str, str]] = {}
|
||||
if after is not None:
|
||||
params["after"] = after
|
||||
if limit is not None:
|
||||
|
|
@ -282,10 +271,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
|
|||
logging_obj: LiteLLMLoggingObj,
|
||||
) -> ContainerFileListResponse:
|
||||
"""Transform the OpenAI container file list response."""
|
||||
response_data: Final = raw_response.json()
|
||||
|
||||
# Transform the response data
|
||||
file_list: Final = ContainerFileListResponse(**response_data)
|
||||
file_list: Final = ContainerFileListResponse.model_validate(raw_response.json())
|
||||
|
||||
return file_list
|
||||
|
||||
|
|
@ -308,7 +294,7 @@ class OpenAIContainerConfig(BaseContainerConfig):
|
|||
url: Final = join_container_api_base_path(api_base, f"/{encoded_container_id}/files/{encoded_file_id}/content")
|
||||
|
||||
# No query parameters needed
|
||||
params: Final[dict[str, Any]] = {}
|
||||
params: Final[dict[str, str]] = {}
|
||||
|
||||
return url, params
|
||||
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ Maps OpenAI TTS spec to RunwayML Text-to-Speech API
|
|||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Coroutine
|
||||
from typing import TYPE_CHECKING, Any, Final, Union
|
||||
from collections.abc import Coroutine, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, TypedDict, Union
|
||||
|
||||
import httpx
|
||||
from typing_extensions import ReadOnly
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_logger
|
||||
|
|
@ -31,6 +32,14 @@ else:
|
|||
HttpxBinaryResponseContent = Any
|
||||
|
||||
|
||||
class _RunwayTtsTaskResponse(TypedDict, total=False):
|
||||
id: ReadOnly[str]
|
||||
status: ReadOnly[str]
|
||||
output: ReadOnly[Sequence[object]]
|
||||
failure: ReadOnly[str]
|
||||
failureCode: ReadOnly[str]
|
||||
|
||||
|
||||
class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig):
|
||||
"""
|
||||
Configuration for RunwayML Text-to-Speech
|
||||
|
|
@ -64,7 +73,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig):
|
|||
litellm_params_dict: dict,
|
||||
logging_obj: "LiteLLMLoggingObj",
|
||||
timeout: float | httpx.Timeout,
|
||||
extra_headers: dict[str, Any] | None,
|
||||
extra_headers: dict[str, object] | None,
|
||||
base_llm_http_handler: Any,
|
||||
aspeech: bool,
|
||||
api_base: str | None,
|
||||
|
|
@ -72,7 +81,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig):
|
|||
**kwargs: Any,
|
||||
) -> Union[
|
||||
"HttpxBinaryResponseContent",
|
||||
Coroutine[Any, Any, "HttpxBinaryResponseContent"],
|
||||
Coroutine[object, object, "HttpxBinaryResponseContent"],
|
||||
]:
|
||||
"""
|
||||
Dispatch method to handle RunwayML TTS requests
|
||||
|
|
@ -242,7 +251,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig):
|
|||
raise TimeoutError(f"RunwayML TTS task polling timed out after {timeout_secs} seconds")
|
||||
|
||||
@staticmethod
|
||||
def _check_task_status(response_data: dict[str, Any]) -> str:
|
||||
def _check_task_status(response_data: _RunwayTtsTaskResponse) -> str:
|
||||
"""
|
||||
Check RunwayML task status from response.
|
||||
|
||||
|
|
@ -314,7 +323,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig):
|
|||
response = client.get(url=task_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
response_data = response.json()
|
||||
response_data: _RunwayTtsTaskResponse = response.json()
|
||||
|
||||
# Check task status
|
||||
status = self._check_task_status(response_data=response_data)
|
||||
|
|
@ -362,7 +371,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig):
|
|||
response = await client.get(url=task_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
response_data = response.json()
|
||||
response_data: _RunwayTtsTaskResponse = response.json()
|
||||
|
||||
# Check task status
|
||||
status = self._check_task_status(response_data=response_data)
|
||||
|
|
@ -453,7 +462,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig):
|
|||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
|
||||
try:
|
||||
response_data: Final = raw_response.json()
|
||||
response_data: Final[_RunwayTtsTaskResponse] = raw_response.json()
|
||||
except Exception as e:
|
||||
raise self.get_error_class(
|
||||
error_message=f"Error parsing RunwayML TTS response: {e}",
|
||||
|
|
@ -483,7 +492,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig):
|
|||
)
|
||||
|
||||
# Get the completed task data
|
||||
task_data: Final = polled_response.json()
|
||||
task_data: Final[_RunwayTtsTaskResponse] = polled_response.json()
|
||||
|
||||
verbose_logger.debug("RunwayML TTS polling complete, downloading audio")
|
||||
|
||||
|
|
@ -522,7 +531,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig):
|
|||
from litellm.types.llms.openai import HttpxBinaryResponseContent
|
||||
|
||||
try:
|
||||
response_data: Final = raw_response.json()
|
||||
response_data: Final[_RunwayTtsTaskResponse] = raw_response.json()
|
||||
except Exception as e:
|
||||
raise self.get_error_class(
|
||||
error_message=f"Error parsing RunwayML TTS response: {e}",
|
||||
|
|
@ -552,7 +561,7 @@ class RunwayMLTextToSpeechConfig(BaseTextToSpeechConfig):
|
|||
)
|
||||
|
||||
# Get the completed task data
|
||||
task_data: Final = polled_response.json()
|
||||
task_data: Final[_RunwayTtsTaskResponse] = polled_response.json()
|
||||
|
||||
verbose_logger.debug("RunwayML TTS polling complete (async), downloading audio")
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ class VertexAIError(BaseLLMException):
|
|||
super().__init__(message=message, status_code=status_code, headers=headers)
|
||||
|
||||
|
||||
def redact_vertex_ai_metadata_from_logged_object(obj: Any) -> None:
|
||||
def redact_vertex_ai_metadata_from_logged_object(obj: object) -> None:
|
||||
if isinstance(obj, dict):
|
||||
for field in VERTEX_AI_PROVIDER_METADATA_FIELDS:
|
||||
if field in obj:
|
||||
|
|
@ -651,7 +651,7 @@ def _build_json_schema(parameters: dict) -> dict:
|
|||
return parameters
|
||||
|
||||
|
||||
def _filter_anyof_fields(schema_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
def _filter_anyof_fields(schema_dict: dict[str, object]) -> dict[str, object]:
|
||||
"""
|
||||
When anyof is present, only keep the anyof field and its contents - otherwise VertexAI will throw an error - https://github.com/BerriAI/litellm/issues/11164
|
||||
Filter out other fields in the same dict.
|
||||
|
|
@ -704,7 +704,7 @@ def process_items(schema, depth=0):
|
|||
process_items(item, depth + 1)
|
||||
|
||||
|
||||
def set_schema_property_ordering(schema: dict[str, Any], depth: int = 0) -> dict[str, Any]:
|
||||
def set_schema_property_ordering(schema: dict[str, object], depth: int = 0) -> dict[str, object]:
|
||||
"""
|
||||
vertex ai and generativeai apis order output of fields alphabetically, unless you specify the order.
|
||||
python dicts retain order, so we just use that. Note that this field only applies to structured outputs, and not tools.
|
||||
|
|
@ -731,7 +731,7 @@ def set_schema_property_ordering(schema: dict[str, Any], depth: int = 0) -> dict
|
|||
return schema
|
||||
|
||||
|
||||
def filter_schema_fields(schema_dict: dict[str, Any], valid_fields: set[str], processed=None) -> dict[str, Any]:
|
||||
def filter_schema_fields(schema_dict: dict[str, object], valid_fields: set[str], processed=None) -> dict[str, object]:
|
||||
"""
|
||||
Recursively filter a schema dictionary to keep only valid fields.
|
||||
"""
|
||||
|
|
@ -905,7 +905,7 @@ def _convert_schema_types(schema, depth=0):
|
|||
"maxProperties",
|
||||
}
|
||||
|
||||
any_of: Final[list[dict[str, Any]]] = []
|
||||
any_of: Final[list[dict[str, object]]] = []
|
||||
for t in type_val:
|
||||
if not isinstance(t, str):
|
||||
continue
|
||||
|
|
@ -916,7 +916,7 @@ def _convert_schema_types(schema, depth=0):
|
|||
|
||||
# For object/array types, include type-specific fields
|
||||
if t in ("object", "array"):
|
||||
item_schema = {"type": t}
|
||||
item_schema: dict[str, object] = {"type": t}
|
||||
# Move type-specific fields into this anyOf item
|
||||
for field in type_specific_fields:
|
||||
if field in schema:
|
||||
|
|
@ -1110,11 +1110,11 @@ class VertexAITokenCounter(BaseTokenCounter):
|
|||
self,
|
||||
model_to_use: str,
|
||||
messages: list[dict[str, Any]] | None,
|
||||
contents: list[dict[str, Any]] | None,
|
||||
contents: list[dict[str, object]] | None,
|
||||
deployment: dict[str, Any] | None = None,
|
||||
request_model: str = "",
|
||||
tools: list[dict[str, Any]] | None = None,
|
||||
system: Any | None = None,
|
||||
tools: list[dict[str, object]] | None = None,
|
||||
system: object | None = None,
|
||||
) -> TokenCountResponse | None:
|
||||
import copy
|
||||
|
||||
|
|
@ -1131,25 +1131,26 @@ class VertexAITokenCounter(BaseTokenCounter):
|
|||
partner_models_handler: Final = VertexAIPartnerModels()
|
||||
|
||||
# Extract vertex-specific params from litellm_params
|
||||
vertex_project = count_tokens_params_request.get("vertex_project") or count_tokens_params_request.get(
|
||||
partner_litellm_params: Final[dict[str, object]] = count_tokens_params_request
|
||||
vertex_project = partner_litellm_params.get("vertex_project") or partner_litellm_params.get(
|
||||
"vertex_ai_project"
|
||||
)
|
||||
|
||||
vertex_location = count_tokens_params_request.get("vertex_location") or count_tokens_params_request.get(
|
||||
vertex_location = partner_litellm_params.get("vertex_location") or partner_litellm_params.get(
|
||||
"vertex_ai_location"
|
||||
)
|
||||
|
||||
# Count tokens not available on global location: https://docs.cloud.google.com/vertex-ai/generative-ai/docs/partner-models/claude/count-tokens
|
||||
vertex_location = count_tokens_params_request.get("vertex_count_tokens_location") or vertex_location
|
||||
vertex_location = partner_litellm_params.get("vertex_count_tokens_location") or vertex_location
|
||||
|
||||
vertex_credentials: Final = count_tokens_params_request.get(
|
||||
"vertex_credentials"
|
||||
) or count_tokens_params_request.get("vertex_ai_credentials")
|
||||
vertex_credentials: Final = partner_litellm_params.get("vertex_credentials") or partner_litellm_params.get(
|
||||
"vertex_ai_credentials"
|
||||
)
|
||||
|
||||
result = await partner_models_handler.count_tokens(
|
||||
model=model_to_use,
|
||||
messages=messages or [],
|
||||
litellm_params=count_tokens_params_request,
|
||||
litellm_params=partner_litellm_params,
|
||||
vertex_project=vertex_project,
|
||||
vertex_location=vertex_location,
|
||||
vertex_credentials=vertex_credentials,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import json
|
|||
import os
|
||||
import re
|
||||
import time
|
||||
from collections.abc import AsyncIterator, Callable, Mapping, Sequence
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypedDict, cast
|
||||
|
|
@ -1210,7 +1210,7 @@ def _deserialize_json_dict(data: str | _StringMap | None) -> dict[str, str] | No
|
|||
return data
|
||||
|
||||
|
||||
def _deserialize_json_list(data: Any) -> list[dict[str, Any]] | None:
|
||||
def _deserialize_json_list(data: object) -> list[dict[str, Any]] | None:
|
||||
"""Deserialize a JSON array stored in the DB (``env_vars`` and friends).
|
||||
|
||||
Returns ``None`` for empty / null / unparseable input. Accepts strings
|
||||
|
|
@ -1223,7 +1223,7 @@ def _deserialize_json_list(data: Any) -> list[dict[str, Any]] | None:
|
|||
return None
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
parsed: Final = json.loads(data)
|
||||
parsed: Final[object] = json.loads(data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
data = parsed
|
||||
|
|
@ -1918,7 +1918,7 @@ class MCPServerManager:
|
|||
|
||||
async def load_servers_from_config(
|
||||
self,
|
||||
mcp_servers_config: dict[str, Any],
|
||||
mcp_servers_config: dict[str, MCPServerConfig],
|
||||
mcp_aliases: dict[str, str] | None = None,
|
||||
):
|
||||
"""
|
||||
|
|
@ -3070,7 +3070,7 @@ class MCPServerManager:
|
|||
return {}
|
||||
|
||||
cache_key: Final = "toolset_perms:" + ",".join(sorted(toolset_ids))
|
||||
cached: Final = await user_api_key_cache.async_get_cache(key=cache_key)
|
||||
cached: Final[dict[str, list[str]] | None] = await user_api_key_cache.async_get_cache(key=cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
|
|
@ -5154,7 +5154,7 @@ class MCPServerManager:
|
|||
|
||||
# Wrapped so the bridge runs inside the task: the caller only holds the task and
|
||||
# gathers it later, so there is no other point that still sees a block here.
|
||||
async def _run_during_call_hook() -> Mapping[str, Any] | None:
|
||||
async def _run_during_call_hook() -> Mapping[str, object] | None:
|
||||
try:
|
||||
return await proxy_logging_obj.during_call_hook(
|
||||
user_api_key_dict=user_api_key_auth,
|
||||
|
|
@ -5655,7 +5655,7 @@ class MCPServerManager:
|
|||
|
||||
async def _gather_openapi_tool_tasks(
|
||||
self,
|
||||
tasks: list[Any],
|
||||
tasks: Sequence[Awaitable[object]],
|
||||
proxy_logging_obj: ProxyLogging | None,
|
||||
) -> CallToolResult:
|
||||
"""Await OpenAPI tool tasks and return the tool call result."""
|
||||
|
|
|
|||
|
|
@ -34,9 +34,11 @@ validation error. Runs before the first registry load on every boot and is idemp
|
|||
a healed fleet has no null rows and the backfill exits after one query.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from typing import Any, Final, Literal
|
||||
from collections.abc import Mapping
|
||||
from typing import Final, Literal, Protocol
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._experimental.mcp_server.db import _decode_oauth_payload, decrypt_credentials
|
||||
|
|
@ -53,14 +55,46 @@ BackfillRule = Literal[
|
|||
]
|
||||
|
||||
_BACKFILL_AUDIT_ACTOR: Final = "oauth2_flow_backfill"
|
||||
_CREDENTIALS_JSON: Final = TypeAdapter(dict[str, object])
|
||||
|
||||
|
||||
def _decrypted_credentials(raw_credentials: Any) -> MCPCredentials | None:
|
||||
class _MCPServerRow(Protocol):
|
||||
"""The MCP server row fields this backfill reads, narrowing the untyped DB record once here."""
|
||||
|
||||
server_id: str
|
||||
authorization_url: str | None
|
||||
registration_url: str | None
|
||||
token_url: str | None
|
||||
credentials: object
|
||||
|
||||
|
||||
class _MCPUserCredentialRow(Protocol):
|
||||
"""The per-user credential row fields this backfill reads."""
|
||||
|
||||
server_id: str
|
||||
credential_b64: str
|
||||
|
||||
|
||||
class _MCPServerTable(Protocol):
|
||||
"""The ``LiteLLM_MCPServerTable`` queries this backfill issues."""
|
||||
|
||||
async def find_many(self, *, where: Mapping[str, object]) -> list[_MCPServerRow]: ...
|
||||
|
||||
async def update_many(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> int: ...
|
||||
|
||||
|
||||
class _MCPUserCredentialsTable(Protocol):
|
||||
"""The ``LiteLLM_MCPUserCredentials`` query this backfill issues."""
|
||||
|
||||
async def find_many(self, *, where: Mapping[str, object]) -> list[_MCPUserCredentialRow]: ...
|
||||
|
||||
|
||||
def _decrypted_credentials(raw_credentials: object) -> MCPCredentials | None:
|
||||
if raw_credentials is None:
|
||||
return None
|
||||
if isinstance(raw_credentials, str):
|
||||
try:
|
||||
parsed = json.loads(raw_credentials)
|
||||
parsed: object = _CREDENTIALS_JSON.validate_json(raw_credentials)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
else:
|
||||
|
|
@ -92,14 +126,16 @@ def classify_null_flow_row(
|
|||
async def backfill_null_oauth2_flows(prisma_client: PrismaClient) -> dict[BackfillRule, int]:
|
||||
"""Classify every ``auth_type=oauth2`` row whose ``oauth2_flow`` is null; stamp the provable
|
||||
ones, warn on the ambiguous ones, and return counts per rule."""
|
||||
null_rows: Final[list[Any]] = await prisma_client.db.litellm_mcpservertable.find_many(
|
||||
server_table: Final[_MCPServerTable] = prisma_client.db.litellm_mcpservertable
|
||||
null_rows: Final = await server_table.find_many(
|
||||
where={"auth_type": "oauth2", "oauth2_flow": None},
|
||||
)
|
||||
if not null_rows:
|
||||
return {}
|
||||
|
||||
server_ids: Final = [row.server_id for row in null_rows]
|
||||
token_rows: Final[list[Any]] = await prisma_client.db.litellm_mcpusercredentials.find_many(
|
||||
user_credentials_table: Final[_MCPUserCredentialsTable] = prisma_client.db.litellm_mcpusercredentials
|
||||
token_rows: Final = await user_credentials_table.find_many(
|
||||
where={"server_id": {"in": server_ids}},
|
||||
)
|
||||
server_ids_with_oauth_tokens: Final[set[str]] = {
|
||||
|
|
@ -141,7 +177,7 @@ async def backfill_null_oauth2_flows(prisma_client: PrismaClient) -> dict[Backfi
|
|||
stamped_flows: Final = {flow for _, (flow, _) in classified if flow is not None}
|
||||
for stamped_flow in stamped_flows:
|
||||
server_ids_for_flow = [row.server_id for row, (row_flow, _) in classified if row_flow == stamped_flow]
|
||||
await prisma_client.db.litellm_mcpservertable.update_many(
|
||||
await server_table.update_many(
|
||||
where={"server_id": {"in": server_ids_for_flow}, "oauth2_flow": None},
|
||||
data={"oauth2_flow": stamped_flow, "updated_by": _BACKFILL_AUDIT_ACTOR},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -956,7 +956,7 @@ def get_key_model_rpm_limit(
|
|||
|
||||
# 2. Check model_max_budget
|
||||
if user_api_key_dict.model_max_budget:
|
||||
model_rpm_limit: Final[dict[str, Any]] = {}
|
||||
model_rpm_limit: Final[dict[str, int]] = {}
|
||||
for model, budget in user_api_key_dict.model_max_budget.items():
|
||||
if isinstance(budget, dict) and budget.get("rpm_limit") is not None:
|
||||
model_rpm_limit[model] = budget["rpm_limit"]
|
||||
|
|
@ -999,7 +999,7 @@ def get_key_model_tpm_limit(
|
|||
|
||||
# 2. Check model_max_budget (iterate per-model like RPM does)
|
||||
if user_api_key_dict.model_max_budget:
|
||||
model_tpm_limit: Final[dict[str, Any]] = {}
|
||||
model_tpm_limit: Final[dict[str, int]] = {}
|
||||
for model, budget in user_api_key_dict.model_max_budget.items():
|
||||
if isinstance(budget, dict) and budget.get("tpm_limit") is not None:
|
||||
model_tpm_limit[model] = budget["tpm_limit"]
|
||||
|
|
@ -1062,7 +1062,7 @@ def _validated_output_token_estimates_per_model(raw: object) -> Mapping[str, int
|
|||
|
||||
|
||||
def _estimated_output_tokens_from_metadata(
|
||||
metadata: Mapping[str, Any] | None,
|
||||
metadata: Mapping[str, object] | None,
|
||||
model_name: str | None,
|
||||
) -> int | None:
|
||||
"""Resolve the per-model, then global, estimate out of one metadata blob.
|
||||
|
|
@ -1628,7 +1628,7 @@ def _dedupe_model_candidates(candidates: list[str]) -> list[str]:
|
|||
return deduped
|
||||
|
||||
|
||||
def _get_case_insensitive_mapping_value(mapping: Mapping[str, Any] | None, key: str) -> Any:
|
||||
def _get_case_insensitive_mapping_value(mapping: Mapping[str, object] | None, key: str) -> object:
|
||||
if not mapping:
|
||||
return None
|
||||
if key in mapping:
|
||||
|
|
@ -1732,8 +1732,8 @@ def _resolve_model_id_with_router(model_id: str | None, llm_router: Router | Non
|
|||
def _extract_model_candidates_from_request(
|
||||
request_data: dict,
|
||||
route: str,
|
||||
request_headers: Mapping[str, Any] | None = None,
|
||||
request_query_params: Mapping[str, Any] | None = None,
|
||||
request_headers: Mapping[str, object] | None = None,
|
||||
request_query_params: Mapping[str, object] | None = None,
|
||||
llm_router: Router | None = None,
|
||||
) -> list[str]:
|
||||
candidates: Final[list[str]] = []
|
||||
|
|
@ -1825,8 +1825,8 @@ def request_dispatched_to_pass_through_endpoint(request: Request | None) -> bool
|
|||
def get_model_from_request(
|
||||
request_data: dict,
|
||||
route: str,
|
||||
request_headers: Mapping[str, Any] | None = None,
|
||||
request_query_params: Mapping[str, Any] | None = None,
|
||||
request_headers: Mapping[str, object] | None = None,
|
||||
request_query_params: Mapping[str, object] | None = None,
|
||||
llm_router: Router | None = None,
|
||||
request: Request | None = None,
|
||||
) -> str | list[str] | None:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import copy
|
|||
import os
|
||||
from collections.abc import Callable, Iterable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, Optional, TypeAlias
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, NoReturn, Optional, TypeAlias, TypeVar
|
||||
|
||||
from typing_extensions import assert_never
|
||||
|
||||
|
|
@ -31,6 +31,8 @@ from litellm.types.utils import (
|
|||
StandardLoggingPayload,
|
||||
)
|
||||
|
||||
_CallbackMetadataT: Final = TypeVar("_CallbackMetadataT")
|
||||
|
||||
_CALLBACK_VAR_MASKER: Final = SensitiveDataMasker()
|
||||
# Compound names that are credential-bearing but don't contain any of the
|
||||
# default sensitive segments (so SensitiveDataMasker won't flag them).
|
||||
|
|
@ -525,7 +527,7 @@ LITELLM_PROXY_INTERNAL_METADATA_KEYS: Final = frozenset(
|
|||
|
||||
|
||||
def sanitize_openai_provider_metadata(
|
||||
metadata: dict[str, Any] | None,
|
||||
metadata: dict[str, object] | None,
|
||||
) -> dict[str, str] | None:
|
||||
"""
|
||||
Keep only provider-safe OpenAI metadata entries (string keys -> string values).
|
||||
|
|
@ -533,8 +535,8 @@ def sanitize_openai_provider_metadata(
|
|||
Strips LiteLLM proxy-internal tracking fields that must not be forwarded to
|
||||
OpenAI batch/file APIs.
|
||||
"""
|
||||
if not metadata:
|
||||
return metadata
|
||||
if metadata is None:
|
||||
return None
|
||||
sanitized: Final[dict[str, str]] = {}
|
||||
for key, value in metadata.items():
|
||||
if key in LITELLM_PROXY_INTERNAL_METADATA_KEYS:
|
||||
|
|
@ -547,7 +549,7 @@ def sanitize_openai_provider_metadata(
|
|||
key,
|
||||
type(value).__name__,
|
||||
)
|
||||
return sanitized or None
|
||||
return None if metadata and not sanitized else sanitized
|
||||
|
||||
|
||||
def add_guardrail_to_applied_guardrails_header(request_data: dict, guardrail_name: str | None):
|
||||
|
|
@ -644,13 +646,13 @@ def process_callback(_callback: str, callback_type: str, environment_variables:
|
|||
return {"name": _callback, "variables": env_vars_dict, "type": callback_type}
|
||||
|
||||
|
||||
def normalize_callback_names(callbacks: Iterable[Any]) -> list[Any]:
|
||||
def normalize_callback_names(callbacks: Iterable[object] | None) -> list[object]:
|
||||
if callbacks is None:
|
||||
return []
|
||||
return [c.lower() if isinstance(c, str) else c for c in callbacks]
|
||||
|
||||
|
||||
def strip_callback_config(metadata: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
def strip_callback_config(metadata: dict[str, object] | None) -> dict[str, object] | None:
|
||||
"""Return key/team metadata without the slots that carry callback credentials."""
|
||||
if not isinstance(metadata, dict):
|
||||
return metadata
|
||||
|
|
@ -674,7 +676,9 @@ def decrypt_callback_vars(metadata: Any) -> Any:
|
|||
return _transform_callback_vars(metadata, _decrypt_or_passthrough)
|
||||
|
||||
|
||||
def _transform_callback_vars(metadata: Any, transform: Callable[[str, Any], Any]) -> Any:
|
||||
def _transform_callback_vars(
|
||||
metadata: _CallbackMetadataT, transform: Callable[[str, object], object]
|
||||
) -> _CallbackMetadataT:
|
||||
if not isinstance(metadata, dict):
|
||||
return metadata
|
||||
out: Final = copy.deepcopy(metadata)
|
||||
|
|
@ -704,7 +708,7 @@ def is_sensitive_callback_key(
|
|||
return _CALLBACK_VAR_MASKER.is_sensitive_key(key)
|
||||
|
||||
|
||||
def _encrypt_if_plaintext(key: str, value: Any) -> Any:
|
||||
def _encrypt_if_plaintext(key: str, value: object) -> object:
|
||||
if not isinstance(value, str) or not value:
|
||||
return value
|
||||
if not is_sensitive_callback_key(key):
|
||||
|
|
@ -725,7 +729,7 @@ def _encrypt_if_plaintext(key: str, value: Any) -> Any:
|
|||
return value
|
||||
|
||||
|
||||
def _decrypt_or_passthrough(key: str, value: Any) -> Any:
|
||||
def _decrypt_or_passthrough(key: str, value: object) -> object:
|
||||
if not isinstance(value, str) or not value:
|
||||
return value
|
||||
if not value.startswith(_CALLBACK_VAR_ENCRYPTED_PREFIX):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, Final
|
||||
from typing import Final, TypeAlias, Union
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
||||
JsonValue: TypeAlias = Union["JsonObject", "JsonArray", str, int, float, bool, None]
|
||||
JsonObject: TypeAlias = dict[str, JsonValue]
|
||||
JsonArray: TypeAlias = list[JsonValue]
|
||||
|
||||
|
||||
class CustomOpenAPISpec:
|
||||
"""
|
||||
|
|
@ -27,7 +31,20 @@ class CustomOpenAPISpec:
|
|||
RESPONSES_API_PATHS = ["/v1/responses", "/responses"]
|
||||
|
||||
@staticmethod
|
||||
def get_pydantic_schema(model_class) -> Mapping[str, object] | None:
|
||||
def _as_object(node: JsonValue) -> JsonObject:
|
||||
return node if isinstance(node, dict) else {}
|
||||
|
||||
@staticmethod
|
||||
def _as_array(node: JsonValue) -> JsonArray:
|
||||
return node if isinstance(node, list) else []
|
||||
|
||||
@staticmethod
|
||||
def _components_schemas(openapi_schema: JsonObject) -> JsonObject:
|
||||
components: Final = CustomOpenAPISpec._as_object(openapi_schema.setdefault("components", {}))
|
||||
return CustomOpenAPISpec._as_object(components.setdefault("schemas", {}))
|
||||
|
||||
@staticmethod
|
||||
def get_pydantic_schema(model_class) -> JsonObject | None:
|
||||
"""
|
||||
Get JSON schema from a Pydantic model, handling both v1 and v2 APIs.
|
||||
|
||||
|
|
@ -54,9 +71,7 @@ class CustomOpenAPISpec:
|
|||
return None
|
||||
|
||||
@staticmethod
|
||||
def add_schema_to_components(
|
||||
openapi_schema: dict[str, Any], schema_name: str, schema_def: Mapping[str, object]
|
||||
) -> None:
|
||||
def add_schema_to_components(openapi_schema: JsonObject, schema_name: str, schema_def: JsonObject) -> None:
|
||||
"""
|
||||
Add a schema definition to the OpenAPI components/schemas section.
|
||||
|
||||
|
|
@ -66,16 +81,25 @@ class CustomOpenAPISpec:
|
|||
schema_def: The schema definition
|
||||
"""
|
||||
# Ensure components/schemas structure exists
|
||||
if "components" not in openapi_schema:
|
||||
openapi_schema["components"] = {}
|
||||
if "schemas" not in openapi_schema["components"]:
|
||||
openapi_schema["components"]["schemas"] = {}
|
||||
_ = CustomOpenAPISpec._components_schemas(openapi_schema)
|
||||
|
||||
# Add the schema
|
||||
CustomOpenAPISpec._move_defs_to_components(openapi_schema, {schema_name: schema_def})
|
||||
|
||||
@staticmethod
|
||||
def add_request_body_to_paths(openapi_schema: dict[str, Any], paths: Sequence[str], schema_ref: str) -> None:
|
||||
def _expanded_request_field(field_name: str, field_def: JsonValue) -> JsonValue:
|
||||
expanded: Final = CustomOpenAPISpec._rewrite_defs_refs(
|
||||
CustomOpenAPISpec._expand_field_definition(CustomOpenAPISpec._as_object(field_def))
|
||||
)
|
||||
if field_name != "messages":
|
||||
return expanded
|
||||
return {
|
||||
**CustomOpenAPISpec._as_object(expanded),
|
||||
"example": [{"role": "user", "content": "Hello, how are you?"}],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def add_request_body_to_paths(openapi_schema: JsonObject, paths: Sequence[str], schema_ref: str) -> None:
|
||||
"""
|
||||
Add request body with expanded form fields for better Swagger UI display.
|
||||
This keeps the request body but expands it to show individual fields in the UI.
|
||||
|
|
@ -86,54 +110,58 @@ class CustomOpenAPISpec:
|
|||
schema_ref: Reference to the schema component (e.g., "#/components/schemas/ModelName")
|
||||
"""
|
||||
for path in paths:
|
||||
if path in openapi_schema.get("paths", {}) and "post" in openapi_schema["paths"][path]:
|
||||
# Get the actual schema to extract ALL field definitions
|
||||
schema_name = schema_ref.split("/")[-1] # Extract "ProxyChatCompletionRequest" from the ref
|
||||
actual_schema = openapi_schema.get("components", {}).get("schemas", {}).get(schema_name, {})
|
||||
schema_properties = actual_schema.get("properties", {})
|
||||
required_fields = actual_schema.get("required", [])
|
||||
path_item = CustomOpenAPISpec._as_object(
|
||||
CustomOpenAPISpec._as_object(openapi_schema.get("paths")).get(path)
|
||||
)
|
||||
if "post" not in path_item:
|
||||
continue
|
||||
|
||||
# Extract $defs and add them to components/schemas
|
||||
# This fixes Pydantic v2 $defs not being resolvable in Swagger/OpenAPI
|
||||
if "$defs" in actual_schema:
|
||||
CustomOpenAPISpec._move_defs_to_components(openapi_schema, actual_schema["$defs"])
|
||||
post_operation = CustomOpenAPISpec._as_object(path_item["post"])
|
||||
|
||||
# Create an expanded inline schema instead of just a $ref
|
||||
# This makes Swagger UI show all individual fields in the request body editor
|
||||
expanded_schema = {
|
||||
"type": "object",
|
||||
"required": required_fields,
|
||||
"properties": {},
|
||||
}
|
||||
# Get the actual schema to extract ALL field definitions
|
||||
schema_name = schema_ref.split("/")[-1] # Extract "ProxyChatCompletionRequest" from the ref
|
||||
components = CustomOpenAPISpec._as_object(openapi_schema.get("components"))
|
||||
actual_schema = CustomOpenAPISpec._as_object(
|
||||
CustomOpenAPISpec._as_object(components.get("schemas")).get(schema_name)
|
||||
)
|
||||
schema_properties = CustomOpenAPISpec._as_object(actual_schema.get("properties"))
|
||||
required_fields = actual_schema.get("required", [])
|
||||
|
||||
# Add all properties with their full definitions
|
||||
for field_name, field_def in schema_properties.items():
|
||||
expanded_field = CustomOpenAPISpec._expand_field_definition(field_def)
|
||||
# Extract $defs and add them to components/schemas
|
||||
# This fixes Pydantic v2 $defs not being resolvable in Swagger/OpenAPI
|
||||
if "$defs" in actual_schema:
|
||||
CustomOpenAPISpec._move_defs_to_components(
|
||||
openapi_schema, CustomOpenAPISpec._as_object(actual_schema["$defs"])
|
||||
)
|
||||
|
||||
# Rewrite $defs references to use components/schemas instead
|
||||
expanded_field = CustomOpenAPISpec._rewrite_defs_refs(expanded_field)
|
||||
# Create an expanded inline schema instead of just a $ref
|
||||
# This makes Swagger UI show all individual fields in the request body editor
|
||||
expanded_schema: JsonObject = {
|
||||
"type": "object",
|
||||
"required": required_fields,
|
||||
"properties": {
|
||||
field_name: CustomOpenAPISpec._expanded_request_field(field_name, field_def)
|
||||
for field_name, field_def in schema_properties.items()
|
||||
},
|
||||
}
|
||||
|
||||
# Add a simple example for the messages field
|
||||
if field_name == "messages":
|
||||
expanded_field["example"] = [{"role": "user", "content": "Hello, how are you?"}]
|
||||
# Set the request body with the expanded schema
|
||||
post_operation["requestBody"] = {
|
||||
"required": True,
|
||||
"content": {"application/json": {"schema": expanded_schema}},
|
||||
}
|
||||
|
||||
expanded_schema["properties"][field_name] = expanded_field
|
||||
|
||||
# Set the request body with the expanded schema
|
||||
openapi_schema["paths"][path]["post"]["requestBody"] = {
|
||||
"required": True,
|
||||
"content": {"application/json": {"schema": expanded_schema}},
|
||||
}
|
||||
|
||||
# Keep any existing parameters (like path parameters) but remove conflicting query params
|
||||
if "parameters" in openapi_schema["paths"][path]["post"]:
|
||||
existing_params = openapi_schema["paths"][path]["post"]["parameters"]
|
||||
# Only keep path parameters, remove query params that conflict with request body
|
||||
filtered_params = [param for param in existing_params if param.get("in") == "path"]
|
||||
openapi_schema["paths"][path]["post"]["parameters"] = filtered_params
|
||||
# Keep any existing parameters (like path parameters) but remove conflicting query params
|
||||
if "parameters" in post_operation:
|
||||
# Only keep path parameters, remove query params that conflict with request body
|
||||
post_operation["parameters"] = [
|
||||
param
|
||||
for param in CustomOpenAPISpec._as_array(post_operation["parameters"])
|
||||
if CustomOpenAPISpec._as_object(param).get("in") == "path"
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _move_defs_to_components(openapi_schema: dict[str, Any], defs: Mapping[str, Mapping[str, Any]]) -> None:
|
||||
def _move_defs_to_components(openapi_schema: JsonObject, defs: Mapping[str, JsonValue]) -> None:
|
||||
"""
|
||||
Move $defs from Pydantic v2 schema to OpenAPI components/schemas.
|
||||
This makes the definitions resolvable in Swagger/OpenAPI viewers.
|
||||
|
|
@ -146,23 +174,31 @@ class CustomOpenAPISpec:
|
|||
return
|
||||
|
||||
# Ensure components/schemas exists
|
||||
if "components" not in openapi_schema:
|
||||
openapi_schema["components"] = {}
|
||||
if "schemas" not in openapi_schema["components"]:
|
||||
openapi_schema["components"]["schemas"] = {}
|
||||
schemas: Final = CustomOpenAPISpec._components_schemas(openapi_schema)
|
||||
|
||||
# Add each definition to components/schemas
|
||||
for def_name, def_schema in defs.items():
|
||||
# Recursively rewrite any nested $defs references within this definition
|
||||
rewritten_def = CustomOpenAPISpec._rewrite_defs_refs(def_schema)
|
||||
openapi_schema["components"]["schemas"][def_name] = rewritten_def
|
||||
schemas[def_name] = CustomOpenAPISpec._rewrite_defs_refs(def_schema)
|
||||
|
||||
# If this definition also has $defs, process them recursively
|
||||
if "$defs" in def_schema:
|
||||
CustomOpenAPISpec._move_defs_to_components(openapi_schema, def_schema["$defs"])
|
||||
def_object = CustomOpenAPISpec._as_object(def_schema)
|
||||
if "$defs" in def_object:
|
||||
CustomOpenAPISpec._move_defs_to_components(
|
||||
openapi_schema, CustomOpenAPISpec._as_object(def_object["$defs"])
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_defs_refs(schema: Any) -> Any:
|
||||
def _rewritten_defs_entry(key: str, value: JsonValue) -> JsonValue:
|
||||
if key == "$ref" and isinstance(value, str) and value.startswith("#/$defs/"):
|
||||
# Rewrite the reference to use components/schemas
|
||||
def_name: Final = value.replace("#/$defs/", "")
|
||||
return f"#/components/schemas/{def_name}"
|
||||
# Recursively process nested structures
|
||||
return CustomOpenAPISpec._rewrite_defs_refs(value)
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_defs_refs(schema: JsonValue) -> JsonValue:
|
||||
"""
|
||||
Recursively rewrite $ref values from #/$defs/... to #/components/schemas/...
|
||||
This converts Pydantic v2 references to OpenAPI-compatible references.
|
||||
|
|
@ -174,26 +210,17 @@ class CustomOpenAPISpec:
|
|||
Schema with rewritten references
|
||||
"""
|
||||
if isinstance(schema, dict):
|
||||
result: Final = {}
|
||||
for key, value in schema.items():
|
||||
if key == "$ref" and isinstance(value, str) and value.startswith("#/$defs/"):
|
||||
# Rewrite the reference to use components/schemas
|
||||
def_name = value.replace("#/$defs/", "")
|
||||
result[key] = f"#/components/schemas/{def_name}"
|
||||
elif key == "$defs":
|
||||
# Remove $defs from the schema since they're moved to components
|
||||
continue
|
||||
else:
|
||||
# Recursively process nested structures
|
||||
result[key] = CustomOpenAPISpec._rewrite_defs_refs(value)
|
||||
return result
|
||||
elif isinstance(schema, list):
|
||||
return {
|
||||
key: CustomOpenAPISpec._rewritten_defs_entry(key, value)
|
||||
for key, value in schema.items()
|
||||
if key != "$defs"
|
||||
}
|
||||
if isinstance(schema, list):
|
||||
return [CustomOpenAPISpec._rewrite_defs_refs(item) for item in schema]
|
||||
else:
|
||||
return schema
|
||||
return schema
|
||||
|
||||
@staticmethod
|
||||
def _extract_field_schema(field_def: dict[str, Any]) -> dict[str, Any]:
|
||||
def _extract_field_schema(field_def: JsonObject) -> JsonValue:
|
||||
"""
|
||||
Extract a simple schema from a Pydantic field definition for parameter display.
|
||||
|
||||
|
|
@ -209,10 +236,10 @@ class CustomOpenAPISpec:
|
|||
|
||||
# Handle anyOf (Optional fields in Pydantic v2)
|
||||
if "anyOf" in field_def:
|
||||
any_of: Final = field_def["anyOf"]
|
||||
any_of: Final = CustomOpenAPISpec._as_array(field_def["anyOf"])
|
||||
# Find the non-null type
|
||||
for option in any_of:
|
||||
if option.get("type") != "null":
|
||||
if CustomOpenAPISpec._as_object(option).get("type") != "null":
|
||||
return option
|
||||
# Fallback to string if all else fails
|
||||
return {"type": "string"}
|
||||
|
|
@ -221,7 +248,7 @@ class CustomOpenAPISpec:
|
|||
return {"type": "string"}
|
||||
|
||||
@staticmethod
|
||||
def _expand_field_definition(field_def: dict[str, object]) -> dict[str, object]:
|
||||
def _expand_field_definition(field_def: JsonObject) -> JsonObject:
|
||||
"""
|
||||
Expand a Pydantic field definition for inline use in OpenAPI schema.
|
||||
This creates a full field definition that Swagger UI can render as individual form fields.
|
||||
|
|
@ -237,12 +264,12 @@ class CustomOpenAPISpec:
|
|||
|
||||
@staticmethod
|
||||
def add_request_schema(
|
||||
openapi_schema: dict[str, object],
|
||||
openapi_schema: JsonObject,
|
||||
model_class: type,
|
||||
schema_name: str,
|
||||
paths: Sequence[str],
|
||||
operation_name: str,
|
||||
) -> dict[str, object]:
|
||||
) -> JsonObject:
|
||||
"""
|
||||
Generic method to add a request schema to OpenAPI specification.
|
||||
|
||||
|
|
@ -282,8 +309,8 @@ class CustomOpenAPISpec:
|
|||
|
||||
@staticmethod
|
||||
def add_chat_completion_request_schema(
|
||||
openapi_schema: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
openapi_schema: JsonObject,
|
||||
) -> JsonObject:
|
||||
"""
|
||||
Add ProxyChatCompletionRequest schema to chat completion endpoints for documentation.
|
||||
This shows the request body in Swagger without runtime validation.
|
||||
|
|
@ -309,7 +336,7 @@ class CustomOpenAPISpec:
|
|||
return openapi_schema
|
||||
|
||||
@staticmethod
|
||||
def add_embedding_request_schema(openapi_schema: dict[str, object]) -> dict[str, object]:
|
||||
def add_embedding_request_schema(openapi_schema: JsonObject) -> JsonObject:
|
||||
"""
|
||||
Add EmbeddingRequest schema to embedding endpoints for documentation.
|
||||
This shows the request body in Swagger without runtime validation.
|
||||
|
|
@ -336,8 +363,8 @@ class CustomOpenAPISpec:
|
|||
|
||||
@staticmethod
|
||||
def add_responses_api_request_schema(
|
||||
openapi_schema: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
openapi_schema: JsonObject,
|
||||
) -> JsonObject:
|
||||
"""
|
||||
Add ResponsesAPIRequestParams schema to responses API endpoints for documentation.
|
||||
This shows the request body in Swagger without runtime validation.
|
||||
|
|
@ -364,8 +391,8 @@ class CustomOpenAPISpec:
|
|||
|
||||
@staticmethod
|
||||
def add_llm_api_request_schema_body(
|
||||
openapi_schema: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
openapi_schema: JsonObject,
|
||||
) -> JsonObject:
|
||||
"""
|
||||
Add LLM API request schema bodies to OpenAPI specification for documentation.
|
||||
|
||||
|
|
@ -376,12 +403,10 @@ class CustomOpenAPISpec:
|
|||
OpenAPI schema with added request body schemas
|
||||
"""
|
||||
# Add chat completion request schema
|
||||
openapi_schema = CustomOpenAPISpec.add_chat_completion_request_schema(openapi_schema)
|
||||
with_chat_completions: Final = CustomOpenAPISpec.add_chat_completion_request_schema(openapi_schema)
|
||||
|
||||
# Add embedding request schema
|
||||
openapi_schema = CustomOpenAPISpec.add_embedding_request_schema(openapi_schema)
|
||||
with_embeddings: Final = CustomOpenAPISpec.add_embedding_request_schema(with_chat_completions)
|
||||
|
||||
# Add responses API request schema
|
||||
openapi_schema = CustomOpenAPISpec.add_responses_api_request_schema(openapi_schema)
|
||||
|
||||
return openapi_schema
|
||||
return CustomOpenAPISpec.add_responses_api_request_schema(with_embeddings)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Final, TypeVar, cast, overload
|
||||
from typing import TYPE_CHECKING, Any, Final, TypeVar, cast, overload
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -9,6 +9,9 @@ from litellm.caching.dual_cache import DualCache
|
|||
from litellm.constants import DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL
|
||||
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from opentelemetry.trace import Span
|
||||
|
||||
T = TypeVar("T", bound=BaseModel)
|
||||
|
||||
|
||||
|
|
@ -40,31 +43,32 @@ class UserApiKeyCache(DualCache):
|
|||
@overload
|
||||
def get_cache(
|
||||
self,
|
||||
key: Any,
|
||||
parent_otel_span: Any = None,
|
||||
key: str,
|
||||
parent_otel_span: Span | None = None,
|
||||
local_only: bool = False,
|
||||
*,
|
||||
model_type: type[T],
|
||||
**kwargs: Any,
|
||||
**kwargs: object,
|
||||
) -> T | None: ...
|
||||
|
||||
@overload
|
||||
def get_cache(
|
||||
self,
|
||||
key: Any,
|
||||
parent_otel_span: Any = None,
|
||||
key: str,
|
||||
parent_otel_span: Span | None = None,
|
||||
local_only: bool = False,
|
||||
**kwargs: Any,
|
||||
model_type: None = None,
|
||||
**kwargs: object,
|
||||
) -> Any: ...
|
||||
|
||||
def get_cache(
|
||||
self,
|
||||
key,
|
||||
parent_otel_span=None,
|
||||
key: str,
|
||||
parent_otel_span: Span | None = None,
|
||||
local_only: bool = False,
|
||||
model_type: type[BaseModel] | None = None,
|
||||
**kwargs,
|
||||
) -> Any | BaseModel | None:
|
||||
**kwargs: object,
|
||||
) -> object:
|
||||
if model_type is None and "model_type" in kwargs:
|
||||
model_type = cast(type[BaseModel] | None, kwargs.pop("model_type", None))
|
||||
cached: Final = super().get_cache(key=key, parent_otel_span=parent_otel_span, local_only=local_only, **kwargs)
|
||||
|
|
@ -85,31 +89,32 @@ class UserApiKeyCache(DualCache):
|
|||
@overload
|
||||
async def async_get_cache(
|
||||
self,
|
||||
key: Any,
|
||||
parent_otel_span: Any = None,
|
||||
key: str,
|
||||
parent_otel_span: Span | None = None,
|
||||
local_only: bool = False,
|
||||
*,
|
||||
model_type: type[T],
|
||||
**kwargs: Any,
|
||||
**kwargs: object,
|
||||
) -> T | None: ...
|
||||
|
||||
@overload
|
||||
async def async_get_cache(
|
||||
self,
|
||||
key: Any,
|
||||
parent_otel_span: Any = None,
|
||||
key: str,
|
||||
parent_otel_span: Span | None = None,
|
||||
local_only: bool = False,
|
||||
**kwargs: Any,
|
||||
model_type: None = None,
|
||||
**kwargs: object,
|
||||
) -> Any: ...
|
||||
|
||||
async def async_get_cache(
|
||||
self,
|
||||
key,
|
||||
parent_otel_span=None,
|
||||
key: str,
|
||||
parent_otel_span: Span | None = None,
|
||||
local_only: bool = False,
|
||||
model_type: type[BaseModel] | None = None,
|
||||
**kwargs,
|
||||
) -> Any | BaseModel | None:
|
||||
**kwargs: object,
|
||||
) -> object:
|
||||
if model_type is None and "model_type" in kwargs:
|
||||
model_type = cast(type[BaseModel] | None, kwargs.pop("model_type", None))
|
||||
cached: Final = await super().async_get_cache(
|
||||
|
|
@ -129,14 +134,14 @@ class UserApiKeyCache(DualCache):
|
|||
return None
|
||||
return decoded
|
||||
|
||||
def set_cache(self, key, value, local_only: bool = False, **kwargs):
|
||||
def set_cache(self, key: str | None, value: object, local_only: bool = False, **kwargs: object):
|
||||
model_type: Final = cast(type[BaseModel] | None, kwargs.pop("model_type", None))
|
||||
payload: Final = CacheCodec.serialize(value, model_type=model_type)
|
||||
payload: Final[object] = CacheCodec.serialize(value, model_type=model_type)
|
||||
return super().set_cache(key=key, value=payload, local_only=local_only, **kwargs)
|
||||
|
||||
async def async_set_cache(self, key, value, local_only: bool = False, **kwargs):
|
||||
async def async_set_cache(self, key: str | None, value: object, local_only: bool = False, **kwargs: object):
|
||||
model_type: Final = cast(type[BaseModel] | None, kwargs.pop("model_type", None))
|
||||
payload: Final = CacheCodec.serialize(value, model_type=model_type)
|
||||
payload: Final[object] = CacheCodec.serialize(value, model_type=model_type)
|
||||
return await super().async_set_cache(key=key, value=payload, local_only=local_only, **kwargs)
|
||||
|
||||
async def async_set_cache_pipeline(self, cache_list: list, local_only: bool = False, **kwargs) -> None:
|
||||
|
|
|
|||
|
|
@ -232,7 +232,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
|
||||
# Resource-less, detect-only InvokeGuardrailChecks mode. Present `checks`
|
||||
# routes the guardrail to InvokeGuardrailChecks; absent => ApplyGuardrail.
|
||||
self.checks: dict[str, Any] | None = self._normalize_checks(checks)
|
||||
self.checks: dict[str, object] | None = self._normalize_checks(checks)
|
||||
# Per-check block thresholds; a score >= threshold blocks. None => the
|
||||
# check is detect-only (logged, never blocks).
|
||||
self.content_filter_threshold = content_filter_threshold
|
||||
|
|
@ -289,7 +289,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
]
|
||||
|
||||
@staticmethod
|
||||
def _normalize_checks(checks: BedrockChecksConfigModel | Mapping[str, object] | None) -> dict[str, Any] | None:
|
||||
def _normalize_checks(checks: BedrockChecksConfigModel | Mapping[str, object] | None) -> dict[str, object] | None:
|
||||
"""Normalize the configured `checks` into a plain dict for the API body.
|
||||
|
||||
Accepts a pydantic ``BedrockChecksConfigModel`` or a raw dict; drops None /
|
||||
|
|
@ -340,7 +340,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
|
||||
def _create_bedrock_output_content_request(
|
||||
self,
|
||||
response: Any | ModelResponse,
|
||||
response: object,
|
||||
messages: list[AllMessageValues] | None = None,
|
||||
) -> BedrockRequest:
|
||||
"""
|
||||
|
|
@ -365,7 +365,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
return bedrock_request
|
||||
|
||||
def _build_response_content_items(
|
||||
self, response: Any | ModelResponse, has_grounding: bool
|
||||
self, response: object, has_grounding: bool
|
||||
) -> list[BedrockContentItem]:
|
||||
"""Build content item(s) from the model response. When the request supplied
|
||||
grounding, the response is qualified ``guard_content`` so Bedrock can score it.
|
||||
|
|
@ -390,7 +390,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
self,
|
||||
source: Literal["INPUT", "OUTPUT"],
|
||||
messages: list[AllMessageValues] | None = None,
|
||||
response: Any | ModelResponse | None = None,
|
||||
response: object | None = None,
|
||||
) -> BedrockRequest:
|
||||
"""
|
||||
Convert the litellm messages/response to the bedrock request format.
|
||||
|
|
@ -911,7 +911,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
async def _apply_guardrail_content_with_chunking(
|
||||
self,
|
||||
content: Sequence[BedrockContentItem],
|
||||
base_request_data: Mapping[str, Any],
|
||||
base_request_data: Mapping[str, object],
|
||||
credentials: "Credentials",
|
||||
aws_region_name: str,
|
||||
api_key: str | None,
|
||||
|
|
@ -1049,7 +1049,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
async def _post_apply_guardrail_content_with_retry(
|
||||
self,
|
||||
content: Sequence[BedrockContentItem],
|
||||
base_request_data: Mapping[str, Any],
|
||||
base_request_data: Mapping[str, object],
|
||||
credentials: "Credentials",
|
||||
aws_region_name: str,
|
||||
api_key: str | None,
|
||||
|
|
@ -1099,7 +1099,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
async def _post_apply_guardrail_content(
|
||||
self,
|
||||
content: Sequence[BedrockContentItem],
|
||||
base_request_data: Mapping[str, Any],
|
||||
base_request_data: Mapping[str, object],
|
||||
credentials: "Credentials",
|
||||
aws_region_name: str,
|
||||
api_key: str | None,
|
||||
|
|
@ -1827,7 +1827,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
return BedrockGuardrailResponse()
|
||||
|
||||
credentials, aws_region_name = self._load_credentials()
|
||||
body: Final[dict[str, Any]] = {"messages": checks_messages, "checks": self.checks}
|
||||
body: Final[dict[str, object]] = {"messages": checks_messages, "checks": self.checks}
|
||||
api_key: Final[str | None] = request_data.get("api_key") if request_data else None
|
||||
|
||||
prepared_request: Final = self._prepare_request(
|
||||
|
|
@ -2309,7 +2309,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
guardrail_name=self.guardrail_name,
|
||||
)
|
||||
|
||||
detail: Final[dict[str, Any]] = {
|
||||
detail: Final[dict[str, object]] = {
|
||||
"error": "Violated guardrail policy",
|
||||
"bedrock_guardrail_response": bedrock_guardrail_output_text,
|
||||
}
|
||||
|
|
@ -2853,7 +2853,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
return updated_messages
|
||||
|
||||
def _mask_content_list(
|
||||
self, content_list: list[Any], masked_texts: list[str], masking_index: int
|
||||
self, content_list: Sequence[object], masked_texts: list[str], masking_index: int
|
||||
) -> tuple[list[Any], int]:
|
||||
"""
|
||||
Apply masking to a list of content items.
|
||||
|
|
@ -2866,7 +2866,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
Returns:
|
||||
Updated content list with masked items
|
||||
"""
|
||||
new_content: Final[list[dict | str]] = []
|
||||
new_content: Final[list[dict[str, object] | str]] = []
|
||||
for item in content_list:
|
||||
if isinstance(item, dict) and "text" in item:
|
||||
new_item = item.copy()
|
||||
|
|
@ -2885,7 +2885,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
|
|||
|
||||
def _apply_masking_to_response(
|
||||
self,
|
||||
response: ModelResponse | Any,
|
||||
response: object,
|
||||
bedrock_guardrail_response: BedrockGuardrailResponse,
|
||||
) -> None:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ The public guardrail class imports this private mixin from
|
|||
while preserving the existing public import path.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, Optional
|
||||
from typing import TYPE_CHECKING, Final, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
|
|
@ -23,7 +24,7 @@ if TYPE_CHECKING:
|
|||
from .cisco_ai_defense import _ScanContext
|
||||
|
||||
|
||||
def _serialize_mcp_content_item(item: object) -> dict[str, Any]:
|
||||
def _serialize_mcp_content_item(item: object) -> dict[str, object]:
|
||||
"""Serialize an MCP content item to a JSON-friendly dict.
|
||||
|
||||
Handles raw dicts, MCP SDK Pydantic models, and simple ``.text`` objects.
|
||||
|
|
@ -57,7 +58,7 @@ class _CiscoAIDefenseMcpMixin:
|
|||
|
||||
def should_run_guardrail(self, data: dict, event_type: GuardrailEventHooks) -> bool: ...
|
||||
|
||||
async def _post_inspection(self, url: str, payload: dict[str, Any], surface: str) -> dict[str, Any]: ...
|
||||
async def _post_inspection(self, url: str, payload: dict[str, object], surface: str) -> dict[str, object]: ...
|
||||
|
||||
def _handle_api_error(
|
||||
self,
|
||||
|
|
@ -67,16 +68,16 @@ class _CiscoAIDefenseMcpMixin:
|
|||
start_time: datetime | None = ...,
|
||||
surface: str = ...,
|
||||
direction: str = ...,
|
||||
) -> dict[str, Any]: ...
|
||||
) -> dict[str, object]: ...
|
||||
|
||||
def _finalize_inspection(
|
||||
self,
|
||||
inspect_response: dict[str, Any],
|
||||
inspect_response: dict[str, object],
|
||||
request_data: dict,
|
||||
context: "_ScanContext",
|
||||
start_time: datetime,
|
||||
response_obj: object = ...,
|
||||
) -> dict[str, Any]: ...
|
||||
) -> dict[str, object]: ...
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# MCP post-tool hook (dispatcher contract)
|
||||
|
|
@ -95,7 +96,7 @@ class _CiscoAIDefenseMcpMixin:
|
|||
if self.inspection_type != "mcp":
|
||||
return None
|
||||
|
||||
request_data: Final[dict[str, Any]] = {}
|
||||
request_data: Final[dict[str, object]] = {}
|
||||
for key in (
|
||||
"name",
|
||||
"litellm_call_id",
|
||||
|
|
@ -188,9 +189,9 @@ class _CiscoAIDefenseMcpMixin:
|
|||
|
||||
original_hidden: Final = getattr(original_response_obj, "hidden_params", None)
|
||||
if isinstance(original_hidden, HiddenParams):
|
||||
hidden_params: Any = original_hidden
|
||||
hidden_params: HiddenParams = original_hidden
|
||||
else:
|
||||
response_cost: Final = getattr(original_hidden, "response_cost", None)
|
||||
response_cost: Final[float | None] = getattr(original_hidden, "response_cost", None)
|
||||
hidden_params = HiddenParams(response_cost=response_cost) if response_cost is not None else HiddenParams()
|
||||
|
||||
return MCPPostCallResponseObject(
|
||||
|
|
@ -200,11 +201,11 @@ class _CiscoAIDefenseMcpMixin:
|
|||
|
||||
@staticmethod
|
||||
def _replace_mcp_tool_response(response_obj: object, replacement_obj: object) -> bool:
|
||||
replacement: Final = getattr(replacement_obj, "mcp_tool_call_response", None)
|
||||
replacement: Final[list[object] | None] = getattr(replacement_obj, "mcp_tool_call_response", None)
|
||||
if replacement is None:
|
||||
return False
|
||||
|
||||
inner: Final = getattr(response_obj, "mcp_tool_call_response", None)
|
||||
inner: Final[object | None] = getattr(response_obj, "mcp_tool_call_response", None)
|
||||
if inner is not None:
|
||||
if _CiscoAIDefenseMcpMixin._replace_mcp_tool_response(inner, replacement_obj):
|
||||
return True
|
||||
|
|
@ -276,7 +277,7 @@ class _CiscoAIDefenseMcpMixin:
|
|||
self,
|
||||
data: dict,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
del user_api_key_dict # carried via logging metadata, not the wire payload
|
||||
url: Final = f"{self.api_base}{self.inspect_path}"
|
||||
payload: Final = self._build_mcp_request_payload(data=data)
|
||||
|
|
@ -312,7 +313,7 @@ class _CiscoAIDefenseMcpMixin:
|
|||
response: object,
|
||||
user_api_key_dict: UserAPIKeyAuth | None = None,
|
||||
redact_response_obj: object = None,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
del user_api_key_dict # carried via logging metadata, not the wire payload
|
||||
url: Final = f"{self.api_base}{self.inspect_path}"
|
||||
payload: Final = self._build_mcp_response_payload(
|
||||
|
|
@ -349,7 +350,7 @@ class _CiscoAIDefenseMcpMixin:
|
|||
def _build_mcp_request_payload(
|
||||
self,
|
||||
data: dict,
|
||||
) -> dict[str, Any] | None:
|
||||
) -> dict[str, object] | None:
|
||||
"""Build the JSON-RPC ``tools/call`` envelope sent to ``/inspect/mcp``.
|
||||
|
||||
The Cisco AI Defense MCP inspect endpoint expects the JSON-RPC
|
||||
|
|
@ -390,7 +391,7 @@ class _CiscoAIDefenseMcpMixin:
|
|||
self,
|
||||
request_data: dict,
|
||||
response: object,
|
||||
) -> dict[str, Any] | None:
|
||||
) -> dict[str, object] | None:
|
||||
"""Build the MCP response-inspection body sent to ``/inspect/mcp``."""
|
||||
request_payload: Final = self._build_mcp_request_payload(data=request_data)
|
||||
if request_payload is None:
|
||||
|
|
@ -415,7 +416,7 @@ class _CiscoAIDefenseMcpMixin:
|
|||
return payload
|
||||
|
||||
@staticmethod
|
||||
def _hydrate_mcp_tool_context(request_data: dict[str, Any]) -> None:
|
||||
def _hydrate_mcp_tool_context(request_data: dict[str, object]) -> None:
|
||||
metadata = request_data.get("mcp_tool_call_metadata")
|
||||
if metadata is None:
|
||||
nested: Final = request_data.get("metadata") or request_data.get("litellm_metadata")
|
||||
|
|
@ -440,7 +441,7 @@ class _CiscoAIDefenseMcpMixin:
|
|||
request_data.setdefault("server_name", server_name)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mcp_response(response: object) -> dict[str, Any] | None:
|
||||
def _normalize_mcp_response(response: object) -> dict[str, object] | None:
|
||||
"""Normalize an MCP tool response into a JSON-RPC envelope.
|
||||
|
||||
Handles JSON-RPC dicts, raw content lists, MCP SDK models, and
|
||||
|
|
@ -502,10 +503,10 @@ class _CiscoAIDefenseMcpMixin:
|
|||
|
||||
@staticmethod
|
||||
def _build_mcp_result(
|
||||
content: list[Any],
|
||||
content: Sequence[object],
|
||||
source: object = None,
|
||||
) -> dict[str, Any]:
|
||||
result: Final[dict[str, Any]] = {"content": [_serialize_mcp_content_item(item) for item in content]}
|
||||
) -> dict[str, object]:
|
||||
result: Final[dict[str, object]] = {"content": [_serialize_mcp_content_item(item) for item in content]}
|
||||
for key in ("structuredContent", "isError"):
|
||||
value = source.get(key) if isinstance(source, dict) else getattr(source, key, None)
|
||||
if value is not None and (key != "isError" or isinstance(value, bool)):
|
||||
|
|
@ -522,7 +523,7 @@ class _CiscoAIDefenseMcpMixin:
|
|||
if response_obj is None:
|
||||
return False
|
||||
|
||||
inner: Final = getattr(response_obj, "mcp_tool_call_response", None)
|
||||
inner: Final[object | None] = getattr(response_obj, "mcp_tool_call_response", None)
|
||||
if inner is not None:
|
||||
return _CiscoAIDefenseMcpMixin._set_mcp_tool_response_text(inner, text)
|
||||
|
||||
|
|
@ -559,7 +560,7 @@ class _CiscoAIDefenseMcpMixin:
|
|||
pass
|
||||
elif isinstance(response_obj, dict):
|
||||
result: Final = response_obj.get("result")
|
||||
target: Final[dict[Any, Any]] = result if isinstance(result, dict) else response_obj
|
||||
target: Final[dict[object, object]] = result if isinstance(result, dict) else response_obj
|
||||
if "structuredContent" in target:
|
||||
target["structuredContent"] = replacement
|
||||
replaced = True
|
||||
|
|
@ -567,11 +568,11 @@ class _CiscoAIDefenseMcpMixin:
|
|||
return replaced
|
||||
|
||||
@staticmethod
|
||||
def _coerce_to_content_list(response_obj: object) -> list[Any] | None:
|
||||
def _coerce_to_content_list(response_obj: object) -> list[object] | None:
|
||||
"""Find the MCP content list inside supported response shapes."""
|
||||
if response_obj is None:
|
||||
return None
|
||||
inner: Final = getattr(response_obj, "mcp_tool_call_response", None)
|
||||
inner: Final[object | None] = getattr(response_obj, "mcp_tool_call_response", None)
|
||||
if inner is not None:
|
||||
return _CiscoAIDefenseMcpMixin._coerce_to_content_list(inner)
|
||||
content: Final = getattr(response_obj, "content", None)
|
||||
|
|
@ -594,8 +595,8 @@ class _CiscoAIDefenseMcpMixin:
|
|||
|
||||
@staticmethod
|
||||
def _extract_sanitized_mcp_arguments(
|
||||
inspect_response: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
inspect_response: dict[str, object],
|
||||
) -> dict[str, object] | None:
|
||||
"""Pull sanitized MCP tool-call arguments off the verdict.
|
||||
|
||||
Cisco can return them at the top level (``params.arguments``) or
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ import jwt
|
|||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.caching import DualCache
|
||||
|
|
@ -89,6 +89,7 @@ from litellm.integrations.custom_guardrail import (
|
|||
log_guardrail_information,
|
||||
)
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
from litellm.types.guardrail_base_init import GuardrailBaseInitKwargs
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import CallTypesLiteral
|
||||
|
||||
|
|
@ -107,6 +108,19 @@ class _JWTDecodeKwargs(TypedDict):
|
|||
issuer: NotRequired[str]
|
||||
|
||||
|
||||
class _DebugHeaderClaims(TypedDict, total=False):
|
||||
sub: ReadOnly[object]
|
||||
iss: ReadOnly[object]
|
||||
exp: ReadOnly[object]
|
||||
scope: ReadOnly[str]
|
||||
|
||||
|
||||
class _SignedClaimSummary(TypedDict):
|
||||
sub: ReadOnly[object]
|
||||
act: ReadOnly[Mapping[str, object]]
|
||||
exp: ReadOnly[object]
|
||||
|
||||
|
||||
# Module-level singleton for the JWKS discovery endpoint to access.
|
||||
_mcp_jwt_signer_instance: Optional["MCPJWTSigner"] = None
|
||||
|
||||
|
|
@ -265,7 +279,8 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
**kwargs: Any,
|
||||
) -> None:
|
||||
kwargs.setdefault("supported_event_hooks", list(self.get_supported_event_hooks()))
|
||||
super().__init__(**kwargs)
|
||||
base_kwargs: Final[GuardrailBaseInitKwargs] = kwargs
|
||||
super().__init__(**base_kwargs)
|
||||
|
||||
# --- Signing key setup ---
|
||||
key_material: Final = os.environ.get(self.SIGNING_KEY_ENV)
|
||||
|
|
@ -677,7 +692,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
data: dict,
|
||||
jwt_claims: Mapping[str, object] | None = None,
|
||||
call_type: CallTypesLiteral | None = None,
|
||||
) -> dict[str, Any]:
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Build JWT claims for the outbound MCP access token.
|
||||
|
||||
|
|
@ -752,7 +767,7 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _build_debug_header(claims: dict[str, Any], kid: str) -> str:
|
||||
def _build_debug_header(claims: _DebugHeaderClaims, kid: str) -> str:
|
||||
"""
|
||||
Build the x-litellm-mcp-debug header value.
|
||||
|
||||
|
|
@ -873,16 +888,18 @@ class MCPJWTSigner(CustomGuardrail):
|
|||
# FR-9: Debug header
|
||||
# ------------------------------------------------------------------
|
||||
if self.debug_headers:
|
||||
new_headers["x-litellm-mcp-debug"] = self._build_debug_header(claims, self._kid)
|
||||
debug_claims: Final[_DebugHeaderClaims] = claims
|
||||
new_headers["x-litellm-mcp-debug"] = self._build_debug_header(debug_claims, self._kid)
|
||||
|
||||
hook_data["extra_headers"] = new_headers
|
||||
|
||||
logged_claims: Final[_SignedClaimSummary] = claims
|
||||
verbose_proxy_logger.debug(
|
||||
"MCPJWTSigner: signed JWT sub=%s act=%s tool=%s exp=%d verified=%s channel=%s call_type=%s",
|
||||
claims.get("sub"),
|
||||
claims.get("act", {}).get("sub"),
|
||||
logged_claims.get("sub"),
|
||||
logged_claims.get("act", {}).get("sub"),
|
||||
hook_data.get("mcp_tool_name"),
|
||||
claims["exp"],
|
||||
logged_claims["exp"],
|
||||
jwt_claims is not None,
|
||||
bool(self.channel_token_audience),
|
||||
call_type,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from litellm.llms.custom_httpx.http_handler import (
|
|||
httpxSpecialProvider,
|
||||
)
|
||||
from litellm.proxy.guardrails.guardrail_hooks.noma.noma import NomaBlockedMessage
|
||||
from litellm.types.guardrail_base_init import GuardrailBaseInitKwargs
|
||||
from litellm.types.guardrails import GuardrailEventHooks
|
||||
from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailStatus
|
||||
|
||||
|
|
@ -80,7 +81,8 @@ class NomaV2Guardrail(CustomGuardrail):
|
|||
|
||||
kwargs.setdefault("supported_event_hooks", list(self.get_supported_event_hooks()))
|
||||
|
||||
super().__init__(**kwargs)
|
||||
base_kwargs: Final[GuardrailBaseInitKwargs] = kwargs
|
||||
super().__init__(**base_kwargs)
|
||||
|
||||
@staticmethod
|
||||
def get_config_model() -> type["GuardrailConfigModel"] | None:
|
||||
|
|
@ -111,7 +113,7 @@ class NomaV2Guardrail(CustomGuardrail):
|
|||
return parsed.hostname == _DEFAULT_API_BASE_HOSTNAME
|
||||
|
||||
@staticmethod
|
||||
def _get_non_empty_str(value: Any) -> str | None:
|
||||
def _get_non_empty_str(value: object) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
stripped: Final = value.strip()
|
||||
|
|
@ -153,7 +155,7 @@ class NomaV2Guardrail(CustomGuardrail):
|
|||
else model_call_details
|
||||
)
|
||||
|
||||
payload: Final[dict[str, Any]] = {
|
||||
payload: Final[dict[str, object]] = {
|
||||
"inputs": inputs,
|
||||
"request_data": payload_request_data,
|
||||
"input_type": input_type,
|
||||
|
|
@ -165,7 +167,7 @@ class NomaV2Guardrail(CustomGuardrail):
|
|||
|
||||
@staticmethod
|
||||
def _sanitize_payload_for_transport(payload: dict) -> dict:
|
||||
def _default(obj: Any) -> Any:
|
||||
def _default(obj: object) -> object:
|
||||
if hasattr(obj, "model_dump"):
|
||||
try:
|
||||
return obj.model_dump()
|
||||
|
|
@ -178,7 +180,7 @@ class NomaV2Guardrail(CustomGuardrail):
|
|||
except (ValueError, TypeError):
|
||||
json_str = safe_dumps(payload)
|
||||
|
||||
safe_payload: Final = safe_json_loads(json_str, default={})
|
||||
safe_payload: Final[object] = safe_json_loads(json_str, default={})
|
||||
if safe_payload == {} and payload:
|
||||
verbose_proxy_logger.warning(
|
||||
"Noma v2 guardrail: payload serialization failed, falling back to empty payload"
|
||||
|
|
@ -215,7 +217,7 @@ class NomaV2Guardrail(CustomGuardrail):
|
|||
response.text,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json: Final = response.json()
|
||||
response_json: Final[dict[str, object]] = response.json()
|
||||
verbose_proxy_logger.debug(
|
||||
"Noma v2 AIDR response parsed: %s",
|
||||
json.dumps(response_json, default=str),
|
||||
|
|
@ -227,7 +229,7 @@ class NomaV2Guardrail(CustomGuardrail):
|
|||
request_data: dict,
|
||||
start_time: datetime,
|
||||
guardrail_status: GuardrailStatus,
|
||||
guardrail_json_response: Any,
|
||||
guardrail_json_response: str | dict[str, object],
|
||||
) -> None:
|
||||
end_time: Final = datetime.now()
|
||||
duration: Final = (end_time - start_time).total_seconds()
|
||||
|
|
@ -270,7 +272,7 @@ class NomaV2Guardrail(CustomGuardrail):
|
|||
) -> GenericGuardrailAPIInputs:
|
||||
start_time: Final = datetime.now()
|
||||
guardrail_status: GuardrailStatus = "success"
|
||||
guardrail_json_response: Any = {}
|
||||
guardrail_json_response: str | dict[str, object] = {}
|
||||
dynamic_params = self.get_guardrail_dynamic_request_body_params(request_data)
|
||||
if not isinstance(dynamic_params, dict):
|
||||
dynamic_params = {}
|
||||
|
|
@ -320,8 +322,9 @@ class NomaV2Guardrail(CustomGuardrail):
|
|||
|
||||
except NomaBlockedMessage as e:
|
||||
guardrail_status = "guardrail_intervened"
|
||||
blocked_detail: Final[dict[str, object]] = {"error": "blocked"}
|
||||
guardrail_json_response = (
|
||||
response_json if isinstance(response_json, dict) else getattr(e, "detail", {"error": "blocked"})
|
||||
response_json if isinstance(response_json, dict) else getattr(e, "detail", blocked_detail)
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@
|
|||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncGenerator, AsyncIterable, Awaitable
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, TypedDict, cast
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, TypedDict, cast
|
||||
|
||||
import aiohttp
|
||||
from typing_extensions import NotRequired, ReadOnly
|
||||
|
|
@ -63,6 +63,14 @@ class _PresidioAnonymizeResponse(TypedDict):
|
|||
items: ReadOnly[NotRequired[list[_PresidioAnonymizeItem]]]
|
||||
|
||||
|
||||
class _JsonResponse(Protocol):
|
||||
def json(self) -> Awaitable[object]: ...
|
||||
|
||||
|
||||
async def _json_body(response: _JsonResponse) -> object:
|
||||
return await response.json()
|
||||
|
||||
|
||||
class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
||||
user_api_key_cache = None
|
||||
ad_hoc_recognizers: list[str] | None = None
|
||||
|
|
@ -345,7 +353,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
f"expected application/json Content-Type but received '{content_type}'; body: '{error_body[:200]}'"
|
||||
)
|
||||
|
||||
analyze_results: Final = await response.json()
|
||||
analyze_results: Final = await _json_body(response)
|
||||
verbose_proxy_logger.debug("analyze_results: %s", analyze_results)
|
||||
|
||||
# Handle error responses from Presidio (e.g., {'error': 'No text provided'})
|
||||
|
|
@ -758,7 +766,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
except Exception as e:
|
||||
raise e
|
||||
|
||||
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]:
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
def run_in_new_loop():
|
||||
|
|
@ -786,7 +794,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
# No running event loop, we can safely run in this thread
|
||||
return run_in_new_loop()
|
||||
|
||||
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]:
|
||||
"""
|
||||
Masks the input and output before logging to langfuse, datadog, etc.
|
||||
"""
|
||||
|
|
@ -853,9 +861,9 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
and not isinstance(result.choices[0], StreamingChoices)
|
||||
):
|
||||
await self._process_response_for_pii(response=result, request_data=kwargs, mode="mask")
|
||||
elif self._is_anthropic_message_response(result):
|
||||
elif isinstance(result, dict) and self._is_anthropic_message_response(result):
|
||||
await self._process_anthropic_response_for_pii(
|
||||
response=cast(dict, result), # cast-ok: _is_anthropic_message_response narrows via isinstance
|
||||
response=result,
|
||||
request_data=kwargs,
|
||||
mode="mask",
|
||||
)
|
||||
|
|
@ -1082,7 +1090,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
|
||||
async def _stream_apply_output_masking(
|
||||
self,
|
||||
response: Any,
|
||||
response: AsyncIterable[object],
|
||||
request_data: dict,
|
||||
) -> AsyncGenerator[ModelResponseStream | bytes, None]:
|
||||
"""Apply Presidio masking to streaming output (apply_to_output=True path)."""
|
||||
|
|
@ -1186,7 +1194,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
|
||||
return "\n".join(result_lines).encode("utf-8")
|
||||
|
||||
def _unmask_responses_api_completed_chunk(self, chunk: Any, pii_tokens: dict[str, str]) -> None:
|
||||
def _unmask_responses_api_completed_chunk(self, chunk: object, pii_tokens: dict[str, str]) -> None:
|
||||
"""
|
||||
Unmask PII tokens in-place for a ``response.completed`` Responses API event.
|
||||
|
||||
|
|
@ -1195,7 +1203,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
blocks; text blocks expose a ``.text`` string attribute. We walk the tree
|
||||
and replace every PII token with its original value.
|
||||
"""
|
||||
response_obj: Final = getattr(chunk, "response", None)
|
||||
response_obj: Final[object] = getattr(chunk, "response", None)
|
||||
if response_obj is None:
|
||||
return
|
||||
|
||||
|
|
@ -1211,7 +1219,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
|
||||
async def _stream_pii_unmasking(
|
||||
self,
|
||||
response: Any,
|
||||
response: AsyncIterable[object],
|
||||
request_data: dict,
|
||||
) -> AsyncGenerator[ModelResponseStream | bytes, None]:
|
||||
"""Apply PII unmasking to streaming output (output_parse_pii=True path)."""
|
||||
|
|
@ -1287,7 +1295,7 @@ class _OPTIONAL_PresidioPIIMasking(CustomGuardrail):
|
|||
async def async_post_call_streaming_iterator_hook(
|
||||
self,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
response: Any,
|
||||
response: AsyncIterable[object],
|
||||
request_data: dict,
|
||||
) -> AsyncGenerator[ModelResponseStream | bytes, None]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -310,7 +310,7 @@ class XecGuardGuardrail(CustomGuardrail):
|
|||
scan_type: str,
|
||||
suppress_errors: bool = False,
|
||||
) -> dict | None:
|
||||
payload: Final[dict[str, Any]] = {
|
||||
payload: Final[dict[str, object]] = {
|
||||
"model": self.xecguard_model,
|
||||
"scan_type": scan_type,
|
||||
"messages": messages,
|
||||
|
|
@ -385,7 +385,7 @@ class XecGuardGuardrail(CustomGuardrail):
|
|||
def _build_full_history(
|
||||
self,
|
||||
request_data: dict,
|
||||
inputs: Any,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
input_type: str,
|
||||
) -> list[dict]:
|
||||
"""Assemble the full message list that will be sent to XecGuard.
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@ Pre-call hook that filters MCP tools semantically before LLM inference.
|
|||
Reduces context window size and improves tool selection accuracy.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Final, Optional
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from typing import TYPE_CHECKING, Final, Optional
|
||||
|
||||
from fastapi import HTTPException
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import (
|
||||
|
|
@ -30,6 +31,13 @@ if TYPE_CHECKING:
|
|||
from litellm.router import Router
|
||||
|
||||
|
||||
class SemanticToolFilterConfig(TypedDict, total=False):
|
||||
enabled: ReadOnly[bool]
|
||||
embedding_model: ReadOnly[str]
|
||||
top_k: ReadOnly[int]
|
||||
similarity_threshold: ReadOnly[float]
|
||||
|
||||
|
||||
def _truncate_csv_at_tool_name_boundary(tool_names_csv: str, max_length: int) -> str:
|
||||
"""Cap a CSV of tool names to max_length, dropping any name that does not fit whole."""
|
||||
if len(tool_names_csv) <= max_length:
|
||||
|
|
@ -68,7 +76,7 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
semantic_filter.top_k,
|
||||
)
|
||||
|
||||
def _should_expand_mcp_tools(self, tools: list[Any]) -> bool:
|
||||
def _should_expand_mcp_tools(self, tools: Iterable[Mapping[str, object]]) -> bool:
|
||||
"""
|
||||
Check if tools contain MCP references with server_url="litellm_proxy".
|
||||
|
||||
|
|
@ -82,9 +90,9 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
|
||||
async def _expand_mcp_tools(
|
||||
self,
|
||||
tools: list[Any],
|
||||
tools: Iterable[Mapping[str, object]],
|
||||
user_api_key_dict: "UserAPIKeyAuth",
|
||||
) -> list[dict[str, Any]]:
|
||||
) -> list[dict[str, object]]:
|
||||
"""
|
||||
Expand MCP references to actual tool definitions.
|
||||
|
||||
|
|
@ -111,7 +119,7 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
)
|
||||
|
||||
# Convert Pydantic models to dicts for compatibility
|
||||
openai_tools_as_dicts: Final = []
|
||||
openai_tools_as_dicts: Final[list[dict[str, object]]] = []
|
||||
for tool in openai_tools:
|
||||
if hasattr(tool, "model_dump"):
|
||||
tool_dict = tool.model_dump(exclude_none=True)
|
||||
|
|
@ -141,8 +149,8 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
async def _filter_expanded_tools(
|
||||
self,
|
||||
data: dict,
|
||||
expanded_tools: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
expanded_tools: list[dict[str, object]],
|
||||
) -> list[dict[str, object]]:
|
||||
"""
|
||||
Apply the semantic filter to expanded MCP tool definitions.
|
||||
|
||||
|
|
@ -159,7 +167,7 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
|
||||
return await self.filter.filter_tools(query=user_query, available_tools=expanded_tools)
|
||||
|
||||
def _selected_tool_names(self, filtered_tools: list[dict[str, Any]]) -> list[str]:
|
||||
def _selected_tool_names(self, filtered_tools: Sequence[object]) -> list[str]:
|
||||
"""Names of the semantically selected tools, as produced by the MCP expansion."""
|
||||
names: Final = (self.filter._extract_tool_info(tool)[0] for tool in filtered_tools)
|
||||
return [name for name in names if name]
|
||||
|
|
@ -217,10 +225,10 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
def _emit_filter_metadata(
|
||||
self,
|
||||
data: dict,
|
||||
mcp_tools: list[object],
|
||||
filtered_mcp_tools: list[object],
|
||||
native_tools: list[object],
|
||||
filtered_tools: list[object],
|
||||
mcp_tools: Sequence[object],
|
||||
filtered_mcp_tools: Sequence[object],
|
||||
native_tools: Sequence[object],
|
||||
filtered_tools: Sequence[object],
|
||||
) -> None:
|
||||
"""
|
||||
Emit response-header metadata when MCP tools were filtered.
|
||||
|
|
@ -252,10 +260,10 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
def _emit_filter_metadata_safe(
|
||||
self,
|
||||
data: dict,
|
||||
mcp_tools: list[object],
|
||||
filtered_mcp_tools: list[object],
|
||||
native_tools: list[object],
|
||||
filtered_tools: list[object],
|
||||
mcp_tools: Sequence[object],
|
||||
filtered_mcp_tools: Sequence[object],
|
||||
native_tools: Sequence[object],
|
||||
filtered_tools: Sequence[object],
|
||||
) -> None:
|
||||
"""
|
||||
Emit filter metadata without letting an emission failure abort the
|
||||
|
|
@ -375,7 +383,7 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
)
|
||||
|
||||
if mcp_tools:
|
||||
filtered_mcp_tools = await self.filter.filter_tools(
|
||||
filtered_mcp_tools: list[object] = await self.filter.filter_tools(
|
||||
query=user_query,
|
||||
available_tools=mcp_tools,
|
||||
)
|
||||
|
|
@ -419,9 +427,9 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
self,
|
||||
data: dict,
|
||||
user_api_key_dict: "UserAPIKeyAuth",
|
||||
response: Any,
|
||||
response: object,
|
||||
request_headers: dict[str, str] | None = None,
|
||||
litellm_call_info: dict[str, Any] | None = None,
|
||||
litellm_call_info: dict[str, object] | None = None,
|
||||
) -> dict[str, str] | None:
|
||||
"""Add semantic filter stats and tool names to response headers."""
|
||||
from litellm.constants import MAX_MCP_SEMANTIC_FILTER_TOOLS_HEADER_LENGTH
|
||||
|
|
@ -446,7 +454,7 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
|
||||
return headers
|
||||
|
||||
def _get_tool_names_csv(self, tools: list[Any]) -> str:
|
||||
def _get_tool_names_csv(self, tools: Sequence[object]) -> str:
|
||||
"""Extract tool names and return as CSV string."""
|
||||
if not tools:
|
||||
return ""
|
||||
|
|
@ -461,7 +469,7 @@ class SemanticToolFilterHook(CustomLogger):
|
|||
|
||||
@staticmethod
|
||||
async def initialize_from_config(
|
||||
config: dict[str, Any] | None,
|
||||
config: SemanticToolFilterConfig | None,
|
||||
llm_router: Optional["Router"],
|
||||
) -> Optional["SemanticToolFilterHook"]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import json
|
|||
import re
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from collections.abc import Mapping, MutableMapping, Sequence
|
||||
from datetime import datetime
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
|
|
@ -52,7 +53,7 @@ from litellm.proxy.common_utils.callback_utils import (
|
|||
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
|
||||
|
||||
# Cache special headers as a frozenset for O(1) lookup performance
|
||||
_SPECIAL_HEADERS_CACHE: Final = frozenset(v.value.lower() for v in SpecialHeaders._member_map_.values())
|
||||
_SPECIAL_HEADERS_CACHE: Final = frozenset(str(v.value).lower() for v in SpecialHeaders)
|
||||
|
||||
_REDACTED_HEADER_VALUE: Final = "***REDACTED***"
|
||||
_CREDENTIAL_HEADER_NAMES: Final = SpecialHeaders.litellm_credential_header_names() | frozenset(
|
||||
|
|
@ -123,7 +124,7 @@ def _stampable_key_hash(user_api_key_dict: UserAPIKeyAuth) -> str | None:
|
|||
_ANTHROPIC_SESSION_ID_VALUE_RE: Final = re.compile(r"^[a-zA-Z0-9_\-]+$")
|
||||
|
||||
|
||||
def _sanitize_for_log(value: Any) -> str:
|
||||
def _sanitize_for_log(value: object) -> str:
|
||||
"""
|
||||
Basic log sanitization helper to reduce log-injection risk.
|
||||
|
||||
|
|
@ -161,7 +162,7 @@ _ENABLE_TEAM_STALE_ALIAS_BYPASS: bool | None = None
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from litellm.proxy.proxy_server import ProxyConfig as _ProxyConfig
|
||||
from litellm.types.proxy.policy_engine import PolicyMatchContext
|
||||
from litellm.types.proxy.policy_engine import Policy, PolicyMatchContext
|
||||
|
||||
ProxyConfig = _ProxyConfig
|
||||
else:
|
||||
|
|
@ -318,7 +319,7 @@ _ALLOW_CLIENT_PRICING_OVERRIDE_METADATA_KEY: Final = "allow_client_pricing_overr
|
|||
_URL_DESTINATION_REQUEST_FIELDS: Final = ("model", "file_id")
|
||||
|
||||
|
||||
def _reject_url_valued_destinations(data: dict[str, Any]) -> None:
|
||||
def _reject_url_valued_destinations(data: dict[str, object]) -> None:
|
||||
"""Reject URL-valued ``model``/``file_id`` unless admin-allowlisted.
|
||||
|
||||
Some providers (HuggingFace, Oobabooga, Gemini files) accept a URL in the
|
||||
|
|
@ -377,7 +378,7 @@ def _invalid_metadata_type_error(field: str, value: object) -> ProxyException:
|
|||
)
|
||||
|
||||
|
||||
def _normalized_metadata_object(field: str, value: object) -> Mapping[str, Any]:
|
||||
def _normalized_metadata_object(field: str, value: object) -> Mapping[str, object]:
|
||||
"""Return ``value`` as a metadata object or raise a 400 like OpenAI does.
|
||||
|
||||
A JSON string that parses to an object is accepted because multipart/form-data
|
||||
|
|
@ -392,6 +393,23 @@ def _normalized_metadata_object(field: str, value: object) -> Mapping[str, Any]:
|
|||
raise _invalid_metadata_type_error(field=field, value=value)
|
||||
|
||||
|
||||
def _normalized_metadata_slot(
|
||||
request_data: MutableMapping[str, object], metadata_variable_name: str
|
||||
) -> dict[str, object]:
|
||||
"""Return the request's metadata slot as a dict, normalising it in place first.
|
||||
|
||||
Metadata can arrive as a JSON string (multipart/form-data, ``extra_body``). Parsing it here keeps
|
||||
existing entries alive through a merge instead of silently overwriting them with an empty dict.
|
||||
"""
|
||||
raw: Final = request_data.get(metadata_variable_name)
|
||||
if isinstance(raw, dict):
|
||||
return raw
|
||||
parsed: Final = safe_json_loads(raw) if isinstance(raw, str) else None
|
||||
normalized: Final[dict[str, object]] = parsed if isinstance(parsed, dict) else {}
|
||||
request_data[metadata_variable_name] = normalized
|
||||
return normalized
|
||||
|
||||
|
||||
def _strip_untrusted_request_header_controls(
|
||||
headers: Any,
|
||||
*,
|
||||
|
|
@ -407,7 +425,7 @@ def _strip_untrusted_request_header_controls(
|
|||
headers.pop(header_name, None)
|
||||
|
||||
|
||||
def _is_false_like(value: Any) -> bool:
|
||||
def _is_false_like(value: object) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value is False
|
||||
if isinstance(value, str):
|
||||
|
|
@ -452,7 +470,7 @@ def _key_or_team_allows_client_pricing_override(
|
|||
)
|
||||
|
||||
|
||||
def _strip_client_message_redaction_opt_out(data: dict[str, Any]) -> None:
|
||||
def _strip_client_message_redaction_opt_out(data: dict[str, object]) -> None:
|
||||
stripped: Final[list[str]] = []
|
||||
if "turn_off_message_logging" in data and _is_false_like(data["turn_off_message_logging"]):
|
||||
stripped.append("turn_off_message_logging")
|
||||
|
|
@ -503,7 +521,7 @@ def _strip_client_callback_credentials(
|
|||
)
|
||||
|
||||
|
||||
def _strip_client_pricing_overrides(data: dict[str, Any]) -> None:
|
||||
def _strip_client_pricing_overrides(data: dict[str, object]) -> None:
|
||||
"""Drop pricing overrides from the request body and any metadata variant.
|
||||
|
||||
Skipped only when the calling key/team carries
|
||||
|
|
@ -556,9 +574,9 @@ def _get_metadata_variable_name(request: Request) -> str:
|
|||
|
||||
|
||||
def _promoted_trace_control_fields(
|
||||
requester_metadata: Mapping[str, Any],
|
||||
litellm_metadata: Mapping[str, Any],
|
||||
) -> tuple[tuple[str, Any], ...]:
|
||||
requester_metadata: Mapping[str, object],
|
||||
litellm_metadata: Mapping[str, object],
|
||||
) -> tuple[tuple[str, object], ...]:
|
||||
"""Return the caller's trace-control fields that ``litellm_metadata`` does not already set."""
|
||||
return tuple(
|
||||
(key, value)
|
||||
|
|
@ -1169,7 +1187,7 @@ class LiteLLMProxyRequestSetup:
|
|||
def add_litellm_data_for_backend_llm_call(
|
||||
*,
|
||||
headers: dict,
|
||||
request_data: Mapping[str, Any],
|
||||
request_data: Mapping[str, object],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
general_settings: dict[str, Any] | None = None,
|
||||
) -> LitellmDataForBackendLLMCall:
|
||||
|
|
@ -1549,14 +1567,7 @@ class LiteLLMProxyRequestSetup:
|
|||
return
|
||||
|
||||
_metadata_variable_name: Final = get_metadata_variable_name_from_kwargs(request_data)
|
||||
metadata = request_data.get(_metadata_variable_name)
|
||||
if isinstance(metadata, str):
|
||||
parsed: Final = safe_json_loads(metadata)
|
||||
metadata = parsed if isinstance(parsed, dict) else {}
|
||||
request_data[_metadata_variable_name] = metadata
|
||||
elif not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
request_data[_metadata_variable_name] = metadata
|
||||
metadata: Final = _normalized_metadata_slot(request_data, _metadata_variable_name)
|
||||
|
||||
existing_tags: Final = metadata.get("tags")
|
||||
metadata["tags"] = LiteLLMProxyRequestSetup._merge_tags(
|
||||
|
|
@ -1608,18 +1619,7 @@ class LiteLLMProxyRequestSetup:
|
|||
# from (litellm_metadata vs metadata) so the merged tags are visible
|
||||
# to _tag_max_budget_check.
|
||||
_metadata_variable_name: Final = get_metadata_variable_name_from_kwargs(request_data)
|
||||
metadata = request_data.get(_metadata_variable_name)
|
||||
# metadata can arrive as a JSON string (multipart/form-data, extra_body).
|
||||
# Parse it so existing tags survive the merge — overwriting the string
|
||||
# with {} would let a caller bypass _tag_max_budget_check on an
|
||||
# over-budget body tag by also sending a within-budget header tag.
|
||||
if isinstance(metadata, str):
|
||||
parsed: Final = safe_json_loads(metadata)
|
||||
metadata = parsed if isinstance(parsed, dict) else {}
|
||||
request_data[_metadata_variable_name] = metadata
|
||||
elif not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
request_data[_metadata_variable_name] = metadata
|
||||
metadata: Final = _normalized_metadata_slot(request_data, _metadata_variable_name)
|
||||
|
||||
existing_tags: Final = metadata.get("tags")
|
||||
metadata["tags"] = LiteLLMProxyRequestSetup._merge_tags(
|
||||
|
|
@ -1759,7 +1759,7 @@ async def add_litellm_data_to_request(
|
|||
# admin-injection strip below so the audit / spend-tracking consumers of
|
||||
# proxy_server_request["body"] see the cleaned metadata rather than
|
||||
# attacker-forged user_api_key_* fields.
|
||||
_litellm_received_at: Final = getattr(request.state, "litellm_received_at", None)
|
||||
_litellm_received_at: Final[datetime | None] = getattr(request.state, "litellm_received_at", None)
|
||||
arrival_time: Final = _litellm_received_at.timestamp() if _litellm_received_at is not None else time.time()
|
||||
data["proxy_server_request"] = {
|
||||
"url": str(request.url),
|
||||
|
|
@ -2423,16 +2423,16 @@ def _resolve_provider_from_deployment(
|
|||
if deployment is None:
|
||||
continue
|
||||
|
||||
litellm_params = getattr(deployment, "litellm_params", None)
|
||||
litellm_params: object = getattr(deployment, "litellm_params", None)
|
||||
if litellm_params is None:
|
||||
continue
|
||||
|
||||
custom_provider = getattr(litellm_params, "custom_llm_provider", None)
|
||||
if custom_provider:
|
||||
if isinstance(custom_provider, str) and custom_provider:
|
||||
return custom_provider
|
||||
|
||||
deployment_model = getattr(litellm_params, "model", "") or ""
|
||||
if "/" in deployment_model:
|
||||
deployment_model = getattr(litellm_params, "model", "")
|
||||
if isinstance(deployment_model, str) and "/" in deployment_model:
|
||||
return deployment_model.split("/", 1)[0]
|
||||
|
||||
return None
|
||||
|
|
@ -2855,8 +2855,8 @@ def _extract_policy_id(s: str) -> str | None:
|
|||
def _match_and_track_policies(
|
||||
data: dict,
|
||||
context: "PolicyMatchContext",
|
||||
request_body_policies: Any,
|
||||
policies_override: dict[str, Any] | None = None,
|
||||
request_body_policies: Sequence[str],
|
||||
policies_override: dict[str, "Policy"] | None = None,
|
||||
) -> tuple[list[str], dict[str, str]]:
|
||||
"""
|
||||
Match policies via attachments and request body, track them in metadata.
|
||||
|
|
@ -2914,7 +2914,7 @@ def _apply_resolved_guardrails_to_metadata(
|
|||
metadata_variable_name: str,
|
||||
context: "PolicyMatchContext",
|
||||
policy_names: list[str] | None = None,
|
||||
policies: dict[str, Any] | None = None,
|
||||
policies: dict[str, "Policy"] | None = None,
|
||||
) -> None:
|
||||
"""Apply resolved guardrails and pipelines to request metadata."""
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -3044,7 +3044,7 @@ async def add_guardrails_from_policy_engine(
|
|||
request_body_names.append(item)
|
||||
|
||||
# Resolve policy versions by ID from in-memory cache (populated by sync job; no DB in hot path)
|
||||
merged_policies: Final[dict[str, Any]] = dict(registry.get_all_policies())
|
||||
merged_policies: Final[dict[str, Policy]] = dict(registry.get_all_policies())
|
||||
fetched_policy_names: Final[list[str]] = []
|
||||
for policy_id in request_body_version_ids:
|
||||
result = registry.get_policy_by_id_for_request(policy_id=policy_id)
|
||||
|
|
|
|||
|
|
@ -2467,7 +2467,7 @@ async def _validate_update_key_data(
|
|||
user_api_key_dict: UserAPIKeyAuth,
|
||||
llm_router: Router | None,
|
||||
premium_user: bool,
|
||||
prisma_client: Any,
|
||||
prisma_client: PrismaClient | None,
|
||||
user_api_key_cache: UserApiKeyCache,
|
||||
) -> None:
|
||||
"""Validate permissions and constraints for key update."""
|
||||
|
|
@ -3700,7 +3700,7 @@ async def info_key_fn(
|
|||
except Exception:
|
||||
# if using pydantic v1
|
||||
key_info = key_info.dict() # pyright: ignore[reportDeprecated] # deliberate pydantic v1 fallback
|
||||
key_token_hash: Final = key_info.pop("token")
|
||||
key_token_hash: Final[str | None] = key_info.pop("token")
|
||||
|
||||
model_max_budget = key_info.get("model_max_budget") or {}
|
||||
budget_table: Final = key_info.get("litellm_budget_table") or {}
|
||||
|
|
@ -5155,7 +5155,7 @@ def _validate_reset_spend_value(reset_to: object, key_in_db: LiteLLM_Verificatio
|
|||
|
||||
max_budget = key_in_db.max_budget
|
||||
if key_in_db.litellm_budget_table is not None:
|
||||
budget_max_budget: Final = getattr(key_in_db.litellm_budget_table, "max_budget", None)
|
||||
budget_max_budget: Final[float | None] = getattr(key_in_db.litellm_budget_table, "max_budget", None)
|
||||
if budget_max_budget is not None:
|
||||
if max_budget is None or budget_max_budget < max_budget:
|
||||
max_budget = budget_max_budget
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import copy
|
|||
import json
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING, Any, Final, Literal, cast
|
||||
from typing import TYPE_CHECKING, Final, Literal, cast
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import Response, StreamingResponse
|
||||
|
|
@ -90,7 +90,7 @@ class _ApplyPoliciesResultBase(TypedDict):
|
|||
class ApplyPoliciesResult(_ApplyPoliciesResultBase, total=False):
|
||||
"""Result of apply_policies. agent_response set when agent_id provided."""
|
||||
|
||||
agent_response: Any
|
||||
agent_response: object
|
||||
|
||||
|
||||
class _ApplyPoliciesPerItemResultBase(TypedDict):
|
||||
|
|
@ -103,7 +103,7 @@ class _ApplyPoliciesPerItemResultBase(TypedDict):
|
|||
class ApplyPoliciesPerItemResult(_ApplyPoliciesPerItemResultBase, total=False):
|
||||
"""Result for one input when using inputs_list. agent_response set when agent_id provided."""
|
||||
|
||||
agent_response: Any
|
||||
agent_response: object
|
||||
|
||||
|
||||
class ApplyPoliciesListResult(TypedDict):
|
||||
|
|
@ -295,8 +295,8 @@ async def test_policies_and_guardrails(
|
|||
from litellm.proxy.proxy_server import chat_completion, proxy_logging_obj
|
||||
from litellm.proxy.utils import handle_exception_on_proxy
|
||||
|
||||
def _serialize_chat_response(response: Any) -> Any:
|
||||
if hasattr(response, "model_dump"):
|
||||
def _serialize_chat_response(response: object) -> object:
|
||||
if isinstance(response, BaseModel):
|
||||
return response.model_dump(exclude_unset=True)
|
||||
if isinstance(response, dict):
|
||||
return response
|
||||
|
|
@ -306,7 +306,7 @@ async def test_policies_and_guardrails(
|
|||
inputs: GenericGuardrailAPIInputs,
|
||||
agent_id: str,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
) -> Any:
|
||||
) -> object:
|
||||
body: Final = _chat_body_from_inputs(inputs, agent_id, data.request_data)
|
||||
req: Final = _request_with_json_body(body)
|
||||
resp: Final = Response()
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import os
|
|||
import re
|
||||
from collections.abc import Callable, Mapping
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Annotated, Any, Final, cast
|
||||
from typing import TYPE_CHECKING, Annotated, Final, cast
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, WebSocket
|
||||
|
|
@ -64,6 +64,7 @@ from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
|||
)
|
||||
from litellm.types.passthrough_endpoints.vertex_ai import VertexPassThroughCredentials
|
||||
from litellm.types.utils import LlmProviders
|
||||
from litellm.types.vector_stores import LiteLLM_ManagedVectorStore
|
||||
from litellm.utils import ProviderConfigManager
|
||||
|
||||
from .passthrough_endpoint_router import PassthroughEndpointRouter
|
||||
|
|
@ -112,7 +113,21 @@ def is_passthrough_request_streaming(request_body: object) -> bool:
|
|||
return bool(request_body.get("stream", False))
|
||||
|
||||
|
||||
def get_passthrough_router_request_metadata(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, Any]:
|
||||
def _optional_str(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _string_keyed_mapping(value: object) -> Mapping[str, object] | None:
|
||||
if isinstance(value, Mapping):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
async def _json_request_body(request: Request) -> Mapping[str, object]:
|
||||
return await request.json()
|
||||
|
||||
|
||||
def get_passthrough_router_request_metadata(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, object]:
|
||||
"""
|
||||
Build the request metadata carrying key-level spend attribution and the
|
||||
pre-call budget reservation for a router-model passthrough request.
|
||||
|
|
@ -201,7 +216,7 @@ async def llm_passthrough_factory_proxy_route(
|
|||
# anthropic is streaming when 'stream' = True is in the body
|
||||
if request.method == "POST":
|
||||
if "multipart/form-data" not in request.headers.get("content-type", ""):
|
||||
_request_body = await request.json()
|
||||
_request_body = await _json_request_body(request)
|
||||
else:
|
||||
_request_body = await get_form_data(request)
|
||||
|
||||
|
|
@ -374,7 +389,7 @@ async def vllm_proxy_route(
|
|||
endpoint=endpoint,
|
||||
request_query_params=request.query_params,
|
||||
request_headers=_safe_get_request_headers(request),
|
||||
stream=request_body.get("stream", False),
|
||||
stream=is_streaming_request,
|
||||
content=None,
|
||||
data=None,
|
||||
files=None,
|
||||
|
|
@ -802,7 +817,7 @@ async def handle_bedrock_passthrough_router_model(
|
|||
|
||||
# Use the common processing path (same as non-router models)
|
||||
# This ensures all metadata, hooks, and logging are properly initialized
|
||||
data: Final[dict[str, Any]] = {}
|
||||
data: Final[dict[str, object]] = {}
|
||||
base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data)
|
||||
|
||||
data["model"] = model
|
||||
|
|
@ -846,8 +861,8 @@ async def handle_bedrock_count_tokens(
|
|||
request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
request_body: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
request_body: dict[str, object],
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Handle AWS Bedrock CountTokens API requests.
|
||||
|
||||
|
|
@ -864,7 +879,7 @@ async def handle_bedrock_count_tokens(
|
|||
handler: Final = BedrockCountTokensHandler()
|
||||
|
||||
# Extract model from request body
|
||||
model: Final = request_body.get("model")
|
||||
model: Final = _optional_str(request_body.get("model"))
|
||||
if not model:
|
||||
raise HTTPException(status_code=400, detail={"error": "Model is required in request body"})
|
||||
|
||||
|
|
@ -996,7 +1011,7 @@ async def bedrock_llm_proxy_route(
|
|||
"Bedrock passthrough: Using direct Bedrock model '%s' for endpoint '%s'", model, endpoint
|
||||
)
|
||||
|
||||
data: Final[dict[str, Any]] = {}
|
||||
data: Final[dict[str, object]] = {}
|
||||
base_llm_response_processor: Final = ProxyBaseLLMRequestProcessing(data=data)
|
||||
|
||||
data["method"] = request.method
|
||||
|
|
@ -1095,7 +1110,7 @@ async def bedrock_proxy_route(
|
|||
headers: Final = {"Content-Type": "application/json"}
|
||||
# Assuming the body contains JSON data, parse it
|
||||
try:
|
||||
data: Final = await request.json()
|
||||
data: Final = await _json_request_body(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail={"error": e})
|
||||
_request: Final = AWSRequest(method="POST", url=str(updated_url), data=json.dumps(data), headers=headers)
|
||||
|
|
@ -1186,7 +1201,7 @@ async def comprehend_medical_proxy_route(
|
|||
)
|
||||
|
||||
try:
|
||||
data: Final = await request.json()
|
||||
data: Final = await _json_request_body(request)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
|
@ -1397,7 +1412,7 @@ async def assemblyai_proxy_route(
|
|||
is_streaming_request = False
|
||||
# assemblyai is streaming when 'stream' = True is in the body
|
||||
if request.method == "POST":
|
||||
_request_body: Final = await request.json()
|
||||
_request_body: Final = await _json_request_body(request)
|
||||
if _request_body.get("stream"):
|
||||
is_streaming_request = True
|
||||
|
||||
|
|
@ -1504,7 +1519,7 @@ async def azure_proxy_route(
|
|||
endpoint=endpoint,
|
||||
request_query_params=request.query_params,
|
||||
request_headers=_safe_get_request_headers(request),
|
||||
stream=request_body.get("stream", False),
|
||||
stream=is_streaming_request,
|
||||
content=None,
|
||||
data=None,
|
||||
files=None,
|
||||
|
|
@ -1591,7 +1606,7 @@ async def azure_proxy_route(
|
|||
|
||||
extra_headers = auth_credentials.get("headers") or {}
|
||||
|
||||
base_target_url = litellm_params.get("api_base")
|
||||
base_target_url = _optional_str(litellm_params.get("api_base"))
|
||||
if base_target_url is None:
|
||||
raise Exception(f"API base not found for {part}")
|
||||
return await BaseOpenAIPassThroughHandler._base_openai_pass_through_handler(
|
||||
|
|
@ -1712,7 +1727,7 @@ def get_vertex_pass_through_handler(
|
|||
|
||||
|
||||
def _override_vertex_params_from_router_credentials(
|
||||
router_credentials: Any | None,
|
||||
router_credentials: LiteLLM_ManagedVectorStore | None,
|
||||
vertex_project: str | None,
|
||||
vertex_location: str | None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
|
|
@ -1732,14 +1747,14 @@ def _override_vertex_params_from_router_credentials(
|
|||
|
||||
verbose_proxy_logger.debug("Using vector store credentials to override vertex project and location")
|
||||
|
||||
litellm_params: Final = router_credentials.get("litellm_params", {})
|
||||
litellm_params: Final = _string_keyed_mapping(router_credentials.get("litellm_params"))
|
||||
if not litellm_params:
|
||||
verbose_proxy_logger.warning("Vector store credentials found but litellm_params is empty")
|
||||
return vertex_project, vertex_location
|
||||
|
||||
# Extract vertex_project and vertex_location from litellm_params
|
||||
vector_store_project: Final = litellm_params.get("vertex_project")
|
||||
vector_store_location: Final = litellm_params.get("vertex_location")
|
||||
vector_store_project: Final = _optional_str(litellm_params.get("vertex_project"))
|
||||
vector_store_location: Final = _optional_str(litellm_params.get("vertex_location"))
|
||||
|
||||
if vector_store_project:
|
||||
verbose_proxy_logger.debug(
|
||||
|
|
@ -1747,7 +1762,6 @@ def _override_vertex_params_from_router_credentials(
|
|||
vertex_project,
|
||||
vector_store_project,
|
||||
)
|
||||
vertex_project = vector_store_project
|
||||
else:
|
||||
verbose_proxy_logger.warning("Vector store credentials found but missing vertex_project in litellm_params")
|
||||
|
||||
|
|
@ -1757,11 +1771,10 @@ def _override_vertex_params_from_router_credentials(
|
|||
vertex_location,
|
||||
vector_store_location,
|
||||
)
|
||||
vertex_location = vector_store_location
|
||||
else:
|
||||
verbose_proxy_logger.warning("Vector store credentials found but missing vertex_location in litellm_params")
|
||||
|
||||
return vertex_project, vertex_location
|
||||
return vector_store_project or vertex_project, vector_store_location or vertex_location
|
||||
|
||||
|
||||
_CREDENTIALLESS_VERTEX_MISSING_CREDENTIAL_DETAIL: Final = (
|
||||
|
|
@ -1869,8 +1882,8 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(
|
|||
|
||||
async def _prepare_vertex_auth_headers(
|
||||
request: Request,
|
||||
vertex_credentials: Any | None,
|
||||
router_credentials: Any | None,
|
||||
vertex_credentials: VertexPassThroughCredentials | None,
|
||||
router_credentials: LiteLLM_ManagedVectorStore | None,
|
||||
vertex_project: str | None,
|
||||
vertex_location: str | None,
|
||||
base_target_url: str | None,
|
||||
|
|
@ -1967,7 +1980,7 @@ async def _base_vertex_proxy_route(
|
|||
fastapi_response: Response,
|
||||
get_vertex_pass_through_handler: BaseVertexAIPassThroughHandler,
|
||||
user_api_key_dict: UserAPIKeyAuth | None = None,
|
||||
router_credentials: Any | None = None,
|
||||
router_credentials: LiteLLM_ManagedVectorStore | None = None,
|
||||
):
|
||||
"""
|
||||
Base function for Vertex AI passthrough routes.
|
||||
|
|
@ -2851,7 +2864,7 @@ async def watsonx_proxy_route(
|
|||
is_streaming_request = False
|
||||
if request.method == "POST":
|
||||
if "multipart/form-data" not in request.headers.get("content-type", ""):
|
||||
_request_body = await request.json()
|
||||
_request_body = await _json_request_body(request)
|
||||
else:
|
||||
_request_body = await get_form_data(request)
|
||||
|
||||
|
|
|
|||
|
|
@ -3146,6 +3146,14 @@ def _get_pass_through_endpoints_from_config() -> list[PassThroughGenericEndpoint
|
|||
return returned_endpoints
|
||||
|
||||
|
||||
def _config_field_endpoints(response: ConfigFieldInfo) -> list[object] | None:
|
||||
return response.field_value
|
||||
|
||||
|
||||
def _request_app(request: Request) -> FastAPI:
|
||||
return request.app
|
||||
|
||||
|
||||
async def _get_pass_through_endpoints_from_db(
|
||||
endpoint_id: str | None = None,
|
||||
user_api_key_dict: UserAPIKeyAuth | None = None,
|
||||
|
|
@ -3162,7 +3170,7 @@ async def _get_pass_through_endpoints_from_db(
|
|||
except Exception:
|
||||
return []
|
||||
|
||||
pass_through_endpoint_data: Final[list | None] = response.field_value
|
||||
pass_through_endpoint_data: Final = _config_field_endpoints(response)
|
||||
if pass_through_endpoint_data is None:
|
||||
return []
|
||||
|
||||
|
|
@ -3325,7 +3333,7 @@ async def update_pass_through_endpoints(
|
|||
detail={"error": "No pass-through endpoints found"},
|
||||
)
|
||||
|
||||
pass_through_endpoint_data: Final[list | None] = response.field_value
|
||||
pass_through_endpoint_data: Final[list | None] = _config_field_endpoints(response)
|
||||
if pass_through_endpoint_data is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
|
|
@ -3396,7 +3404,7 @@ async def update_pass_through_endpoints(
|
|||
_custom_headers: dict | None = updated_endpoint.headers or {}
|
||||
_custom_headers = await set_env_variables_in_header(custom_headers=_custom_headers)
|
||||
|
||||
route_app: Final[FastAPI] = request.app
|
||||
route_app: Final = _request_app(request)
|
||||
if updated_endpoint.include_subpath:
|
||||
InitPassThroughEndpointHelpers.add_subpath_route(
|
||||
app=route_app,
|
||||
|
|
@ -3488,7 +3496,7 @@ async def create_pass_through_endpoints(
|
|||
_custom_headers: dict | None = created_endpoint.headers or {}
|
||||
_custom_headers = await set_env_variables_in_header(custom_headers=_custom_headers)
|
||||
|
||||
route_app: Final[FastAPI] = request.app
|
||||
route_app: Final = _request_app(request)
|
||||
if created_endpoint.include_subpath:
|
||||
InitPassThroughEndpointHelpers.add_subpath_route(
|
||||
app=route_app,
|
||||
|
|
@ -3556,7 +3564,7 @@ async def delete_pass_through_endpoints(
|
|||
response = ConfigFieldInfo(field_name="pass_through_endpoints", field_value=None)
|
||||
|
||||
## Update field by removing endpoint
|
||||
pass_through_endpoint_data: Final[list | None] = response.field_value
|
||||
pass_through_endpoint_data: Final[list | None] = _config_field_endpoints(response)
|
||||
if response.field_value is None or pass_through_endpoint_data is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
|
|||
|
|
@ -7,12 +7,14 @@ Provides:
|
|||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
import orjson
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
||||
from fastapi.responses import ORJSONResponse, StreamingResponse
|
||||
from starlette.datastructures import UploadFile
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -45,6 +47,16 @@ if TYPE_CHECKING:
|
|||
router: Final = APIRouter()
|
||||
|
||||
|
||||
def _as_string_keyed_mapping(value: object) -> Mapping[str, object] | None:
|
||||
if isinstance(value, Mapping):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _response_attr(source: object, name: str) -> object:
|
||||
return getattr(source, name, None)
|
||||
|
||||
|
||||
def _raise_vector_store_scan_depth_exceeded() -> None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
|
|
@ -53,8 +65,8 @@ def _raise_vector_store_scan_depth_exceeded() -> None:
|
|||
|
||||
|
||||
def _append_payload_to_scan_stack(
|
||||
payload_stack: list[tuple[Any, int]],
|
||||
value: Any,
|
||||
payload_stack: list[tuple[object, int]],
|
||||
value: object,
|
||||
next_depth: int,
|
||||
) -> None:
|
||||
if isinstance(value, dict):
|
||||
|
|
@ -117,7 +129,7 @@ async def _authorize_nested_vector_store_ids(
|
|||
|
||||
|
||||
def _build_file_metadata_entry(
|
||||
response: Any,
|
||||
response: object,
|
||||
file_data: tuple[str, bytes, str] | None = None,
|
||||
file_url: str | None = None,
|
||||
) -> Mapping[str, str | int | None]:
|
||||
|
|
@ -135,11 +147,11 @@ def _build_file_metadata_entry(
|
|||
from datetime import datetime, timezone
|
||||
|
||||
# Extract file_id from response
|
||||
file_id = None
|
||||
if hasattr(response, "get"):
|
||||
file_id = response.get("file_id")
|
||||
elif hasattr(response, "file_id"):
|
||||
file_id = response.file_id
|
||||
mapping_response: Final = _as_string_keyed_mapping(response)
|
||||
raw_file_id: Final = (
|
||||
mapping_response.get("file_id") if mapping_response is not None else _response_attr(response, "file_id")
|
||||
)
|
||||
file_id: Final = raw_file_id if isinstance(raw_file_id, str) else None
|
||||
|
||||
# Extract file information from file_data tuple
|
||||
filename = None
|
||||
|
|
@ -152,7 +164,7 @@ def _build_file_metadata_entry(
|
|||
content_type = file_data[2] if len(file_data) > 2 else None
|
||||
|
||||
# Build file metadata entry
|
||||
file_entry: Final = {
|
||||
file_entry: Final[dict[str, str | int | None]] = {
|
||||
"file_id": file_id,
|
||||
"filename": filename,
|
||||
"file_url": file_url,
|
||||
|
|
@ -169,7 +181,7 @@ def _build_file_metadata_entry(
|
|||
|
||||
|
||||
async def _save_vector_store_to_db_from_rag_ingest(
|
||||
response: Any,
|
||||
response: object,
|
||||
ingest_options: Mapping[str, dict[str, str | None]],
|
||||
prisma_client: "PrismaClient",
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
|
|
@ -197,10 +209,11 @@ async def _save_vector_store_to_db_from_rag_ingest(
|
|||
)
|
||||
|
||||
# Handle both dict and object responses
|
||||
if hasattr(response, "get"):
|
||||
vector_store_id = response.get("vector_store_id")
|
||||
mapping_response: Final = _as_string_keyed_mapping(response)
|
||||
if mapping_response is not None:
|
||||
vector_store_id = mapping_response.get("vector_store_id")
|
||||
elif hasattr(response, "vector_store_id"):
|
||||
vector_store_id = response.vector_store_id
|
||||
vector_store_id = _response_attr(response, "vector_store_id")
|
||||
else:
|
||||
verbose_proxy_logger.warning("Unable to extract vector_store_id from response type: %s", type(response))
|
||||
return
|
||||
|
|
@ -266,14 +279,13 @@ async def _save_vector_store_to_db_from_rag_ingest(
|
|||
verbose_proxy_logger.info("Vector store %s already exists, appending file to metadata", vector_store_id)
|
||||
|
||||
# Update existing vector store with new file
|
||||
existing_metadata = existing_vector_store.vector_store_metadata or {}
|
||||
if isinstance(existing_metadata, str):
|
||||
import json
|
||||
stored_metadata: Final = existing_vector_store.vector_store_metadata or {}
|
||||
existing_metadata: dict[str, object] = (
|
||||
json.loads(stored_metadata) if isinstance(stored_metadata, str) else stored_metadata
|
||||
)
|
||||
|
||||
existing_metadata = json.loads(existing_metadata)
|
||||
|
||||
ingested_files: Final = existing_metadata.get("ingested_files", [])
|
||||
ingested_files.append(file_entry)
|
||||
previous_files: Final = existing_metadata.get("ingested_files", [])
|
||||
ingested_files: Final = [*previous_files, file_entry] if isinstance(previous_files, list) else [file_entry]
|
||||
existing_metadata["ingested_files"] = ingested_files
|
||||
|
||||
# Update the vector store
|
||||
|
|
@ -340,9 +352,9 @@ async def parse_rag_ingest_request(
|
|||
|
||||
# Get file
|
||||
file_obj = form_data.get("file")
|
||||
if file_obj is not None and hasattr(file_obj, "read"):
|
||||
if isinstance(file_obj, UploadFile):
|
||||
file_content = await file_obj.read(MAX_UPLOAD_SIZE_BYTES + 1)
|
||||
file_data = (file_obj.filename, file_content, file_obj.content_type)
|
||||
file_data = (file_obj.filename or "", file_content, file_obj.content_type or "")
|
||||
|
||||
# Parse JSON from 'request' form field (contains full request body as JSON)
|
||||
request_json_str: Final[str | bytes | None] = form_data.get("request")
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ https://platform.openai.com/docs/api-reference/responses-streaming
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from collections.abc import Callable, Sequence
|
||||
from typing import TYPE_CHECKING, Final, TypedDict, cast
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
|
@ -38,19 +38,55 @@ class _StreamOutputItem(TypedDict, total=False):
|
|||
content: ReadOnly[Sequence[_StreamContentPart | None]]
|
||||
|
||||
|
||||
class _StreamResponsePayload(TypedDict, total=False):
|
||||
status: ReadOnly[str]
|
||||
error: ReadOnly[dict[str, object] | None]
|
||||
usage: ReadOnly[dict[str, object] | None]
|
||||
reasoning: ReadOnly[dict[str, object] | None]
|
||||
tool_choice: ReadOnly[object]
|
||||
tools: ReadOnly[list[object] | None]
|
||||
model: ReadOnly[str | None]
|
||||
instructions: ReadOnly[str | None]
|
||||
temperature: ReadOnly[float | None]
|
||||
top_p: ReadOnly[float | None]
|
||||
max_output_tokens: ReadOnly[int | None]
|
||||
previous_response_id: ReadOnly[str | None]
|
||||
text: ReadOnly[dict[str, object] | None]
|
||||
truncation: ReadOnly[str | None]
|
||||
parallel_tool_calls: ReadOnly[bool | None]
|
||||
user: ReadOnly[str | None]
|
||||
store: ReadOnly[bool | None]
|
||||
incomplete_details: ReadOnly[dict[str, object] | None]
|
||||
output: ReadOnly[Sequence[_StreamOutputItem]]
|
||||
|
||||
|
||||
class _StreamEvent(TypedDict, total=False):
|
||||
type: ReadOnly[str]
|
||||
item: ReadOnly[_StreamOutputItem]
|
||||
item_id: ReadOnly[str]
|
||||
part: ReadOnly[_StreamContentPart]
|
||||
content_index: ReadOnly[int]
|
||||
delta: ReadOnly[str]
|
||||
response: ReadOnly[_StreamResponsePayload]
|
||||
|
||||
|
||||
def _parse_stream_event(serialized_event: str) -> _StreamEvent:
|
||||
return json.loads(serialized_event)
|
||||
|
||||
|
||||
async def background_streaming_task(
|
||||
polling_id: str,
|
||||
data,
|
||||
data: dict[str, object],
|
||||
polling_handler: ResponsePollingHandler,
|
||||
request: Request,
|
||||
fastapi_response: Response,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
general_settings,
|
||||
general_settings: dict[str, object],
|
||||
llm_router: "Router | None",
|
||||
proxy_config: "ProxyConfig",
|
||||
proxy_logging_obj: "ProxyLogging",
|
||||
select_data_generator,
|
||||
user_model,
|
||||
select_data_generator: Callable[..., object] | None,
|
||||
user_model: str | None,
|
||||
user_temperature: float | None,
|
||||
user_request_timeout: float | None,
|
||||
user_max_tokens: int | None,
|
||||
|
|
@ -180,7 +216,7 @@ async def background_streaming_task(
|
|||
break
|
||||
|
||||
try:
|
||||
event = json.loads(chunk_data)
|
||||
event = _parse_stream_event(chunk_data)
|
||||
event_type = event.get("type", "")
|
||||
|
||||
# Process different event types based on OpenAI streaming spec
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from collections.abc import Mapping, Sequence
|
|||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Final, NoReturn, cast
|
||||
from typing import Final, NoReturn, SupportsFloat, SupportsIndex, SupportsInt, cast
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
|
@ -32,6 +32,7 @@ from litellm.proxy.common_utils.user_api_key_cache import (
|
|||
)
|
||||
from litellm.proxy.utils import PrismaClient, ProxyLogging
|
||||
from litellm.router import Router
|
||||
from litellm.types.router import DeploymentTypedDict
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -637,7 +638,7 @@ def _get_budget_limit_counters(
|
|||
for window in budget_limits:
|
||||
window_dict = _coerce_window(window)
|
||||
budget_duration = window_dict.get("budget_duration")
|
||||
max_budget = window_dict.get("max_budget")
|
||||
max_budget = _to_float(window_dict.get("max_budget"))
|
||||
if not budget_duration or max_budget is None or max_budget <= 0:
|
||||
continue
|
||||
window_start = get_budget_window_start(window_dict)
|
||||
|
|
@ -663,18 +664,20 @@ def _get_budget_limit_counters(
|
|||
return counters
|
||||
|
||||
|
||||
def _coerce_window(window: Any) -> dict:
|
||||
if isinstance(window, dict):
|
||||
def _coerce_window(window: object) -> Mapping[str, object]:
|
||||
if isinstance(window, Mapping):
|
||||
return window
|
||||
if isinstance(window, str):
|
||||
try:
|
||||
parsed: Final = json.loads(window)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
parsed: Final[object] = json.loads(window)
|
||||
except Exception:
|
||||
return {}
|
||||
if hasattr(window, "model_dump"):
|
||||
return window.model_dump()
|
||||
return {}
|
||||
return parsed if isinstance(parsed, Mapping) else {}
|
||||
model_dump: Final = getattr(window, "model_dump", None)
|
||||
if not callable(model_dump):
|
||||
return {}
|
||||
dumped: Final[object] = model_dump()
|
||||
return dumped if isinstance(dumped, Mapping) else {}
|
||||
|
||||
|
||||
async def _reserve_counter(
|
||||
|
|
@ -891,7 +894,7 @@ def _get_entry_reserved_cost(entry: dict, default_reserved_cost: float) -> float
|
|||
return default_reserved_cost
|
||||
|
||||
|
||||
def get_budget_window_start(window: Any) -> datetime | None:
|
||||
def get_budget_window_start(window: object) -> datetime | None:
|
||||
window_dict: Final = _coerce_window(window)
|
||||
budget_duration: Final = window_dict.get("budget_duration")
|
||||
if budget_duration is None:
|
||||
|
|
@ -909,7 +912,7 @@ def get_budget_window_start(window: Any) -> datetime | None:
|
|||
return reset_at - timedelta(seconds=duration_seconds)
|
||||
|
||||
|
||||
def _coerce_datetime(value: Any) -> datetime | None:
|
||||
def _coerce_datetime(value: object) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
|
|
@ -1183,11 +1186,11 @@ def _get_model_cost_infos(
|
|||
|
||||
|
||||
def _deployment_tiered_pricing_table(
|
||||
deployment: dict[str, Any],
|
||||
deployment: DeploymentTypedDict,
|
||||
llm_router: Router,
|
||||
) -> list[dict] | None:
|
||||
model_id: Final = deployment.get("model_info", {}).get("id")
|
||||
backend_model: Final = deployment.get("litellm_params", {}).get("model")
|
||||
) -> Sequence[Mapping[str, object]] | None:
|
||||
model_id: Final = _get_value(_get_value(deployment, "model_info"), "id")
|
||||
backend_model: Final = _get_value(_get_value(deployment, "litellm_params"), "model")
|
||||
if not isinstance(model_id, str) or not isinstance(backend_model, str):
|
||||
return None
|
||||
deployment_model_info: Final = llm_router.get_deployment_model_info(model_id=model_id, model_name=backend_model)
|
||||
|
|
@ -1352,7 +1355,7 @@ def _estimate_output_tokens(
|
|||
return min(requested, model_ceiling)
|
||||
|
||||
|
||||
def _count_text_tokens(model: str, text: Any) -> int:
|
||||
def _count_text_tokens(model: str, text: object) -> int:
|
||||
if text is None:
|
||||
return 0
|
||||
|
||||
|
|
@ -1392,8 +1395,8 @@ def _is_input_only_route(route: str) -> bool:
|
|||
)
|
||||
|
||||
|
||||
def _to_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
def _to_float(value: object) -> float | None:
|
||||
if not isinstance(value, (SupportsFloat, SupportsIndex, str, bytes, bytearray)):
|
||||
return None
|
||||
try:
|
||||
return float(value)
|
||||
|
|
@ -1401,8 +1404,8 @@ def _to_float(value: Any) -> float | None:
|
|||
return None
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int | None:
|
||||
if value is None:
|
||||
def _to_int(value: object) -> int | None:
|
||||
if not isinstance(value, (SupportsInt, SupportsIndex, str, bytes, bytearray)):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
|
|
@ -1410,7 +1413,7 @@ def _to_int(value: Any) -> int | None:
|
|||
return None
|
||||
|
||||
|
||||
def _get_value(obj: Any, key: str) -> Any:
|
||||
if isinstance(obj, dict):
|
||||
def _get_value(obj: object, key: str) -> object:
|
||||
if isinstance(obj, Mapping):
|
||||
return obj.get(key)
|
||||
return getattr(obj, key, None)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import os
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime as dt
|
||||
from typing import Any, Final, Literal, cast
|
||||
from typing import Final, Literal, Protocol, cast, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
|
@ -187,7 +188,28 @@ def get_spend_logs_id(call_type: str, response_obj: dict, kwargs: dict) -> str |
|
|||
return resolved_id
|
||||
|
||||
|
||||
def _extract_usage_for_ocr_call(response_obj: Any, response_obj_dict: dict) -> dict:
|
||||
_MISSING_ATTRIBUTE: Final = object()
|
||||
|
||||
|
||||
def _attribute_or_missing(source: object, name: str) -> object:
|
||||
return getattr(source, name, _MISSING_ATTRIBUTE)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _ModelDumpable(Protocol):
|
||||
def model_dump(self) -> object: ...
|
||||
|
||||
|
||||
def _dumped_usage_info(usage_info: object) -> object:
|
||||
if isinstance(usage_info, _ModelDumpable):
|
||||
return usage_info.model_dump()
|
||||
instance_dict: Final = _attribute_or_missing(usage_info, "__dict__")
|
||||
if instance_dict is not _MISSING_ATTRIBUTE:
|
||||
return instance_dict
|
||||
return usage_info
|
||||
|
||||
|
||||
def _extract_usage_for_ocr_call(response_obj: object, response_obj_dict: dict) -> dict:
|
||||
"""
|
||||
Extract usage information for OCR/AOCR calls.
|
||||
|
||||
|
|
@ -208,12 +230,10 @@ def _extract_usage_for_ocr_call(response_obj: Any, response_obj_dict: dict) -> d
|
|||
usage_info = response_obj_dict.get("usage_info")
|
||||
|
||||
# Try to extract usage_info from object attributes if not found in dict
|
||||
if not usage_info and hasattr(response_obj, "usage_info"):
|
||||
usage_info = response_obj.usage_info
|
||||
if hasattr(usage_info, "model_dump"):
|
||||
usage_info = usage_info.model_dump()
|
||||
elif hasattr(usage_info, "__dict__"):
|
||||
usage_info = vars(usage_info)
|
||||
if not usage_info:
|
||||
attribute_usage_info: Final = _attribute_or_missing(response_obj, "usage_info")
|
||||
if attribute_usage_info is not _MISSING_ATTRIBUTE:
|
||||
usage_info = _dumped_usage_info(attribute_usage_info)
|
||||
|
||||
# For OCR, we track pages instead of tokens
|
||||
if usage_info is not None:
|
||||
|
|
@ -549,6 +569,14 @@ def _ensure_datetime_utc(timestamp: datetime) -> datetime:
|
|||
return timestamp
|
||||
|
||||
|
||||
async def _query_raw_rows(
|
||||
prisma_client: PrismaClient,
|
||||
sql_query: str,
|
||||
*args: object,
|
||||
) -> Sequence[Mapping[str, object]] | None:
|
||||
return await prisma_client.db.query_raw(sql_query, *args)
|
||||
|
||||
|
||||
async def get_spend_by_team(
|
||||
start_date: dt,
|
||||
end_date: dt,
|
||||
|
|
@ -610,7 +638,7 @@ async def get_spend_by_team(
|
|||
group_by_day;
|
||||
"""
|
||||
|
||||
db_response: Final = await prisma_client.db.query_raw(sql_query, start_date, end_date, team_id)
|
||||
db_response: Final = await _query_raw_rows(prisma_client, sql_query, start_date, end_date, team_id)
|
||||
if db_response is None:
|
||||
return []
|
||||
|
||||
|
|
@ -685,7 +713,7 @@ async def get_spend_by_team_and_customer(
|
|||
group_by_day;
|
||||
"""
|
||||
|
||||
db_response: Final = await prisma_client.db.query_raw(sql_query, start_date, end_date, team_id, customer_id)
|
||||
db_response: Final = await _query_raw_rows(prisma_client, sql_query, start_date, end_date, team_id, customer_id)
|
||||
if db_response is None:
|
||||
return []
|
||||
|
||||
|
|
@ -740,7 +768,7 @@ def _sanitize_request_body_for_spend_logs_payload(
|
|||
return {}
|
||||
visited.add(obj_id)
|
||||
|
||||
def _sanitize_value(value: Any) -> Any:
|
||||
def _sanitize_value(value: object) -> object:
|
||||
if isinstance(value, dict):
|
||||
return _sanitize_request_body_for_spend_logs_payload(value, visited, max_string_length_prompt_in_db)
|
||||
elif isinstance(value, list):
|
||||
|
|
@ -1035,7 +1063,7 @@ def _sanitize_error_information_for_spend_logs(
|
|||
return cast(StandardLoggingPayloadErrorInformation, sanitized)
|
||||
|
||||
|
||||
def _convert_to_json_serializable_dict(obj: Any, visited: set | None = None, max_depth: int = 20) -> Any:
|
||||
def _convert_to_json_serializable_dict(obj: object, visited: set[int] | None = None, max_depth: int = 20) -> object:
|
||||
"""
|
||||
Convert object to JSON-serializable dict, handling Pydantic models safely.
|
||||
|
||||
|
|
@ -1089,6 +1117,13 @@ def _convert_to_json_serializable_dict(obj: Any, visited: set | None = None, max
|
|||
visited.remove(obj_id)
|
||||
|
||||
|
||||
def _convert_mapping_to_json_serializable(obj: Mapping[str, object]) -> dict[str, object]:
|
||||
converted: Final = _convert_to_json_serializable_dict(obj)
|
||||
if isinstance(converted, dict):
|
||||
return converted
|
||||
return dict(obj)
|
||||
|
||||
|
||||
def _get_proxy_server_request_for_spend_logs_payload(
|
||||
metadata: dict,
|
||||
litellm_params: dict,
|
||||
|
|
@ -1125,7 +1160,7 @@ def _get_proxy_server_request_for_spend_logs_payload(
|
|||
|
||||
# If redaction is enabled, convert to serializable dict before redacting
|
||||
if should_redact_message_logging(model_call_details=model_call_details):
|
||||
_request_body = _convert_to_json_serializable_dict(_request_body)
|
||||
_request_body = _convert_mapping_to_json_serializable(_request_body)
|
||||
perform_redaction(model_call_details=_request_body, result=None)
|
||||
|
||||
_request_body = _sanitize_request_body_for_spend_logs_payload(_request_body)
|
||||
|
|
@ -1170,7 +1205,7 @@ def _get_response_for_spend_logs_payload(
|
|||
if payload is None:
|
||||
return "{}"
|
||||
if _should_store_prompts_and_responses_in_spend_logs():
|
||||
response_obj: Any = payload.get("response")
|
||||
response_obj: object = payload.get("response")
|
||||
if response_obj is None:
|
||||
return "{}"
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,11 @@ import asyncio
|
|||
import json
|
||||
import os
|
||||
from collections import Counter
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from types import MappingProxyType
|
||||
from typing import (
|
||||
Any,
|
||||
Final,
|
||||
NamedTuple,
|
||||
Protocol,
|
||||
cast, # noqa: TID251 # prisma types Json columns as fields.Json but de-serializes them to plain python on read
|
||||
)
|
||||
|
|
@ -15,6 +16,7 @@ from urllib.parse import urlparse
|
|||
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
|
||||
from pydantic import ConfigDict, JsonValue, ValidationError, create_model
|
||||
from pydantic.fields import FieldInfo
|
||||
from typing_extensions import NotRequired, ReadOnly, TypedDict
|
||||
|
||||
import litellm
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -44,6 +46,31 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
|
|||
router: Final = APIRouter()
|
||||
|
||||
|
||||
JsonSchemaItems: Final = TypedDict(
|
||||
"JsonSchemaItems",
|
||||
{"$ref": ReadOnly[str], "enum": ReadOnly[Sequence[JsonValue]]},
|
||||
total=False,
|
||||
)
|
||||
|
||||
|
||||
class JsonSchemaNode(TypedDict, total=False):
|
||||
type: ReadOnly[str]
|
||||
description: ReadOnly[str]
|
||||
enum: ReadOnly[Sequence[JsonValue]]
|
||||
anyOf: ReadOnly[Sequence["JsonSchemaNode"]]
|
||||
items: ReadOnly["JsonSchemaItems"]
|
||||
properties: ReadOnly[Mapping[str, "JsonSchemaNode"]]
|
||||
|
||||
|
||||
_EMPTY_SCHEMA_DEFS: Final[Mapping[str, "JsonSchemaNode"]] = MappingProxyType({})
|
||||
|
||||
|
||||
class JsonSchemaPropertyEntry(TypedDict):
|
||||
description: ReadOnly[str]
|
||||
type: ReadOnly[str]
|
||||
items: NotRequired[ReadOnly["JsonSchemaItems"]]
|
||||
|
||||
|
||||
class _SsoSettingsMappingRow(Protocol):
|
||||
@property
|
||||
def sso_settings(self) -> Mapping[str, object] | None: ...
|
||||
|
|
@ -157,10 +184,10 @@ class UIThemeConfig(BaseModel):
|
|||
class SettingsResponse(BaseModel):
|
||||
"""Base response model for settings with values and schema information"""
|
||||
|
||||
values: dict[str, Any]
|
||||
values: dict[str, object]
|
||||
"""The current configuration values"""
|
||||
|
||||
field_schema: dict[str, Any]
|
||||
field_schema: dict[str, object]
|
||||
"""Schema information including descriptions and property types for UI display"""
|
||||
|
||||
|
||||
|
|
@ -548,6 +575,62 @@ async def delete_allowed_ip(
|
|||
return {"message": f"IP {ip_address.ip} deleted successfully", "status": "success"}
|
||||
|
||||
|
||||
def _resolve_non_null_variant(field_info: JsonSchemaNode) -> JsonSchemaNode:
|
||||
"""Pydantic v2 renders Optional fields as ``anyOf: [actual_type, null]``."""
|
||||
if "anyOf" not in field_info:
|
||||
return field_info
|
||||
return next((variant for variant in field_info["anyOf"] if variant.get("type") != "null"), field_info)
|
||||
|
||||
|
||||
def _schema_items_entry(resolved: JsonSchemaNode, defs: Mapping[str, JsonSchemaNode]) -> "JsonSchemaItems | None":
|
||||
"""Items info (including enum values) for array fields, so the UI can render a multi-select dropdown."""
|
||||
if "items" not in resolved:
|
||||
return None
|
||||
items: Final = resolved["items"]
|
||||
if "$ref" not in items:
|
||||
return items
|
||||
ref_def: Final = defs.get(items["$ref"].split("/")[-1])
|
||||
if ref_def is None or "enum" not in ref_def:
|
||||
return None
|
||||
enum_items: Final[JsonSchemaItems] = {"enum": ref_def["enum"]}
|
||||
return enum_items
|
||||
|
||||
|
||||
def _schema_property_entry(field_info: JsonSchemaNode, defs: Mapping[str, JsonSchemaNode]) -> JsonSchemaPropertyEntry:
|
||||
resolved: Final = _resolve_non_null_variant(field_info)
|
||||
items_entry: Final = _schema_items_entry(resolved, defs)
|
||||
description: Final = field_info.get("description", "")
|
||||
type_name: Final = resolved.get("type", "string")
|
||||
if items_entry is None:
|
||||
entry: Final[JsonSchemaPropertyEntry] = {"description": description, "type": type_name}
|
||||
return entry
|
||||
entry_with_items: Final[JsonSchemaPropertyEntry] = {
|
||||
"description": description,
|
||||
"type": type_name,
|
||||
"items": items_entry,
|
||||
}
|
||||
return entry_with_items
|
||||
|
||||
|
||||
class _RootSchema(NamedTuple):
|
||||
description: str
|
||||
properties: Mapping[str, JsonSchemaNode]
|
||||
nested_defs: Mapping[str, JsonSchemaNode]
|
||||
defs: Mapping[str, JsonSchemaNode]
|
||||
|
||||
|
||||
def _root_schema(settings_class: type[BaseModel]) -> _RootSchema:
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
raw_schema: Final = TypeAdapter(settings_class).json_schema(by_alias=True)
|
||||
return _RootSchema(
|
||||
description=raw_schema.get("description", ""),
|
||||
properties=raw_schema["properties"],
|
||||
nested_defs=raw_schema.get("definitions", _EMPTY_SCHEMA_DEFS),
|
||||
defs=raw_schema["$defs"] if "$defs" in raw_schema else raw_schema.get("definitions", _EMPTY_SCHEMA_DEFS),
|
||||
)
|
||||
|
||||
|
||||
async def _get_settings_with_schema(
|
||||
settings_key: str,
|
||||
settings_class: type[BaseModel],
|
||||
|
|
@ -561,69 +644,43 @@ async def _get_settings_with_schema(
|
|||
settings_class: The Pydantic class to use for schema
|
||||
config: The config dictionary
|
||||
"""
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
litellm_settings: Final = config.get("litellm_settings", {}) or {}
|
||||
settings_data: Final = litellm_settings.get(settings_key, {}) or {}
|
||||
|
||||
# Create the settings object
|
||||
settings: Final = settings_class(**(settings_data))
|
||||
# Get the schema
|
||||
schema: Final = TypeAdapter(settings_class).json_schema(by_alias=True)
|
||||
root_schema: Final = _root_schema(settings_class)
|
||||
|
||||
# Convert to dict for response
|
||||
settings_dict: Final = settings.model_dump()
|
||||
|
||||
# Add descriptions to the response
|
||||
result: Final = {
|
||||
"values": settings_dict,
|
||||
"field_schema": {
|
||||
"description": schema.get("description", ""),
|
||||
"properties": {},
|
||||
},
|
||||
schema_properties_out: Final[Mapping[str, JsonSchemaPropertyEntry]] = {
|
||||
field_name: _schema_property_entry(field_info, root_schema.defs)
|
||||
for field_name, field_info in root_schema.properties.items()
|
||||
}
|
||||
|
||||
# Add property descriptions
|
||||
defs: Final = schema.get("$defs", schema.get("definitions", {}))
|
||||
for field_name, field_info in schema["properties"].items():
|
||||
# For Optional fields, Pydantic v2 uses anyOf with [actual_type, null].
|
||||
# Resolve the non-null variant to get the real type and items.
|
||||
resolved = field_info
|
||||
if "anyOf" in field_info:
|
||||
for variant in field_info["anyOf"]:
|
||||
if variant.get("type") != "null":
|
||||
resolved = variant
|
||||
break
|
||||
|
||||
prop_entry: dict = {
|
||||
"description": field_info.get("description", ""),
|
||||
"type": resolved.get("type", "string"),
|
||||
}
|
||||
# Pass through items info (including enum values) for array fields
|
||||
# so the UI can render a multi-select dropdown
|
||||
if "items" in resolved:
|
||||
items = resolved["items"]
|
||||
# Resolve $ref to enum definitions if needed
|
||||
if "$ref" in items:
|
||||
ref_name = items["$ref"].split("/")[-1]
|
||||
ref_def = defs.get(ref_name, {})
|
||||
if "enum" in ref_def:
|
||||
prop_entry["items"] = {"enum": ref_def["enum"]}
|
||||
else:
|
||||
prop_entry["items"] = items
|
||||
result["field_schema"]["properties"][field_name] = prop_entry
|
||||
|
||||
# Add nested object descriptions
|
||||
for def_name, def_schema in schema.get("definitions", {}).items():
|
||||
result["field_schema"][def_name] = {
|
||||
nested_defs_out: Final[Mapping[str, Mapping[str, object]]] = {
|
||||
def_name: {
|
||||
"description": def_schema.get("description", ""),
|
||||
"properties": {
|
||||
prop_name: {"description": prop_info.get("description", "")}
|
||||
for prop_name, prop_info in def_schema.get("properties", {}).items()
|
||||
},
|
||||
}
|
||||
for def_name, def_schema in root_schema.nested_defs.items()
|
||||
}
|
||||
|
||||
return result
|
||||
return {
|
||||
"values": settings_dict,
|
||||
"field_schema": {
|
||||
"description": root_schema.description,
|
||||
"properties": schema_properties_out,
|
||||
**nested_defs_out,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
@ -930,32 +987,29 @@ async def get_sso_settings():
|
|||
resolved: Final = resolve_sso_config(sso_db_settings, os.environ)
|
||||
|
||||
# Get the schema for UI display
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
schema: Final = TypeAdapter(SSOConfig).json_schema(by_alias=True)
|
||||
root_schema: Final = _root_schema(SSOConfig)
|
||||
|
||||
# Convert to dict for response, masking OAuth client secrets so plaintext
|
||||
# is never sent to the UI.
|
||||
sso_dict: Final = mask_sensitive_keys(resolved.config.model_dump(), set(SSO_SECRET_FIELDS))
|
||||
|
||||
# Add descriptions to the response
|
||||
result: Final = {
|
||||
"values": sso_dict,
|
||||
"provenance": resolved.provenance,
|
||||
"field_schema": {
|
||||
"description": schema.get("description", ""),
|
||||
"properties": {},
|
||||
},
|
||||
}
|
||||
|
||||
# Add property descriptions
|
||||
for field_name, field_info in schema["properties"].items():
|
||||
result["field_schema"]["properties"][field_name] = {
|
||||
schema_properties_out: Final[Mapping[str, Mapping[str, str]]] = {
|
||||
field_name: {
|
||||
"description": field_info.get("description", ""),
|
||||
"type": field_info.get("type", "string"),
|
||||
}
|
||||
for field_name, field_info in root_schema.properties.items()
|
||||
}
|
||||
|
||||
return result
|
||||
return {
|
||||
"values": sso_dict,
|
||||
"provenance": resolved.provenance,
|
||||
"field_schema": {
|
||||
"description": root_schema.description,
|
||||
"properties": schema_properties_out,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.patch(
|
||||
|
|
@ -1309,7 +1363,7 @@ UI_SETTINGS_CACHE_KEY: Final = "ui_settings:settings_dict"
|
|||
UI_SETTINGS_CACHE_TTL: Final = 600 # 10 minutes
|
||||
|
||||
|
||||
async def get_ui_settings_cached() -> dict[str, Any]:
|
||||
async def get_ui_settings_cached() -> dict[str, JsonValue]:
|
||||
"""
|
||||
Return the persisted UI settings dict, using DualCache for reads.
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,16 @@ def _is_json_array(value: object) -> TypeIs[list[object]]: # guard-ok: trivial
|
|||
return isinstance(value, list)
|
||||
|
||||
|
||||
def _optional_str(value: object) -> str | None:
|
||||
"""Keep a JSON payload entry only when it is a string, since the wire format is caller-controlled."""
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
|
||||
def _json_array_or_empty(value: object) -> Sequence[object]:
|
||||
"""Narrow a JSON payload entry that the caller iterates, tolerating a missing or malformed value."""
|
||||
return value if _is_json_array(value) else ()
|
||||
|
||||
|
||||
def _is_str_mapping(value: object) -> TypeIs[dict[str, str]]: # guard-ok: verifies every value is str
|
||||
return _is_json_object(value) and all(isinstance(item, str) for item in value.values())
|
||||
|
||||
|
|
@ -301,7 +311,7 @@ class BaseResponsesAPIStreamingIterator:
|
|||
ResponsesAPIStreamEvents.OUTPUT_ITEM_ADDED,
|
||||
ResponsesAPIStreamEvents.OUTPUT_ITEM_DONE,
|
||||
):
|
||||
_item: Final = getattr(openai_responses_api_chunk, "item", None)
|
||||
_item: Final[object] = getattr(openai_responses_api_chunk, "item", None)
|
||||
if _item is not None:
|
||||
ResponsesAPIRequestUtils._encode_container_id_on_output_item(
|
||||
item=_item,
|
||||
|
|
@ -309,7 +319,7 @@ class BaseResponsesAPIStreamingIterator:
|
|||
model_id=_stream_model_id,
|
||||
)
|
||||
elif _event_type == ResponsesAPIStreamEvents.OUTPUT_TEXT_ANNOTATION_ADDED:
|
||||
_annotation: Final = getattr(openai_responses_api_chunk, "annotation", None)
|
||||
_annotation: Final[object] = getattr(openai_responses_api_chunk, "annotation", None)
|
||||
if _annotation is not None:
|
||||
ResponsesAPIRequestUtils._encode_container_id_on_output_item(
|
||||
item=_annotation,
|
||||
|
|
@ -1081,7 +1091,7 @@ class _HasModelDumpJson(Protocol):
|
|||
def model_dump_json(self, *, exclude_none: bool = ...) -> str: ...
|
||||
|
||||
|
||||
def _dump_response_object(obj: object) -> dict[str, Any]:
|
||||
def _dump_response_object(obj: object) -> dict[str, object]:
|
||||
if isinstance(obj, _HasModelDump):
|
||||
return obj.model_dump()
|
||||
if _is_json_object(obj):
|
||||
|
|
@ -1254,7 +1264,7 @@ def _build_synthetic_response_events(
|
|||
)
|
||||
|
||||
if item_type == "message":
|
||||
content_parts: Sequence[object] = output_item_payload.get("content", []) or []
|
||||
content_parts: Sequence[object] = _json_array_or_empty(output_item_payload.get("content"))
|
||||
for content_index, part in enumerate(content_parts):
|
||||
part_payload = _dump_response_object(part)
|
||||
events.append(
|
||||
|
|
@ -1302,7 +1312,7 @@ def _build_synthetic_response_events(
|
|||
)
|
||||
)
|
||||
elif item_type == "reasoning":
|
||||
summaries: Sequence[object] = output_item_payload.get("summary", []) or []
|
||||
summaries: Sequence[object] = _json_array_or_empty(output_item_payload.get("summary"))
|
||||
for summary_index, summary in enumerate(summaries):
|
||||
summary_payload = _dump_response_object(summary)
|
||||
summary_text = str(summary_payload.get("text") or "")
|
||||
|
|
@ -2018,7 +2028,7 @@ class ManagedResponsesWebSocketHandler:
|
|||
model: str,
|
||||
logging_obj: LiteLLMLoggingObj,
|
||||
user_api_key_dict: UserAPIKeyAuth | None = None,
|
||||
litellm_metadata: dict[str, Any] | None = None,
|
||||
litellm_metadata: dict[str, object] | None = None,
|
||||
api_key: str | None = None,
|
||||
api_base: str | None = None,
|
||||
timeout: float | None = None,
|
||||
|
|
@ -2031,9 +2041,9 @@ class ManagedResponsesWebSocketHandler:
|
|||
self.model = model
|
||||
self.logging_obj = logging_obj
|
||||
self.user_api_key_dict = user_api_key_dict
|
||||
self.litellm_metadata: dict[str, Any] = litellm_metadata or {}
|
||||
self.model_group: str | None = self.litellm_metadata.get("model_group") or self.litellm_metadata.get(
|
||||
"deployment_model_name"
|
||||
self.litellm_metadata: dict[str, object] = litellm_metadata or {}
|
||||
self.model_group: str | None = _optional_str(
|
||||
self.litellm_metadata.get("model_group") or self.litellm_metadata.get("deployment_model_name")
|
||||
)
|
||||
self.api_key = api_key
|
||||
self.api_base = api_base
|
||||
|
|
@ -2055,7 +2065,7 @@ class ManagedResponsesWebSocketHandler:
|
|||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _serialize_chunk(chunk: Any) -> str | None:
|
||||
def _serialize_chunk(chunk: object) -> str | None:
|
||||
"""Serialize a streaming chunk to a JSON string for WebSocket transmission."""
|
||||
try:
|
||||
if isinstance(chunk, _HasModelDumpJson):
|
||||
|
|
@ -2246,7 +2256,7 @@ class ManagedResponsesWebSocketHandler:
|
|||
await self.websocket.send_text(serialized)
|
||||
|
||||
@staticmethod
|
||||
def _build_base_call_kwargs(msg_obj: dict[str, object]) -> dict[str, Any]:
|
||||
def _build_base_call_kwargs(msg_obj: dict[str, object]) -> dict[str, object]:
|
||||
"""
|
||||
Extract Responses API params from the event, handling both wire formats:
|
||||
Nested: {"type": "response.create", "response": {"input": [...], ...}}
|
||||
|
|
@ -2462,12 +2472,12 @@ class ManagedResponsesWebSocketHandler:
|
|||
# reuse the router-resolved self.model; passing the alias raw to
|
||||
# litellm.aresponses fails in get_llm_provider. A genuinely different
|
||||
# provider-prefixed per-frame model is still honored.
|
||||
requested_model: Final[str | None] = call_kwargs.pop("model", None)
|
||||
requested_model: Final[str | None] = _optional_str(call_kwargs.pop("model", None))
|
||||
model: Final[str] = (
|
||||
self.model if requested_model is None or requested_model == self.model_group else requested_model
|
||||
)
|
||||
|
||||
previous_response_id: Final[str | None] = call_kwargs.pop("previous_response_id", None)
|
||||
previous_response_id: Final[str | None] = _optional_str(call_kwargs.pop("previous_response_id", None))
|
||||
current_messages: Final = self._input_to_messages(call_kwargs.get("input"))
|
||||
|
||||
# Fetch history once; reused in both _apply_history and _save_turn_history
|
||||
|
|
|
|||
24
litellm/types/guardrail_base_init.py
Normal file
24
litellm/types/guardrail_base_init.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"""Typed view of the scalar keyword payload guardrails forward to ``CustomGuardrail.__init__``.
|
||||
|
||||
Guardrail subclasses collect their base-class options in ``**kwargs`` and splat them into
|
||||
``super().__init__``. Declaring the payload's shape here lets the checker resolve each
|
||||
forwarded argument to its real parameter type instead of ``Any``.
|
||||
"""
|
||||
|
||||
from typing_extensions import ReadOnly, TypedDict
|
||||
|
||||
|
||||
class GuardrailBaseInitKwargs(TypedDict, total=False):
|
||||
guardrail_name: ReadOnly[str | None]
|
||||
default_on: ReadOnly[bool]
|
||||
mask_request_content: ReadOnly[bool]
|
||||
mask_response_content: ReadOnly[bool]
|
||||
violation_message_template: ReadOnly[str | None]
|
||||
end_session_after_n_fails: ReadOnly[int | None]
|
||||
on_violation: ReadOnly[str | None]
|
||||
realtime_violation_message: ReadOnly[str | None]
|
||||
on_sensitive_data: ReadOnly[str | None]
|
||||
sensitive_data_route_to_model: ReadOnly[str | None]
|
||||
sticky_session_routing: ReadOnly[bool]
|
||||
run_in_parallel: ReadOnly[bool]
|
||||
only_scan_new_messages: ReadOnly[bool]
|
||||
Loading…
Add table
Reference in a new issue