chore(typing): clear basedpyright Any errors in streaming, passthrough, and proxy hot paths

This commit is contained in:
mateo-berri 2026-08-04 04:30:21 +00:00
parent 491eda319c
commit a38ce9e585
No known key found for this signature in database
20 changed files with 2961 additions and 1799 deletions

View file

@ -1,15 +1,15 @@
{
"reportAny": {
"limit": 29813
"limit": 28296
},
"reportArgumentType": {
"limit": 2645
"limit": 2634
},
"reportAssignmentType": {
"limit": 329
},
"reportAttributeAccessIssue": {
"limit": 516
"limit": 511
},
"reportCallIssue": {
"limit": 123
@ -24,7 +24,7 @@
"limit": 42
},
"reportExplicitAny": {
"limit": 9473
"limit": 9093
},
"reportFunctionMemberAccess": {
"limit": 11
@ -54,13 +54,13 @@
"limit": 0
},
"reportMissingParameterType": {
"limit": 5855
"limit": 5829
},
"reportMissingTypeArgument": {
"limit": 15852
"limit": 15785
},
"reportMissingTypeStubs": {
"limit": 41
"limit": 40
},
"reportOperatorIssue": {
"limit": 0
@ -72,7 +72,7 @@
"limit": 0
},
"reportOptionalMemberAccess": {
"limit": 1079
"limit": 1071
},
"reportOptionalOperand": {
"limit": 0
@ -84,13 +84,13 @@
"limit": 77
},
"reportPrivateUsage": {
"limit": 2437
"limit": 2436
},
"reportRedeclaration": {
"limit": 12
},
"reportReturnType": {
"limit": 219
"limit": 218
},
"reportTypedDictNotRequiredAccess": {
"limit": 27
@ -99,31 +99,31 @@
"limit": 0
},
"reportUnknownArgumentType": {
"limit": 45324
"limit": 45178
},
"reportUnknownLambdaType": {
"limit": 113
},
"reportUnknownMemberType": {
"limit": 40452
"limit": 40239
},
"reportUnknownParameterType": {
"limit": 20309
"limit": 20229
},
"reportUnknownVariableType": {
"limit": 31978
"limit": 31822
},
"reportUnnecessaryCast": {
"limit": 177
"limit": 172
},
"reportUnnecessaryComparison": {
"limit": 1021
"limit": 1019
},
"reportUnnecessaryContains": {
"limit": 7
},
"reportUnnecessaryIsInstance": {
"limit": 1204
"limit": 1199
},
"reportUntypedBaseClass": {
"limit": 165

View file

@ -1,17 +1,24 @@
import base64
import time
from typing import TYPE_CHECKING, Any, Union, cast
from collections.abc import Sequence
from typing import TYPE_CHECKING, Literal
from pydantic import TypeAdapter
from typing_extensions import Required, TypedDict
from litellm._logging import verbose_logger
from litellm.types.llms.openai import (
AllMessageValues,
ChatCompletionAssistantContentValue,
ChatCompletionAudioDelta,
ChatCompletionRedactedThinkingBlock,
ChatCompletionThinkingBlock,
)
from litellm.types.utils import (
CacheCreationTokenDetails,
ChatCompletionAudioResponse,
ChatCompletionDeltaToolCall,
ChatCompletionMessageToolCall,
Choices,
CompletionTokensDetails,
CompletionTokensDetailsWrapper,
Function,
@ -20,57 +27,158 @@ from litellm.types.utils import (
ModelResponseStream,
PromptTokensDetailsWrapper,
ServerToolUse,
StreamingChoices,
Usage,
)
from litellm.utils import print_verbose, token_counter
if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging
from litellm.types.litellm_core_utils.streaming_chunk_builder_utils import (
UsagePerChunk,
)
from litellm.types.llms.openai import (
ChatCompletionRedactedThinkingBlock,
ChatCompletionThinkingBlock,
)
RawHiddenParamsDict = dict[str, object]
class ChunkHiddenParamsDict(TypedDict, total=False):
created_at: int | float
custom_llm_provider: str | None
class UsageDict(TypedDict, total=False):
prompt_tokens: int
completion_tokens: int
total_tokens: int
reasoning_tokens: int | None
cache_creation_input_tokens: int | None
cache_read_input_tokens: int | None
cost: float | None
prompt_tokens_details: dict[str, object] | None
completion_tokens_details: dict[str, object] | None
server_tool_use: dict[str, object] | None
class ToolCallFunctionDict(TypedDict, total=False):
name: str | None
arguments: str
provider_specific_fields: dict[str, object] | None
class ToolCallDict(TypedDict, total=False):
id: str | None
type: str | None
index: int
function: ToolCallFunctionDict | Function | None
provider_specific_fields: dict[str, object] | None
class DeltaDict(TypedDict, total=False):
role: str | None
content: str | None
reasoning_content: str | None
function_call: FunctionCall | None
tool_calls: list[ToolCallDict | ChatCompletionDeltaToolCall] | None
audio: ChatCompletionAudioDelta | None
thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] | None
class ChoiceDict(TypedDict, total=False):
index: int
delta: Required[DeltaDict]
finish_reason: str | None
class ChunkDict(TypedDict, total=False):
id: Required[str]
object: Required[str]
created: Required[int]
model: Required[str]
choices: Required[list[ChoiceDict | StreamingChoices]]
system_fingerprint: str | None
usage: Usage | UsageDict | None
_hidden_params: RawHiddenParamsDict
class AccumulatedToolCallDict(TypedDict):
id: str | None
name: str | None
type: str | None
arguments: list[str]
provider_specific_fields: dict[str, object] | None
class UsageChunkCalculationDict(TypedDict):
prompt_tokens: int | None
completion_tokens: int | None
cache_creation_input_tokens: int | None
cache_read_input_tokens: int | None
completion_tokens_details: CompletionTokensDetails | None
prompt_tokens_details: PromptTokensDetailsWrapper | None
cost: float | None
class CompletionTokensDetailsDumpDict(TypedDict, total=False):
accepted_prediction_tokens: int | None
audio_tokens: int | None
reasoning_tokens: int | None
rejected_prediction_tokens: int | None
text_tokens: int | None
image_tokens: int | None
video_tokens: int | None
class UsageDumpDict(TypedDict, total=False):
prompt_tokens: int
completion_tokens: int
total_tokens: int
completion_tokens_details: dict[str, object] | None
prompt_tokens_details: dict[str, object] | None
server_tool_use: dict[str, object] | None
cost: float | None
cache_creation_input_tokens: int
cache_read_input_tokens: int
_CHUNK_HIDDEN_PARAMS_ADAPTER: TypeAdapter[ChunkHiddenParamsDict] = TypeAdapter(ChunkHiddenParamsDict)
_AUDIO_DELTA_ADAPTER: TypeAdapter[ChatCompletionAudioDelta | None] = TypeAdapter(ChatCompletionAudioDelta | None)
_COMPLETION_TOKENS_DETAILS_DUMP_ADAPTER: TypeAdapter[CompletionTokensDetailsDumpDict] = TypeAdapter(
CompletionTokensDetailsDumpDict
)
_USAGE_DUMP_ADAPTER: TypeAdapter[UsageDumpDict] = TypeAdapter(UsageDumpDict)
class ChunkProcessor:
def __init__(self, chunks: list, messages: list | None = None):
def __init__(self, chunks: list[ChunkDict], messages: list[AllMessageValues] | None = None):
self.chunks = self._sort_chunks(chunks)
self.messages = messages
self.first_chunk = chunks[0]
def _sort_chunks(self, chunks: list) -> list:
@staticmethod
def _chunk_hidden_params(chunk: "ChunkDict | ModelResponseStream") -> ChunkHiddenParamsDict:
candidate: object = (
chunk.get("_hidden_params", {}) if isinstance(chunk, dict) else getattr(chunk, "_hidden_params", {})
)
if isinstance(candidate, dict):
return _CHUNK_HIDDEN_PARAMS_ADAPTER.validate_python(candidate)
return {}
@staticmethod
def _chunk_created_at(chunk: "ChunkDict | ModelResponseStream") -> int | float:
return ChunkProcessor._chunk_hidden_params(chunk).get("created_at", float("inf"))
def _sort_chunks(self, chunks: list[ChunkDict]) -> list[ChunkDict]:
if not chunks:
return []
first_chunk = chunks[0]
first_hidden_params: dict[str, Any] = {}
if isinstance(first_chunk, dict):
candidate = first_chunk.get("_hidden_params", {})
if isinstance(candidate, dict):
first_hidden_params = candidate
else:
candidate = getattr(first_chunk, "_hidden_params", {})
if isinstance(candidate, dict):
first_hidden_params = candidate
first_hidden_params = self._chunk_hidden_params(chunks[0])
if first_hidden_params.get("created_at"):
def _created_at(chunk: Any) -> int | float:
if isinstance(chunk, dict):
params = chunk.get("_hidden_params", {})
else:
params = getattr(chunk, "_hidden_params", {})
if isinstance(params, dict):
return cast(int | float, params.get("created_at", float("inf")))
return float("inf")
return sorted(chunks, key=_created_at)
return sorted(chunks, key=self._chunk_created_at)
return chunks
def update_model_response_with_hidden_params(
self, model_response: ModelResponse, chunk: dict[str, Any] | None = None
self, model_response: ModelResponse, chunk: ChunkDict | None = None
) -> ModelResponse:
if chunk is None:
return model_response
@ -82,17 +190,17 @@ class ChunkProcessor:
@staticmethod
def apply_provider_assembled_streaming_metadata(
response: ModelResponse,
chunks: list[Any],
logging_obj: Any | None = None,
chunks: list[ChunkDict],
logging_obj: "Logging | None" = None,
) -> None:
if not chunks:
return
model = getattr(response, "model", None)
model: str | None = getattr(response, "model", None)
if not model:
return
custom_llm_provider = None
custom_llm_provider: str | None = None
if logging_obj is not None:
custom_llm_provider = logging_obj.model_call_details.get("custom_llm_provider")
@ -126,7 +234,7 @@ class ChunkProcessor:
)
@staticmethod
def _get_chunk_id(chunks: list[dict[str, Any]]) -> str:
def _get_chunk_id(chunks: Sequence[ChunkDict]) -> str:
"""
Chunks:
[{"id": ""}, {"id": "1"}, {"id": "1"}]
@ -137,7 +245,7 @@ class ChunkProcessor:
return ""
@staticmethod
def _get_model_from_chunks(chunks: list[dict[str, Any]], first_chunk_model: str) -> str:
def _get_model_from_chunks(chunks: Sequence[ChunkDict], first_chunk_model: str) -> str:
"""
Get the actual model from chunks, preferring a model that differs from the first chunk.
@ -153,7 +261,7 @@ class ChunkProcessor:
# Fall back to first chunk's model if no different model found
return first_chunk_model
def build_base_response(self, chunks: list[dict[str, Any]]) -> ModelResponse:
def build_base_response(self, chunks: Sequence[ChunkDict]) -> ModelResponse:
chunk = self.first_chunk
id = ChunkProcessor._get_chunk_id(chunks)
object = chunk["object"]
@ -164,15 +272,19 @@ class ChunkProcessor:
system_fingerprint = chunk.get("system_fingerprint", None)
first_chunk_with_choices = next((c for c in chunks if c.get("choices")), chunk)
role = first_chunk_with_choices["choices"][0]["delta"]["role"]
first_choice = first_chunk_with_choices["choices"][0]
if isinstance(first_choice, dict):
role = first_choice["delta"].get("role")
else:
role = first_choice.delta.role
finish_reason = "stop"
for chunk in chunks:
if "choices" in chunk and len(chunk["choices"]) > 0:
chunk_finish_reason = None
if hasattr(chunk["choices"][0], "finish_reason"):
chunk_finish_reason = chunk["choices"][0].finish_reason
elif "finish_reason" in chunk["choices"][0]:
chunk_finish_reason = chunk["choices"][0]["finish_reason"]
finish_reason_choice = chunk["choices"][0]
if isinstance(finish_reason_choice, dict):
chunk_finish_reason = finish_reason_choice.get("finish_reason")
else:
chunk_finish_reason = finish_reason_choice.finish_reason
if chunk_finish_reason is not None:
finish_reason = chunk_finish_reason
@ -202,15 +314,18 @@ class ChunkProcessor:
response = self.update_model_response_with_hidden_params(model_response=response, chunk=chunk)
return response
def get_combined_tool_content(self, tool_call_chunks: list[dict[str, Any]]) -> list[ChatCompletionMessageToolCall]:
def get_combined_tool_content(self, tool_call_chunks: Sequence[ChunkDict]) -> list[ChatCompletionMessageToolCall]:
tool_calls_list: list[ChatCompletionMessageToolCall] = []
tool_call_map: dict[int, dict[str, Any]] = {} # Map to store tool calls by index
tool_call_map: dict[int, AccumulatedToolCallDict] = {} # Map to store tool calls by index
for chunk in tool_call_chunks:
choices = chunk["choices"]
for choice in choices:
delta = choice.get("delta", {})
tool_calls = delta.get("tool_calls", [])
tool_calls: Sequence[ToolCallDict | ChatCompletionDeltaToolCall]
if isinstance(choice, dict):
tool_calls = choice.get("delta", {}).get("tool_calls") or []
else:
tool_calls = choice.delta.tool_calls or []
for tool_call in tool_calls:
# Handle both dict and object formats
@ -222,7 +337,7 @@ class ChunkProcessor:
if isinstance(tool_call, dict):
has_function = "function" in tool_call and tool_call["function"] is not None
else:
has_function = hasattr(tool_call, "function") and tool_call.function is not None
has_function = getattr(tool_call, "function", None) is not None
if not has_function:
continue
@ -244,22 +359,26 @@ class ChunkProcessor:
# Extract id, type, and function data (handle both dict and object)
if isinstance(tool_call, dict):
if tool_call.get("id"):
tool_call_map[index]["id"] = tool_call["id"]
if tool_call.get("type"):
tool_call_map[index]["type"] = tool_call["type"]
tool_call_id = tool_call.get("id")
if tool_call_id:
tool_call_map[index]["id"] = tool_call_id
tool_call_type = tool_call.get("type")
if tool_call_type:
tool_call_map[index]["type"] = tool_call_type
function = tool_call.get("function", {})
if isinstance(function, dict):
if function.get("name"):
tool_call_map[index]["name"] = function["name"]
if function.get("arguments"):
tool_call_map[index]["arguments"].append(function["arguments"])
function_name = function.get("name")
if function_name:
tool_call_map[index]["name"] = function_name
function_arguments = function.get("arguments")
if function_arguments:
tool_call_map[index]["arguments"].append(function_arguments)
else:
# function is an object
if hasattr(function, "name") and function.name:
if function is not None and hasattr(function, "name") and function.name:
tool_call_map[index]["name"] = function.name
if hasattr(function, "arguments") and function.arguments:
if function is not None and hasattr(function, "arguments") and function.arguments:
tool_call_map[index]["arguments"].append(function.arguments)
else:
# tool_call is an object
@ -274,27 +393,24 @@ class ChunkProcessor:
tool_call_map[index]["arguments"].append(tool_call.function.arguments)
# Preserve provider_specific_fields from streaming chunks
provider_fields = None
provider_fields: dict[str, object] | None = None
if isinstance(tool_call, dict):
provider_fields = tool_call.get("provider_specific_fields")
if not provider_fields and isinstance(tool_call.get("function"), dict):
provider_fields = tool_call["function"].get("provider_specific_fields")
if not provider_fields:
function_value = tool_call.get("function")
if isinstance(function_value, dict):
provider_fields = function_value.get("provider_specific_fields")
else:
if hasattr(tool_call, "provider_specific_fields") and tool_call.provider_specific_fields:
provider_fields = tool_call.provider_specific_fields
elif (
hasattr(tool_call, "function")
and hasattr(tool_call.function, "provider_specific_fields")
and tool_call.function.provider_specific_fields
):
provider_fields = tool_call.function.provider_specific_fields
object_provider_fields = getattr(tool_call, "provider_specific_fields", None) or getattr(
getattr(tool_call, "function", None), "provider_specific_fields", None
)
if isinstance(object_provider_fields, dict) and object_provider_fields:
provider_fields = object_provider_fields
if provider_fields:
if isinstance(provider_fields, dict) and provider_fields:
# Merge provider_specific_fields if multiple chunks have them
if tool_call_map[index]["provider_specific_fields"] is None:
tool_call_map[index]["provider_specific_fields"] = {}
if isinstance(provider_fields, dict):
tool_call_map[index]["provider_specific_fields"].update(provider_fields)
existing_fields = tool_call_map[index]["provider_specific_fields"] or {}
tool_call_map[index]["provider_specific_fields"] = {**existing_fields, **provider_fields}
# Convert the map to a list of tool calls
for index in sorted(tool_call_map.keys()):
@ -308,39 +424,48 @@ class ChunkProcessor:
name=tool_call_data["name"],
)
# Prepare params for ChatCompletionMessageToolCall
tool_call_params = {
"id": tool_call_data["id"],
"function": function,
"type": tool_call_data["type"] or "function",
}
# Add provider_specific_fields if present (for thought signatures in Gemini 3)
if tool_call_data.get("provider_specific_fields"):
tool_call_params["provider_specific_fields"] = tool_call_data["provider_specific_fields"]
tool_call = ChatCompletionMessageToolCall(**tool_call_params)
tool_calls_list.append(tool_call)
provider_specific_fields = tool_call_data["provider_specific_fields"]
if provider_specific_fields:
tool_calls_list.append(
ChatCompletionMessageToolCall(
id=tool_call_data["id"],
function=function,
type=tool_call_data["type"] or "function",
provider_specific_fields=provider_specific_fields,
)
)
else:
tool_calls_list.append(
ChatCompletionMessageToolCall(
id=tool_call_data["id"],
function=function,
type=tool_call_data["type"] or "function",
)
)
return tool_calls_list
def get_combined_function_call_content(self, function_call_chunks: list[dict[str, Any]]) -> FunctionCall:
argument_list = []
delta = function_call_chunks[0]["choices"][0]["delta"]
function_call = delta.get("function_call", "")
function_call_name = function_call.name
def get_combined_function_call_content(self, function_call_chunks: Sequence[ChunkDict]) -> FunctionCall:
argument_list: list[str] = []
first_choice = function_call_chunks[0]["choices"][0]
if isinstance(first_choice, dict):
first_function_call = first_choice["delta"].get("function_call")
else:
first_function_call = getattr(first_choice.delta, "function_call", None)
function_call_name = first_function_call.name
for chunk in function_call_chunks:
choices = chunk["choices"]
for choice in choices:
delta = choice.get("delta", {})
function_call = delta.get("function_call", "")
if isinstance(choice, dict):
function_call = choice.get("delta", {}).get("function_call")
else:
function_call = getattr(choice.delta, "function_call", None)
# Check if a function call is present
if function_call:
# Now, function_call is expected to be a dictionary
arguments = function_call.arguments
argument_list.append(arguments)
argument_list.append(function_call.arguments)
combined_arguments = "".join(argument_list)
@ -350,14 +475,16 @@ class ChunkProcessor:
)
def get_combined_content(
self, chunks: list[dict[str, Any]], delta_key: str = "content"
self, chunks: Sequence[ChunkDict], delta_key: Literal["content", "reasoning_content"] = "content"
) -> ChatCompletionAssistantContentValue:
content_list: list[str] = []
for chunk in chunks:
choices = chunk["choices"]
for choice in choices:
delta = choice.get("delta", {})
content = delta.get(delta_key, "")
if isinstance(choice, dict):
content = choice.get("delta", {}).get(delta_key, "")
else:
content = getattr(choice.delta, delta_key, "")
if content is None:
continue # openai v1.0.0 sets content = None for chunks
content_list.append(content)
@ -369,13 +496,8 @@ class ChunkProcessor:
return combined_content
def get_combined_thinking_content(
self, chunks: list[dict[str, Any]]
) -> list[Union["ChatCompletionThinkingBlock", "ChatCompletionRedactedThinkingBlock"]] | None:
from litellm.types.llms.openai import (
ChatCompletionRedactedThinkingBlock,
ChatCompletionThinkingBlock,
)
self, chunks: Sequence[ChunkDict]
) -> list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] | None:
thinking_blocks: list[ChatCompletionThinkingBlock | ChatCompletionRedactedThinkingBlock] = []
current_thinking_text_parts: list[str] = []
current_signature: str | None = None
@ -396,12 +518,13 @@ class ChunkProcessor:
for chunk in chunks:
choices = chunk["choices"]
for choice in choices:
delta = choice.get("delta", {})
thinking = delta.get("thinking_blocks", None)
if thinking and isinstance(thinking, list):
if isinstance(choice, dict):
thinking = choice.get("delta", {}).get("thinking_blocks", None)
else:
thinking = getattr(choice.delta, "thinking_blocks", None)
if isinstance(thinking, list) and thinking:
for thinking_block in thinking:
thinking_type = thinking_block.get("type", None)
if thinking_type and thinking_type == "redacted_thinking":
if thinking_block.get("type", None) == "redacted_thinking":
_flush_thinking_block()
redacted_data = thinking_block.get("data", None)
if redacted_data:
@ -426,10 +549,10 @@ class ChunkProcessor:
return thinking_blocks
return None
def get_combined_reasoning_content(self, chunks: list[dict[str, Any]]) -> ChatCompletionAssistantContentValue:
def get_combined_reasoning_content(self, chunks: Sequence[ChunkDict]) -> ChatCompletionAssistantContentValue:
return self.get_combined_content(chunks, delta_key="reasoning_content")
def get_combined_audio_content(self, chunks: list[dict[str, Any]]) -> ChatCompletionAudioResponse:
def get_combined_audio_content(self, chunks: Sequence[ChunkDict]) -> ChatCompletionAudioResponse:
base64_data_list: list[str] = []
transcript_list: list[str] = []
expires_at: int | None = None
@ -438,8 +561,12 @@ class ChunkProcessor:
for chunk in chunks:
choices = chunk["choices"]
for choice in choices:
delta = choice.get("delta") or {}
audio: ChatCompletionAudioDelta | None = delta.get("audio")
audio: ChatCompletionAudioDelta | None
if isinstance(choice, dict):
delta: DeltaDict = choice.get("delta") or {}
audio = delta.get("audio")
else:
audio = _AUDIO_DELTA_ADAPTER.validate_python(getattr(choice.delta, "audio", None))
if audio is not None:
for k, v in audio.items():
if k == "data" and v is not None and isinstance(v, str):
@ -459,9 +586,9 @@ class ChunkProcessor:
id=id,
)
def _usage_chunk_calculation_helper(self, usage_chunk: Usage) -> dict:
prompt_tokens = 0
completion_tokens = 0
def _usage_chunk_calculation_helper(self, usage_chunk: Usage) -> UsageChunkCalculationDict:
prompt_tokens: int = 0
completion_tokens: int = 0
## anthropic prompt caching information ##
cache_creation_input_tokens: int | None = None
cache_read_input_tokens: int | None = None
@ -503,30 +630,30 @@ class ChunkProcessor:
def count_reasoning_tokens(self, response: ModelResponse) -> int | None:
reasoning_tokens: int | None = None
for choice in response.choices:
if (
hasattr(cast(Choices, choice).message, "reasoning_content")
and cast(Choices, choice).message.reasoning_content is not None
):
if hasattr(choice.message, "reasoning_content") and choice.message.reasoning_content is not None:
if reasoning_tokens is None:
reasoning_tokens = 0
reasoning_tokens += token_counter(
text=cast(Choices, choice).message.reasoning_content,
text=choice.message.reasoning_content,
count_response_tokens=True,
)
return reasoning_tokens
@staticmethod
def _extract_usage_chunk(chunk: dict[str, Any] | ModelResponse | ModelResponseStream) -> Usage | None:
usage_chunk: Usage | dict[str, Any] | None = None
if hasattr(chunk, "usage") and chunk.usage is not None:
usage_chunk = chunk.usage
elif "usage" in chunk:
usage_chunk = chunk["usage"]
elif (isinstance(chunk, ModelResponse) or isinstance(chunk, ModelResponseStream)) and hasattr(
chunk, "_hidden_params"
):
usage_chunk = chunk._hidden_params.get("usage", None)
def _extract_usage_chunk(chunk: ChunkDict | ModelResponse | ModelResponseStream) -> Usage | None:
usage_chunk: Usage | UsageDict | None = None
if isinstance(chunk, dict):
usage_chunk = chunk.get("usage")
elif hasattr(chunk, "usage"):
usage_chunk = getattr(chunk, "usage", None)
elif hasattr(chunk, "_hidden_params"):
hidden_usage: object = chunk._hidden_params.get("usage", None)
if isinstance(hidden_usage, Usage):
return hidden_usage
if isinstance(hidden_usage, dict):
return Usage(**hidden_usage)
return None
if isinstance(usage_chunk, dict):
return Usage(**usage_chunk)
@ -534,7 +661,7 @@ class ChunkProcessor:
def _calculate_usage_per_chunk(
self,
chunks: list[dict[str, Any] | ModelResponse],
chunks: Sequence[ChunkDict],
) -> "UsagePerChunk":
from litellm.types.litellm_core_utils.streaming_chunk_builder_utils import (
UsagePerChunk,
@ -601,24 +728,15 @@ class ChunkProcessor:
server_tool_use = usage_chunk.server_tool_use
else:
server_tool_use = ServerToolUse.model_validate(usage_chunk.server_tool_use)
if (
usage_chunk_dict["prompt_tokens_details"] is not None
and getattr(
usage_chunk_dict["prompt_tokens_details"],
"web_search_requests",
None,
)
is not None
):
web_search_requests = getattr(
usage_chunk_dict["prompt_tokens_details"],
"web_search_requests",
)
prompt_tokens_details = cast(
PromptTokensDetailsWrapper | None,
web_search_requests_value: int | None = getattr(
usage_chunk_dict["prompt_tokens_details"],
"web_search_requests",
None,
)
if usage_chunk_dict["prompt_tokens_details"] is not None and web_search_requests_value is not None:
web_search_requests = web_search_requests_value
prompt_tokens_details = usage_chunk_dict["prompt_tokens_details"]
cache_creation_token_details = self._capture_cache_creation_token_details(
prompt_tokens_details, cache_creation_token_details
@ -654,9 +772,10 @@ class ChunkProcessor:
prompt_tokens_details: PromptTokensDetailsWrapper | None,
current: CacheCreationTokenDetails | None,
) -> CacheCreationTokenDetails | None:
incoming = cast(
CacheCreationTokenDetails | None,
getattr(prompt_tokens_details, "cache_creation_token_details", None),
if prompt_tokens_details is None:
return current
incoming: CacheCreationTokenDetails | None = getattr(
prompt_tokens_details, "cache_creation_token_details", None
)
if incoming is not None:
return incoming
@ -669,9 +788,8 @@ class ChunkProcessor:
) -> PromptTokensDetailsWrapper | None:
if prompt_tokens_details is None or cache_creation_token_details is None:
return prompt_tokens_details
existing = cast(
CacheCreationTokenDetails | None,
getattr(prompt_tokens_details, "cache_creation_token_details", None),
existing: CacheCreationTokenDetails | None = getattr(
prompt_tokens_details, "cache_creation_token_details", None
)
if existing is not None:
return prompt_tokens_details
@ -679,7 +797,7 @@ class ChunkProcessor:
@staticmethod
def _reset_anthropic_cursor_completion_tokens(
chunks: list[dict[str, Any] | ModelResponse],
chunks: Sequence[ChunkDict],
completion_tokens: int,
completion_usage_updates: int,
) -> int:
@ -704,13 +822,7 @@ class ChunkProcessor:
custom_llm_provider: str | None = None
if chunks:
first_chunk = chunks[0]
if isinstance(first_chunk, dict):
hp = first_chunk.get("_hidden_params")
else:
hp = getattr(first_chunk, "_hidden_params", None)
if isinstance(hp, dict):
custom_llm_provider = hp.get("custom_llm_provider")
custom_llm_provider = ChunkProcessor._chunk_hidden_params(chunks[0]).get("custom_llm_provider")
if custom_llm_provider == "anthropic" and completion_tokens == 1:
return 0
@ -718,10 +830,10 @@ class ChunkProcessor:
def calculate_usage(
self,
chunks: list[dict[str, Any] | ModelResponse],
chunks: Sequence[ChunkDict],
model: str,
completion_output: str,
messages: list | None = None,
messages: list[AllMessageValues] | None = None,
reasoning_tokens: int | None = None,
) -> Usage:
"""
@ -773,7 +885,7 @@ class ChunkProcessor:
if completion_tokens_details is not None:
if isinstance(completion_tokens_details, CompletionTokensDetails):
returned_usage.completion_tokens_details = CompletionTokensDetailsWrapper(
**completion_tokens_details.model_dump()
**_COMPLETION_TOKENS_DETAILS_DUMP_ADAPTER.validate_python(completion_tokens_details.model_dump())
)
else:
returned_usage.completion_tokens_details = completion_tokens_details
@ -806,12 +918,12 @@ class ChunkProcessor:
# Return a new usage object with the new values
returned_usage = Usage(**returned_usage.model_dump())
returned_usage = Usage(**_USAGE_DUMP_ADAPTER.validate_python(returned_usage.model_dump()))
return returned_usage
def concatenate_base64_list(base64_strings: list[str]) -> str:
def concatenate_base64_list(base64_strings: Sequence[str]) -> str:
"""
Concatenates a list of base64-encoded strings.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,233 @@
from collections.abc import Mapping, Sequence
from typing import Protocol, Union, runtime_checkable
from pydantic import BaseModel
from typing_extensions import ReadOnly, Required, TypedDict
from litellm.types.llms.openai import (
ChatCompletionToolCallChunk,
ChatCompletionToolCallFunctionChunk,
)
from litellm.types.utils import (
ChatCompletionDeltaToolCall,
Function,
ModelResponseStream,
)
class CompletionUsageLike(Protocol):
prompt_tokens: int
completion_tokens: int
total_tokens: int
class ParsedProviderChunk(TypedDict, total=False):
text: ReadOnly[str | None]
is_finished: ReadOnly[bool]
finish_reason: ReadOnly[str | None]
logprobs: ReadOnly[object]
original_chunk: ReadOnly[ModelResponseStream | None]
usage: ReadOnly[object]
tool_use: ReadOnly[ChatCompletionToolCallChunk | None]
tool_calls: ReadOnly[list[ChatCompletionDeltaToolCall] | None]
provider_specific_fields: ReadOnly[dict[str, object] | None]
prompt_tokens: ReadOnly[int]
completion_tokens: ReadOnly[int]
index: ReadOnly[int]
class SimpleParsedChunk(ParsedProviderChunk):
text: Required[ReadOnly[str | None]]
is_finished: Required[ReadOnly[bool]]
finish_reason: Required[ReadOnly[str | None]]
class TextCompletionParsedChunk(SimpleParsedChunk):
usage: Required[ReadOnly[CompletionUsageLike | None]]
class OpenAIChatParsedChunk(SimpleParsedChunk):
logprobs: Required[ReadOnly[object]]
original_chunk: Required[ReadOnly[ModelResponseStream | None]]
usage: Required[ReadOnly[object]]
class StreamingCompletionObj(TypedDict, total=False):
content: Required[str | None]
role: str
tool_calls: list[ChatCompletionToolCallChunk] | list[ChatCompletionDeltaToolCall] | None
function_call: ChatCompletionToolCallFunctionChunk | Function | None
provider_specific_fields: dict[str, object] | None
index: int
class TextCompletionChoiceLike(Protocol):
@property
def text(self) -> str: ...
@property
def finish_reason(self) -> str | None: ...
@runtime_checkable
class HasTextChoices(Protocol):
@property
def choices(self) -> Sequence[TextCompletionChoiceLike]: ...
@runtime_checkable
class HasCompletionUsage(Protocol):
usage: CompletionUsageLike | None
@runtime_checkable
class HasFunctionsAttr(Protocol):
functions: object
@runtime_checkable
class HasStatusCode(Protocol):
@property
def status_code(self) -> int: ...
VertexArgValue = Union[
str,
int,
float,
bool,
None,
Sequence["VertexArgValue"],
Mapping[str, "VertexArgValue"],
]
class VertexFunctionCallLike(Protocol):
@property
def args(self) -> Mapping[str, VertexArgValue]: ...
@property
def name(self) -> str: ...
class VertexPartLike(Protocol):
@property
def function_call(self) -> VertexFunctionCallLike: ...
class VertexContentLike(Protocol):
@property
def parts(self) -> Sequence[VertexPartLike]: ...
class VertexEnumNameLike(Protocol):
@property
def name(self) -> str: ...
class VertexCandidateLike(Protocol):
@property
def content(self) -> VertexContentLike: ...
@property
def finish_reason(self) -> VertexEnumNameLike: ...
@runtime_checkable
class VertexProtoChunkLike(Protocol):
@property
def candidates(self) -> Sequence[VertexCandidateLike]: ...
class VllmCompletionOutputLike(Protocol):
@property
def text(self) -> str: ...
@runtime_checkable
class VllmRequestOutputLike(Protocol):
@property
def outputs(self) -> Sequence[VllmCompletionOutputLike]: ...
@runtime_checkable
class HasModelAttr(Protocol):
model: str | None
class PredibaseStreamToken(BaseModel):
text: str | None = None
class PredibaseStreamDetails(BaseModel):
finish_reason: str | None = None
class PredibaseStreamChunk(BaseModel):
token: PredibaseStreamToken | None = None
details: PredibaseStreamDetails | None = None
generated_text: str | None = None
error: str | None = None
class AI21StreamData(BaseModel):
text: str
class AI21StreamCompletion(BaseModel):
data: AI21StreamData
class AI21StreamChunk(BaseModel):
completions: list[AI21StreamCompletion]
class MaritalkStreamChunk(BaseModel):
answer: str
class NlpCloudStreamChunk(BaseModel):
generated_text: str
class AlephAlphaStreamCompletion(BaseModel):
completion: str
class AlephAlphaStreamChunk(BaseModel):
completions: list[AlephAlphaStreamCompletion]
class AzureStreamDelta(BaseModel):
content: str | None = ""
class AzureStreamChoice(BaseModel):
delta: AzureStreamDelta | None = None
finish_reason: str | None = None
class AzureStreamChunk(BaseModel):
choices: list[AzureStreamChoice]
class BasetenStreamToken(BaseModel):
text: str | None = None
class BasetenModelOutput(BaseModel):
data: list[str] | None = None
class BasetenStreamChunk(BaseModel):
token: BasetenStreamToken | None = None
model_output: BasetenModelOutput | str | None = None
completion: str | None = None
class TritonStreamChunk(BaseModel):
text_output: str | None = ""
stop_reason: str | None = None
is_finished: bool = False
input_token_count: int = 0
generated_token_count: int = 0

View file

@ -1,9 +1,11 @@
from collections.abc import AsyncIterator, Coroutine, Iterator
from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping, Sequence
from typing import (
Any,
cast,
)
from pydantic import TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.asyncify import run_async_function
@ -27,8 +29,27 @@ from litellm.utils import get_model_info
# Anthropic-only keys already mapped by the translator; strip on extra_kwargs re-merge.
ANTHROPIC_ONLY_REQUEST_KEYS: frozenset[str] = frozenset({"output_config"})
ContextManagementSpec = dict[str, object] | list[dict[str, object]] | None
def _messages_have_compaction_block(messages: list[dict]) -> bool:
_CONTEXT_MANAGEMENT_SPEC_ADAPTER: TypeAdapter[ContextManagementSpec] = TypeAdapter(ContextManagementSpec)
def _validate_context_management_spec(raw: object) -> ContextManagementSpec:
"""Validate the raw ``context_management`` request value into a typed spec.
Shapes the dispatcher cannot use (non-dict scalars, lists with non-dict
entries, non-string keys) normalize to ``None``, matching the dispatcher's
own isinstance-based rejection of malformed specs.
"""
if raw is None:
return None
try:
return _CONTEXT_MANAGEMENT_SPEC_ADAPTER.validate_python(raw, strict=True)
except ValidationError:
return None
def _messages_have_compaction_block(messages: Sequence[Mapping[str, object]]) -> bool:
"""Return True when any message carries a ``compaction`` content block."""
for msg in messages:
content = msg.get("content")
@ -40,7 +61,7 @@ def _messages_have_compaction_block(messages: list[dict]) -> bool:
return False
def _extract_proxy_litellm_metadata(kwargs: dict[str, Any]) -> dict[str, Any] | None:
def _extract_proxy_litellm_metadata(kwargs: Mapping[str, object]) -> dict[str, object] | None:
"""Return ``kwargs["litellm_metadata"]`` when it's a dict; ``None`` otherwise.
The proxy attaches its auth/spend-attribution fields (``user_api_key``,
@ -61,14 +82,14 @@ def _extract_proxy_litellm_metadata(kwargs: dict[str, Any]) -> dict[str, Any] |
async def _prepare_context_managed_request(
*,
model: str,
messages: list[dict],
tools: list[dict] | None,
system: Any | None,
context_management_spec: Any,
litellm_metadata: dict | None,
additional_drop_params: list[str] | None,
llm_router: Any,
user_api_key_auth: Any = None,
messages: list[dict[str, object]],
tools: list[dict[str, object]] | None,
system: str | list[dict[str, object]] | None,
context_management_spec: ContextManagementSpec,
litellm_metadata: dict[str, object] | None,
additional_drop_params: Sequence[str] | None,
llm_router: object,
user_api_key_auth: object = None,
) -> PolyfillResult | None:
"""Apply client compaction history, then optional context_management polyfill."""
from litellm.llms.anthropic.experimental_pass_through.context_management.editors.compact import (
@ -88,11 +109,11 @@ async def _prepare_context_managed_request(
if polyfill_will_run:
history_result: PolyfillResult | None = None
working_messages: list[dict] = messages
working_system: Any | None = system
working_messages: list[dict[str, object]] = messages
working_system: str | list[dict[str, object]] | None = system
else:
history_result = apply_client_compaction_block_history(
messages=cast(list[dict[str, Any]], messages),
messages=messages,
system=system,
)
working_messages = history_result.messages if history_result is not None else messages
@ -122,7 +143,7 @@ async def _prepare_context_managed_request(
# to non-Anthropic backends that would reject them.
if polyfill_will_run and history_result is None:
history_result = apply_client_compaction_block_history(
messages=cast(list[dict[str, Any]], messages),
messages=messages,
system=system,
)
return history_result
@ -130,8 +151,8 @@ async def _prepare_context_managed_request(
def _polyfill_will_run(
*,
context_management_spec: Any,
additional_drop_params: list[str] | None,
context_management_spec: ContextManagementSpec,
additional_drop_params: Sequence[str] | None,
) -> bool:
"""Return True when ``compact_20260112`` will run via the polyfill dispatcher.
@ -157,8 +178,8 @@ def _polyfill_will_run(
def _spec_has_non_compact_edits(
*,
context_management_spec: Any,
additional_drop_params: list[str] | None,
context_management_spec: ContextManagementSpec,
additional_drop_params: Sequence[str] | None,
) -> bool:
"""Return True when the spec includes edits other than ``compact_20260112``.
@ -184,7 +205,7 @@ def _spec_has_non_compact_edits(
)
def _context_management_explicitly_dropped(additional_drop_params: list[str] | None) -> bool:
def _context_management_explicitly_dropped(additional_drop_params: Sequence[str] | None) -> bool:
"""True when the caller opted out of context_management via ``additional_drop_params``.
``drop_params`` deliberately does NOT gate the polyfill: ``context_management``
@ -198,9 +219,9 @@ def _context_management_explicitly_dropped(additional_drop_params: list[str] | N
def _normalize_spec_edits(
*,
context_management_spec: Any,
additional_drop_params: list[str] | None,
) -> list[dict[str, Any]] | None:
context_management_spec: ContextManagementSpec,
additional_drop_params: Sequence[str] | None,
) -> Sequence[Mapping[str, object]] | None:
"""Return the normalized ``edits`` list, or ``None`` if the polyfill won't run.
Delegates spec-shape normalization to the dispatcher's ``_normalize_spec``
@ -225,14 +246,14 @@ def _normalize_spec_edits(
async def _run_polyfill_if_enabled(
*,
model: str,
messages: list[dict],
tools: list[dict] | None,
system: Any | None,
context_management_spec: Any,
litellm_metadata: dict | None,
additional_drop_params: list[str] | None,
llm_router: Any,
user_api_key_auth: Any = None,
messages: list[dict[str, object]],
tools: list[dict[str, object]] | None,
system: str | list[dict[str, object]] | None,
context_management_spec: ContextManagementSpec,
litellm_metadata: dict[str, object] | None,
additional_drop_params: Sequence[str] | None,
llm_router: object,
user_api_key_auth: object = None,
) -> PolyfillResult | None:
"""Run the async context_management polyfill if a spec is present.
@ -293,9 +314,9 @@ ANTHROPIC_ADAPTER = AnthropicAdapter()
class LiteLLMMessagesToCompletionTransformationHandler:
@staticmethod
def _route_openai_thinking_to_responses_api_if_needed(
completion_kwargs: dict[str, Any],
completion_kwargs: dict[str, object],
*,
thinking: dict[str, Any] | None,
thinking: Mapping[str, object] | None,
) -> None:
"""
When users call `litellm.anthropic.messages.*` with a non-Anthropic model and
@ -308,8 +329,9 @@ class LiteLLMMessagesToCompletionTransformationHandler:
If the user provides a `summary` field in the thinking dict, it is passed
through to the OpenAI reasoning params (opt-in per OpenAI spec).
"""
custom_llm_provider = completion_kwargs.get("custom_llm_provider")
if custom_llm_provider is None:
raw_provider = completion_kwargs.get("custom_llm_provider")
custom_llm_provider: str | None
if raw_provider is None:
try:
_, inferred_provider, _, _ = litellm.utils.get_llm_provider(
model=cast(str, completion_kwargs.get("model"))
@ -317,6 +339,8 @@ class LiteLLMMessagesToCompletionTransformationHandler:
custom_llm_provider = inferred_provider
except Exception:
custom_llm_provider = None
else:
custom_llm_provider = raw_provider if isinstance(raw_provider, str) else None
if custom_llm_provider != "openai":
return
@ -342,7 +366,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
reasoning_effort = completion_kwargs.get("reasoning_effort")
summary = thinking.get("summary")
if isinstance(reasoning_effort, str) and reasoning_effort:
reasoning_dict: dict[str, Any] = {"effort": reasoning_effort}
reasoning_dict: dict[str, object] = {"effort": reasoning_effort}
if summary:
reasoning_dict["summary"] = summary
elif auto_summary:
@ -358,7 +382,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
@staticmethod
def _normalize_reasoning_effort(
completion_kwargs: dict[str, Any],
completion_kwargs: dict[str, object],
) -> None:
"""
Normalize reasoning_effort values based on target model capabilities.
@ -375,7 +399,8 @@ class LiteLLMMessagesToCompletionTransformationHandler:
return
model = cast(str, completion_kwargs.get("model", ""))
custom_llm_provider = completion_kwargs.get("custom_llm_provider")
raw_provider = completion_kwargs.get("custom_llm_provider")
custom_llm_provider = raw_provider if isinstance(raw_provider, str) else None
if isinstance(reasoning_effort, str):
normalized = normalize_reasoning_effort_value(
@ -396,21 +421,21 @@ class LiteLLMMessagesToCompletionTransformationHandler:
def _prepare_completion_kwargs(
*,
max_tokens: int,
messages: list[dict],
messages: Sequence[Mapping[str, object]],
model: str,
metadata: dict | None = None,
stop_sequences: list[str] | None = None,
metadata: Mapping[str, object] | None = None,
stop_sequences: Sequence[str] | None = None,
stream: bool | None = False,
system: str | list[dict[str, Any]] | None = None,
system: str | Sequence[Mapping[str, object]] | None = None,
temperature: float | None = None,
thinking: dict | None = None,
tool_choice: dict | None = None,
tools: list[dict] | None = None,
thinking: Mapping[str, object] | None = None,
tool_choice: Mapping[str, object] | None = None,
tools: Sequence[Mapping[str, object]] | None = None,
top_k: int | None = None,
top_p: float | None = None,
output_format: dict | None = None,
extra_kwargs: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], dict[str, str]]:
output_format: Mapping[str, object] | None = None,
extra_kwargs: Mapping[str, object] | None = None,
) -> tuple[Mapping[str, Any], dict[str, str]]: # any-ok: kwargs spread into litellm.completion's open params
"""Prepare kwargs for litellm.completion/acompletion.
Returns:
@ -422,7 +447,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
Logging as LiteLLMLoggingObject,
)
request_data = {
request_data: dict[str, object] = {
"model": model,
"messages": messages,
"max_tokens": max_tokens,
@ -467,7 +492,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
if openai_request is None:
raise ValueError("Failed to translate request to OpenAI format")
completion_kwargs: dict[str, Any] = dict(openai_request)
completion_kwargs: dict[str, Any] = dict(openai_request) # any-ok: heterogeneous per-provider kwargs
if stream:
completion_kwargs["stream"] = stream
@ -517,24 +542,25 @@ class LiteLLMMessagesToCompletionTransformationHandler:
@staticmethod
async def async_anthropic_messages_handler(
max_tokens: int,
messages: list[dict],
messages: list[dict[str, object]],
model: str,
metadata: dict | None = None,
stop_sequences: list[str] | None = None,
metadata: Mapping[str, object] | None = None,
stop_sequences: Sequence[str] | None = None,
stream: bool | None = False,
system: str | None = None,
temperature: float | None = None,
thinking: dict | None = None,
tool_choice: dict | None = None,
tools: list[dict] | None = None,
thinking: Mapping[str, object] | None = None,
tool_choice: Mapping[str, object] | None = None,
tools: list[dict[str, object]] | None = None,
top_k: int | None = None,
top_p: float | None = None,
output_format: dict | None = None,
**kwargs,
) -> AnthropicMessagesResponse | AsyncIterator[Any] | Iterator[bytes]:
output_format: Mapping[str, object] | None = None,
**kwargs: object,
) -> AnthropicMessagesResponse | AsyncIterator[bytes] | Iterator[bytes]:
"""Handle non-Anthropic models asynchronously using the adapter"""
context_management = kwargs.pop("context_management", None)
additional_drop_params: list[str] | None = kwargs.get("additional_drop_params", None)
context_management = _validate_context_management_spec(kwargs.pop("context_management", None))
raw_drop_params = kwargs.get("additional_drop_params", None)
additional_drop_params: list[str] | None = raw_drop_params if isinstance(raw_drop_params, list) else None
litellm_router = kwargs.pop("litellm_router", None)
if litellm_router is None:
try:
@ -585,7 +611,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
extra_kwargs=kwargs,
)
completion_response = await litellm.acompletion(**completion_kwargs)
completion_response = await litellm.acompletion(**completion_kwargs) # any-ok: open per-provider kwargs
if stream:
transformed_stream = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming(
@ -611,26 +637,26 @@ class LiteLLMMessagesToCompletionTransformationHandler:
@staticmethod
def anthropic_messages_handler(
max_tokens: int,
messages: list[dict],
messages: list[dict[str, object]],
model: str,
metadata: dict | None = None,
stop_sequences: list[str] | None = None,
metadata: Mapping[str, object] | None = None,
stop_sequences: Sequence[str] | None = None,
stream: bool | None = False,
system: str | None = None,
temperature: float | None = None,
thinking: dict | None = None,
tool_choice: dict | None = None,
tools: list[dict] | None = None,
thinking: Mapping[str, object] | None = None,
tool_choice: Mapping[str, object] | None = None,
tools: list[dict[str, object]] | None = None,
top_k: int | None = None,
top_p: float | None = None,
output_format: dict | None = None,
output_format: Mapping[str, object] | None = None,
_is_async: bool = False,
**kwargs,
**kwargs: object,
) -> (
AnthropicMessagesResponse
| Iterator[bytes]
| AsyncIterator[Any]
| Coroutine[Any, Any, AnthropicMessagesResponse | AsyncIterator[Any] | Iterator[bytes]]
| AsyncIterator[bytes]
| Coroutine[None, None, AnthropicMessagesResponse | AsyncIterator[bytes] | Iterator[bytes]]
):
"""Handle non-Anthropic models using the adapter."""
if _is_async is True:
@ -657,8 +683,9 @@ class LiteLLMMessagesToCompletionTransformationHandler:
# ``clear_tool_uses_20250919``. The dispatcher is async (so the
# ``compact_20260112`` editor can ``await`` the summarization model);
# bridge to it via ``run_async_function``.
context_management = kwargs.pop("context_management", None)
additional_drop_params: list[str] | None = kwargs.get("additional_drop_params", None)
context_management = _validate_context_management_spec(kwargs.pop("context_management", None))
raw_drop_params = kwargs.get("additional_drop_params", None)
additional_drop_params: list[str] | None = raw_drop_params if isinstance(raw_drop_params, list) else None
# Deliberately do NOT auto-attach the proxy ``llm_router`` here:
# ``run_async_function`` spawns a new event loop in a worker thread
# to bridge to the async dispatcher, but the proxy router's httpx
@ -722,7 +749,7 @@ class LiteLLMMessagesToCompletionTransformationHandler:
extra_kwargs=kwargs,
)
completion_response = litellm.completion(**completion_kwargs)
completion_response = litellm.completion(**completion_kwargs) # any-ok: open per-provider kwargs
if stream:
transformed_stream = ANTHROPIC_ADAPTER.translate_completion_output_params_streaming(

View file

@ -4,13 +4,17 @@ import logging
import math
import time
import traceback
from collections.abc import AsyncGenerator, Callable, Mapping
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable, Coroutine, Mapping
from datetime import datetime
from functools import lru_cache
from typing import (
TYPE_CHECKING,
Any,
Literal,
Protocol,
TypedDict,
TypeVar,
runtime_checkable,
)
import anyio
@ -18,6 +22,7 @@ import httpx
import orjson
from fastapi import HTTPException, Request, status
from fastapi.responses import JSONResponse, Response, StreamingResponse
from pydantic import TypeAdapter
from starlette.types import Receive, Scope, Send
import litellm
@ -58,10 +63,76 @@ from litellm.types.router import RouterRateLimitError
from litellm.types.utils import ServerToolUse
# Type alias for streaming chunk serializer (chunk after hooks + cost injection -> wire format)
StreamChunkSerializer = Callable[[Any], str]
StreamChunkSerializer = Callable[[Any], str] # any-ok: chunks are heterogeneous provider frames forwarded verbatim
# Type alias for streaming error serializer (ProxyException -> wire format)
StreamErrorSerializer = Callable[[ProxyException], str]
_T = TypeVar("_T")
_OBJECT_ADAPTER: TypeAdapter[object] = TypeAdapter(object)
@runtime_checkable
class _AsyncHttpResponseLike(Protocol):
status_code: int
headers: httpx.Headers
async def aread(self) -> bytes: ...
class _ModelInfoIdPayload(TypedDict, total=False):
id: str
class _ModelInfoMetadataPayload(TypedDict, total=False):
model_info: _ModelInfoIdPayload
class _ModelInfoLitellmParamsPayload(TypedDict, total=False):
model_info: _ModelInfoIdPayload
metadata: _ModelInfoMetadataPayload
class _LoggingKwargsPayload(TypedDict, total=False):
litellm_params: _ModelInfoLitellmParamsPayload
class _StreamUsageKwargsPayload(TypedDict, total=False):
prompt_tokens: int
completion_tokens: int
total_tokens: int
completion_tokens_details: dict[str, object]
prompt_tokens_details: dict[str, object]
server_tool_use: ServerToolUse
cache_creation_input_tokens: int
cache_read_input_tokens: int
async def _await_str_stream_coroutine(
coro: "Coroutine[None, None, AsyncGenerator[str, None]]",
) -> AsyncGenerator[str, None]:
return await coro
async def _await_bytes_stream_coroutine(
coro: "Coroutine[None, None, AsyncIterator[bytes]]",
) -> AsyncIterator[bytes]:
return await coro
@runtime_checkable
class _BytesAsyncIterator(Protocol):
def __aiter__(self) -> AsyncIterator[bytes]: ...
def __anext__(self) -> Awaitable[bytes]: ...
def _as_bytes_async_iterator(response: object) -> AsyncIterator[bytes]:
if isinstance(response, _BytesAsyncIterator):
return response
raise TypeError(f"expected an async iterator of bytes, got {type(response).__name__}")
if TYPE_CHECKING:
from litellm.proxy.proxy_server import ProxyConfig as _ProxyConfig
@ -232,7 +303,7 @@ async def _bill_partial_streamed_spend_on_disconnect(request_data: dict, respons
return True
async def _cancel_pending_gather_tasks(tasks: list["asyncio.Task[Any]"]) -> None:
async def _cancel_pending_gather_tasks(tasks: list["asyncio.Task[object]"]) -> None:
pending_tasks = [task for task in tasks if not task.done()]
for task in pending_tasks:
task.cancel()
@ -309,8 +380,8 @@ def _stream_usage_tracking_updates(
def _serialize_http_exception_detail(
detail: Any,
) -> tuple[str, dict | None]:
detail: object,
) -> tuple[str, dict[str, object] | None]:
"""
Convert an HTTPException.detail value into (message, structured_fields)
for ProxyException / SSE error frames.
@ -339,7 +410,7 @@ def _serialize_http_exception_detail(
return str(detail), None
def _collect_response_file_search_vector_store_ids(data: dict[str, Any]) -> set[str]:
def _collect_response_file_search_vector_store_ids(data: dict[str, object]) -> set[str]:
vector_store_ids: set[str] = set()
tools = data.get("tools")
if not isinstance(tools, list):
@ -366,7 +437,7 @@ def _collect_response_file_search_vector_store_ids(data: dict[str, Any]) -> set[
async def _authorize_response_file_search_vector_stores(
data: dict[str, Any],
data: dict[str, object],
user_api_key_dict: UserAPIKeyAuth,
) -> None:
vector_store_ids = _collect_response_file_search_vector_store_ids(data)
@ -392,7 +463,7 @@ async def _parse_event_data_for_error(event_line: str | bytes) -> int | None:
if not json_str or json_str == "[DONE]": # handle empty data or [DONE] message
return None
try:
data = orjson.loads(json_str)
data = _OBJECT_ADAPTER.validate_python(orjson.loads(json_str))
if isinstance(data, dict) and "error" in data and isinstance(data["error"], dict):
error_code_raw = data["error"].get("code")
error_code: int | None = None
@ -445,7 +516,7 @@ def _extract_error_from_sse_chunk(event_line: str | bytes) -> dict:
return default_error
try:
data = orjson.loads(json_str)
data = _OBJECT_ADAPTER.validate_python(orjson.loads(json_str))
if isinstance(data, dict) and "error" in data:
error_obj = data["error"]
if isinstance(error_obj, dict):
@ -596,7 +667,7 @@ async def create_response(
try:
# Handle coroutine that returns a generator
if asyncio.iscoroutine(generator):
generator = await generator
generator = await _await_str_stream_coroutine(generator)
# Now get the first chunk from the actual generator
first_chunk_value = await _buffer_first_chunk_honoring_disconnect(generator, request)
@ -668,13 +739,13 @@ async def create_response(
existing_fields = getattr(e, "provider_specific_fields", None) or {}
if structured_fields:
merged_fields: dict | None = {**existing_fields, **structured_fields}
merged_fields: dict[str, object] | None = {**existing_fields, **structured_fields}
else:
merged_fields = existing_fields or None
# Match ProxyException.to_dict() shape so streaming and non-streaming
# error frames are byte-identical.
error_obj: dict[str, Any] = {
error_obj: dict[str, object] = {
"message": message,
"type": getattr(e, "type", "None"),
"param": getattr(e, "param", "None"),
@ -740,7 +811,7 @@ def _is_azure_model_router_request(model: str) -> bool:
def _override_openai_response_model(
*,
response_obj: Any,
response_obj: object,
requested_model: str,
log_context: str,
return_raw_model_name: bool = False,
@ -913,7 +984,7 @@ def _log_llm_api_exception(e: Exception) -> None:
async def _cancel_llm_call_on_client_disconnect(
request: Request,
llm_api_call: "asyncio.Future[Any]",
llm_api_call: "asyncio.Future[_T]",
disconnect_event: asyncio.Event,
) -> None:
try:
@ -932,8 +1003,8 @@ async def _cancel_llm_call_on_client_disconnect(
async def _await_llm_call_cancelling_on_disconnect(
request: Request,
llm_api_call: "asyncio.Future[Any]",
) -> Any:
llm_api_call: "asyncio.Future[_T]",
) -> _T:
disconnect_event = asyncio.Event()
monitor = asyncio.create_task(_cancel_llm_call_on_client_disconnect(request, llm_api_call, disconnect_event))
try:
@ -1054,7 +1125,7 @@ class ProxyBaseLLMRequestProcessing:
@staticmethod
async def build_litellm_proxy_success_headers_from_llm_response(
*,
response: Any,
response: object,
request_data: dict,
request: Request,
user_api_key_dict: UserAPIKeyAuth,
@ -1309,7 +1380,7 @@ class ProxyBaseLLMRequestProcessing:
## LOGGING OBJECT ## - initialize logging object for logging success/failure events for call
## IMPORTANT Note: - initialize this before running pre-call checks. Ensures we log rejected requests to langfuse.
logging_obj, self.data = litellm.utils.function_setup(
logging_obj, self.data = litellm.utils.function_setup( # any-ok: function_setup (litellm/utils.py) is untyped
original_function=route_type,
rules_obj=litellm.utils.Rules(),
start_time=start_time,
@ -1357,7 +1428,7 @@ class ProxyBaseLLMRequestProcessing:
self.data["router_settings_override"] = router_settings
if "messages" in self.data and self.data["messages"]:
logging_obj.update_messages(self.data["messages"])
logging_obj.update_messages(self.data["messages"]) # any-ok: logging_obj/messages come from untyped seams
return self.data, logging_obj
@ -1509,7 +1580,7 @@ class ProxyBaseLLMRequestProcessing:
@staticmethod
def _response_cost_from_logging_obj(
*,
response: Any,
response: Any, # any-ok: forwarded verbatim to the legacy calculator union in litellm_logging.py
logging_obj: LiteLLMLoggingObj,
) -> float | str:
"""
@ -1523,7 +1594,7 @@ class ProxyBaseLLMRequestProcessing:
stored_cost = logging_obj.model_call_details.get("response_cost")
if isinstance(stored_cost, (int, float)):
return float(stored_cost)
recomputed_cost = logging_obj._response_cost_calculator(result=response)
recomputed_cost = logging_obj._response_cost_calculator(result=response) # any-ok: untyped response seam
return recomputed_cost if isinstance(recomputed_cost, (int, float)) else ""
def _debug_log_request_payload(self) -> None:
@ -1654,7 +1725,7 @@ class ProxyBaseLLMRequestProcessing:
is_streaming_request: bool | None = False,
contents: list | None = None, # Add contents parameter
skip_pre_call_logic: bool = False,
) -> Any:
) -> object:
"""
Common request processing logic for both chat completions and responses API endpoints
"""
@ -1664,7 +1735,7 @@ class ProxyBaseLLMRequestProcessing:
self._debug_log_request_payload()
if skip_pre_call_logic:
logging_obj = self.data.get("litellm_logging_obj")
logging_obj: LiteLLMLoggingObj | None = self.data.get("litellm_logging_obj")
if logging_obj is None:
raise ValueError(
"skip_pre_call_logic=True requires litellm_logging_obj to be set in data. "
@ -1737,7 +1808,9 @@ class ProxyBaseLLMRequestProcessing:
llm_call_task = asyncio.create_task(llm_call)
tasks.append(llm_call_task)
llm_responses = asyncio.gather(*tasks) # run the moderation check in parallel to the actual llm api call
llm_responses: asyncio.Future[list[object]] = asyncio.gather(
*tasks
) # run the moderation check in parallel to the actual llm api call
try:
if general_settings.get("cancel_on_disconnect", False):
@ -1747,7 +1820,7 @@ class ProxyBaseLLMRequestProcessing:
finally:
await _cancel_pending_gather_tasks(tasks)
response = responses[1]
response: object = responses[1]
_exception_raised = False
try:
@ -1830,7 +1903,9 @@ class ProxyBaseLLMRequestProcessing:
_captured_user_api_key_dict = user_api_key_dict
_captured_logging_obj = logging_obj
async def _on_deferred_stream_complete(assembled_response, cache_hit):
async def _on_deferred_stream_complete(
assembled_response: ModelResponse, cache_hit: bool | None
) -> None:
await ProxyBaseLLMRequestProcessing._run_deferred_stream_guardrails(
captured_data=_captured_data,
captured_user_api_key_dict=_captured_user_api_key_dict,
@ -1845,17 +1920,15 @@ class ProxyBaseLLMRequestProcessing:
# Check if response is an async generator
if self._is_streaming_response(response):
if asyncio.iscoroutine(response):
generator = await response
generator = await _await_bytes_stream_coroutine(response)
else:
generator = response
generator = _as_bytes_async_iterator(response)
if (
self._has_post_call_guardrails_for_passthrough()
and self._passthrough_endpoint_has_stream_guardrail_handler()
):
body_bytes = b"".join(
[chunk async for chunk in generator] # type: ignore[union-attr]
)
body_bytes = b"".join([chunk async for chunk in generator])
modified_bytes = await self._handle_event_stream_allm_passthrough_route(
body_bytes=body_bytes,
proxy_logging_obj=proxy_logging_obj,
@ -1874,7 +1947,7 @@ class ProxyBaseLLMRequestProcessing:
# For passthrough routes, stream directly without error parsing
# since we're dealing with raw binary data (e.g., AWS event streams)
return StreamingResponse(
content=generator, # type: ignore[arg-type]
content=generator,
status_code=status.HTTP_200_OK,
headers=custom_headers,
)
@ -1888,11 +1961,12 @@ class ProxyBaseLLMRequestProcessing:
)
if _early is not None:
return _early
return StreamingResponse(
content=response.aiter_bytes(), # type: ignore[union-attr]
status_code=response.status_code, # type: ignore[union-attr]
headers=custom_headers,
)
if isinstance(response, httpx.Response):
return StreamingResponse(
content=response.aiter_bytes(),
status_code=response.status_code,
headers=custom_headers,
)
elif route_type == "anthropic_messages":
# Check if response is actually a streaming response (async generator)
# Non-streaming responses (dict) should be returned directly
@ -1914,7 +1988,7 @@ class ProxyBaseLLMRequestProcessing:
)
# Non-streaming response - fall through to normal response handling
elif select_data_generator:
selected_data_generator = select_data_generator(
selected_data_generator: AsyncGenerator[str, None] = select_data_generator(
response=response,
user_api_key_dict=user_api_key_dict,
request_data=self.data,
@ -1978,10 +2052,12 @@ class ProxyBaseLLMRequestProcessing:
if _early is not None:
return _early
response = await proxy_logging_obj.post_call_success_hook(
data=self.data,
user_api_key_dict=user_api_key_dict,
response=response, # type: ignore[arg-type]
response = _OBJECT_ADAPTER.validate_python(
await proxy_logging_obj.post_call_success_hook(
data=self.data,
user_api_key_dict=user_api_key_dict,
response=response, # type: ignore[arg-type]
)
)
except Exception:
_exception_raised = True
@ -2080,7 +2156,7 @@ class ProxyBaseLLMRequestProcessing:
@staticmethod
async def _record_container_owners_from_responses_if_needed(
response: Any,
response: object,
user_api_key_dict: UserAPIKeyAuth,
) -> None:
"""Register code-interpreter containers so follow-up file APIs pass ownership checks."""
@ -2103,7 +2179,7 @@ class ProxyBaseLLMRequestProcessing:
)
@staticmethod
def _extract_completed_responses_response(stream_response: Any) -> Any:
def _extract_completed_responses_response(stream_response: object) -> object:
"""Pull the assembled ``ResponsesAPIResponse`` off a streaming iterator.
``ResponsesAPIStreamingIterator`` stores the terminal stream event
@ -2113,20 +2189,20 @@ class ProxyBaseLLMRequestProcessing:
``ResponsesAPIResponse`` directly. Handle both shapes so the
container-ownership recording path can walk ``.output`` either way.
"""
completed = getattr(stream_response, "completed_response", None)
completed: object = getattr(stream_response, "completed_response", None)
if completed is None:
return None
response_obj = getattr(completed, "response", None)
response_obj: object = getattr(completed, "response", None)
if response_obj is not None:
return response_obj
return completed
@staticmethod
async def _wrap_responses_stream_for_container_ownership(
original_stream_response: Any,
wrapped_generator: Any,
original_stream_response: object,
wrapped_generator: AsyncGenerator[str, None],
user_api_key_dict: UserAPIKeyAuth,
):
) -> AsyncGenerator[str, None]:
"""Forward SSE chunks, then record container ownership at stream end.
Streaming ``/v1/responses`` short-circuits out of
@ -2222,6 +2298,9 @@ class ProxyBaseLLMRequestProcessing:
if isinstance(result, Response):
return result
if not isinstance(result, httpx.Response):
return result
content = await result.aread()
return Response(
content=content,
@ -2232,7 +2311,7 @@ class ProxyBaseLLMRequestProcessing:
),
)
def _is_streaming_response(self, response: Any) -> bool:
def _is_streaming_response(self, response: object) -> bool:
"""
Check if the response object is actually a streaming response by inspecting its type.
@ -2340,7 +2419,7 @@ class ProxyBaseLLMRequestProcessing:
async def _handle_non_streaming_allm_passthrough_route(
self,
response: Any,
response: object,
proxy_logging_obj: "ProxyLogging",
user_api_key_dict: "UserAPIKeyAuth",
custom_headers: dict,
@ -2358,12 +2437,12 @@ class ProxyBaseLLMRequestProcessing:
HttpPassThroughEndpointHelpers,
)
try:
response_status: int = response.status_code # type: ignore[union-attr]
content_type: str = response.headers.get("content-type", "") # type: ignore[union-attr]
except AttributeError:
if not isinstance(response, _AsyncHttpResponseLike):
return None
response_status: int = response.status_code
content_type: str = dict(response.headers).get("content-type", "")
if response_status >= 300:
return None
@ -2374,7 +2453,7 @@ class ProxyBaseLLMRequestProcessing:
return None
response_headers = HttpPassThroughEndpointHelpers.get_response_headers(
headers=response.headers, # type: ignore[union-attr]
headers=response.headers,
custom_headers=custom_headers,
)
callback_headers = await proxy_logging_obj.post_call_response_headers_hook(
@ -2387,7 +2466,7 @@ class ProxyBaseLLMRequestProcessing:
response_headers.update(callback_headers)
if is_event_stream:
body_bytes = await response.aread() # type: ignore[union-attr]
body_bytes = await response.aread()
modified_bytes = await self._handle_event_stream_allm_passthrough_route(
body_bytes=body_bytes,
proxy_logging_obj=proxy_logging_obj,
@ -2400,9 +2479,9 @@ class ProxyBaseLLMRequestProcessing:
headers=response_headers,
)
body_bytes = await response.aread() # type: ignore[union-attr]
body_bytes = await response.aread()
try:
parsed = _json.loads(body_bytes)
parsed = _json.loads(body_bytes) # any-ok: provider-shaped JSON consumed by duck-typed guardrail hooks
except (_json.JSONDecodeError, UnicodeDecodeError):
return Response(
content=body_bytes,
@ -2413,7 +2492,7 @@ class ProxyBaseLLMRequestProcessing:
processed = await proxy_logging_obj.post_call_success_hook(
data=self.data,
user_api_key_dict=user_api_key_dict,
response=parsed,
response=parsed, # any-ok: hook contract declares the OpenAI model union; passthrough bodies are dicts
)
if isinstance(processed, dict):
content = _json.dumps(processed).encode()
@ -2449,7 +2528,7 @@ class ProxyBaseLLMRequestProcessing:
@staticmethod
def _flush_deferred_async_logging(
logging_obj: Any,
logging_obj: object,
exception_raised: bool,
) -> None:
"""
@ -2474,10 +2553,10 @@ class ProxyBaseLLMRequestProcessing:
Extracted as a static method so tests can exercise the production
gating logic directly rather than reimplementing the finally block.
"""
_enqueue_fn = getattr(logging_obj, "_enqueue_deferred_logging", None)
_enqueue_fn: Callable[[], None] | None = getattr(logging_obj, "_enqueue_deferred_logging", None)
if _enqueue_fn is None:
return
logging_obj._enqueue_deferred_logging = None # type: ignore[union-attr]
setattr(logging_obj, "_enqueue_deferred_logging", None)
if exception_raised:
return
try:
@ -2489,9 +2568,9 @@ class ProxyBaseLLMRequestProcessing:
async def _run_deferred_stream_guardrails(
captured_data: dict,
captured_user_api_key_dict: "UserAPIKeyAuth",
captured_logging_obj: Any,
assembled_response: Any,
cache_hit: Any,
captured_logging_obj: LiteLLMLoggingObj,
assembled_response: ModelResponse,
cache_hit: bool | None,
) -> None:
"""
Run non-streaming post-call guardrail hooks on an assembled streaming
@ -2526,7 +2605,7 @@ class ProxyBaseLLMRequestProcessing:
):
continue
try:
guardrail_result = None
guardrail_result: ModelResponse | None = None
if "apply_guardrail" in type(cb).__dict__:
# Skip — apply_guardrail guardrails already ran via
# unified_guardrail's end-of-stream block in the
@ -2541,7 +2620,7 @@ class ProxyBaseLLMRequestProcessing:
# here would duplicate the scan and can spuriously block the guardrail that already passed / failed.
continue
else:
guardrail_result = await cb.async_post_call_success_hook(
guardrail_result = await cb.async_post_call_success_hook( # any-ok: hook declared -> Any
user_api_key_dict=captured_user_api_key_dict,
data=guardrail_data,
response=_response,
@ -2640,9 +2719,9 @@ class ProxyBaseLLMRequestProcessing:
headers = getattr(e, "headers", None) or {}
if not headers:
# Try to get headers from e.response.headers (httpx.Response)
_response = getattr(e, "response", None)
_response: httpx.Response | None = getattr(e, "response", None)
if _response is not None:
_response_headers = getattr(_response, "headers", None)
_response_headers: httpx.Headers | None = getattr(_response, "headers", None)
if _response_headers:
headers = get_response_headers(dict(_response_headers))
headers.update(custom_headers)
@ -2736,7 +2815,7 @@ class ProxyBaseLLMRequestProcessing:
#########################################################
@staticmethod
def return_sse_chunk(chunk: Any) -> str:
def return_sse_chunk(chunk: Any) -> str: # any-ok: chunks are provider frames (str/bytes/dict) forwarded verbatim
"""
Helper function to format streaming chunks for Anthropic API format
@ -2751,13 +2830,13 @@ class ProxyBaseLLMRequestProcessing:
chunk_str = safe_dumps(chunk)
return f"{STREAM_SSE_DATA_PREFIX}{chunk_str}\n\n"
else:
return chunk
return chunk # any-ok: non-dict frames (str/bytes) pass through unchanged
@staticmethod
async def _finalize_streaming_generator_cleanup(
request: Request | None,
request_data: dict,
response: Any,
response: object,
stream_completed: bool = False,
client_disconnected: bool = False,
user_api_key_dict: UserAPIKeyAuth | None = None,
@ -2794,9 +2873,10 @@ class ProxyBaseLLMRequestProcessing:
):
await proxy_logging_obj._arelease_max_parallel_requests_on_disconnect(user_api_key_dict)
if hasattr(response, "aclose"):
aclose_fn: Callable[[], Awaitable[object]] | None = getattr(response, "aclose", None)
if aclose_fn is not None:
try:
await response.aclose()
await aclose_fn()
except BaseException as e: # noqa: BLE001
verbose_proxy_logger.debug(
"async_streaming_data_generator: error closing response stream: %s",
@ -2805,7 +2885,7 @@ class ProxyBaseLLMRequestProcessing:
@staticmethod
async def async_streaming_data_generator(
response: Any,
response: object,
user_api_key_dict: UserAPIKeyAuth,
request_data: dict,
proxy_logging_obj: ProxyLogging,
@ -2859,7 +2939,7 @@ class ProxyBaseLLMRequestProcessing:
str_so_far += response_str
elif hasattr(chunk, "model_dump"):
try:
d = chunk.model_dump(mode="json", exclude_none=True)
d: dict[str, object] = chunk.model_dump(mode="json", exclude_none=True)
if isinstance(d, dict):
str_so_far += str(d.get("content", ""))
except Exception:
@ -2936,7 +3016,7 @@ class ProxyBaseLLMRequestProcessing:
@staticmethod
def async_sse_data_generator(
response: Any,
response: object,
user_api_key_dict: UserAPIKeyAuth,
request_data: dict,
proxy_logging_obj: ProxyLogging,
@ -2963,7 +3043,7 @@ class ProxyBaseLLMRequestProcessing:
)
@staticmethod
def _process_chunk_with_cost_injection(chunk: Any, model_name: str) -> Any:
def _process_chunk_with_cost_injection(chunk: object, model_name: str) -> object:
"""
Process a streaming chunk and inject cost information if enabled.
@ -3023,12 +3103,13 @@ class ProxyBaseLLMRequestProcessing:
if stripped_ln.startswith("data:"):
json_part = stripped_ln.split("data:", 1)[1].strip()
if json_part and json_part != "[DONE]":
obj = json.loads(json_part)
maybe_modified = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict(obj, model_name)
if maybe_modified is not None:
# Replace just this line with updated JSON using safe_dumps
lines[idx] = f"data: {safe_dumps(maybe_modified)}"
return "\n".join(lines)
obj = _OBJECT_ADAPTER.validate_python(json.loads(json_part))
if isinstance(obj, dict):
maybe_modified = ProxyBaseLLMRequestProcessing._inject_cost_into_usage_dict(obj, model_name)
if maybe_modified is not None:
# Replace just this line with updated JSON using safe_dumps
lines[idx] = f"data: {safe_dumps(maybe_modified)}"
return "\n".join(lines)
return None
except Exception:
return None
@ -3054,13 +3135,13 @@ class ProxyBaseLLMRequestProcessing:
)
# Extract additional usage fields
cache_creation_input_tokens = _usage.get("cache_creation_input_tokens")
cache_read_input_tokens = _usage.get("cache_read_input_tokens")
web_search_requests = _usage.get("web_search_requests")
completion_tokens_details = _usage.get("completion_tokens_details")
prompt_tokens_details = _usage.get("prompt_tokens_details")
cache_creation_input_tokens: int | None = _usage.get("cache_creation_input_tokens")
cache_read_input_tokens: int | None = _usage.get("cache_read_input_tokens")
web_search_requests: int | None = _usage.get("web_search_requests")
completion_tokens_details: dict[str, object] | None = _usage.get("completion_tokens_details")
prompt_tokens_details: dict[str, object] | None = _usage.get("prompt_tokens_details")
usage_kwargs: dict[str, Any] = {
usage_kwargs: _StreamUsageKwargsPayload = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
@ -3120,18 +3201,18 @@ class ProxyBaseLLMRequestProcessing:
# 2. Fallback to kwargs (initial)
if not model_id:
_kwargs = getattr(_logging_obj, "kwargs", None)
_kwargs: _LoggingKwargsPayload | None = getattr(_logging_obj, "kwargs", None)
if _kwargs:
litellm_params = _kwargs.get("litellm_params", {})
kwargs_litellm_params = _kwargs.get("litellm_params", {})
# First check direct model_info path
model_info = litellm_params.get("model_info") or {}
model_id = model_info.get("id", None)
kwargs_model_info: _ModelInfoIdPayload = kwargs_litellm_params.get("model_info") or {}
model_id = kwargs_model_info.get("id", None)
# Fallback to nested metadata path
if not model_id:
metadata = litellm_params.get("metadata") or {}
model_info = metadata.get("model_info") or {}
model_id = model_info.get("id", None)
kwargs_metadata: _ModelInfoMetadataPayload = kwargs_litellm_params.get("metadata") or {}
kwargs_model_info = kwargs_metadata.get("model_info") or {}
model_id = kwargs_model_info.get("id", None)
# 3. Final fallback to self.data["litellm_metadata"] (for routes like /v1/responses that populate data before error)
if not model_id:

View file

@ -8,7 +8,7 @@ import asyncio
import binascii
import os
import uuid
from collections.abc import Callable
from collections.abc import Awaitable, Callable, Mapping, Sequence
from contextvars import ContextVar
from dataclasses import dataclass, field
from datetime import datetime
@ -16,11 +16,12 @@ from typing import (
TYPE_CHECKING,
Any,
Literal,
Protocol,
TypedDict,
Union,
cast,
)
from pydantic import TypeAdapter
from litellm import DualCache
from litellm._logging import verbose_proxy_logger
from litellm.constants import DYNAMIC_RATE_LIMIT_ERROR_THRESHOLD_PER_MINUTE
@ -54,9 +55,10 @@ if TYPE_CHECKING:
from opentelemetry.trace import Span as _Span
from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache
from litellm.types.agents import AgentResponse
from litellm.types.caching import RedisPipelineIncrementOperation
Span = Union[_Span, Any]
Span = _Span
InternalUsageCache = _InternalUsageCache
else:
Span = Any
@ -342,6 +344,71 @@ class RateLimitResponseWithDescriptors(TypedDict):
response: RateLimitResponse
class _WindowLimitMetadata(TypedDict):
requests_limit: int | None
tokens_limit: int | None
window_size: int
descriptor_key: str
class _AtomicCounterMeta(TypedDict):
descriptor_key: str
current_limit: int
rate_limit_type: Literal["requests", "tokens"]
window_key: str
counter_key: str
increment: int
ttl: int
window_size: int
class _AtomicCounterState(TypedDict):
window_expired: bool
current: int
class _TokenUsageDict(TypedDict, total=False):
prompt_tokens: int | None
completion_tokens: int | None
total_tokens: int | None
prompt_tokens_details: Mapping[str, int | None] | None
class _RedisLuaScript(Protocol):
def __call__(self, keys: Sequence[str], args: Sequence[int | float | str]) -> Awaitable[Sequence[object]]: ...
class _CallTypeRateLimiter(Protocol):
async def async_pre_call_hook(
self,
user_api_key_dict: UserAPIKeyAuth,
cache: DualCache,
data: dict[str, object],
call_type: str,
) -> Exception | str | dict[str, object] | None: ...
_CACHED_VALUE_ADAPTER: TypeAdapter[object] = TypeAdapter(object)
def _as_int(value: object) -> int:
if isinstance(value, (int, float, str, bytes)):
return int(value)
raise TypeError(f"Expected an int-convertible rate limiter value, got {type(value).__name__}")
def _str_or_none(value: object) -> str | None:
return value if isinstance(value, str) else None
def _object_dict_or_none(value: object) -> dict[object, object] | None:
return value if isinstance(value, dict) else None
def _str_object_dict_or_none(value: object) -> dict[str, object] | None:
return value if isinstance(value, dict) else None
@dataclass(slots=True)
class RequestRateLimiterStash:
"""
@ -391,7 +458,7 @@ def get_or_create_request_stash() -> RequestRateLimiterStash:
return stash
def claim_request_stash_for_data(data: dict) -> RequestRateLimiterStash:
def claim_request_stash_for_data(data: Mapping) -> RequestRateLimiterStash:
stash = get_or_create_request_stash()
owner_call_id = data.get("litellm_call_id")
if isinstance(owner_call_id, str):
@ -424,23 +491,23 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
self.internal_usage_cache = internal_usage_cache
self._time_provider = time_provider or datetime.now
if self.internal_usage_cache.dual_cache.redis_cache is not None:
self.batch_rate_limiter_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
BATCH_RATE_LIMITER_SCRIPT
self.batch_rate_limiter_script: _RedisLuaScript | None = (
self.internal_usage_cache.dual_cache.redis_cache.async_register_script(BATCH_RATE_LIMITER_SCRIPT)
)
self.token_increment_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
TOKEN_INCREMENT_SCRIPT
self.token_increment_script: _RedisLuaScript | None = (
self.internal_usage_cache.dual_cache.redis_cache.async_register_script(TOKEN_INCREMENT_SCRIPT)
)
self.check_and_increment_by_n_script = (
self.check_and_increment_by_n_script: _RedisLuaScript | None = (
self.internal_usage_cache.dual_cache.redis_cache.async_register_script(CHECK_AND_INCREMENT_BY_N_SCRIPT)
)
self.parallel_acquire_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
PARALLEL_ACQUIRE_SCRIPT
self.parallel_acquire_script: _RedisLuaScript | None = (
self.internal_usage_cache.dual_cache.redis_cache.async_register_script(PARALLEL_ACQUIRE_SCRIPT)
)
self.parallel_release_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
PARALLEL_RELEASE_SCRIPT
self.parallel_release_script: _RedisLuaScript | None = (
self.internal_usage_cache.dual_cache.redis_cache.async_register_script(PARALLEL_RELEASE_SCRIPT)
)
self.parallel_count_script = self.internal_usage_cache.dual_cache.redis_cache.async_register_script(
PARALLEL_COUNT_SCRIPT
self.parallel_count_script: _RedisLuaScript | None = (
self.internal_usage_cache.dual_cache.redis_cache.async_register_script(PARALLEL_COUNT_SCRIPT)
)
else:
self.batch_rate_limiter_script = None
@ -459,7 +526,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
self.tpm_reservation_enabled = os.getenv("LITELLM_TPM_TOKEN_RESERVATION_ENABLED", "true").lower() == "true"
# Batch rate limiter (lazy loaded)
self._batch_rate_limiter: Any | None = None
self._batch_rate_limiter: _CallTypeRateLimiter | None = None
# Serializes multi-phase check+increment sequences (batch + dynamic
# limiters) within this process to close the TOCTOU window between
@ -477,7 +544,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# one round-trip.
self._check_and_increment_lock = asyncio.Lock()
def _get_batch_rate_limiter(self) -> Any | None:
def _get_batch_rate_limiter(self) -> _CallTypeRateLimiter | None:
"""Get or lazy-load the batch rate limiter."""
if self._batch_rate_limiter is None:
try:
@ -497,6 +564,15 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
"""Return the current time for rate limiting calculations."""
return self._time_provider()
async def _get_local_cache_value(self, key: str, parent_otel_span: Span | None) -> object:
return _CACHED_VALUE_ADAPTER.validate_python(
await self.internal_usage_cache.async_get_cache(
key=key,
litellm_parent_otel_span=parent_otel_span,
local_only=True,
)
)
@staticmethod
def _no_max_tokens_output_floor(
min_configured_tpm_limit: int | None,
@ -514,7 +590,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
def _estimate_tokens_for_request(
self,
data: dict,
data: Mapping,
model: str | None = None,
min_configured_tpm_limit: int | None = None,
) -> int:
@ -601,15 +677,15 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async def in_memory_cache_sliding_window(
self,
keys: list[str],
keys: Sequence[str],
now_int: int,
window_size: int,
) -> list[Any]:
) -> Sequence[object]:
"""
Implement sliding window rate limiting logic using in-memory cache operations.
This follows the same logic as the Redis Lua script but uses async cache operations.
"""
results: list[Any] = []
results: list[object] = []
# Process each window/counter pair
for i in range(0, len(keys), 2):
@ -618,14 +694,10 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
increment_value = 1
# Get the window start time
window_start = await self.internal_usage_cache.async_get_cache(
key=window_key,
litellm_parent_otel_span=None,
local_only=True,
)
window_start = await self._get_local_cache_value(key=window_key, parent_otel_span=None)
# Check if window exists and is valid
if window_start is None or (now_int - int(window_start)) >= window_size:
if window_start is None or (now_int - _as_int(window_start)) >= window_size:
# Reset window and counter
await self.internal_usage_cache.async_set_cache(
key=window_key,
@ -645,12 +717,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
results.append(increment_value) # counter
else:
# Increment the counter
current_counter = await self.internal_usage_cache.async_get_cache(
key=counter_key,
litellm_parent_otel_span=None,
local_only=True,
)
new_counter_value = (int(current_counter) if current_counter is not None else 0) + increment_value
current_counter = await self._get_local_cache_value(key=counter_key, parent_otel_span=None)
new_counter_value = (_as_int(current_counter) if current_counter is not None else 0) + increment_value
await self.internal_usage_cache.async_set_cache(
key=counter_key,
value=new_counter_value,
@ -678,9 +746,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
def is_cache_list_over_limit(
self,
keys_to_fetch: list[str],
cache_values: list[Any],
key_metadata: dict[str, Any],
keys_to_fetch: Sequence[str],
cache_values: Sequence[object],
key_metadata: Mapping[str, _WindowLimitMetadata],
) -> RateLimitResponse:
"""
Check if the cache values are over the limit.
@ -709,12 +777,12 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
if current_limit is None or rate_limit_type is None:
continue
if counter_value is not None and int(counter_value) > current_limit:
if counter_value is not None and _as_int(counter_value) > current_limit:
overall_code = "OVER_LIMIT"
item_code = "OVER_LIMIT"
# Only compute limit_remaining if current_limit is not None
limit_remaining = current_limit - int(counter_value) if counter_value is not None else current_limit
limit_remaining = current_limit - _as_int(counter_value) if counter_value is not None else current_limit
statuses.append(
{
@ -783,7 +851,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
self,
keys_to_fetch: list[str],
now_int: int,
) -> list[Any]:
) -> Sequence[object]:
"""
Execute Redis operations grouped by hash tag for cluster compatibility.
@ -792,13 +860,13 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
now_int: int - Current timestamp
Returns:
List[Any] - List of cache values
List of cache values
"""
if self.batch_rate_limiter_script is None:
return []
key_groups = self._group_keys_by_hash_tag(keys_to_fetch)
all_cache_values = []
all_cache_values: list[object] = []
for hash_tag, group_keys in key_groups.items():
try:
@ -821,7 +889,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async def should_rate_limit(
self,
descriptors: list[RateLimitDescriptor],
descriptors: Sequence[RateLimitDescriptor],
parent_otel_span: Span | None = None,
read_only: bool = False,
skip_tpm_check: bool = False,
@ -866,7 +934,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
windowed_response = RateLimitResponse(overall_code="OK", statuses=[])
if keys_to_fetch:
## CHECK IN-MEMORY CACHE
cache_values = await self.internal_usage_cache.async_batch_get_cache(
cache_values: Sequence[object] | None = await self.internal_usage_cache.async_batch_get_cache(
keys=keys_to_fetch,
parent_otel_span=parent_otel_span,
local_only=True,
@ -888,9 +956,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# For keys that don't exist yet, set them to 0
if cache_values is None:
cache_values = []
for _ in keys_to_fetch:
cache_values.append(str(now_int) if _.endswith(":window") else 0)
cache_values = [str(now_int) if key.endswith(":window") else 0 for key in keys_to_fetch]
elif self.batch_rate_limiter_script is not None:
# NORMAL MODE: Increment counters in Redis
# Group keys by hash tag for Redis cluster compatibility
@ -947,16 +1013,16 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
def _collect_windowed_keys_and_gauges(
self,
descriptors: list[RateLimitDescriptor],
descriptors: Sequence[RateLimitDescriptor],
skip_tpm_check: bool,
) -> tuple[list[str], dict[str, dict[str, Any]], list[ParallelRequestGauge]]:
) -> tuple[list[str], dict[str, _WindowLimitMetadata], list[ParallelRequestGauge]]:
"""
Split descriptors into the windowed (window_key, counter_key) fetch
list with its per-window metadata, and the concurrency gauges for
descriptors carrying a max_parallel_requests limit.
"""
keys_to_fetch: list[str] = []
key_metadata: dict[str, dict[str, Any]] = {}
key_metadata: dict[str, _WindowLimitMetadata] = {}
gauges: list[ParallelRequestGauge] = []
for descriptor in descriptors:
descriptor_key = descriptor["key"]
@ -1012,7 +1078,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
descriptor_key=gauge["descriptor_key"],
)
def _gauge_in_flight_from_cache_value(self, raw_value: Any) -> int:
def _gauge_in_flight_from_cache_value(self, raw_value: object) -> int:
"""
In-flight count from a cached gauge value: a dict of slot_id ->
acquire timestamp when the in-memory registry is authoritative, or
@ -1020,14 +1086,15 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
"""
if raw_value is None:
return 0
if isinstance(raw_value, dict):
slot_acquire_times = _object_dict_or_none(raw_value)
if slot_acquire_times is not None:
cutoff = self._get_current_time().timestamp() - PARALLEL_REQUEST_SLOT_TTL_SECONDS
return sum(1 for ts in raw_value.values() if isinstance(ts, (int, float)) and ts >= cutoff)
return max(0, int(raw_value))
return sum(1 for ts in slot_acquire_times.values() if isinstance(ts, (int, float)) and ts >= cutoff)
return max(0, _as_int(raw_value))
async def _check_parallel_request_gauges(
self,
gauges: list[ParallelRequestGauge],
gauges: Sequence[ParallelRequestGauge],
slot_id: str,
parent_otel_span: Span | None = None,
read_only: bool = False,
@ -1053,7 +1120,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
keys=gauge_keys,
args=[PARALLEL_REQUEST_SLOT_TTL_SECONDS for _ in gauges],
)
counts = [max(0, int(value)) for value in raw_counts]
counts = [max(0, _as_int(value)) for value in raw_counts]
except Exception as e: # noqa: BLE001 - any Redis/Lua failure degrades to the local mirror, never a 500
verbose_proxy_logger.warning(f"parallel_count_script failed, using local mirror: {e!s}")
counts = await self._read_local_gauge_counts(gauge_keys, parent_otel_span)
@ -1088,22 +1155,22 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
verbose_proxy_logger.warning(f"parallel_acquire_script failed, falling back to in-memory gauge: {e!s}")
async with self._check_and_increment_lock:
return await self._acquire_parallel_slots_in_memory(gauges, slot_id, parent_otel_span)
if int(raw[0]) == 1:
gauge = gauges[int(raw[1]) - 1]
if _as_int(raw[0]) == 1:
gauge = gauges[_as_int(raw[1]) - 1]
return RateLimitResponse(
overall_code="OVER_LIMIT",
statuses=[self._gauge_status(gauge, int(raw[2]), "OVER_LIMIT")],
statuses=[self._gauge_status(gauge, _as_int(raw[2]), "OVER_LIMIT")],
)
statuses = []
for gauge, in_flight in zip(gauges, raw[1:]):
await self.internal_usage_cache.async_set_cache(
key=gauge["counter_key"],
value=int(in_flight),
value=_as_int(in_flight),
ttl=PARALLEL_REQUEST_SLOT_TTL_SECONDS,
litellm_parent_otel_span=parent_otel_span,
local_only=True,
)
statuses.append(self._gauge_status(gauge, int(in_flight), "OK"))
statuses.append(self._gauge_status(gauge, _as_int(in_flight), "OK"))
return RateLimitResponse(overall_code="OK", statuses=statuses)
async with self._check_and_increment_lock:
@ -1113,8 +1180,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
self,
gauge_keys: list[str],
parent_otel_span: Span | None = None,
) -> list[int]:
values = await self.internal_usage_cache.async_batch_get_cache(
) -> Sequence[int]:
values: Sequence[object] | None = await self.internal_usage_cache.async_batch_get_cache(
keys=gauge_keys,
parent_otel_span=parent_otel_span,
local_only=True,
@ -1125,7 +1192,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async def _acquire_parallel_slots_in_memory(
self,
gauges: list[ParallelRequestGauge],
gauges: Sequence[ParallelRequestGauge],
slot_id: str,
parent_otel_span: Span | None = None,
) -> RateLimitResponse:
@ -1143,14 +1210,16 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
cutoff = now - PARALLEL_REQUEST_SLOT_TTL_SECONDS
states: list[tuple[dict[str, float] | None, int]] = []
for gauge in gauges:
raw_value = await self.internal_usage_cache.async_get_cache(
raw_value = await self._get_local_cache_value(
key=gauge["counter_key"],
litellm_parent_otel_span=parent_otel_span,
local_only=True,
parent_otel_span=parent_otel_span,
)
if isinstance(raw_value, dict):
slot_acquire_times = _object_dict_or_none(raw_value)
if slot_acquire_times is not None:
registry: dict[str, float] | None = {
key: float(ts) for key, ts in raw_value.items() if isinstance(ts, (int, float)) and ts >= cutoff
key: float(ts)
for key, ts in slot_acquire_times.items()
if isinstance(key, str) and isinstance(ts, (int, float)) and ts >= cutoff
}
in_flight = len(registry or {})
elif raw_value is None:
@ -1158,7 +1227,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
in_flight = 0
else:
registry = None
in_flight = max(0, int(raw_value))
in_flight = max(0, _as_int(raw_value))
if in_flight + 1 > gauge["limit"]:
return RateLimitResponse(
overall_code="OVER_LIMIT",
@ -1205,7 +1274,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
for counter_key, remaining in zip(counter_keys, raw):
await self.internal_usage_cache.async_set_cache(
key=counter_key,
value=max(0, int(remaining)),
value=max(0, _as_int(remaining)),
ttl=PARALLEL_REQUEST_SLOT_TTL_SECONDS,
litellm_parent_otel_span=parent_otel_span,
local_only=True,
@ -1218,19 +1287,21 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async with self._check_and_increment_lock:
for counter_key in counter_keys:
raw_value = await self.internal_usage_cache.async_get_cache(
raw_value = await self._get_local_cache_value(
key=counter_key,
litellm_parent_otel_span=parent_otel_span,
local_only=True,
parent_otel_span=parent_otel_span,
)
if isinstance(raw_value, dict):
if slot_id not in raw_value:
slot_acquire_times = _object_dict_or_none(raw_value)
if slot_acquire_times is not None:
if slot_id not in slot_acquire_times:
continue
new_value: dict[str, float] | int = {key: ts for key, ts in raw_value.items() if key != slot_id}
new_value: dict[object, object] | int = {
key: ts for key, ts in slot_acquire_times.items() if key != slot_id
}
elif raw_value is None:
continue
else:
new_value = max(0, int(raw_value) - 1)
new_value = max(0, _as_int(raw_value) - 1)
await self.internal_usage_cache.async_set_cache(
key=counter_key,
value=new_value,
@ -1241,8 +1312,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async def atomic_check_and_increment_by_n(
self,
descriptors: list[RateLimitDescriptor],
increments: list[dict[Literal["requests", "tokens"], int]],
descriptors: Sequence[RateLimitDescriptor],
increments: Sequence[Mapping[Literal["requests", "tokens"], int]],
parent_otel_span: Span | None = None,
) -> RateLimitResponse:
"""
@ -1277,7 +1348,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# Build per-descriptor (keys, args, meta) groups. All keys within a
# group share the descriptor's {key:value} hash tag, so a single Lua
# call per group never triggers CROSSSLOT on Redis Cluster.
descriptor_groups: list[tuple[list[str], list[Any], list[dict[str, Any]]]] = []
descriptor_groups: list[tuple[Sequence[str], Sequence[int], Sequence[_AtomicCounterMeta]]] = []
for descriptor, increment_amounts in zip(descriptors, increments):
keys, args, meta = self._build_descriptor_atomic_payload(
descriptor=descriptor,
@ -1300,7 +1371,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
parent_otel_span=parent_otel_span,
)
flat_meta: list[dict[str, Any]] = [m for _keys, _args, group_meta in descriptor_groups for m in group_meta]
flat_meta: Sequence[_AtomicCounterMeta] = [
m for _keys, _args, group_meta in descriptor_groups for m in group_meta
]
async with self._check_and_increment_lock:
return await self._atomic_check_and_increment_in_memory(
per_counter_meta=flat_meta,
@ -1310,8 +1383,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
def _build_descriptor_atomic_payload(
self,
descriptor: RateLimitDescriptor,
increment_amounts: dict[Literal["requests", "tokens"], int],
) -> tuple[list[str], list[Any], list[dict[str, Any]]]:
increment_amounts: Mapping[Literal["requests", "tokens"], int],
) -> tuple[list[str], list[int], list[_AtomicCounterMeta]]:
"""
Build (KEYS, ARGV, per-counter meta) for a single descriptor's Lua
call. All keys returned share the descriptor's {key:value} hash tag.
@ -1325,11 +1398,11 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
window_key = f"{{{descriptor_key}:{descriptor_value}}}:window"
keys: list[str] = []
args: list[Any] = []
meta: list[dict[str, Any]] = []
args: list[int] = []
meta: list[_AtomicCounterMeta] = []
for rate_limit_type in ("requests", "tokens"):
rlt: Literal["requests", "tokens"] = cast(Literal["requests", "tokens"], rate_limit_type)
rlt: Literal["requests", "tokens"] = rate_limit_type
if rlt == "requests":
limit_value = rate_limit.get("requests_per_unit")
inc_amount = int(increment_amounts.get("requests", 0) or 0)
@ -1365,7 +1438,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async def _atomic_lua_per_descriptor(
self,
descriptor_groups: list[tuple[list[str], list[Any], list[dict[str, Any]]]],
descriptor_groups: Sequence[tuple[Sequence[str], Sequence[int], Sequence[_AtomicCounterMeta]]],
parent_otel_span: Span | None = None,
) -> RateLimitResponse:
"""
@ -1374,7 +1447,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
descriptor i, refund descriptors 0..i-1's increments. On Lua failure
mid-loop, refund applied increments and fall back to in-memory.
"""
applied: list[list[dict[str, Any]]] = []
applied: list[Sequence[_AtomicCounterMeta]] = []
statuses: list[RateLimitStatus] = []
for _idx, (keys, args, meta) in enumerate(descriptor_groups):
@ -1397,7 +1470,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
f"{self.window_size}s)."
)
await self._refund_applied_descriptor_groups(applied)
flat_meta: list[dict[str, Any]] = [m for _k, _a, group_meta in descriptor_groups for m in group_meta]
flat_meta: Sequence[_AtomicCounterMeta] = [
m for _k, _a, group_meta in descriptor_groups for m in group_meta
]
async with self._check_and_increment_lock:
return await self._atomic_check_and_increment_in_memory(
per_counter_meta=flat_meta,
@ -1415,7 +1490,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async def _refund_applied_descriptor_groups(
self,
applied: list[list[dict[str, Any]]],
applied: Sequence[Sequence[_AtomicCounterMeta]],
) -> None:
"""
Decrement counters for descriptor groups already applied via Lua.
@ -1441,8 +1516,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
def _build_atomic_response(
self,
raw: list[Any],
per_counter_meta: list[dict[str, Any]],
raw: Sequence[object],
per_counter_meta: Sequence[_AtomicCounterMeta],
) -> RateLimitResponse:
"""Convert Lua script return value to RateLimitResponse.
@ -1458,12 +1533,12 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
if not raw:
return RateLimitResponse(overall_code="OK", statuses=[])
status_code = int(raw[0])
status_code = _as_int(raw[0])
if status_code == 1:
# Over limit: { 1, counter_index (1-based), current_counter, limit }
descriptor_index = int(raw[1]) - 1
current_counter = int(raw[2])
limit = int(raw[3])
descriptor_index = _as_int(raw[1]) - 1
current_counter = _as_int(raw[2])
limit = _as_int(raw[3])
meta = per_counter_meta[descriptor_index]
return RateLimitResponse(
overall_code="OVER_LIMIT",
@ -1484,7 +1559,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
RateLimitStatus(
code="OK",
current_limit=meta["current_limit"],
limit_remaining=max(0, meta["current_limit"] - int(new_counter)),
limit_remaining=max(0, meta["current_limit"] - _as_int(new_counter)),
rate_limit_type=meta["rate_limit_type"],
descriptor_key=meta["descriptor_key"],
)
@ -1493,7 +1568,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async def _atomic_check_and_increment_in_memory(
self,
per_counter_meta: list[dict[str, Any]],
per_counter_meta: Sequence[_AtomicCounterMeta],
parent_otel_span: Span | None = None,
) -> RateLimitResponse:
"""In-memory all-or-nothing check-and-increment. Caller holds lock.
@ -1508,27 +1583,23 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
now_int = int(self._get_current_time().timestamp())
# Pass 1: read state, validate.
descriptor_state: list[dict[str, Any]] = []
descriptor_state: list[_AtomicCounterState] = []
for meta in per_counter_meta:
window_size = meta["window_size"]
window_start = await self.internal_usage_cache.async_get_cache(
window_start = await self._get_local_cache_value(
key=meta["window_key"],
litellm_parent_otel_span=parent_otel_span,
local_only=True,
parent_otel_span=parent_otel_span,
)
window_expired = window_start is None or (now_int - int(window_start)) >= window_size
current_counter = (
0
window_expired = window_start is None or (now_int - _as_int(window_start)) >= window_size
counter_raw = (
None
if window_expired
else int(
await self.internal_usage_cache.async_get_cache(
key=meta["counter_key"],
litellm_parent_otel_span=parent_otel_span,
local_only=True,
)
or 0
else await self._get_local_cache_value(
key=meta["counter_key"],
parent_otel_span=parent_otel_span,
)
)
current_counter = 0 if window_expired else _as_int(counter_raw or 0)
over_limit = (
current_counter + meta["increment"] > meta["current_limit"]
if meta["increment"] > 0
@ -1581,7 +1652,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async def reserve_tpm_tokens(
self,
descriptors: list[RateLimitDescriptor],
descriptors: Sequence[RateLimitDescriptor],
estimated_tokens: int,
parent_otel_span: Span | None = None,
) -> RateLimitResponse:
@ -1595,7 +1666,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
atomicity (Lua on Redis, asyncio-locked DualCache otherwise) to the
shared primitive.
"""
tpm_descriptors: list[RateLimitDescriptor] = [
tpm_descriptors: Sequence[RateLimitDescriptor] = [
RateLimitDescriptor(
key=d["key"],
value=d["value"],
@ -1610,7 +1681,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
if not tpm_descriptors:
return RateLimitResponse(overall_code="OK", statuses=[])
increments: list[dict[Literal["requests", "tokens"], int]] = [
increments: Sequence[Mapping[Literal["requests", "tokens"], int]] = [
{"tokens": estimated_tokens} for _ in tpm_descriptors
]
return await self.atomic_check_and_increment_by_n(
@ -1621,7 +1692,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
def create_organization_rate_limit_descriptor(
self, user_api_key_dict: UserAPIKeyAuth, requested_model: str | None = None
) -> list[RateLimitDescriptor]:
) -> Sequence[RateLimitDescriptor]:
descriptors: list[RateLimitDescriptor] = []
# Global org rate limits
@ -1920,7 +1991,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
"""
return rpm_limit_type == "dynamic" or tpm_limit_type == "dynamic"
def _get_agent_from_registry(self, agent_id: str) -> Any | None:
def _get_agent_from_registry(self, agent_id: str) -> "AgentResponse | None":
"""Look up an agent from the in-memory registry by ID."""
from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry
@ -1931,7 +2002,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
Resolve the agent_id from either the API key or request metadata.
Key-level agent_id takes precedence over metadata/header-supplied agent_id.
"""
key_agent_id = getattr(user_api_key_dict, "agent_id", None)
key_agent_id_raw: object = getattr(user_api_key_dict, "agent_id", None)
key_agent_id = _str_or_none(key_agent_id_raw)
if key_agent_id:
return key_agent_id
metadata = data.get("metadata") or {}
@ -1969,8 +2041,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
if agent is None:
return descriptors
agent_rpm = getattr(agent, "rpm_limit", None)
agent_tpm = getattr(agent, "tpm_limit", None)
agent_rpm = agent.rpm_limit
agent_tpm = agent.tpm_limit
if agent_rpm is not None or agent_tpm is not None:
descriptors.append(
RateLimitDescriptor(
@ -1984,8 +2056,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
)
)
session_rpm = getattr(agent, "session_rpm_limit", None)
session_tpm = getattr(agent, "session_tpm_limit", None)
session_rpm = agent.session_rpm_limit
session_tpm = agent.session_tpm_limit
if session_rpm is not None or session_tpm is not None:
session_id = self._get_session_id_from_data(data)
if session_id is not None:
@ -2244,7 +2316,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# Fail safe: enforce limits if we can't check
return True
def get_rate_limiter_for_call_type(self, call_type: str) -> Any | None:
def get_rate_limiter_for_call_type(self, call_type: str) -> _CallTypeRateLimiter | None:
"""Get the rate limiter for the call type."""
if call_type == "acreate_batch":
batch_limiter = self._get_batch_rate_limiter()
@ -2371,7 +2443,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
self,
user_api_key_dict: UserAPIKeyAuth,
cache: DualCache,
data: dict,
data: dict[str, object],
call_type: str,
):
"""
@ -2402,7 +2474,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# For dynamic mode, check if the model has recent failures
model_has_failures = False
requested_model = data.get("model", None)
requested_model = _str_or_none(data.get("model", None))
if (
self._is_dynamic_rate_limiting_enabled(
@ -2604,7 +2676,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
return pipeline_operations
def _get_total_tokens_from_usage(
self, usage: Any | None, rate_limit_type: Literal["output", "input", "total"]
self, usage: "Usage | _TokenUsageDict | None", rate_limit_type: Literal["output", "input", "total"]
) -> int:
"""
Get total tokens from response usage for rate limiting.
@ -2616,34 +2688,33 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
total_tokens = 0
cached_tokens = 0
if usage:
if isinstance(usage, Usage):
if rate_limit_type == "output":
total_tokens = usage.completion_tokens or 0
elif rate_limit_type == "input":
total_tokens = usage.prompt_tokens or 0
elif rate_limit_type == "total":
total_tokens = usage.total_tokens or 0
if isinstance(usage, Usage):
if rate_limit_type == "output":
total_tokens = usage.completion_tokens or 0
elif rate_limit_type == "input":
total_tokens = usage.prompt_tokens or 0
elif rate_limit_type == "total":
total_tokens = usage.total_tokens or 0
# Get cached tokens to exclude from input/total
if rate_limit_type in ("input", "total"):
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details is not None:
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
# Get cached tokens to exclude from input/total
if rate_limit_type in ("input", "total"):
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details is not None:
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
elif isinstance(usage, dict):
# Responses API usage comes as a dict
if rate_limit_type == "output":
total_tokens = usage.get("completion_tokens", 0) or 0
elif rate_limit_type == "input":
total_tokens = usage.get("prompt_tokens", 0) or 0
elif rate_limit_type == "total":
total_tokens = usage.get("total_tokens", 0) or 0
elif isinstance(usage, dict) and usage:
# Responses API usage comes as a dict
if rate_limit_type == "output":
total_tokens = usage.get("completion_tokens", 0) or 0
elif rate_limit_type == "input":
total_tokens = usage.get("prompt_tokens", 0) or 0
elif rate_limit_type == "total":
total_tokens = usage.get("total_tokens", 0) or 0
# Get cached tokens from dict
if rate_limit_type in ("input", "total"):
prompt_details = usage.get("prompt_tokens_details") or {}
if isinstance(prompt_details, dict):
cached_tokens = prompt_details.get("cached_tokens", 0) or 0
# Get cached tokens from dict
if rate_limit_type in ("input", "total"):
prompt_details = usage.get("prompt_tokens_details") or {}
if isinstance(prompt_details, dict):
cached_tokens = prompt_details.get("cached_tokens", 0) or 0
# Subtract cached tokens for input/total (providers don't count them)
if cached_tokens > 0:
@ -2652,7 +2723,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
return total_tokens
@staticmethod
def _aggregate_only_total_tokens(usage: Usage | dict | None) -> int:
def _aggregate_only_total_tokens(usage: "Usage | _TokenUsageDict | None") -> int:
"""Total for usage that carries no input/output split, else 0.
A source that can only report one number for the whole request (a
@ -2767,15 +2838,15 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
@staticmethod
def _merge_ratelimit_statuses_into_additional_headers(
additional_headers: dict[str, Any],
additional_headers: Mapping[str, object],
statuses: list[RateLimitStatus],
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Return ``additional_headers`` extended with
``x-ratelimit-{descriptor_key}-{remaining|limit}-{rate_limit_type}``
entries. Non-mutating so callers pick their own target dict.
"""
merged: dict[str, Any] = dict(additional_headers)
merged: dict[str, object] = dict(additional_headers)
for status in statuses:
prefix = f"x-ratelimit-{status['descriptor_key']}"
merged[f"{prefix}-remaining-{status['rate_limit_type']}"] = status["limit_remaining"]
@ -2784,8 +2855,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
def _collect_tpm_scope_targets(
self,
standard_logging_metadata: dict[str, Any],
kwargs: Any,
standard_logging_metadata: Mapping[str, object],
kwargs: Mapping[str, object],
model_group: str | None,
) -> list[tuple[str, str]]:
"""
@ -2795,16 +2866,18 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
the emitter; this helper just lists the candidate scopes so callers
can split reserved-vs-unreserved.
"""
user_api_key = standard_logging_metadata.get("user_api_key_hash")
user_api_key_user_id = standard_logging_metadata.get("user_api_key_user_id")
user_api_key_team_id = standard_logging_metadata.get("user_api_key_team_id")
user_api_key_organization_id = standard_logging_metadata.get("user_api_key_org_id")
user_api_key_project_id = standard_logging_metadata.get("user_api_key_project_id")
user_api_key_end_user_id = (
user_api_key = _str_or_none(standard_logging_metadata.get("user_api_key_hash"))
user_api_key_user_id = _str_or_none(standard_logging_metadata.get("user_api_key_user_id"))
user_api_key_team_id = _str_or_none(standard_logging_metadata.get("user_api_key_team_id"))
user_api_key_organization_id = _str_or_none(standard_logging_metadata.get("user_api_key_org_id"))
user_api_key_project_id = _str_or_none(standard_logging_metadata.get("user_api_key_project_id"))
user_api_key_end_user_id = _str_or_none(
kwargs.get("user") if isinstance(kwargs, dict) else None
) or standard_logging_metadata.get("user_api_key_end_user_id")
agent_id = standard_logging_metadata.get("agent_id")
session_id = standard_logging_metadata.get("session_id") or standard_logging_metadata.get("trace_id")
) or _str_or_none(standard_logging_metadata.get("user_api_key_end_user_id"))
agent_id = _str_or_none(standard_logging_metadata.get("agent_id"))
session_id = _str_or_none(standard_logging_metadata.get("session_id")) or _str_or_none(
standard_logging_metadata.get("trace_id")
)
targets: list[tuple[str, str]] = []
if user_api_key:
@ -2882,8 +2955,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
def _build_success_event_pipeline_operations(
self,
kwargs: Any,
response_obj: Any,
kwargs: dict[str, object],
response_obj: object,
rate_limit_type: Literal["output", "input", "total"],
) -> list[RedisPipelineIncrementOperation]:
"""Build Redis pipeline increment ops for TPM / parallel-request counters."""
@ -2893,8 +2966,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# Get metadata from standard_logging_object - this correctly handles both
# 'metadata' and 'litellm_metadata' fields from litellm_params
standard_logging_object = kwargs.get("standard_logging_object") or {}
standard_logging_metadata = standard_logging_object.get("metadata") or {}
standard_logging_object = _str_object_dict_or_none(kwargs.get("standard_logging_object")) or {}
standard_logging_metadata = _str_object_dict_or_none(standard_logging_object.get("metadata")) or {}
model_group = get_model_group_from_litellm_kwargs(kwargs)
@ -2903,7 +2976,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
# than parsed out of the body) carry their usage in
# ``combined_usage_object`` instead, and would otherwise never charge
# the TPM window.
_usage: Usage | dict | None = None
_usage: Usage | _TokenUsageDict | None = None
if isinstance(
response_obj,
(
@ -2967,7 +3040,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
return pipeline_operations
async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
async def async_log_success_event(
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
):
"""
Update TPM usage on successful API calls by incrementing counters using pipeline
"""
@ -3007,10 +3082,10 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
async def async_logging_hook(
self,
kwargs: dict,
result: Any,
kwargs: dict[str, object],
result: object,
call_type: str,
) -> tuple[dict, Any]:
) -> tuple[dict[str, object], object]:
"""
Mirror the pre-call rate-limit snapshot into the SLP so streaming
success callbacks see the same ``x-ratelimit-*`` headers the
@ -3027,8 +3102,8 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
def _mirror_ratelimit_response_into_logging_payload(
self,
kwargs: Any,
response_obj: Any,
kwargs: object,
response_obj: object,
) -> None:
"""
Copy the stashed ``RateLimitResponse`` into the SLP's
@ -3056,7 +3131,7 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
)
standard_logging_object["hidden_params"] = hidden_params
response_hidden = getattr(response_obj, "_hidden_params", None)
response_hidden: object = getattr(response_obj, "_hidden_params", None)
if isinstance(response_hidden, dict):
existing = response_hidden.get("additional_headers")
response_hidden["additional_headers"] = self._merge_ratelimit_statuses_into_additional_headers(
@ -3064,7 +3139,9 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
statuses=statuses,
)
async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time):
async def async_log_failure_event(
self, kwargs: dict[str, object], response_obj: object, start_time: object, end_time: object
):
"""
On failure: decrement max_parallel_requests and refund the upfront
TPM reservation only against the scopes the reservation actually
@ -3162,19 +3239,17 @@ class _PROXY_MaxParallelRequestsHandler_v3(CustomLogger):
if litellm_proxy_rate_limit_response is not None:
# Update response headers
if hasattr(response, "_hidden_params"):
_hidden_params = getattr(response, "_hidden_params")
_hidden_params_raw: object = getattr(response, "_hidden_params", None)
if isinstance(_hidden_params_raw, BaseModel):
_hidden_params: dict[str, object] | None = _hidden_params_raw.model_dump()
else:
_hidden_params = None
if _hidden_params is not None and (
isinstance(_hidden_params, BaseModel) or isinstance(_hidden_params, dict)
):
if isinstance(_hidden_params, BaseModel):
_hidden_params = _hidden_params.model_dump()
_hidden_params = _str_object_dict_or_none(_hidden_params_raw)
if _hidden_params is not None:
existing_headers = _str_object_dict_or_none(_hidden_params.get("additional_headers"))
_additional_headers = self._merge_ratelimit_statuses_into_additional_headers(
additional_headers=_hidden_params.get("additional_headers", {}) or {},
additional_headers=existing_headers or {},
statuses=litellm_proxy_rate_limit_response["statuses"],
)

View file

@ -13,8 +13,8 @@ model/{model_id}/update - PATCH endpoint for model update.
import asyncio
import datetime
import json
from collections.abc import Mapping, Sequence
from typing import Any, Literal, cast
from collections.abc import Callable, Coroutine, Mapping, Sequence
from typing import TYPE_CHECKING, Literal, Protocol, TypeVar, cast
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field
@ -76,8 +76,99 @@ from litellm.types.router import (
)
from litellm.utils import get_utc_datetime
if TYPE_CHECKING:
from prisma.models import LiteLLM_ProxyModelTable as PrismaProxyModelTable
from prisma.types import LiteLLM_ProxyModelTableWhereInput
router = APIRouter()
_TableT_co = TypeVar("_TableT_co", covariant=True)
class _RepositoryTable(Protocol[_TableT_co]):
@property
def table(self) -> _TableT_co: ...
class _ProxyModelRow(Protocol):
@property
def model_id(self) -> str: ...
@property
def litellm_params(self) -> Mapping[str, object]: ...
def model_dump(self, *, exclude_none: bool = ...) -> dict[str, object]: ...
def model_dump_json(self, *, exclude_none: bool = ...) -> str: ...
class _ProxyModelQueryClient(Protocol):
async def find_many(self, *, where: "LiteLLM_ProxyModelTableWhereInput") -> "Sequence[PrismaProxyModelTable]": ...
class _ProxyModelTableClient(_ProxyModelQueryClient, Protocol):
async def find_unique(self, *, where: Mapping[str, object]) -> "_ProxyModelRow | None": ...
async def create(self, *, data: Mapping[str, object]) -> LiteLLM_ProxyModelTable: ...
async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> "_ProxyModelRow": ...
async def delete(self, *, where: Mapping[str, object]) -> "_ProxyModelRow | None": ...
class _TeamRow(Protocol):
@property
def team_id(self) -> str: ...
@property
def models(self) -> Sequence[str]: ...
def model_dump(self) -> dict[str, object]: ...
class _TeamTableClient(Protocol):
async def find_unique(self, *, where: Mapping[str, object]) -> "_TeamRow | None": ...
async def update(
self,
*,
where: Mapping[str, object],
data: Mapping[str, object],
include: Mapping[str, object] | None = ...,
) -> LiteLLM_TeamTable: ...
class _ModelAliasRow(Protocol):
@property
def id(self) -> int: ...
@property
def model_aliases(self) -> dict[str, str]: ...
@property
def team(self) -> "_TeamRow | None": ...
class _ModelAliasTableClient(Protocol):
async def find_many(self, *, include: Mapping[str, object]) -> "Sequence[_ModelAliasRow]": ...
async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> "_ModelAliasRow": ...
def _proxy_model_table(repository: "_RepositoryTable[_ProxyModelTableClient]") -> "_ProxyModelTableClient":
return repository.table
def _team_table(repository: "_RepositoryTable[_TeamTableClient]") -> "_TeamTableClient":
return repository.table
def _model_alias_table(repository: "_RepositoryTable[_ModelAliasTableClient]") -> "_ModelAliasTableClient":
return repository.table
_loads_json: Callable[[str], object] = json.loads
async def update_team(*args, **kwargs):
"""
@ -96,15 +187,12 @@ class UpdatePublicModelGroupsRequest(BaseModel):
async def get_db_model(model_id: str, prisma_client: PrismaClient) -> Deployment | None:
db_model = cast(
BaseModel | None,
await ModelRepository(prisma_client).table.find_unique(where={"model_id": model_id}),
)
db_model = await _proxy_model_table(ModelRepository(prisma_client)).find_unique(where={"model_id": model_id})
if not db_model:
return None
deployment_pydantic_obj = Deployment(**db_model.model_dump(exclude_none=True))
deployment_pydantic_obj = Deployment.model_validate(db_model.model_dump(exclude_none=True))
return deployment_pydantic_obj
@ -151,7 +239,9 @@ def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> Pr
merged_deployment_dict = DeploymentTypedDict(
model_name=db_model.model_name,
litellm_params=LiteLLMParamsTypedDict(
**db_model.litellm_params.model_dump(exclude_none=True) # type: ignore
**db_model.litellm_params.model_dump(
exclude_none=True
) # any-ok: raw pydantic dump is Any-valued; TypedDict validation would drop extra="allow" params and reject encrypted values
),
model_info=db_model.model_info.model_dump(exclude_none=True),
)
@ -163,7 +253,12 @@ def update_db_model(db_model: Deployment, updated_patch: updateDeployment) -> Pr
if updated_patch.litellm_params:
# Encrypt any sensitive values
encrypted_params = {
k: encrypt_value_helper(v) for k, v in updated_patch.litellm_params.model_dump(exclude_none=True).items()
k: encrypt_value_helper(
v
) # any-ok: v is Any from pydantic model_dump; encrypt_value_helper handles non-str at runtime
for k, v in updated_patch.litellm_params.model_dump(
exclude_none=True
).items() # any-ok: same pydantic model_dump seam
}
merged_deployment_dict["litellm_params"].update(encrypted_params) # type: ignore
@ -322,7 +417,7 @@ async def patch_model(
update_data["updated_at"] = cast(str, get_utc_datetime())
# Perform partial update
updated_model = await ModelRepository(prisma_client).table.update(
updated_model = await _proxy_model_table(ModelRepository(prisma_client)).update(
where={"model_id": model_id},
data=update_data,
)
@ -425,7 +520,7 @@ async def _set_model_blocked_status(
param=None,
)
updated_model = await ModelRepository(prisma_client).table.update(
updated_model = await _proxy_model_table(ModelRepository(prisma_client)).update(
where={"model_id": data.model_id},
data={
"blocked": blocked,
@ -459,7 +554,7 @@ async def _set_model_blocked_status(
still_desired=still_desired_ids,
)
return updated_model
return LiteLLM_ProxyModelTable.model_validate(updated_model.model_dump())
except Exception as e:
verbose_proxy_logger.exception(f"Error in model {action}: {e!s}")
@ -549,27 +644,30 @@ async def _add_model_to_db(
# encrypt litellm params #
_litellm_params_dict = model_params.litellm_params.dict(exclude_none=True)
_original_litellm_model_name = model_params.litellm_params.model
for k, v in _litellm_params_dict.items():
encrypted_value = encrypt_value_helper(value=v, new_encryption_key=new_encryption_key)
for (
k,
v,
) in (
_litellm_params_dict.items()
): # any-ok: v is Any from pydantic dict(); encrypt_value_helper handles non-str at runtime
encrypted_value = encrypt_value_helper(
value=v, new_encryption_key=new_encryption_key
) # any-ok: same pydantic dict() seam
model_params.litellm_params[k] = encrypted_value
_data: dict = {
_data: dict[str, str | None] = {
"model_id": model_params.model_info.id,
"model_name": model_params.model_name,
"litellm_params": model_params.litellm_params.model_dump_json(exclude_none=True), # type: ignore
"model_info": model_params.model_info.model_dump_json( # type: ignore
exclude_none=True
),
"litellm_params": model_params.litellm_params.model_dump_json(exclude_none=True),
"model_info": model_params.model_info.model_dump_json(exclude_none=True),
"created_by": user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
"updated_by": user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
}
if model_params.model_info.id is not None:
_data["model_id"] = model_params.model_info.id
if should_create_model_in_db:
model_response = await ModelRepository(prisma_client).table.create(
data=_data # type: ignore
)
model_response = await _proxy_model_table(ModelRepository(prisma_client)).create(data=_data)
else:
model_response = LiteLLM_ProxyModelTable(**_data)
model_response = LiteLLM_ProxyModelTable.model_validate(_data)
return model_response
@ -757,8 +855,8 @@ async def _setup_new_team_model_assignment(
async def _get_team_deployments(
team_id: str, prisma_client: PrismaClient, table: Any | None = None
) -> list[LiteLLM_ProxyModelTable]:
team_id: str, prisma_client: PrismaClient, table: "_ProxyModelQueryClient | None" = None
) -> "list[PrismaProxyModelTable]":
"""
Fetch all deployments for a given team_id from the database.
@ -773,7 +871,7 @@ async def _get_team_deployments(
existing transaction.
"""
prefix = f"model_name_{team_id}_"
table = table or ModelRepository(prisma_client).table
table = table or _proxy_model_table(ModelRepository(prisma_client))
response = await table.find_many(
where={
"model_name": {"startswith": prefix},
@ -783,7 +881,7 @@ async def _get_team_deployments(
return []
# Confirm team_id in model_info (defensive check)
result = []
result: list[PrismaProxyModelTable] = []
for row in response:
model_info = model_info_as_mapping(row.model_info)
if model_info is not None and model_info.get("team_id") == team_id:
@ -794,7 +892,7 @@ async def _get_team_deployments(
async def delete_team_models(
team_ids: list[str],
prisma_client: PrismaClient,
llm_router: Any | None,
llm_router: Router | None,
) -> list[str]:
"""
Delete every BYOK model owned by the given teams, from the DB and the router.
@ -808,7 +906,7 @@ async def delete_team_models(
Returns the model_ids that were deleted.
"""
deleted_model_ids: list[str] = []
async with prisma_client.db.tx() as tx:
async with prisma_client.tx() as tx:
for team_id in team_ids:
rows = await _get_team_deployments(team_id, prisma_client, table=tx.litellm_proxymodeltable)
model_ids = [row.model_id for row in rows]
@ -908,14 +1006,14 @@ async def _remove_unbacked_team_models(
if not names_to_remove:
return
existing_team_row = await prisma_client.db.litellm_teamtable.find_unique(where={"team_id": team_id})
existing_team_row = await _team_table(TeamRepository(prisma_client)).find_unique(where={"team_id": team_id})
if existing_team_row is None:
return
updated_team_row: LiteLLM_TeamTable = await prisma_client.db.litellm_teamtable.update(
updated_team_row: LiteLLM_TeamTable = await _team_table(TeamRepository(prisma_client)).update(
where={"team_id": team_id},
data={"models": [model for model in existing_team_row.models if model not in names_to_remove]},
include={"object_permission": True}, # type: ignore
include={"object_permission": True},
)
await _refresh_cached_team(
team_row=updated_team_row,
@ -941,7 +1039,7 @@ async def _update_existing_team_model_assignment(
"""
def _get_team_public_model_name(
model_info: dict | str | None,
model_info: object,
) -> str | None:
parsed = model_info_as_mapping(model_info)
if parsed is None:
@ -1050,7 +1148,7 @@ class ModelManagementAuthChecks:
detail={"error": CommonProxyErrors.not_premium_user.value},
)
_existing_team_row = await TeamRepository(prisma_client).table.find_unique(
_existing_team_row = await _team_table(TeamRepository(prisma_client)).find_unique(
where={"team_id": model_params.model_info.team_id}
)
@ -1079,7 +1177,7 @@ class ModelManagementAuthChecks:
) -> Literal[True]:
## Check team model auth
if model_params.model_info is not None and model_params.model_info.team_id is not None:
team_obj_row = await TeamRepository(prisma_client).table.find_unique(
team_obj_row = await _team_table(TeamRepository(prisma_client)).find_unique(
where={"team_id": model_params.model_info.team_id}
)
if team_obj_row is None:
@ -1157,14 +1255,16 @@ async def delete_model(
},
)
model_in_db = await ModelRepository(prisma_client).table.find_unique(where={"model_id": model_info.id})
model_in_db = await _proxy_model_table(ModelRepository(prisma_client)).find_unique(
where={"model_id": model_info.id}
)
if model_in_db is None:
raise HTTPException(
status_code=400,
detail={"error": f"Model with id={model_info.id} not found in db"},
)
model_params = Deployment(**model_in_db.model_dump())
model_params = Deployment.model_validate(model_in_db.model_dump())
await ModelManagementAuthChecks.can_user_make_model_call(
model_params=model_params,
user_api_key_dict=user_api_key_dict,
@ -1180,7 +1280,7 @@ async def delete_model(
- store keys separately
"""
# encrypt litellm params #
result = await ModelRepository(prisma_client).table.delete(where={"model_id": model_info.id})
result = await _proxy_model_table(ModelRepository(prisma_client)).delete(where={"model_id": model_info.id})
if result is None:
raise HTTPException(
@ -1253,9 +1353,9 @@ async def delete_team_model_alias(
Returns:
- List of team id + model alias pairs that were removed
"""
team_model_aliases = await ModelTableRepository(prisma_client).table.find_many(include={"team": True})
tasks = []
removed_model_aliases = []
team_model_aliases = await _model_alias_table(ModelTableRepository(prisma_client)).find_many(include={"team": True})
tasks: list[Coroutine[None, None, _ModelAliasRow]] = []
removed_model_aliases: list[tuple[str, str]] = []
for team_model_alias in team_model_aliases:
model_aliases = team_model_alias.model_aliases # {"alias": "public model name"}
id = team_model_alias.id
@ -1266,7 +1366,7 @@ async def delete_team_model_alias(
removed_model_aliases.append((team_model_alias.team.team_id, key))
del model_aliases[key]
tasks.append(
ModelTableRepository(prisma_client).table.update(
_model_alias_table(ModelTableRepository(prisma_client)).update(
where={"id": id},
data={"model_aliases": json.dumps(model_aliases)},
)
@ -1481,7 +1581,7 @@ async def update_model(
)
_model_id = None
_model_info = getattr(model_params, "model_info", None)
_model_info = model_params.model_info
if _model_info is None:
raise Exception("model_info not provided")
@ -1489,7 +1589,9 @@ async def update_model(
if _model_id is None:
raise Exception("model_info.id not provided")
_existing_litellm_params = await ModelRepository(prisma_client).table.find_unique(where={"model_id": _model_id})
_existing_litellm_params = await _proxy_model_table(ModelRepository(prisma_client)).find_unique(
where={"model_id": _model_id}
)
if _existing_litellm_params is None:
if llm_router is not None and llm_router.get_deployment(model_id=_model_id) is not None:
@ -1499,7 +1601,7 @@ async def update_model(
)
else:
raise Exception("model not found")
deployment = Deployment(**_existing_litellm_params.model_dump())
deployment = Deployment.model_validate(_existing_litellm_params.model_dump())
await ModelManagementAuthChecks.can_user_make_model_call(
model_params=deployment,
@ -1523,15 +1625,23 @@ async def update_model(
_new_litellm_params_dict = model_params.litellm_params.dict(exclude_none=True)
### ENCRYPT PARAMS ###
for k, v in _new_litellm_params_dict.items():
encrypted_value = encrypt_value_helper(value=v)
for (
k,
v,
) in (
_new_litellm_params_dict.items()
): # any-ok: v is Any from pydantic dict(); encrypt_value_helper handles non-str at runtime
encrypted_value = encrypt_value_helper(value=v) # any-ok: same pydantic dict() seam
model_params.litellm_params[k] = encrypted_value
### MERGE WITH EXISTING DATA ###
merged_dictionary = {}
_mp = model_params.litellm_params.dict()
for key, value in _mp.items():
for (
key,
value,
) in _mp.items(): # any-ok: value is Any from pydantic dict(); merged verbatim into the stored params
if value is not None:
merged_dictionary[key] = value
elif key in _existing_litellm_params_dict and _existing_litellm_params_dict[key] is not None:
@ -1539,13 +1649,13 @@ async def update_model(
else:
pass
_data: dict = {
"litellm_params": json.dumps(merged_dictionary), # type: ignore
_data: dict[str, str] = {
"litellm_params": json.dumps(merged_dictionary),
"updated_by": user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME,
}
model_response = await ModelRepository(prisma_client).table.update(
model_response = await _proxy_model_table(ModelRepository(prisma_client)).update(
where={"model_id": _model_id},
data=_data, # type: ignore
data=_data,
)
# Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates)
@ -1787,7 +1897,7 @@ def model_info_as_mapping(model_info: object) -> Mapping[str, object] | None:
if not isinstance(model_info, str):
return None
try:
parsed = json.loads(model_info)
parsed = _loads_json(model_info)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, Mapping) else None

View file

@ -2383,7 +2383,7 @@ async def insert_sso_user(
)
if result_openid and hasattr(result_openid, "provider"):
new_user_request.metadata = {"auth_provider": getattr(result_openid, "provider")}
new_user_request.metadata = {"auth_provider": result_openid.provider}
response = await new_user(
data=new_user_request,

View file

@ -725,7 +725,7 @@ async def handle_bedrock_passthrough_router_model(
user_max_tokens: int | None,
user_api_base: str | None,
version: str | None,
) -> Response | StreamingResponse:
) -> object:
"""
Handle Bedrock passthrough for router models (models defined in config.yaml).

View file

@ -32,10 +32,12 @@ from __future__ import annotations
import json
import re
from typing import Any
from collections.abc import Mapping
from typing import TYPE_CHECKING, Literal, TypeVar
from urllib.parse import quote, unquote
from fastapi import HTTPException
from pydantic import JsonValue, TypeAdapter
from litellm._logging import verbose_proxy_logger
from litellm.llms.base_llm.managed_resources.isolation import (
@ -43,13 +45,26 @@ from litellm.llms.base_llm.managed_resources.isolation import (
can_access_resource,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.repositories.table_repositories import (
ManagedFileRepository,
ManagedObjectRepository,
)
from litellm.types.llms.openai import OpenAIFileObject
from .managed_id_codec import ManagedIdPayload, decode, is_managed, new_managed_id
from .managed_id_rewriter_types import (
ManagedFileRowLike,
ManagedObjectRowLike,
PassthroughListResponse,
as_file_row,
as_file_rows,
as_managed_file_reader,
as_managed_file_writer,
as_object_row,
as_object_rows,
file_table_actions,
object_table_actions,
)
if TYPE_CHECKING:
from litellm.integrations.custom_logger import CustomLogger
from litellm.proxy.utils import PrismaClient
# ---------------------------------------------------------------------------
# Field map
@ -172,7 +187,7 @@ class _RawIdGuardBudget:
def __init__(self, limit: int = _MAX_RAW_ID_GUARD_LOOKUPS) -> None:
self._remaining = limit
self._seen: set = set()
self._seen: set[str] = set()
def reserve(self, raw_id: str) -> bool:
"""Return True when a guard lookup for *raw_id* should run. Returns
@ -196,8 +211,10 @@ class _RawIdGuardBudget:
# to the upstream provider, so each caller only sees IDs they own.
# ---------------------------------------------------------------------------
_ListResourceKind = Literal["files", "batches"]
# Maps (provider, canonical_path) -> "files" | "batches"
_LIST_ROUTE_TABLE: dict[tuple[str, str], str] = {
_LIST_ROUTE_TABLE: dict[tuple[str, str], _ListResourceKind] = {
("openai", "/v1/files"): "files",
("openai", "/v1/batches"): "batches",
("azure", "/v1/files"): "files",
@ -263,8 +280,8 @@ async def _resolve_one(
managed_id: str,
provider: str,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
managed_files_hook: Any,
prisma_client: PrismaClient | None,
managed_files_hook: CustomLogger | None,
) -> str:
"""
Resolve a single value that may be a passthrough managed ID.
@ -307,7 +324,7 @@ async def _resolve_one(
# File table — use hook's internal cache for speed when available
if managed_files_hook is not None:
try:
file_row = await managed_files_hook.get_unified_file_id(
file_row = await as_managed_file_reader(managed_files_hook).get_unified_file_id(
managed_id,
litellm_parent_otel_span=None,
)
@ -322,8 +339,8 @@ async def _resolve_one(
)
if not found and prisma_client is not None:
try:
db_row = await ManagedFileRepository(prisma_client).table.find_first(
where={"unified_file_id": managed_id}
db_row = as_file_row(
await file_table_actions(prisma_client).find_first(where={"unified_file_id": managed_id})
)
if db_row is not None:
row_created_by = db_row.created_by
@ -338,8 +355,8 @@ async def _resolve_one(
# Object table (batches, responses)
if prisma_client is not None:
try:
obj_row = await ManagedObjectRepository(prisma_client).table.find_first(
where={"unified_object_id": managed_id}
obj_row = as_object_row(
await object_table_actions(prisma_client).find_first(where={"unified_object_id": managed_id})
)
if obj_row is not None:
row_created_by = obj_row.created_by
@ -372,7 +389,7 @@ async def _guard_raw_provider_id(
raw_id: str,
provider: str,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
prisma_client: PrismaClient | None,
budget: _RawIdGuardBudget | None = None,
) -> None:
"""Deny a raw provider ID that maps to a managed resource the caller does
@ -398,15 +415,15 @@ async def _guard_raw_provider_id(
# id and scope to the current provider in the application layer (same as
# _mint_or_reuse_file's dedup).
try:
candidates = await ManagedFileRepository(prisma_client).table.find_many(
where={"flat_model_file_ids": {"has": raw_id}},
candidates = as_file_rows(
await file_table_actions(prisma_client).find_many(
where={"flat_model_file_ids": {"has": raw_id}},
)
)
except Exception:
verbose_proxy_logger.debug("managed_id_rewriter: raw file-id guard lookup failed", exc_info=True)
return
provider_rows = [
row for row in (candidates or []) if _managed_id_matches_provider(row.unified_file_id, provider)
]
provider_rows = [row for row in candidates if _managed_id_matches_provider(row.unified_file_id, provider)]
if provider_rows and not any(
can_access_resource(user_api_key_dict, row.created_by, row.team_id) for row in provider_rows
):
@ -419,8 +436,10 @@ async def _guard_raw_provider_id(
# Object rows store model_object_id as "passthrough:{provider}:{raw}", so
# the lookup is exact and already provider-scoped.
try:
existing = await ManagedObjectRepository(prisma_client).table.find_first(
where={"model_object_id": f"passthrough:{provider}:{raw_id}"}
existing = as_object_row(
await object_table_actions(prisma_client).find_first(
where={"model_object_id": f"passthrough:{provider}:{raw_id}"}
)
)
except Exception:
verbose_proxy_logger.debug("managed_id_rewriter: raw object-id guard lookup failed", exc_info=True)
@ -434,7 +453,7 @@ async def _guard_raw_provider_id(
# ---------------------------------------------------------------------------
def _build_managed_file_object(snapshot: dict[str, Any] | None, managed_id: str) -> OpenAIFileObject | None:
def _build_managed_file_object(snapshot: Mapping[str, object] | None, managed_id: str) -> OpenAIFileObject | None:
"""Build an ``OpenAIFileObject`` (with the managed ID swapped in) from an
upstream file response so the DB-served list returns the same metadata as a
direct file GET. Returns ``None`` when no usable snapshot is available, in
@ -442,7 +461,7 @@ def _build_managed_file_object(snapshot: dict[str, Any] | None, managed_id: str)
if not snapshot:
return None
try:
return OpenAIFileObject(**{**snapshot, "id": managed_id})
return OpenAIFileObject.model_validate({**snapshot, "id": managed_id})
except Exception:
verbose_proxy_logger.debug(
"managed_id_rewriter: file object snapshot incomplete; storing file row without list metadata",
@ -455,9 +474,9 @@ async def _mint_or_reuse_file(
raw_id: str,
provider: str,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
managed_files_hook: Any,
file_object_snapshot: dict[str, Any] | None = None,
prisma_client: PrismaClient | None,
managed_files_hook: CustomLogger | None,
file_object_snapshot: Mapping[str, object] | None = None,
is_create_route: bool = True,
) -> str:
"""Return an existing managed file ID or mint + store a new one."""
@ -479,16 +498,16 @@ async def _mint_or_reuse_file(
# reuse a stable row instead of minting duplicate rows on every call.
if prisma_client is not None:
try:
candidates = await ManagedFileRepository(prisma_client).table.find_many(
where={"flat_model_file_ids": {"has": raw_id}},
order={"created_at": "asc"},
candidates = as_file_rows(
await file_table_actions(prisma_client).find_many(
where={"flat_model_file_ids": {"has": raw_id}},
order={"created_at": "asc"},
)
)
except Exception:
candidates = []
verbose_proxy_logger.debug("managed_id_rewriter: file dedup lookup failed", exc_info=True)
provider_rows = [
row for row in (candidates or []) if _managed_id_matches_provider(row.unified_file_id, provider)
]
provider_rows = [row for row in candidates if _managed_id_matches_provider(row.unified_file_id, provider)]
owned_row = next(
(row for row in provider_rows if can_access_resource(user_api_key_dict, row.created_by, row.team_id)),
None,
@ -525,7 +544,7 @@ async def _mint_or_reuse_file(
)
if managed_files_hook is not None:
try:
await managed_files_hook.store_unified_file_id(
await as_managed_file_writer(managed_files_hook).store_unified_file_id(
file_id=managed_id,
file_object=_build_managed_file_object(file_object_snapshot, managed_id),
litellm_parent_otel_span=None,
@ -551,14 +570,15 @@ async def _mint_or_reuse_object(
raw_id: str,
provider: str,
file_purpose: str,
body_snapshot: dict,
body_snapshot: dict[str, object],
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
prisma_client: PrismaClient | None,
is_create_route: bool,
) -> str:
"""Return an existing managed object ID (batch/response) or mint + store one."""
if prisma_client is None:
return raw_id
client = prisma_client
# Namespace raw_id with provider so two providers that happen to issue
# the same raw batch/response ID get distinct rows. The @unique constraint
@ -569,7 +589,7 @@ async def _mint_or_reuse_object(
# f"{purpose}:{provider}:{raw_id}" for the same reason.
namespaced_model_object_id = f"passthrough:{provider}:{raw_id}"
async def _reuse_existing(existing: Any, refresh_snapshot: bool) -> str:
async def _reuse_existing(existing: ManagedObjectRowLike, refresh_snapshot: bool) -> str:
"""Resolve an already-persisted namespaced row: enforce the access
check, optionally refresh the snapshot, and return its managed ID."""
if not can_access_resource(user_api_key_dict, existing.created_by, existing.team_id):
@ -598,7 +618,7 @@ async def _mint_or_reuse_object(
# the batch's latest state (e.g. output_file_id / error_file_id that
# were null at creation but populated once the batch completed).
try:
await ManagedObjectRepository(prisma_client).table.update(
await object_table_actions(client).update(
where={"unified_object_id": existing.unified_object_id},
data={
"file_object": json.dumps(body_snapshot),
@ -618,8 +638,8 @@ async def _mint_or_reuse_object(
# Dedup: look up by the namespaced key — guaranteed unique per provider.
try:
existing = await ManagedObjectRepository(prisma_client).table.find_first(
where={"model_object_id": namespaced_model_object_id}
existing = as_object_row(
await object_table_actions(client).find_first(where={"model_object_id": namespaced_model_object_id})
)
except Exception:
verbose_proxy_logger.debug("managed_id_rewriter: object dedup lookup failed", exc_info=True)
@ -635,7 +655,7 @@ async def _mint_or_reuse_object(
raw_id.split("_", 1)[0],
)
try:
await ManagedObjectRepository(prisma_client).table.upsert(
await object_table_actions(client).upsert(
where={"unified_object_id": managed_id},
data={
"create": {
@ -659,8 +679,8 @@ async def _mint_or_reuse_object(
# the winner's managed ID so both callers converge on one ID instead of
# the loser silently keeping the raw id.
try:
raced = await ManagedObjectRepository(prisma_client).table.find_first(
where={"model_object_id": namespaced_model_object_id}
raced = as_object_row(
await object_table_actions(client).find_first(where={"model_object_id": namespaced_model_object_id})
)
except Exception:
raced = None
@ -677,14 +697,17 @@ async def _mint_or_reuse_object(
return managed_id
_BODY_DICT_ADAPTER: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object])
async def rewrite_response_ids(
provider: str,
method: str,
route: str,
body: dict,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
managed_files_hook: Any,
prisma_client: PrismaClient | None,
managed_files_hook: CustomLogger | None,
) -> dict:
"""
Mint managed IDs for raw provider values listed in
@ -714,7 +737,8 @@ async def rewrite_response_ids(
# creates may degrade to a raw id on a cross-owner collision.
is_create_route = "{" not in canonical
mutated = dict(body) # shallow copy; only return if something changed
typed_body = _BODY_DICT_ADAPTER.validate_python(body)
mutated = dict(typed_body) # shallow copy; only return if something changed
changed = False
def _record(field_name: str, raw_value: str, managed_id: str) -> None:
@ -746,7 +770,7 @@ async def rewrite_response_ids(
managed_files_hook,
# The file's own ``id`` carries the full upstream metadata; nested
# references do not, so only the former is persisted as a snapshot.
file_object_snapshot=body if field_name == "id" else None,
file_object_snapshot=typed_body if field_name == "id" else None,
is_create_route=is_create_route,
)
_record(field_name, raw_value, managed_id)
@ -795,21 +819,24 @@ def is_passthrough_list_route(provider: str, method: str, route: str) -> bool:
return (provider, canonical) in _LIST_ROUTE_TABLE
def _parse_file_object(file_object: Any) -> Any:
_FILE_OBJECT_DICT_ADAPTER: TypeAdapter[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue])
def _parse_file_object(file_object: object) -> dict[str, JsonValue] | None:
"""Prisma may return ``Json`` columns as either a parsed dict or the raw
JSON string (depending on driver / row source). Mirror the handling used
elsewhere (see ``openai_files_endpoints/common_utils.py``) so callers can
treat the result uniformly.
"""
if isinstance(file_object, str):
try:
return json.loads(file_object)
except (TypeError, ValueError):
return None
return file_object
try:
if isinstance(file_object, str):
return _FILE_OBJECT_DICT_ADAPTER.validate_json(file_object)
return _FILE_OBJECT_DICT_ADAPTER.validate_python(file_object)
except (TypeError, ValueError):
return None
def _empty_list_response() -> dict[str, Any]:
def _empty_list_response() -> PassthroughListResponse:
return {
"object": "list",
"data": [],
@ -819,7 +846,7 @@ def _empty_list_response() -> dict[str, Any]:
}
def _parse_list_limit(query_params: dict[str, Any] | None) -> tuple[int, int]:
def _parse_list_limit(query_params: Mapping[str, str] | None) -> tuple[int, int]:
params = query_params or {}
try:
raw_limit = int(params.get("limit", 20))
@ -830,17 +857,17 @@ def _parse_list_limit(query_params: dict[str, Any] | None) -> tuple[int, int]:
async def _build_list_where_with_cursor(
prisma_client: Any,
resource_kind: str,
prisma_client: PrismaClient,
resource_kind: _ListResourceKind,
provider: str,
owner_filter: dict[str, Any],
query_params: dict[str, Any] | None,
) -> tuple[dict[str, Any], str]:
owner_filter: dict[str, object],
query_params: Mapping[str, str] | None,
) -> tuple[dict[str, object], str]:
"""Return a Prisma ``where`` clause and fetch order for a list query."""
params = query_params or {}
after_id: str | None = params.get("after")
before_id: str | None = params.get("before")
where: dict[str, Any] = dict(owner_filter)
where: dict[str, object] = dict(owner_filter)
fetch_order = "desc"
cursor_id = after_id or before_id
@ -850,14 +877,14 @@ async def _build_list_where_with_cursor(
if not cursor_id or not _managed_id_matches_provider(cursor_id, provider):
return where, fetch_order
cursor_table = (
ManagedFileRepository(prisma_client).table
if resource_kind == "files"
else ManagedObjectRepository(prisma_client).table
)
cursor_field = "unified_file_id" if resource_kind == "files" else "unified_object_id"
try:
cursor_row = await cursor_table.find_first(where={**owner_filter, cursor_field: cursor_id})
cursor_where: dict[str, object] = {**owner_filter, cursor_field: cursor_id}
cursor_row: ManagedFileRowLike | ManagedObjectRowLike | None
if resource_kind == "files":
cursor_row = as_file_row(await file_table_actions(prisma_client).find_first(where=cursor_where))
else:
cursor_row = as_object_row(await object_table_actions(prisma_client).find_first(where=cursor_where))
if cursor_row is not None:
if after_id:
op = "lt"
@ -867,7 +894,7 @@ async def _build_list_where_with_cursor(
# created_at is not unique, so the boundary must also compare the
# unique id (the secondary sort key) to avoid skipping or repeating
# rows that share the cursor row's timestamp across a page boundary.
boundary = {
boundary: dict[str, object] = {
"OR": [
{"created_at": {op: cursor_row.created_at}},
{
@ -884,110 +911,117 @@ async def _build_list_where_with_cursor(
return where, fetch_order
async def _fetch_list_rows(
prisma_client: Any,
resource_kind: str,
where: dict[str, Any],
async def _fetch_file_list_rows(
prisma_client: PrismaClient,
provider: str,
where: dict[str, object],
fetch_order: str,
fetch_limit: int,
) -> list[Any] | None:
# created_at is not unique, so a second sort on the unique id column gives a
# total order, keeping the limit+1 page boundary and cursor deterministic
# across rows that share a created_at timestamp.
) -> list[ManagedFileRowLike] | None:
"""Fetch list rows scoped to *provider* at the DB level: file rows carry
``_passthrough_provider:{provider}`` in ``flat_model_file_ids`` (see
``_mint_or_reuse_file``), since the file table has no provider column.
Pushing the scope into the query means a single DB round-trip serves the
page, with no application-layer scanning that could truncate large pools.
created_at is not unique, so a second sort on the unique id column gives a
total order, keeping the limit+1 page boundary and cursor deterministic
across rows that share a created_at timestamp.
A DB failure returns ``None`` (fail closed) so the caller serves an empty
page and never falls through to the upstream provider.
"""
scoped_where: dict[str, object] = {
**where,
"flat_model_file_ids": {"has": _passthrough_provider_marker(provider)},
}
try:
if resource_kind == "files":
return await ManagedFileRepository(prisma_client).table.find_many(
where=where,
return as_file_rows(
await file_table_actions(prisma_client).find_many(
where=scoped_where,
order=[{"created_at": fetch_order}, {"unified_file_id": fetch_order}],
take=fetch_limit,
)
return await ManagedObjectRepository(prisma_client).table.find_many(
where={**where, "file_purpose": "batch"},
order=[{"created_at": fetch_order}, {"unified_object_id": fetch_order}],
take=fetch_limit,
)
except Exception:
verbose_proxy_logger.warning("managed_id_rewriter: list DB query failed", exc_info=True)
return None
async def _fetch_provider_scoped_list_rows(
prisma_client: Any,
resource_kind: str,
async def _fetch_batch_list_rows(
prisma_client: PrismaClient,
provider: str,
where: dict[str, Any],
where: dict[str, object],
fetch_order: str,
raw_limit: int,
fetch_limit: int,
) -> tuple[list[Any], bool]:
"""Fetch one page of list rows scoped to *provider* at the DB level.
Both resource kinds carry a provider-distinguishing value that the query
filters on directly: object rows namespace ``model_object_id`` as
``passthrough:{provider}:{raw}`` (see ``_mint_or_reuse_object``) and file
rows carry ``_passthrough_provider:{provider}`` in ``flat_model_file_ids``
(see ``_mint_or_reuse_file``), since the file table has no provider column.
Pushing the scope into the query means a single DB round-trip serves the
page, with no application-layer scanning that could truncate large pools.
A DB failure returns an empty page (fail closed) so the caller never falls
through to the upstream provider.
) -> list[ManagedObjectRowLike] | None:
"""Fetch list rows scoped to *provider* at the DB level: object rows
namespace ``model_object_id`` as ``passthrough:{provider}:{raw}`` (see
``_mint_or_reuse_object``), so the query filters on it directly. Ordering
and failure semantics mirror ``_fetch_file_list_rows``.
"""
scoped_where = dict(where)
if resource_kind == "files":
scoped_where["flat_model_file_ids"] = {"has": _passthrough_provider_marker(provider)}
else:
scoped_where["model_object_id"] = {"startswith": f"passthrough:{provider}:"}
scoped_where: dict[str, object] = {
**where,
"model_object_id": {"startswith": f"passthrough:{provider}:"},
"file_purpose": "batch",
}
try:
return as_object_rows(
await object_table_actions(prisma_client).find_many(
where=scoped_where,
order=[{"created_at": fetch_order}, {"unified_object_id": fetch_order}],
take=fetch_limit,
)
)
except Exception:
verbose_proxy_logger.warning("managed_id_rewriter: list DB query failed", exc_info=True)
return None
rows = await _fetch_list_rows(prisma_client, resource_kind, scoped_where, fetch_order, fetch_limit)
_RowT = TypeVar("_RowT")
def _page_from_rows(rows: list[_RowT] | None, raw_limit: int, fetch_order: str) -> tuple[list[_RowT], bool]:
if rows is None:
return [], False
effective_limit = min(raw_limit, 100)
has_more = len(rows) > effective_limit
page = rows[:effective_limit]
if fetch_order == "asc":
page = list(reversed(page))
return list(reversed(page)), has_more
return page, has_more
def _serialize_file_list_item(row: Any) -> dict[str, Any]:
item: dict[str, Any] = {
def _serialize_file_list_item(row: ManagedFileRowLike) -> dict[str, JsonValue]:
item: dict[str, JsonValue] = {
"id": row.unified_file_id,
"object": "file",
"created_at": int(row.created_at.timestamp()) if row.created_at else None,
}
file_object = _parse_file_object(row.file_object)
if isinstance(file_object, dict):
if file_object is not None:
item.update(file_object)
item["id"] = row.unified_file_id # managed ID always wins over stored raw id
return item
def _serialize_batch_list_item(row: Any) -> dict[str, Any]:
item: dict[str, Any] = {}
def _serialize_batch_list_item(row: ManagedObjectRowLike) -> dict[str, JsonValue]:
item: dict[str, JsonValue] = {}
file_object = _parse_file_object(row.file_object)
if isinstance(file_object, dict):
if file_object is not None:
item.update(file_object)
item["id"] = row.unified_object_id # managed ID always wins
item["object"] = "batch"
return item
def _list_boundary_ids(rows: list[Any], resource_kind: str) -> tuple[str | None, str | None]:
if not rows:
return None, None
id_attr = "unified_file_id" if resource_kind == "files" else "unified_object_id"
return getattr(rows[0], id_attr), getattr(rows[-1], id_attr)
async def list_passthrough_ids_from_db(
provider: str,
route: str,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
query_params: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
prisma_client: PrismaClient | None,
query_params: Mapping[str, str] | None = None,
) -> PassthroughListResponse | None:
"""Query the DB for managed IDs the caller owns and return an OpenAI-style
paginated list response.
@ -1020,21 +1054,25 @@ async def list_passthrough_ids_from_db(
where, fetch_order = await _build_list_where_with_cursor(
prisma_client, resource_kind, provider, owner_filter, query_params
)
page, has_more = await _fetch_provider_scoped_list_rows(
prisma_client,
resource_kind,
provider,
where,
fetch_order,
raw_limit,
fetch_limit,
)
if resource_kind == "files":
data = [_serialize_file_list_item(row) for row in page]
file_page, has_more = _page_from_rows(
await _fetch_file_list_rows(prisma_client, provider, where, fetch_order, fetch_limit),
raw_limit,
fetch_order,
)
data = [_serialize_file_list_item(row) for row in file_page]
first_id = file_page[0].unified_file_id if file_page else None
last_id = file_page[-1].unified_file_id if file_page else None
else:
data = [_serialize_batch_list_item(row) for row in page]
batch_page, has_more = _page_from_rows(
await _fetch_batch_list_rows(prisma_client, provider, where, fetch_order, fetch_limit),
raw_limit,
fetch_order,
)
data = [_serialize_batch_list_item(row) for row in batch_page]
first_id = batch_page[0].unified_object_id if batch_page else None
last_id = batch_page[-1].unified_object_id if batch_page else None
first_id, last_id = _list_boundary_ids(page, resource_kind)
verbose_proxy_logger.debug(
"managed_id_rewriter: list served from DB provider=%s kind=%s count=%d admin=%s",
provider,
@ -1060,8 +1098,8 @@ async def rewrite_path_ids(
path: str,
provider: str,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
managed_files_hook: Any,
prisma_client: PrismaClient | None,
managed_files_hook: CustomLogger | None,
) -> str:
"""
Walk URL path segments and resolve any passthrough managed IDs to raw
@ -1092,12 +1130,12 @@ async def rewrite_path_ids(
async def rewrite_query_ids(
params: dict[str, Any] | None,
params: dict[str, object] | None,
provider: str,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
managed_files_hook: Any,
) -> dict[str, Any] | None:
prisma_client: PrismaClient | None,
managed_files_hook: CustomLogger | None,
) -> dict[str, object] | None:
"""
Walk query param values and resolve any passthrough managed IDs.
Returns *params* unchanged (same object) when nothing is resolved.
@ -1123,13 +1161,19 @@ async def rewrite_query_ids(
return mutated if rewritten_keys else params
def _is_litellm_internal_key(key: object) -> bool:
if not isinstance(key, str):
return False
return key.startswith("litellm_")
async def rewrite_body_ids(
body: dict[str, Any] | None,
body: dict[str, JsonValue] | None,
provider: str,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: Any,
managed_files_hook: Any,
) -> dict[str, Any] | None:
prisma_client: PrismaClient | None,
managed_files_hook: CustomLogger | None,
) -> dict[str, JsonValue] | None:
"""
Recursively walk a request body dict/list and resolve any passthrough
managed IDs. Skips litellm internal keys (``litellm_*``).
@ -1140,15 +1184,15 @@ async def rewrite_body_ids(
budget = _RawIdGuardBudget()
async def _walk(node: Any, depth: int) -> Any:
async def _walk(node: JsonValue, depth: int) -> JsonValue:
if depth >= _MAX_BODY_REWRITE_DEPTH:
return node
if isinstance(node, dict):
result: dict[str, Any] = {}
result: dict[str, JsonValue] = {}
changed_inner = False
for k, v in node.items():
# Skip litellm internal injection keys (e.g. litellm_logging_obj)
if isinstance(k, str) and k.startswith("litellm_"):
if _is_litellm_internal_key(k):
result[k] = v
continue
new_v = await _walk(v, depth + 1)
@ -1156,12 +1200,12 @@ async def rewrite_body_ids(
if new_v is not v:
changed_inner = True
return result if changed_inner else node
elif isinstance(node, list):
if isinstance(node, list):
new_list = [await _walk(item, depth + 1) for item in node]
if any(n is not o for n, o in zip(new_list, node)):
return new_list
return node
elif isinstance(node, str):
if isinstance(node, str):
if is_managed(node):
return await _resolve_one(node, provider, user_api_key_dict, prisma_client, managed_files_hook)
await _guard_raw_provider_id(node, provider, user_api_key_dict, prisma_client, budget)
@ -1169,6 +1213,7 @@ async def rewrite_body_ids(
return node
rewritten = await _walk(body, 0)
if rewritten is not body:
if isinstance(rewritten, dict) and rewritten is not body:
verbose_proxy_logger.debug("managed_id_rewriter: body ids rewritten provider=%s", provider)
return rewritten
return rewritten
return body

View file

@ -0,0 +1,148 @@
"""
Typed boundary for ``managed_id_rewriter``'s untyped dependencies.
The managed file/object Prisma tables are reached through
``PrismaTableRepository.table`` (untyped) and the enterprise managed-files
hook arrives as a bare ``CustomLogger``. This module narrows both to
structural protocols once, at the boundary, so the rewriter itself works
with fully typed values.
"""
from __future__ import annotations
from collections.abc import Awaitable, Mapping, Sequence
from datetime import datetime
from typing import TYPE_CHECKING, Literal, Protocol, TypedDict, runtime_checkable
from pydantic import JsonValue
from litellm.repositories.table_repositories import (
ManagedFileRepository,
ManagedObjectRepository,
)
if TYPE_CHECKING:
from litellm.integrations.custom_logger import CustomLogger
from litellm.models.managed_files import LiteLLM_ManagedFileTable
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.utils import PrismaClient
from litellm.types.llms.openai import OpenAIFileObject
@runtime_checkable
class ManagedFileRowLike(Protocol):
unified_file_id: str
created_by: str | None
team_id: str | None
created_at: datetime | None
file_object: object
@runtime_checkable
class ManagedObjectRowLike(Protocol):
unified_object_id: str
created_by: str | None
team_id: str | None
created_at: datetime | None
file_object: object
@runtime_checkable
class ManagedTableActions(Protocol):
def find_first(self, where: Mapping[str, object]) -> Awaitable[object]: ...
def find_many(
self,
where: Mapping[str, object],
order: Sequence[Mapping[str, str]] | Mapping[str, str] | None = None,
take: int | None = None,
) -> Awaitable[Sequence[object]]: ...
def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> Awaitable[object]: ...
def upsert(self, where: Mapping[str, object], data: Mapping[str, Mapping[str, object]]) -> Awaitable[object]: ...
@runtime_checkable
class ManagedFileReaderLike(Protocol):
def get_unified_file_id(
self,
file_id: str,
litellm_parent_otel_span: object = None,
) -> Awaitable[LiteLLM_ManagedFileTable | None]: ...
@runtime_checkable
class ManagedFileWriterLike(Protocol):
def store_unified_file_id(
self,
file_id: str,
file_object: OpenAIFileObject | None,
litellm_parent_otel_span: object,
model_mappings: dict[str, str],
user_api_key_dict: UserAPIKeyAuth,
) -> Awaitable[None]: ...
class PassthroughListResponse(TypedDict):
object: Literal["list"]
data: list[dict[str, JsonValue]]
first_id: str | None
last_id: str | None
has_more: bool
class _TableHolder(Protocol):
@property
def table(self) -> object: ...
def _table_actions(holder: _TableHolder) -> ManagedTableActions:
table = holder.table
if isinstance(table, ManagedTableActions):
return table
raise TypeError("Prisma table object does not expose find/update/upsert actions")
def file_table_actions(prisma_client: PrismaClient) -> ManagedTableActions:
return _table_actions(ManagedFileRepository(prisma_client))
def object_table_actions(prisma_client: PrismaClient) -> ManagedTableActions:
return _table_actions(ManagedObjectRepository(prisma_client))
def as_managed_file_reader(hook: CustomLogger) -> ManagedFileReaderLike:
if isinstance(hook, ManagedFileReaderLike):
return hook
raise TypeError("managed_files hook does not expose get_unified_file_id")
def as_managed_file_writer(hook: CustomLogger) -> ManagedFileWriterLike:
if isinstance(hook, ManagedFileWriterLike):
return hook
raise TypeError("managed_files hook does not expose store_unified_file_id")
def as_file_row(record: object) -> ManagedFileRowLike | None:
if isinstance(record, ManagedFileRowLike):
return record
return None
def as_object_row(record: object) -> ManagedObjectRowLike | None:
if isinstance(record, ManagedObjectRowLike):
return record
return None
def as_file_rows(records: Sequence[object] | None) -> list[ManagedFileRowLike]:
if records is None:
return []
return [record for record in records if isinstance(record, ManagedFileRowLike)]
def as_object_rows(records: Sequence[object] | None) -> list[ManagedObjectRowLike]:
if records is None:
return []
return [record for record in records if isinstance(record, ManagedObjectRowLike)]

View file

@ -5,10 +5,11 @@ import json
import posixpath
import traceback
from base64 import b64encode
from collections.abc import AsyncGenerator, Mapping
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence
from datetime import datetime
from functools import partial
from itertools import groupby
from typing import Any, cast
from typing import Literal, Protocol, TypedDict, cast, runtime_checkable
from urllib.parse import urlencode, urlparse
import httpx
@ -23,7 +24,9 @@ from fastapi import (
WebSocket,
status,
)
from fastapi import params as fastapi_params
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError
from starlette.datastructures import UploadFile as StarletteUploadFile
from starlette.websockets import WebSocketState
from websockets.asyncio.client import connect
@ -91,13 +94,94 @@ router = APIRouter()
pass_through_endpoint_logging = PassThroughEndpointLogging()
@runtime_checkable
class RouteRegistrableApp(Protocol):
@property
def routes(self) -> Sequence[object]: ...
def add_api_route(
self,
path: str,
endpoint: Callable[..., object],
*,
methods: list[str] | None = None,
dependencies: Sequence[fastapi_params.Depends] | None = None,
) -> None: ...
@runtime_checkable
class TeamTableClient(Protocol):
def find_unique(self, where: dict[str, str]) -> Awaitable[object]: ...
class RegisteredPassthroughParams(TypedDict):
target: str
custom_headers: dict[str, object] | None
forward_headers: bool | None
merge_query_params: bool | None
default_query_params: dict[str, object] | None
dependencies: Sequence[object] | None
cost_per_request: float | None
guardrails: dict[str, object] | None
timeout: float | None
class RegisteredPassthroughRoute(TypedDict):
endpoint_id: str
path: str
type: Literal["exact", "subpath"]
methods: list[str]
auth: bool
passthrough_params: RegisteredPassthroughParams
# Global registry to track registered pass-through routes and prevent memory leaks
_registered_pass_through_routes: dict[str, dict[str, str | bool | list[str] | dict[str, Any]]] = {}
_registered_pass_through_routes: dict[str, RegisteredPassthroughRoute] = {}
_OBJECT_DICT_ADAPTER: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object])
_OPTIONAL_OBJECT_DICT_ADAPTER: TypeAdapter[dict[str, object] | None] = TypeAdapter(dict[str, object] | None)
_EMPTY_ROUTE_PARAMS: Mapping[str, object] = {}
_JSON_VALUE_ADAPTER: TypeAdapter[JsonValue] = TypeAdapter(JsonValue)
_JSON_DICT_ADAPTER: TypeAdapter[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue])
_CUSTOM_LOGGER_ADAPTER: TypeAdapter[CustomLogger] = TypeAdapter(
CustomLogger, config=ConfigDict(arbitrary_types_allowed=True)
)
_APP_ADAPTER: TypeAdapter[RouteRegistrableApp] = TypeAdapter(
RouteRegistrableApp, config=ConfigDict(arbitrary_types_allowed=True)
)
_TEAM_TABLE_ADAPTER: TypeAdapter[TeamTableClient] = TypeAdapter(
TeamTableClient, config=ConfigDict(arbitrary_types_allowed=True)
)
_TEAM_METADATA_ADAPTER: TypeAdapter[dict[str, JsonValue] | None] = TypeAdapter(dict[str, JsonValue] | None)
_ALLOWED_ROUTES_ADAPTER: TypeAdapter[list[JsonValue] | dict[str, JsonValue] | str | None] = TypeAdapter(
list[JsonValue] | dict[str, JsonValue] | str | None
)
_ENDPOINT_LIST_ADAPTER: TypeAdapter[list[dict[str, object] | PassThroughGenericEndpoint] | None] = TypeAdapter(
list[dict[str, object] | PassThroughGenericEndpoint] | None
)
def get_response_body(response: httpx.Response) -> dict | None:
def _parse_json_value(raw: str | bytes) -> JsonValue:
return _JSON_VALUE_ADAPTER.validate_python(json.loads(raw))
def _json_dict_or_none(value: JsonValue) -> dict[str, JsonValue] | None:
return value if isinstance(value, dict) else None
def _truthiness_or_none(value: JsonValue) -> bool | None:
return None if value is None else bool(value)
def _read_header(headers: Mapping[str, str], key: str) -> str | None:
return headers.get(key)
def get_response_body(response: httpx.Response) -> dict[str, object] | None:
try:
return response.json()
return _OBJECT_DICT_ADAPTER.validate_python(response.json())
except Exception:
return None
@ -174,9 +258,9 @@ async def chat_completion_pass_through_endpoint(
body = await request.body()
body_str = body.decode()
try:
data = ast.literal_eval(body_str)
data = _OBJECT_DICT_ADAPTER.validate_python(ast.literal_eval(body_str))
except Exception:
data = json.loads(body_str)
data = _OBJECT_DICT_ADAPTER.validate_python(json.loads(body_str))
data["adapter_id"] = adapter_id
@ -848,7 +932,7 @@ async def pass_through_request(
# parsed dict (hooks mutate it, breaking the signature / Content-Length).
# Tolerate request objects without `state` (test fixtures) and only honor
# values httpx accepts for `content=`.
_request_state = getattr(request, "state", None)
_request_state = request.state if hasattr(request, "state") else None
state_raw_body: str | bytes | None = (
getattr(_request_state, LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY, None)
if _request_state is not None
@ -1125,15 +1209,17 @@ async def pass_through_request(
else:
# SigV4-signed callers (Bedrock) supply the exact pre-signed bytes;
# otherwise httpx encodes the parsed JSON dict as before.
body_kwargs: dict[str, Any] = (
{"content": state_raw_body} if state_raw_body is not None else {"json": _parsed_body}
)
req = async_client.build_request(
build_streaming_request = partial(
async_client.build_request,
request.method,
url,
params=requested_query_params,
headers=headers,
**body_kwargs,
)
req = (
build_streaming_request(content=state_raw_body)
if state_raw_body is not None
else build_streaming_request(json=_OPTIONAL_OBJECT_DICT_ADAPTER.validate_python(_parsed_body))
)
response = await async_client.send(req, stream=stream)
@ -1583,7 +1669,12 @@ def _update_metadata_with_tags_in_header(request: Request, metadata: dict) -> di
async def _parse_request_data_by_content_type(
request: Request,
) -> tuple[Any | None, Any | None, Any | None, Any | None]:
) -> tuple[
dict[str, JsonValue] | dict[str, str] | str | StarletteUploadFile | None,
dict[str, JsonValue] | str | StarletteUploadFile | None,
None,
bool | None,
]:
"""
Parse request data based on content type.
@ -1594,18 +1685,18 @@ async def _parse_request_data_by_content_type(
"""
content_type = request.headers.get("content-type", "")
query_params_data = None
custom_body_data = None
query_params_data: dict[str, JsonValue] | dict[str, str] | str | StarletteUploadFile | None = None
custom_body_data: dict[str, JsonValue] | str | StarletteUploadFile | None = None
file_data = None
stream = None
stream: bool | None = None
if "application/json" in content_type:
# ✅ Handle JSON
try:
body = await request.json()
query_params_data = body.get("query_params")
custom_body_data = body.get("custom_body")
stream = body.get("stream")
body = _JSON_DICT_ADAPTER.validate_python(await request.json())
query_params_data = _json_dict_or_none(body.get("query_params"))
custom_body_data = _json_dict_or_none(body.get("custom_body"))
stream = _truthiness_or_none(body.get("stream"))
except json.JSONDecodeError:
# Handle requests with no body (e.g., DELETE requests)
pass
@ -1613,13 +1704,14 @@ async def _parse_request_data_by_content_type(
# ✅ Try to parse as JSON first (handles misconfigured clients sending JSON with multipart content-type)
# If that fails, skip parsing - pass_through_request will handle actual multipart
try:
body = await request.json()
body = _JSON_DICT_ADAPTER.validate_python(await request.json())
# Successfully parsed as JSON - treat as JSON body
query_params_data = body.get("query_params")
custom_body_data = body.get("custom_body")
stream = body.get("stream")
raw_custom_body = body.get("custom_body")
query_params_data = _json_dict_or_none(body.get("query_params"))
custom_body_data = _json_dict_or_none(raw_custom_body)
stream = _truthiness_or_none(body.get("stream"))
# If custom_body is not set, use the entire body
if custom_body_data is None and body:
if raw_custom_body is None and body:
custom_body_data = body
except (json.JSONDecodeError, Exception):
# Not JSON - this is actual multipart data
@ -1643,17 +1735,17 @@ async def _parse_request_data_by_content_type(
def create_pass_through_route(
endpoint,
target: str,
custom_headers: Mapping[str, Any] | None = None,
custom_headers: Mapping[str, object] | None = None,
_forward_headers: bool | None = False,
_merge_query_params: bool | None = False,
dependencies: list | None = None,
dependencies: Sequence[fastapi_params.Depends] | None = None,
include_subpath: bool | None = False,
cost_per_request: float | None = None,
custom_llm_provider: str | None = None,
is_streaming_request: bool | None = False,
query_params: dict | None = None,
default_query_params: dict | None = None,
guardrails: dict[str, Any] | None = None,
guardrails: dict[str, object] | None = None,
config_file_path: str | None = None,
timeout: float | None = None,
):
@ -1665,7 +1757,9 @@ def create_pass_through_route(
if isinstance(target, CustomLogger):
adapter = target
else:
adapter = get_instance_fn(value=target, config_file_path=config_file_path)
adapter = _CUSTOM_LOGGER_ADAPTER.validate_python(
get_instance_fn(value=target, config_file_path=config_file_path)
)
adapter_id = str(uuid.uuid4())
litellm.adapters = [{"id": adapter_id, "adapter": adapter}]
@ -1725,7 +1819,7 @@ def create_pass_through_route(
status_code=status.HTTP_405_METHOD_NOT_ALLOWED,
detail=f"Method {request.method} is not allowed for pass-through endpoint {path}.",
)
target_params = {
target_params: dict[str, object] = {
"target": target,
"custom_headers": custom_headers,
"forward_headers": _forward_headers,
@ -1736,7 +1830,7 @@ def create_pass_through_route(
}
if passthrough_params is not None:
target_params.update(passthrough_params.get("passthrough_params", {}))
target_params.update(passthrough_params.get("passthrough_params", _EMPTY_ROUTE_PARAMS))
# Extract and cast parameters with proper types
param_target = target_params.get("target") or target
@ -1757,12 +1851,18 @@ def create_pass_through_route(
# Ensure custom_headers is a dict. Botocore returns a HeadersDict
# for SigV4-prepared requests, which is a Mapping but not a dict.
headers_dict = dict(param_custom_headers) if isinstance(param_custom_headers, Mapping) else {}
headers_dict = (
_OBJECT_DICT_ADAPTER.validate_python(param_custom_headers)
if isinstance(param_custom_headers, Mapping)
else {}
)
# Ensure query_params and custom_body are dicts or None
final_query_params = query_params_data if isinstance(query_params_data, dict) else {}
final_query_params: dict[str, object] = (
{key: value for key, value in query_params_data.items()} if isinstance(query_params_data, dict) else {}
)
if query_params:
final_query_params.update(query_params)
final_query_params.update(_OBJECT_DICT_ADAPTER.validate_python(query_params))
# Programmatic callers set LITELLM_PASS_THROUGH_CUSTOM_BODY_STATE_KEY on
# request.state (see Bedrock proxy). Parsed JSON envelope otherwise.
state_custom_body: dict | None = getattr(
@ -1884,7 +1984,7 @@ async def websocket_passthrough_request(
# Initialize tracking variables
start_time = datetime.now()
websocket_messages: list[dict[str, Any]] = []
websocket_messages: list[JsonValue] = []
litellm_call_id = str(uuid.uuid4())
verbose_proxy_logger.info(f"WebSocket passthrough ({endpoint}): Starting WebSocket connection to {target}")
@ -1977,10 +2077,9 @@ async def websocket_passthrough_request(
)
### CALL HOOKS ### - modify incoming data / reject request before calling the model
websocket_data: dict[str, Any] = {}
websocket_data = await proxy_logging_obj.pre_call_hook(
await proxy_logging_obj.pre_call_hook(
user_api_key_dict=user_api_key_dict,
data=websocket_data,
data={},
call_type="pass_through_endpoint",
)
@ -2007,14 +2106,14 @@ async def websocket_passthrough_request(
text_data = message.get("text")
bytes_data = message.get("bytes")
if text_data is not None:
if isinstance(text_data, str):
# Try to extract model from client setup message for Vertex AI Live
if endpoint and "/vertex_ai/live" in endpoint:
verbose_proxy_logger.debug(
f"WebSocket passthrough ({endpoint}): Processing client message for model extraction"
)
try:
client_message = json.loads(text_data)
client_message = _parse_json_value(text_data)
if isinstance(client_message, dict) and "setup" in client_message:
setup_data = client_message["setup"]
verbose_proxy_logger.debug(
@ -2044,14 +2143,14 @@ async def websocket_passthrough_request(
verbose_proxy_logger.debug(
f"WebSocket passthrough ({endpoint}): Client message does not contain setup data"
)
except (json.JSONDecodeError, KeyError, TypeError) as e:
except (json.JSONDecodeError, ValidationError, KeyError, TypeError) as e:
verbose_proxy_logger.debug(
f"WebSocket passthrough ({endpoint}): Client message is not a valid setup message: {e}"
)
# Not a JSON message or doesn't contain setup data
await upstream_ws.send(text_data)
elif bytes_data is not None:
elif isinstance(bytes_data, bytes):
await upstream_ws.send(bytes_data)
except asyncio.CancelledError:
raise
@ -2069,7 +2168,7 @@ async def websocket_passthrough_request(
# Ensure raw_response is bytes before decoding
if isinstance(raw_response, str):
raw_response = raw_response.encode("ascii")
setup_response = json.loads(raw_response.decode("ascii"))
setup_response = _parse_json_value(raw_response.decode("ascii"))
verbose_proxy_logger.debug(f"Setup response: {setup_response}")
# Extract model and provider from setup response for Vertex AI Live
@ -2106,17 +2205,17 @@ async def websocket_passthrough_request(
await websocket.send_bytes(upstream_message)
# Parse and collect for cost tracking
try:
message_data = json.loads(upstream_message.decode())
message_data = _parse_json_value(upstream_message.decode())
websocket_messages.append(message_data)
except (json.JSONDecodeError, UnicodeDecodeError):
except (json.JSONDecodeError, ValidationError, UnicodeDecodeError):
pass
else:
await websocket.send_text(upstream_message)
# Parse and collect for cost tracking
try:
message_data = json.loads(upstream_message)
message_data = _parse_json_value(upstream_message)
websocket_messages.append(message_data)
except json.JSONDecodeError:
except (json.JSONDecodeError, ValidationError):
pass
except (ConnectionClosedOK, ConnectionClosedError) as e:
@ -2272,7 +2371,7 @@ async def websocket_passthrough_request(
def _is_streaming_response(response: httpx.Response) -> bool:
_content_type = response.headers.get("content-type")
_content_type = _read_header(response.headers, "content-type")
if _content_type is not None and "text/event-stream" in _content_type:
return True
return False
@ -2290,7 +2389,7 @@ def _should_buffer_passthrough_response(response: httpx.Response) -> bool:
"""
if response.status_code >= 400:
return True
media_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
media_type = (_read_header(response.headers, "content-type") or "").split(";")[0].strip().lower()
return media_type in ("", "application/json") or media_type.endswith("+json")
@ -2342,7 +2441,7 @@ async def _relay_passthrough_response_bytes(
)
def _extract_model_from_vertex_ai_setup(setup_response: dict) -> str | None:
def _extract_model_from_vertex_ai_setup(setup_response: JsonValue) -> str | None:
"""
Extract the model name from Vertex AI Live setup response.
@ -2381,7 +2480,7 @@ class SafeRouteAdder:
"""
@staticmethod
def _is_path_registered(app: FastAPI, path: str, methods: list[str]) -> bool:
def _is_path_registered(app: RouteRegistrableApp, path: str, methods: list[str]) -> bool:
"""
Check if a path with any of the specified methods is already registered on the app.
@ -2406,11 +2505,11 @@ class SafeRouteAdder:
@staticmethod
def add_api_route_if_not_exists(
app: FastAPI,
app: RouteRegistrableApp,
path: str,
endpoint: Any,
endpoint: Callable[..., object],
methods: list[str],
dependencies: list | None = None,
dependencies: Sequence[fastapi_params.Depends] | None = None,
) -> bool:
"""
Add an API route to the app only if it doesn't already exist.
@ -2450,13 +2549,13 @@ class SafeRouteAdder:
class InitPassThroughEndpointHelpers:
@staticmethod
def add_exact_path_route(
app: FastAPI,
app: RouteRegistrableApp,
path: str,
target: str,
custom_headers: dict | None,
forward_headers: bool | None,
merge_query_params: bool | None,
dependencies: list | None,
dependencies: Sequence[fastapi_params.Depends] | None,
cost_per_request: float | None,
endpoint_id: str,
guardrails: dict | None = None,
@ -2533,13 +2632,13 @@ class InitPassThroughEndpointHelpers:
@staticmethod
def add_subpath_route(
app: FastAPI,
app: RouteRegistrableApp,
path: str,
target: str,
custom_headers: dict | None,
forward_headers: bool | None,
merge_query_params: bool | None,
dependencies: list | None,
dependencies: Sequence[fastapi_params.Depends] | None,
cost_per_request: float | None,
endpoint_id: str,
guardrails: dict | None = None,
@ -2624,15 +2723,14 @@ class InitPassThroughEndpointHelpers:
]
for key in keys_to_remove:
route_info = _registered_pass_through_routes[key]
path = route_info.get("path")
if isinstance(path, str):
openai_routes = LiteLLMRoutes.openai_routes.value
if path in openai_routes:
openai_routes.remove(path)
if route_info.get("type") == "subpath":
wildcard_path = path.rstrip("/") + "/*"
if wildcard_path in openai_routes:
openai_routes.remove(wildcard_path)
path = route_info["path"]
openai_routes = LiteLLMRoutes.openai_routes.value
if path in openai_routes:
openai_routes.remove(path)
if route_info.get("type") == "subpath":
wildcard_path = path.rstrip("/") + "/*"
if wildcard_path in openai_routes:
openai_routes.remove(wildcard_path)
del _registered_pass_through_routes[key]
verbose_proxy_logger.debug("Removed pass-through route from registry: %s", key)
@ -2699,7 +2797,7 @@ class InitPassThroughEndpointHelpers:
return False
@staticmethod
def get_registered_pass_through_route(route: str, method: str | None = None) -> dict[str, Any] | None:
def get_registered_pass_through_route(route: str, method: str | None = None) -> RegisteredPassthroughRoute | None:
"""Get passthrough params for a given route and optionally filter by HTTP method"""
comparison_route = InitPassThroughEndpointHelpers._route_for_registry_lookup(route)
for key in _registered_pass_through_routes:
@ -2712,7 +2810,7 @@ class InitPassThroughEndpointHelpers:
# but keep supporting test fixtures / older registry entries that
# only encoded methods in the route key.
methods_entry = _registered_pass_through_routes[key].get("methods", [])
route_methods: list[str] = methods_entry if isinstance(methods_entry, list) else []
route_methods: list[str] = methods_entry if methods_entry else []
if not route_methods and len(parts) == 4:
route_methods = parts[3].split(",")
@ -2740,14 +2838,27 @@ def _get_combined_pass_through_endpoints(
return pass_through_endpoints + config_pass_through_endpoints
class _RegisteredEndpointFields(BaseModel):
path: str | None = None
target: str | None = None
headers: dict[str, object] | None = None
forward_headers: bool | None = None
merge_query_params: bool | None = None
default_query_params: dict[str, object] | None = None
guardrails: dict[str, object] | None = None
methods: list[str] | None = None
cost_per_request: float | None = None
timeout: float | None = None
async def _register_pass_through_endpoint(
endpoint: dict[str, Any] | PassThroughGenericEndpoint,
endpoint: dict[str, object] | PassThroughGenericEndpoint,
app: FastAPI,
premium_user: bool,
visited_endpoints: set[str],
config_file_path: str | None = None,
) -> None:
endpoint_data: dict[str, Any]
endpoint_data: dict[str, object]
if isinstance(endpoint, PassThroughGenericEndpoint):
endpoint_data = endpoint.model_dump()
else:
@ -2757,17 +2868,18 @@ async def _register_pass_through_endpoint(
endpoint_data["id"] = str(uuid.uuid4())
endpoint_id = cast(str, endpoint_data["id"])
target = endpoint_data.get("target")
path = endpoint_data.get("path")
endpoint_fields = _RegisteredEndpointFields.model_validate(endpoint_data)
target = endpoint_fields.target
path = endpoint_fields.path
if path is None:
raise ValueError("Path is required for pass-through endpoint")
custom_headers = await set_env_variables_in_header(custom_headers=endpoint_data.get("headers"))
forward_headers = endpoint_data.get("forward_headers")
merge_query_params = endpoint_data.get("merge_query_params")
default_query_params = endpoint_data.get("default_query_params")
custom_headers = await set_env_variables_in_header(custom_headers=endpoint_fields.headers)
forward_headers = endpoint_fields.forward_headers
merge_query_params = endpoint_fields.merge_query_params
default_query_params = endpoint_fields.default_query_params
auth = endpoint_data.get("auth")
dependencies = None
dependencies: list[fastapi_params.Depends] | None = None
auth_enforced = auth is not None and str(auth).lower() == "true"
if auth_enforced:
@ -2782,10 +2894,10 @@ async def _register_pass_through_endpoint(
if target is None:
return
guardrails = endpoint_data.get("guardrails")
methods = endpoint_data.get("methods")
cost_per_request = endpoint_data.get("cost_per_request")
timeout = endpoint_data.get("timeout")
guardrails = endpoint_fields.guardrails
methods = endpoint_fields.methods
cost_per_request = endpoint_fields.cost_per_request
timeout = endpoint_fields.timeout
verbose_proxy_logger.debug("Initializing pass through endpoint: %s (ID: %s)", path, endpoint_id)
InitPassThroughEndpointHelpers.add_exact_path_route(
@ -2925,12 +3037,12 @@ def _get_pass_through_endpoints_from_config() -> list[PassThroughGenericEndpoint
if isinstance(endpoint, dict):
endpoint_dict = dict(endpoint)
endpoint_dict["is_from_config"] = True
returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict))
returned_endpoints.append(PassThroughGenericEndpoint.model_validate(endpoint_dict))
elif isinstance(endpoint, PassThroughGenericEndpoint):
# Create a copy with is_from_config=True
endpoint_dict = endpoint.model_dump()
endpoint_dict["is_from_config"] = True
returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict))
returned_endpoints.append(PassThroughGenericEndpoint.model_validate(endpoint_dict))
except ValidationError as e:
verbose_proxy_logger.warning(
"Skipping malformed pass-through endpoint from config: %s",
@ -2954,10 +3066,10 @@ async def _get_pass_through_endpoints_from_db(
response: ConfigFieldInfo = await get_config_general_settings(
field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict
)
pass_through_endpoint_data = _ENDPOINT_LIST_ADAPTER.validate_python(getattr(response, "field_value"))
except Exception:
return []
pass_through_endpoint_data: list | None = response.field_value
if pass_through_endpoint_data is None:
return []
@ -2965,14 +3077,11 @@ async def _get_pass_through_endpoints_from_db(
if endpoint_id is None:
# Return all endpoints from DB, mark as not from config
for endpoint in pass_through_endpoint_data:
if isinstance(endpoint, dict):
endpoint_dict = dict(endpoint)
endpoint_dict["is_from_config"] = False
returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict))
elif isinstance(endpoint, PassThroughGenericEndpoint):
endpoint_dict = endpoint.model_dump()
endpoint_dict["is_from_config"] = False
returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict))
endpoint_dict = (
endpoint.model_dump() if isinstance(endpoint, PassThroughGenericEndpoint) else dict(endpoint)
)
endpoint_dict["is_from_config"] = False
returned_endpoints.append(PassThroughGenericEndpoint.model_validate(endpoint_dict))
else:
# Find specific endpoint by ID
found_endpoint = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id)
@ -2983,7 +3092,7 @@ async def _get_pass_through_endpoints_from_db(
else dict(found_endpoint)
)
endpoint_dict["is_from_config"] = False
returned_endpoints.append(PassThroughGenericEndpoint(**endpoint_dict))
returned_endpoints.append(PassThroughGenericEndpoint.model_validate(endpoint_dict))
return returned_endpoints
@ -3008,9 +3117,8 @@ async def _filter_endpoints_by_team_allowed_routes(
HTTPException: If team is not found
"""
# retrieve team from db
team = await TeamRepository(prisma_client).table.find_unique(
where={"team_id": team_id},
)
team_table = _TEAM_TABLE_ADAPTER.validate_python(getattr(TeamRepository(prisma_client), "table"))
team = await team_table.find_unique(where={"team_id": team_id})
if team is None:
raise HTTPException(
status_code=404,
@ -3018,14 +3126,15 @@ async def _filter_endpoints_by_team_allowed_routes(
)
# retrieve team metadata
team_metadata = team.metadata
if team_metadata is not None and team_metadata.get("allowed_passthrough_routes") is not None:
team_metadata = _TEAM_METADATA_ADAPTER.validate_python(getattr(team, "metadata"))
allowed_routes = (
_ALLOWED_ROUTES_ADAPTER.validate_python(team_metadata.get("allowed_passthrough_routes"))
if team_metadata is not None
else None
)
if allowed_routes is not None:
## FILTER pass_through_endpoints by allowed_passthrough_routes
pass_through_endpoints = [
endpoint
for endpoint in pass_through_endpoints
if endpoint.path in team_metadata.get("allowed_passthrough_routes")
]
pass_through_endpoints = [endpoint for endpoint in pass_through_endpoints if endpoint.path in allowed_routes]
return pass_through_endpoints
@ -3115,7 +3224,7 @@ async def update_pass_through_endpoints(
detail={"error": "No pass-through endpoints found"},
)
pass_through_endpoint_data: list | None = response.field_value
pass_through_endpoint_data = _ENDPOINT_LIST_ADAPTER.validate_python(getattr(response, "field_value"))
if pass_through_endpoint_data is None:
raise HTTPException(
status_code=404,
@ -3134,7 +3243,7 @@ async def update_pass_through_endpoints(
# Find the index for updating the list
endpoint_index = None
for idx, endpoint in enumerate(pass_through_endpoint_data):
_endpoint = PassThroughGenericEndpoint(**endpoint) if isinstance(endpoint, dict) else endpoint
_endpoint = PassThroughGenericEndpoint.model_validate(endpoint) if isinstance(endpoint, dict) else endpoint
if _endpoint.id == endpoint_id:
endpoint_index = idx
break
@ -3165,7 +3274,7 @@ async def update_pass_through_endpoints(
endpoint_dict.pop("is_from_config", None)
# Create updated endpoint object
updated_endpoint = PassThroughGenericEndpoint(**endpoint_dict)
updated_endpoint = PassThroughGenericEndpoint.model_validate(endpoint_dict)
# Update the list
pass_through_endpoint_data[endpoint_index] = endpoint_dict
@ -3186,9 +3295,10 @@ 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)
request_app = _APP_ADAPTER.validate_python(getattr(request, "app"))
if updated_endpoint.include_subpath:
InitPassThroughEndpointHelpers.add_subpath_route(
app=request.app,
app=request_app,
path=updated_endpoint.path,
target=updated_endpoint.target,
custom_headers=_custom_headers,
@ -3205,7 +3315,7 @@ async def update_pass_through_endpoints(
)
else:
InitPassThroughEndpointHelpers.add_exact_path_route(
app=request.app,
app=request_app,
path=updated_endpoint.path,
target=updated_endpoint.target,
custom_headers=_custom_headers,
@ -3257,29 +3367,28 @@ async def create_pass_through_endpoints(
if data_dict.get("id") is None:
data_dict["id"] = str(uuid.uuid4())
if response.field_value is None:
response.field_value = [data_dict]
elif isinstance(response.field_value, list):
response.field_value.append(data_dict)
existing_field_value = _ENDPOINT_LIST_ADAPTER.validate_python(getattr(response, "field_value"))
updated_field_value = [data_dict] if existing_field_value is None else [*existing_field_value, data_dict]
## Update db
updated_data = ConfigFieldUpdate(
field_name="pass_through_endpoints",
field_value=response.field_value,
field_value=updated_field_value,
config_type="general_settings",
)
await update_config_general_settings(data=updated_data, user_api_key_dict=user_api_key_dict)
# Return the created endpoint with the generated ID
created_endpoint = PassThroughGenericEndpoint(**data_dict)
created_endpoint = PassThroughGenericEndpoint.model_validate(data_dict)
# Register the new route
_custom_headers: dict | None = created_endpoint.headers or {}
_custom_headers = await set_env_variables_in_header(custom_headers=_custom_headers)
request_app = _APP_ADAPTER.validate_python(getattr(request, "app"))
if created_endpoint.include_subpath:
InitPassThroughEndpointHelpers.add_subpath_route(
app=request.app,
app=request_app,
path=created_endpoint.path,
target=created_endpoint.target,
custom_headers=_custom_headers,
@ -3296,7 +3405,7 @@ async def create_pass_through_endpoints(
)
else:
InitPassThroughEndpointHelpers.add_exact_path_route(
app=request.app,
app=request_app,
path=created_endpoint.path,
target=created_endpoint.target,
custom_headers=_custom_headers,
@ -3344,8 +3453,8 @@ 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: list | None = response.field_value
if response.field_value is None or pass_through_endpoint_data is None:
pass_through_endpoint_data = _ENDPOINT_LIST_ADAPTER.validate_python(getattr(response, "field_value"))
if pass_through_endpoint_data is None:
raise HTTPException(
status_code=400,
detail={"error": "There are no pass-through endpoints setup."},
@ -3363,7 +3472,7 @@ async def delete_pass_through_endpoints(
# Find the index for deleting from the list
endpoint_index = None
for idx, endpoint in enumerate(pass_through_endpoint_data):
_endpoint = PassThroughGenericEndpoint(**endpoint) if isinstance(endpoint, dict) else endpoint
_endpoint = PassThroughGenericEndpoint.model_validate(endpoint) if isinstance(endpoint, dict) else endpoint
if _endpoint.id == endpoint_id:
endpoint_index = idx
break
@ -3393,7 +3502,7 @@ async def delete_pass_through_endpoints(
def _find_endpoint_by_id(
endpoints_data: list,
endpoints_data: list[dict[str, object] | PassThroughGenericEndpoint],
endpoint_id: str,
) -> PassThroughGenericEndpoint | None:
"""
@ -3407,14 +3516,14 @@ def _find_endpoint_by_id(
Found endpoint or None if not found
"""
for endpoint in endpoints_data:
_endpoint: PassThroughGenericEndpoint | None = None
if isinstance(endpoint, dict):
_endpoint = PassThroughGenericEndpoint(**endpoint)
elif isinstance(endpoint, PassThroughGenericEndpoint):
_endpoint = endpoint
_endpoint = (
endpoint
if isinstance(endpoint, PassThroughGenericEndpoint)
else PassThroughGenericEndpoint.model_validate(endpoint)
)
# Only compare IDs to IDs
if _endpoint is not None and _endpoint.id == endpoint_id:
if _endpoint.id == endpoint_id:
return _endpoint
return None

View file

@ -137,7 +137,7 @@ class ResponsesSessionHandler:
model_response = ModelResponse(**_response_output)
for choice in model_response.choices:
if hasattr(choice, "message"):
chat_completion_message_history.append(getattr(choice, "message"))
chat_completion_message_history.append(choice.message)
return chat_completion_message_history
@staticmethod

View file

@ -4,12 +4,13 @@ Handles transforming from Responses API -> LiteLLM completion (Chat Completion
import json
import re
from collections.abc import Sequence
from typing import Any, Literal, cast
from collections.abc import Iterable, Mapping, Sequence
from typing import Literal, Protocol, cast
from openai.types.responses import ResponseFunctionToolCall
from openai.types.responses.response_create_params import ResponseInputParam
from openai.types.responses.tool_param import FunctionToolParam
from pydantic import BaseModel, InstanceOf, TypeAdapter
from typing_extensions import TypedDict
from litellm._logging import verbose_logger
@ -27,6 +28,7 @@ from litellm.types.llms.openai import (
ChatCompletionImageUrlObject,
ChatCompletionResponseMessage,
ChatCompletionSystemMessage,
ChatCompletionTextObject,
ChatCompletionToolCallChunk,
ChatCompletionToolCallFunctionChunk,
ChatCompletionToolMessage,
@ -72,6 +74,52 @@ from .custom_tools import (
unwrap_custom_tool_arguments,
)
_STR_OBJECT_DICT_ADAPTER: TypeAdapter[dict[str, object]] = TypeAdapter(dict[str, object])
_STR_OBJECT_DICT_INSTANCE_ADAPTER = TypeAdapter(InstanceOf[dict[str, object]])
_OBJECT_LIST_INSTANCE_ADAPTER = TypeAdapter(InstanceOf[list[object]])
_OBJECT_SEQUENCE_INSTANCE_ADAPTER = TypeAdapter(InstanceOf[Sequence[object]])
_OBJECT_ITERABLE_INSTANCE_ADAPTER = TypeAdapter(InstanceOf[Iterable[object]])
_TEXT_OBJECT_LIST_INSTANCE_ADAPTER = TypeAdapter(InstanceOf[list[ChatCompletionTextObject]])
def _as_str_object_dict(value: object) -> dict[str, object] | None:
return _STR_OBJECT_DICT_INSTANCE_ADAPTER.validate_python(value) if isinstance(value, dict) else None
def _as_object_list(value: object) -> list[object] | None:
return _OBJECT_LIST_INSTANCE_ADAPTER.validate_python(value) if isinstance(value, list) else None
def _as_object_sequence(value: object) -> Sequence[object] | None:
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
return None
return _OBJECT_SEQUENCE_INSTANCE_ADAPTER.validate_python(value)
def _as_object_iterable(value: object) -> Iterable[object] | None:
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
return None
return _OBJECT_ITERABLE_INSTANCE_ADAPTER.validate_python(value)
def _as_tool_message_content(value: object) -> str | list[ChatCompletionTextObject] | None:
if isinstance(value, str):
return value
return _TEXT_OBJECT_LIST_INSTANCE_ADAPTER.validate_python(value) if isinstance(value, list) else None
def _model_as_dict(model: BaseModel) -> dict[str, object]:
return _STR_OBJECT_DICT_ADAPTER.validate_python(model.model_dump())
class SupportsApplyPatchOperation(Protocol):
@property
def call_id(self) -> str: ...
@property
def operation(self) -> BaseModel: ...
########### Initialize Classes used for Responses API ###########
TOOL_CALLS_CACHE = InMemoryCache()
@ -116,8 +164,8 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_tool_choice(
tool_choice: Any,
) -> str | dict[str, Any] | None:
tool_choice: str | Mapping[str, object] | None,
) -> str | Mapping[str, object] | None:
"""
Transform tool_choice from various formats to OpenAI Chat Completion format.
@ -145,7 +193,8 @@ class LiteLLMCompletionResponsesConfig:
tool_choice_type = tool_choice.get("type")
# If it has a function with name, it's standard OpenAI format - pass through
if tool_choice.get("function") and tool_choice.get("function", {}).get("name"):
function_dict = _as_str_object_dict(tool_choice.get("function"))
if function_dict is not None and function_dict.get("name"):
return tool_choice
# Handle Cursor IDE dict formats without function name
@ -189,7 +238,7 @@ class LiteLLMCompletionResponsesConfig:
responses_api_request: ResponsesAPIOptionalRequestParams,
custom_llm_provider: str | None = None,
stream: bool | None = None,
extra_headers: dict[str, Any] | None = None,
extra_headers: dict[str, str] | None = None,
**kwargs,
) -> dict:
"""
@ -446,7 +495,9 @@ class LiteLLMCompletionResponsesConfig:
if not chat_completion_messages:
continue
deduped_in_place: list[Any] = []
deduped_in_place: list[
AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage
] = []
for m in chat_completion_messages:
role = ""
if isinstance(m, dict):
@ -456,19 +507,17 @@ class LiteLLMCompletionResponsesConfig:
# Drop assistant tool_calls wrappers if we already have this call_id
if role == "assistant":
tool_calls: Any = (
tool_calls: object = (
m.get("tool_calls") if isinstance(m, dict) else getattr(m, "tool_calls", None)
)
call_id = ""
if (
isinstance(tool_calls, Sequence)
and not isinstance(tool_calls, (str, bytes))
and len(tool_calls) > 0
):
first_call = tool_calls[0]
call_id_raw = (
first_call.get("id")
if isinstance(first_call, dict)
tool_calls_seq = _as_object_sequence(tool_calls)
if tool_calls_seq is not None and len(tool_calls_seq) > 0:
first_call = tool_calls_seq[0]
first_call_dict = _as_str_object_dict(first_call)
call_id_raw: object = (
first_call_dict.get("id")
if first_call_dict is not None
else getattr(first_call, "id", None)
)
if call_id_raw:
@ -518,23 +567,19 @@ class LiteLLMCompletionResponsesConfig:
call_id = ""
if role == "assistant":
tool_calls: Any = None
tool_calls: object = None
if isinstance(tool_call_message, dict):
tool_calls = tool_call_message.get("tool_calls")
else:
tool_calls = getattr(tool_call_message, "tool_calls", None)
if (
isinstance(tool_calls, Sequence)
and not isinstance(tool_calls, (str, bytes))
and len(tool_calls) > 0
):
first_call = tool_calls[0]
call_id_raw = None
if isinstance(first_call, dict):
call_id_raw = first_call.get("id")
else:
call_id_raw = getattr(first_call, "id", None)
tool_calls_seq = _as_object_sequence(tool_calls)
if tool_calls_seq is not None and len(tool_calls_seq) > 0:
first_call = tool_calls_seq[0]
first_call_dict = _as_str_object_dict(first_call)
call_id_raw: object = (
first_call_dict.get("id") if first_call_dict is not None else getattr(first_call, "id", None)
)
if call_id_raw:
call_id = str(call_id_raw)
@ -562,25 +607,40 @@ class LiteLLMCompletionResponsesConfig:
return False
@staticmethod
def _find_previous_assistant_idx(messages: list[Any], current_idx: int) -> int | None:
def _find_previous_assistant_idx(
messages: Sequence[
AllMessageValues
| GenericChatCompletionMessage
| ChatCompletionResponseMessage
| ChatCompletionMessageToolCall
| Message
],
current_idx: int,
) -> int | None:
"""Find the index of the previous assistant message."""
for j in range(current_idx - 1, -1, -1):
if messages[j].get("role") == "assistant":
candidate = messages[j]
candidate_dict = _as_str_object_dict(candidate)
role = candidate_dict.get("role") if candidate_dict is not None else getattr(candidate, "role", None)
if role == "assistant":
return j
return None
@staticmethod
def _recover_tool_call_id_from_assistant(assistant_message: Any, message: Any) -> str:
def _recover_tool_call_id_from_assistant(assistant_message: object, message: object) -> str:
"""Try to recover empty tool_call_id from assistant message's tool_calls."""
tool_calls_raw = (
assistant_message.get("tool_calls")
if isinstance(assistant_message, dict)
assistant_dict = _as_str_object_dict(assistant_message)
tool_calls_raw: object = (
assistant_dict.get("tool_calls")
if assistant_dict is not None
else getattr(assistant_message, "tool_calls", None)
)
if tool_calls_raw and isinstance(tool_calls_raw, list) and len(tool_calls_raw) > 0:
first_tool_call = tool_calls_raw[0]
if isinstance(first_tool_call, dict):
tool_call_id_raw = first_tool_call.get("id", "")
tool_calls_list = _as_object_list(tool_calls_raw)
if tool_calls_list is not None and len(tool_calls_list) > 0:
first_tool_call = tool_calls_list[0]
first_tool_call_dict = _as_str_object_dict(first_tool_call)
if first_tool_call_dict is not None:
tool_call_id_raw: object = first_tool_call_dict.get("id", "")
return str(tool_call_id_raw) if tool_call_id_raw is not None else ""
elif hasattr(first_tool_call, "id"):
tool_call_id_raw = getattr(first_tool_call, "id", None)
@ -588,28 +648,32 @@ class LiteLLMCompletionResponsesConfig:
return ""
@staticmethod
def _get_tool_calls_list(assistant_message: Any) -> list[Any]:
def _get_tool_calls_list(assistant_message: object) -> list[object]:
"""Extract tool_calls as a list from assistant message."""
tool_calls_raw = (
assistant_message.get("tool_calls")
if isinstance(assistant_message, dict)
assistant_dict = _as_str_object_dict(assistant_message)
tool_calls_raw: object = (
assistant_dict.get("tool_calls")
if assistant_dict is not None
else getattr(assistant_message, "tool_calls", None)
)
if tool_calls_raw is None:
return []
if isinstance(tool_calls_raw, list):
return tool_calls_raw
if hasattr(tool_calls_raw, "__iter__") and not isinstance(tool_calls_raw, (str, bytes)):
return list(tool_calls_raw)
tool_calls_list = _as_object_list(tool_calls_raw)
if tool_calls_list is not None:
return tool_calls_list
tool_calls_iterable = _as_object_iterable(tool_calls_raw)
if tool_calls_iterable is not None:
return list(tool_calls_iterable)
return []
@staticmethod
def _check_tool_call_exists(tool_calls: list[Any], tool_call_id: str) -> bool:
def _check_tool_call_exists(tool_calls: Sequence[object], tool_call_id: str) -> bool:
"""Check if a tool_call with the given ID exists in the list."""
for tool_call in tool_calls:
tool_call_id_to_check: str | None = None
if isinstance(tool_call, dict):
tool_call_id_to_check = tool_call.get("id")
tool_call_id_to_check: object = None
tool_call_dict = _as_str_object_dict(tool_call)
if tool_call_dict is not None:
tool_call_id_to_check = tool_call_dict.get("id")
elif hasattr(tool_call, "id"):
tool_call_id_to_check = getattr(tool_call, "id", None)
if tool_call_id_to_check == tool_call_id:
@ -617,52 +681,51 @@ class LiteLLMCompletionResponsesConfig:
return False
@staticmethod
def _reconstruct_tool_call_from_tools(tool_call_id: str, tools: list[Any]) -> dict[str, Any] | None:
def _reconstruct_tool_call_from_tools(tool_call_id: str, tools: Sequence[object]) -> dict[str, object] | None:
"""Reconstruct a minimal tool_call definition from tools list."""
for tool in tools:
if isinstance(tool, dict):
tool_function = tool.get("function") or {}
tool_name = tool_function.get("name") or tool.get("name") or ""
if tool_name:
return {
"id": tool_call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": "{}", # We don't know the arguments, use empty
},
}
tool_dict = _as_str_object_dict(tool)
if tool_dict is None:
continue
tool_function_dict: Mapping[str, object] = _as_str_object_dict(tool_dict.get("function")) or {}
tool_name = tool_function_dict.get("name") or tool_dict.get("name") or ""
if tool_name:
return {
"id": tool_call_id,
"type": "function",
"function": {
"name": tool_name,
"arguments": "{}", # We don't know the arguments, use empty
},
}
return None
@staticmethod
def _get_mapping_or_attr_value(obj: Any, key: str, default: Any = None) -> Any:
def _get_mapping_or_attr_value(obj: object, key: str, default: object = None) -> object:
"""
Safely read a field from dict-like or attribute-based objects.
"""
if obj is None:
return default
if isinstance(obj, dict):
return obj.get(key, default)
obj_dict = _as_str_object_dict(obj)
if obj_dict is not None:
return obj_dict.get(key, default)
getter = getattr(obj, "get", None)
if callable(getter):
try:
return getter(key, default)
except (TypeError, AttributeError):
pass
if isinstance(obj, BaseModel):
return _model_as_dict(obj).get(key, default)
return getattr(obj, key, default)
@staticmethod
def _create_tool_call_chunk(
tool_use_definition: dict[str, Any], tool_call_id: str, index: int
tool_use_definition: dict[str, object], tool_call_id: str, index: int
) -> ChatCompletionToolCallChunk:
"""Create a ChatCompletionToolCallChunk from tool_use_definition."""
function_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(tool_use_definition, "function")
function_name_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(function_raw, "name")
function_arguments_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(function_raw, "arguments")
function: dict[str, Any] = {
function: dict[str, object] = {
"name": function_name_raw or "",
"arguments": function_arguments_raw or "{}",
}
@ -681,15 +744,16 @@ class LiteLLMCompletionResponsesConfig:
)
@staticmethod
def _normalize_tool_use_definition(tool_use_definition: Any, tool_call_id: str) -> dict[str, Any] | None:
def _normalize_tool_use_definition(tool_use_definition: object, tool_call_id: str) -> dict[str, object] | None:
"""
Normalize cached tool_call definitions to a dict-like shape consumed by _create_tool_call_chunk.
"""
if not tool_use_definition:
return None
if isinstance(tool_use_definition, dict):
normalized_definition: dict[str, Any] = dict(tool_use_definition)
tool_use_definition_dict = _as_str_object_dict(tool_use_definition)
if tool_use_definition_dict is not None:
normalized_definition: dict[str, object] = dict(tool_use_definition_dict)
else:
tool_use_id_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(tool_use_definition, "id")
tool_use_type_raw = LiteLLMCompletionResponsesConfig._get_mapping_or_attr_value(tool_use_definition, "type")
@ -722,20 +786,22 @@ class LiteLLMCompletionResponsesConfig:
return normalized_definition
@staticmethod
def _add_tool_call_to_assistant(assistant_message: Any, tool_call_chunk: ChatCompletionToolCallChunk) -> None:
def _add_tool_call_to_assistant(assistant_message: object, tool_call_chunk: ChatCompletionToolCallChunk) -> None:
"""Add a tool_call to an assistant message."""
if isinstance(assistant_message, dict):
prev_assistant_dict = cast(dict[str, Any], assistant_message)
if "tool_calls" not in prev_assistant_dict:
prev_assistant_dict["tool_calls"] = []
tool_calls_list = prev_assistant_dict["tool_calls"]
if isinstance(tool_calls_list, list):
assistant_dict = _as_str_object_dict(assistant_message)
if assistant_dict is not None:
if "tool_calls" not in assistant_dict:
assistant_dict["tool_calls"] = []
tool_calls_list = _as_object_list(assistant_dict["tool_calls"])
if tool_calls_list is not None:
tool_calls_list.append(tool_call_chunk)
elif hasattr(assistant_message, "tool_calls"):
if assistant_message.tool_calls is None:
assistant_message.tool_calls = []
if isinstance(assistant_message.tool_calls, list):
assistant_message.tool_calls.append(tool_call_chunk)
if getattr(assistant_message, "tool_calls", None) is None:
setattr(assistant_message, "tool_calls", [])
existing_tool_calls: object = getattr(assistant_message, "tool_calls", None)
existing_tool_calls_list = _as_object_list(existing_tool_calls)
if existing_tool_calls_list is not None:
existing_tool_calls_list.append(tool_call_chunk)
@staticmethod
def _ensure_tool_results_have_corresponding_tool_calls(
@ -746,7 +812,7 @@ class LiteLLMCompletionResponsesConfig:
| ChatCompletionMessageToolCall
| Message
],
tools: list[Any] | None = None,
tools: Sequence[object] | None = None,
) -> list[
AllMessageValues
| GenericChatCompletionMessage
@ -808,9 +874,8 @@ class LiteLLMCompletionResponsesConfig:
)
if tool_call_id:
# Type-safe way to set tool_call_id on tool message
if isinstance(message, dict):
# Cast to dict to allow setting tool_call_id
message_dict = cast(dict[str, Any], message)
message_dict = _as_str_object_dict(message)
if message_dict is not None:
message_dict["tool_call_id"] = tool_call_id
elif hasattr(message, "tool_call_id"):
setattr(message, "tool_call_id", tool_call_id)
@ -862,7 +927,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_responses_api_input_item_to_chat_completion_message(
input_item: Any,
input_item: Mapping[str, object],
) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]:
"""
Transform a Responses API input item into a Chat Completion message
@ -897,9 +962,10 @@ class LiteLLMCompletionResponsesConfig:
# Since guardrails skip None content anyway, we return empty list to exclude it from structured messages
if content is None:
return []
role_value = input_item.get("role")
return [
GenericChatCompletionMessage(
role=input_item.get("role") or "user",
role=role_value if isinstance(role_value, str) and role_value else "user",
content=LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(
content
),
@ -907,7 +973,7 @@ class LiteLLMCompletionResponsesConfig:
]
@staticmethod
def _is_input_item_tool_call_output(input_item: Any) -> bool:
def _is_input_item_tool_call_output(input_item: Mapping[str, object]) -> bool:
"""
Check if the input item is a tool call output
"""
@ -920,7 +986,7 @@ class LiteLLMCompletionResponsesConfig:
]
@staticmethod
def _is_input_item_function_call(input_item: Any) -> bool:
def _is_input_item_function_call(input_item: Mapping[str, object]) -> bool:
"""
Check if the input item is a function call or custom tool call.
Both need to be reconstructed as assistant tool_calls for Chat
@ -930,7 +996,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_responses_api_tool_call_output_to_chat_completion_message(
tool_call_output: dict[str, Any],
tool_call_output: Mapping[str, object],
) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]:
"""
ChatCompletionToolMessage is used to indicate the output from a tool call
@ -942,8 +1008,8 @@ class LiteLLMCompletionResponsesConfig:
return []
def _normalize_function_call_output_to_tool_content(
output: Any,
) -> Any:
output: object,
) -> str | list[dict[str, object]]:
"""
Normalize Responses API function_call_output.output into a shape that downstream
chat adapters (esp. Gemini) can reliably consume.
@ -964,22 +1030,25 @@ class LiteLLMCompletionResponsesConfig:
return output
# Some adapters represent tool output as a list of "input_*" parts
if isinstance(output, list):
normalized_blocks: list[dict[str, Any]] = []
output_list = _as_object_list(output)
if output_list is not None:
normalized_blocks: list[dict[str, object]] = []
text_acc: list[str] = []
for part in output:
if not isinstance(part, dict):
for part in output_list:
part_dict = _as_str_object_dict(part)
if part_dict is None:
continue
part_type = part.get("type")
part_type = part_dict.get("type")
if part_type in ("input_text", "output_text", "text"):
txt = part.get("text")
txt = part_dict.get("text")
if isinstance(txt, str) and txt:
text_acc.append(txt)
normalized_blocks.append({"type": "text", "text": txt})
elif part_type in ("input_image", "image_url"):
image_url_val = part.get("image_url") or part.get("url")
if isinstance(image_url_val, dict):
url = image_url_val.get("url")
image_url_val = part_dict.get("image_url") or part_dict.get("url")
image_url_dict = _as_str_object_dict(image_url_val)
if image_url_dict is not None:
url = image_url_dict.get("url")
if isinstance(url, str) and url:
normalized_blocks.append({"type": "image_url", "image_url": {"url": url}})
elif isinstance(image_url_val, str) and image_url_val:
@ -1012,9 +1081,11 @@ class LiteLLMCompletionResponsesConfig:
except Exception:
return str(output)
normalized_output = _normalize_function_call_output_to_tool_content(tool_call_output.get("output"))
tool_message_content = _as_tool_message_content(normalized_output)
tool_output_message = ChatCompletionToolMessage(
role="tool",
content=_normalize_function_call_output_to_tool_content(tool_call_output.get("output")),
content=tool_message_content if tool_message_content is not None else "",
tool_call_id=str(call_id),
)
@ -1066,7 +1137,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_responses_api_function_call_to_chat_completion_message(
function_call: dict[str, Any],
function_call: Mapping[str, object],
) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]:
"""
Transform a Responses API function_call into a Chat Completion message with tool calls
@ -1087,15 +1158,17 @@ class LiteLLMCompletionResponsesConfig:
# Create a tool call for the function call. Custom tool calls
# store their payload in "input" (raw string) rather than
# "arguments" (JSON string), so normalize to arguments here.
raw_arguments = function_call.get("arguments")
raw_arguments: object = function_call.get("arguments")
if not raw_arguments and function_call.get("type") == "custom_tool_call":
raw_input = function_call.get("input") or ""
raw_arguments = json.dumps({"content": raw_input}) if raw_input else ""
call_id_value = function_call.get("call_id") or function_call.get("id") or ""
name_value = function_call.get("name") or ""
tool_call = ChatCompletionToolCallChunk(
id=function_call.get("call_id") or function_call.get("id") or "",
id=str(call_id_value),
type="function",
function=ChatCompletionToolCallFunctionChunk(
name=function_call.get("name") or "",
name=str(name_value),
arguments=str(raw_arguments or ""),
),
index=0,
@ -1111,16 +1184,17 @@ class LiteLLMCompletionResponsesConfig:
return [chat_completion_response_message]
@staticmethod
def _resolve_file_id(item: dict[str, Any]) -> str | None:
def _resolve_file_id(item: Mapping[str, object]) -> str | None:
"""
Return the effective file_id for a Responses API input_file item.
Explicit file_id takes precedence; file_url is used as fallback so
downstream providers (Anthropic, Gemini) can handle the URL natively.
"""
return item.get("file_id") or item.get("file_url") or None
resolved = item.get("file_id") or item.get("file_url") or None
return resolved if isinstance(resolved, str) else None
@staticmethod
def _transform_input_file_item_to_file_item(item: dict[str, Any]) -> dict[str, Any]:
def _transform_input_file_item_to_file_item(item: Mapping[str, object]) -> dict[str, object]:
"""
Transform a Responses API input_file item to a Chat Completion file item
@ -1130,35 +1204,38 @@ class LiteLLMCompletionResponsesConfig:
Returns:
Dictionary with transformed file structure for Chat Completion
"""
file_dict: dict[str, Any] = {}
file_dict: dict[str, object] = {}
file_id = LiteLLMCompletionResponsesConfig._resolve_file_id(item)
if file_id:
file_dict["file_id"] = file_id
if item.get("file_data"):
file_dict["file_data"] = item["file_data"]
new_item: dict[str, Any] = {"type": "file", "file": file_dict}
new_item: dict[str, object] = {"type": "file", "file": file_dict}
if "cache_control" in item:
new_item["cache_control"] = item["cache_control"]
return new_item
@staticmethod
def _transform_input_image_item_to_image_item(
item: dict[str, Any],
item: Mapping[str, object],
) -> ChatCompletionImageObject:
"""
Transform a Responses API input_image item to a Chat Completion image item
"""
url_value = item.get("image_url")
detail_value = item.get("detail")
image_url_obj = ChatCompletionImageUrlObject(
url=item.get("image_url") or "", detail=item.get("detail") or "auto"
url=url_value if isinstance(url_value, str) and url_value else "",
detail=detail_value if isinstance(detail_value, str) and detail_value else "auto",
)
return ChatCompletionImageObject(type="image_url", image_url=image_url_obj)
@staticmethod
def _transform_responses_api_content_to_chat_completion_content(
content: Any,
) -> str | list[str | dict[str, Any]]:
content: object,
) -> str | list[str | dict[str, object]]:
"""
Transform a Responses API content into a Chat Completion content
@ -1171,37 +1248,41 @@ class LiteLLMCompletionResponsesConfig:
return ""
elif isinstance(content, str):
return content
elif isinstance(content, list):
content_list: list[str | dict[str, Any]] = []
for item in content:
elif (content_items := _as_object_list(content)) is not None:
content_list: list[str | dict[str, object]] = []
for item in content_items:
if isinstance(item, str):
content_list.append(item)
elif isinstance(item, dict):
if item.get("type") == "input_file":
content_list.append(
LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(item)
)
elif item.get("type") == "input_image":
image_block = dict(
LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item)
)
if "cache_control" in item:
image_block["cache_control"] = item["cache_control"]
content_list.append(image_block)
else:
# Skip text blocks with None text to avoid downstream errors
text_value = item.get("text")
if text_value is None:
continue
content_block: dict[str, Any] = {
"type": LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type(
item.get("type") or "text"
),
"text": text_value,
}
if "cache_control" in item:
content_block["cache_control"] = item["cache_control"]
content_list.append(content_block)
continue
item_dict = _as_str_object_dict(item)
if item_dict is None:
continue
if item_dict.get("type") == "input_file":
content_list.append(
LiteLLMCompletionResponsesConfig._transform_input_file_item_to_file_item(item_dict)
)
elif item_dict.get("type") == "input_image":
image_block: dict[str, object] = dict(
LiteLLMCompletionResponsesConfig._transform_input_image_item_to_image_item(item_dict)
)
if "cache_control" in item_dict:
image_block["cache_control"] = item_dict["cache_control"]
content_list.append(image_block)
else:
# Skip text blocks with None text to avoid downstream errors
text_value = item_dict.get("text")
if text_value is None:
continue
block_type = item_dict.get("type")
content_block: dict[str, object] = {
"type": LiteLLMCompletionResponsesConfig._get_chat_completion_request_content_type(
block_type if isinstance(block_type, str) and block_type else "text"
),
"text": text_value,
}
if "cache_control" in item_dict:
content_block["cache_control"] = item_dict["cache_control"]
content_list.append(content_block)
return content_list
else:
raise ValueError(f"Invalid content type: {type(content)}")
@ -1283,7 +1364,7 @@ class LiteLLMCompletionResponsesConfig:
parameters = dict(typed_tool.get("parameters", {}) or {})
if not parameters or "type" not in parameters:
parameters["type"] = "object"
chat_completion_tool: dict[str, Any] = {
chat_completion_tool: dict[str, object] = {
"type": "function",
"function": {
"name": typed_tool.get("name") or "",
@ -1324,7 +1405,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def transform_chat_completion_tool_params_to_responses_api_tools(
chat_completion_tools: list[ChatCompletionToolParam | OpenAIMcpServerTool] | None,
) -> list[dict[str, Any]]:
) -> list[dict[str, object]]:
"""
Transform Chat Completion tool params (e.g. from guardrail output) back to
Responses API request tool format. Inverse of
@ -1332,17 +1413,18 @@ class LiteLLMCompletionResponsesConfig:
"""
if chat_completion_tools is None or not chat_completion_tools:
return []
result: list[dict[str, Any]] = []
result: list[dict[str, object]] = []
for tool in chat_completion_tools:
if not isinstance(tool, dict):
result.append(tool) # type: ignore
continue
if tool.get("type") == "function":
fn = cast(dict[str, Any], tool.get("function") or {})
parameters = dict(fn.get("parameters", {}) or {})
fn: Mapping[str, object] = _as_str_object_dict(tool.get("function")) or {}
parameters_dict = _as_str_object_dict(fn.get("parameters"))
parameters: dict[str, object] = dict(parameters_dict) if parameters_dict is not None else {}
if not parameters or "type" not in parameters:
parameters["type"] = "object"
responses_tool: dict[str, Any] = {
responses_tool: dict[str, object] = {
"type": "function",
"name": fn.get("name") or "",
"description": fn.get("description") or "",
@ -1360,9 +1442,17 @@ class LiteLLMCompletionResponsesConfig:
result.append(responses_tool)
else:
# mcp or other: pass through unchanged
result.append(dict(tool))
passthrough_tool: dict[str, object] = dict(tool)
result.append(passthrough_tool)
return result
@staticmethod
def _read_provider_specific_fields(source: BaseModel) -> dict[str, object] | None:
raw_dict = _as_str_object_dict(_model_as_dict(source).get("provider_specific_fields"))
if raw_dict:
return raw_dict
return None
@staticmethod
def transform_chat_completion_tools_to_responses_tools(
chat_completion_response: ModelResponse,
@ -1414,25 +1504,9 @@ class LiteLLMCompletionResponsesConfig:
responses_tools.append(custom_item)
else:
# Build regular function_call output item
provider_specific_fields: dict | None = None
if hasattr(tool, "provider_specific_fields") and getattr(tool, "provider_specific_fields", None):
provider_specific_fields = getattr(tool, "provider_specific_fields")
if not isinstance(provider_specific_fields, dict):
provider_specific_fields = (
dict(provider_specific_fields) # type: ignore
if hasattr(provider_specific_fields, "__dict__")
else {}
)
elif hasattr(function_definition, "provider_specific_fields") and getattr(
function_definition, "provider_specific_fields", None
):
provider_specific_fields = getattr(function_definition, "provider_specific_fields")
if not isinstance(provider_specific_fields, dict):
provider_specific_fields = (
dict(provider_specific_fields) # type: ignore
if hasattr(provider_specific_fields, "__dict__")
else {}
)
provider_specific_fields = LiteLLMCompletionResponsesConfig._read_provider_specific_fields(
tool
) or LiteLLMCompletionResponsesConfig._read_provider_specific_fields(function_definition)
output_tool_call: ResponseFunctionToolCall = ResponseFunctionToolCall(
name=tool_name,
@ -1496,9 +1570,9 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def convert_response_function_tool_call_to_chat_completion_tool_call(
tool_call_item: Any,
tool_call_item: object,
index: int = 0,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Convert ResponseFunctionToolCall to ChatCompletionToolCallChunk format.
@ -1509,37 +1583,36 @@ class LiteLLMCompletionResponsesConfig:
Returns:
Dictionary in ChatCompletionToolCallChunk format
"""
# Extract provider_specific_fields if present
provider_specific_fields = getattr(tool_call_item, "provider_specific_fields", None)
if provider_specific_fields and not isinstance(provider_specific_fields, dict):
provider_specific_fields = (
dict(provider_specific_fields) if hasattr(provider_specific_fields, "__dict__") else {}
)
elif hasattr(tool_call_item, "get") and callable(tool_call_item.get): # type: ignore
provider_fields = tool_call_item.get("provider_specific_fields") # type: ignore
if provider_fields:
provider_specific_fields = (
provider_fields
if isinstance(provider_fields, dict)
else (
dict(provider_fields) # type: ignore
if hasattr(provider_fields, "__dict__")
else {}
)
)
if isinstance(tool_call_item, BaseModel):
dumped = _model_as_dict(tool_call_item)
name_value: object = dumped.get("name")
arguments_value: object = dumped.get("arguments")
item_id_value: object = dumped.get("id")
call_id_value: object = dumped.get("call_id")
psf_value: object = dumped.get("provider_specific_fields")
else:
name_value = getattr(tool_call_item, "name", None)
arguments_value = getattr(tool_call_item, "arguments", None)
item_id_value = getattr(tool_call_item, "id", None)
call_id_value = getattr(tool_call_item, "call_id", None)
psf_value = getattr(tool_call_item, "provider_specific_fields", None)
function_dict: dict[str, Any] = {
"name": tool_call_item.name,
"arguments": tool_call_item.arguments,
# Extract provider_specific_fields if present
psf_dict = _as_str_object_dict(psf_value)
provider_specific_fields = psf_dict if psf_dict else None
function_dict: dict[str, object] = {
"name": name_value,
"arguments": arguments_value,
}
if provider_specific_fields:
function_dict["provider_specific_fields"] = provider_specific_fields
tool_call_dict: dict[str, Any] = {
tool_call_dict: dict[str, object] = {
"id": LiteLLMCompletionResponsesConfig._tool_call_id_from_responses_item(
getattr(tool_call_item, "id", None),
getattr(tool_call_item, "call_id", None),
item_id_value if isinstance(item_id_value, str) else None,
call_id_value if isinstance(call_id_value, str) else None,
),
"function": function_dict,
"type": "function",
@ -1553,9 +1626,9 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def convert_apply_patch_tool_call_to_chat_completion_tool_call(
tool_call_item: Any,
tool_call_item: SupportsApplyPatchOperation,
index: int = 0,
) -> dict[str, Any]:
) -> dict[str, object]:
"""
Convert ResponseApplyPatchToolCall to ChatCompletionToolCallChunk format.
@ -1570,10 +1643,8 @@ class LiteLLMCompletionResponsesConfig:
Returns:
Dictionary in ChatCompletionToolCallChunk format
"""
import json
operation_dict = tool_call_item.operation.model_dump()
tool_call_dict: dict[str, Any] = {
operation_dict = _model_as_dict(tool_call_item.operation)
tool_call_dict: dict[str, object] = {
"id": tool_call_item.call_id,
"function": {
"name": "apply_patch",
@ -1711,11 +1782,11 @@ class LiteLLMCompletionResponsesConfig:
"""
output_items: list = []
for choice in chat_completion_response.choices or []:
message = getattr(choice, "message", None)
message: Message | None = getattr(choice, "message", None)
if not message:
continue
psf = getattr(message, "provider_specific_fields", None)
if not psf or not isinstance(psf, dict):
psf = message.provider_specific_fields
if not psf:
continue
results = psf.get("code_interpreter_results")
if results and isinstance(results, list):
@ -1783,13 +1854,16 @@ class LiteLLMCompletionResponsesConfig:
"""
image_generation_items: list[OutputImageGenerationCall] = []
images = getattr(choice.message, "images", [])
images = choice.message.images or []
if not images:
return image_generation_items
for idx, image_item in enumerate(images):
# Extract base64 from data URL
image_url = image_item.get("image_url", {}).get("url", "")
image_url_value: object = image_item.get("image_url")
image_url_dict = _as_str_object_dict(image_url_value)
url_value = image_url_dict.get("url", "") if image_url_dict is not None else ""
image_url = url_value if isinstance(url_value, str) else ""
base64_data = LiteLLMCompletionResponsesConfig._extract_base64_from_data_url(image_url)
if base64_data:
@ -2034,8 +2108,8 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_text_format_to_response_format(
text_param: dict[str, Any] | Any,
) -> dict[str, Any] | None:
text_param: object,
) -> dict[str, object] | None:
"""
Transform Responses API text.format parameter to Chat Completion response_format parameter.
@ -2062,18 +2136,19 @@ class LiteLLMCompletionResponsesConfig:
if not text_param:
return None
if isinstance(text_param, dict):
format_param = text_param.get("format")
if format_param and isinstance(format_param, dict):
format_type = format_param.get("type")
text_param_dict = _as_str_object_dict(text_param)
if text_param_dict is not None:
format_param_dict = _as_str_object_dict(text_param_dict.get("format"))
if format_param_dict:
format_type = format_param_dict.get("type")
if format_type == "json_schema":
return {
"type": "json_schema",
"json_schema": {
"name": format_param.get("name", "response_schema"),
"schema": format_param.get("schema", {}),
"strict": format_param.get("strict", False),
"name": format_param_dict.get("name", "response_schema"),
"schema": format_param_dict.get("schema", {}),
"strict": format_param_dict.get("strict", False),
},
}
elif format_type == "json_object":

View file

@ -64,7 +64,7 @@ async def create_mcp_list_tools_events(
# Convert tools to dict format for the event
mcp_tools_dict = []
for tool in filtered_mcp_tools:
if hasattr(tool, "model_dump") and callable(getattr(tool, "model_dump")):
if hasattr(tool, "model_dump") and callable(tool.model_dump):
# Type cast to help mypy understand this is safe after hasattr check
mcp_tools_dict.append(cast(Any, tool).model_dump())
elif hasattr(tool, "__dict__"):
@ -103,9 +103,9 @@ async def create_mcp_list_tools_events(
# Add input_schema if available
if hasattr(tool, "inputSchema"):
tool_dict["input_schema"] = getattr(tool, "inputSchema")
tool_dict["input_schema"] = tool.inputSchema
elif hasattr(tool, "input_schema"):
tool_dict["input_schema"] = getattr(tool, "input_schema")
tool_dict["input_schema"] = tool.input_schema
formatted_tools.append(tool_dict)

File diff suppressed because it is too large Load diff

View file

@ -1,30 +1,30 @@
{
"ANN001": {
"limit": 3104
"limit": 3080
},
"ANN002": {
"limit": 69
},
"ANN003": {
"limit": 831
"limit": 829
},
"ANN201": {
"limit": 2137
"limit": 2119
},
"ANN202": {
"limit": 941
"limit": 939
},
"ANN204": {
"limit": 724
"limit": 723
},
"ANN205": {
"limit": 127
"limit": 126
},
"ANN206": {
"limit": 130
},
"ANN401": {
"limit": 1851
"limit": 1726
},
"ASYNC230": {
"limit": 14
@ -42,7 +42,7 @@
"limit": 84
},
"B010": {
"limit": 194
"limit": 192
},
"B018": {
"limit": 5
@ -72,7 +72,7 @@
"limit": 22
},
"C408": {
"limit": 14
"limit": 13
},
"C414": {
"limit": 7
@ -81,7 +81,7 @@
"limit": 4
},
"C901": {
"limit": 312
"limit": 311
},
"D419": {
"limit": 9
@ -255,16 +255,16 @@
"limit": 480
},
"S110": {
"limit": 236
"limit": 235
},
"S112": {
"limit": 24
},
"SIM101": {
"limit": 61
"limit": 59
},
"SIM102": {
"limit": 324
"limit": 323
},
"SIM103": {
"limit": 129
@ -321,7 +321,7 @@
"limit": 121
},
"TRY300": {
"limit": 879
"limit": 878
},
"UP006": {
"limit": 0

View file

@ -2649,7 +2649,7 @@ class _TxProxyModelTable:
class _TxPrismaClient:
"""Minimal prisma stub whose ``db.tx()`` yields a transaction and records commit."""
"""Minimal prisma stub whose ``tx()`` yields a transaction and records commit."""
def __init__(self, rows):
self.events: list = []
@ -2668,6 +2668,7 @@ class _TxPrismaClient:
self.db = MagicMock()
self.db.tx = MagicMock(return_value=_TxCM())
self.tx = self.db.tx
class _RecordingRouter:

View file

@ -1,9 +1,9 @@
{
"LIT001": {
"limit": 23350
"limit": 23263
},
"LIT002": {
"limit": 27256
"limit": 27216
},
"LIT003": {
"limit": 292
@ -15,7 +15,7 @@
"limit": 0
},
"LIT006": {
"limit": 1105
"limit": 1085
},
"LIT007": {
"limit": 0
@ -24,6 +24,6 @@
"limit": 1004
},
"LIT009": {
"limit": 2465
"limit": 2426
}
}