diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json
index 19c28716025..e3bac754074 100644
--- a/basedpyright-code-budget.json
+++ b/basedpyright-code-budget.json
@@ -1,18 +1,18 @@
{
"reportAny": {
- "limit": 19949
+ "limit": 18505
},
"reportArgumentType": {
- "limit": 2566
+ "limit": 2564
},
"reportAssignmentType": {
"limit": 320
},
"reportAttributeAccessIssue": {
- "limit": 488
+ "limit": 483
},
"reportCallIssue": {
- "limit": 114
+ "limit": 113
},
"reportConstantRedefinition": {
"limit": 40
@@ -24,7 +24,7 @@
"limit": 19
},
"reportExplicitAny": {
- "limit": 6049
+ "limit": 5976
},
"reportFunctionMemberAccess": {
"limit": 7
@@ -45,7 +45,7 @@
"limit": 35
},
"reportInvalidTypeForm": {
- "limit": 35
+ "limit": 34
},
"reportInvalidTypeVarUse": {
"limit": 2
@@ -54,10 +54,10 @@
"limit": 0
},
"reportMissingParameterType": {
- "limit": 5661
+ "limit": 5659
},
"reportMissingTypeArgument": {
- "limit": 15555
+ "limit": 15504
},
"reportMissingTypeStubs": {
"limit": 40
@@ -72,7 +72,7 @@
"limit": 0
},
"reportOptionalMemberAccess": {
- "limit": 1061
+ "limit": 1058
},
"reportOptionalOperand": {
"limit": 0
@@ -99,31 +99,31 @@
"limit": 0
},
"reportUnknownArgumentType": {
- "limit": 44655
+ "limit": 44530
},
"reportUnknownLambdaType": {
"limit": 109
},
"reportUnknownMemberType": {
- "limit": 39009
+ "limit": 38828
},
"reportUnknownParameterType": {
- "limit": 19883
+ "limit": 19847
},
"reportUnknownVariableType": {
- "limit": 30569
+ "limit": 30386
},
"reportUnnecessaryCast": {
"limit": 117
},
"reportUnnecessaryComparison": {
- "limit": 699
+ "limit": 697
},
"reportUnnecessaryContains": {
"limit": 5
},
"reportUnnecessaryIsInstance": {
- "limit": 836
+ "limit": 833
},
"reportUntypedBaseClass": {
"limit": 0
diff --git a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
index 76e92538aaa..aee3295d1da 100644
--- a/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
+++ b/enterprise/litellm_enterprise/proxy/common_utils/check_batch_cost.py
@@ -14,6 +14,8 @@ from litellm.constants import (
)
if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy._types import LiteLLM_ManagedObjectTable
from litellm.proxy.utils import PrismaClient, ProxyLogging
@@ -351,7 +353,7 @@ class CheckBatchCost:
return isinstance(error, (NotFoundError, openai.NotFoundError)) and output_file_id in str(error)
async def _finalize_unbilled_terminal_job(
- self, job: "LiteLLM_ManagedObjectTable", response: "LiteLLMBatch"
+ self, job: "prisma_models.LiteLLM_ManagedObjectTable", response: "LiteLLMBatch"
) -> None:
"""Persist a terminal batch that has nothing billable, converting any raw
provider file ids to managed ids, and take it out of the poll page."""
diff --git a/litellm/constants.py b/litellm/constants.py
index 765bbfe1e54..23e92d26a59 100644
--- a/litellm/constants.py
+++ b/litellm/constants.py
@@ -49,6 +49,8 @@ LITELLM_MAX_STREAMING_DURATION_SECONDS: Final = (
# Set to 0 to disable truncation.
MAX_BASE64_LENGTH_FOR_LOGGING: Final = int(os.getenv("MAX_BASE64_LENGTH_FOR_LOGGING", 64))
REDACTED_BY_LITELLM: Final = "redacted-by-litellm"
+# in-memory stand-in handed to provider converters for redacted arguments; never stored
+REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER: Final = "{}"
MAX_STRING_LENGTH_STDOUT_LOG: Final = get_env_int("MAX_STRING_LENGTH_STDOUT_LOG", 4096)
@@ -462,6 +464,8 @@ CONNECTION_ERROR_PATTERNS: Final[list[str]] = [
]
STREAM_SSE_DONE_STRING: Final[str] = "[DONE]"
STREAM_SSE_DATA_PREFIX: Final[str] = "data: "
+STREAM_SSE_KEEPALIVE_PING_CHUNK: Final[str] = 'event: ping\ndata: {"type": "ping"}\n\n'
+STREAM_SSE_KEEPALIVE_PING_BYTES: Final[bytes] = STREAM_SSE_KEEPALIVE_PING_CHUNK.encode("utf-8")
### SPEND TRACKING ###
DEFAULT_REPLICATE_GPU_PRICE_PER_SECOND: Final = float(
os.getenv("DEFAULT_REPLICATE_GPU_PRICE_PER_SECOND", 0.001400)
diff --git a/litellm/integrations/compression_interception/handler.py b/litellm/integrations/compression_interception/handler.py
index 7ea60053e6f..76720682101 100644
--- a/litellm/integrations/compression_interception/handler.py
+++ b/litellm/integrations/compression_interception/handler.py
@@ -7,7 +7,7 @@ litellm_content_retrieve tool calls server-side via the typed agentic loop plan.
import time
import uuid
-from typing import Any, Final, cast
+from typing import Any, ClassVar, Final, cast
from litellm._logging import verbose_logger
from litellm.compression import compress
@@ -72,6 +72,8 @@ class CompressionInterceptionLogger(CustomLogger):
4. Build typed rerun plan with tool_result blocks from the compressed cache.
"""
+ server_fulfilled_tool_names: ClassVar[frozenset[str]] = frozenset({LITELLM_CONTENT_RETRIEVE_TOOL_NAME})
+
def __init__(
self,
enabled: bool = True,
diff --git a/litellm/integrations/custom_logger.py b/litellm/integrations/custom_logger.py
index 195eb85c07d..0339be106ea 100644
--- a/litellm/integrations/custom_logger.py
+++ b/litellm/integrations/custom_logger.py
@@ -3,7 +3,7 @@
import re
import traceback
from collections.abc import AsyncGenerator
-from typing import TYPE_CHECKING, Any, Final, Optional
+from typing import TYPE_CHECKING, Any, ClassVar, Final, Optional
from pydantic import BaseModel
@@ -60,6 +60,7 @@ _BASE64_INLINE_PATTERN: Final = re.compile(
class CustomLogger: # https://docs.litellm.ai/docs/observability/custom_callback#callback-class
# Class variables or attributes
+ server_fulfilled_tool_names: ClassVar[frozenset[str]] = frozenset()
enforces_request_content: bool = False
"""
diff --git a/litellm/integrations/langfuse/langfuse_otel.py b/litellm/integrations/langfuse/langfuse_otel.py
index a93c45ef840..3d044c3ea15 100644
--- a/litellm/integrations/langfuse/langfuse_otel.py
+++ b/litellm/integrations/langfuse/langfuse_otel.py
@@ -10,6 +10,7 @@ from litellm.integrations.langfuse.langfuse_otel_attributes import (
LangfuseLLMObsOTELAttributes,
)
from litellm.integrations.opentelemetry import OpenTelemetry, OpenTelemetryConfig
+from litellm.litellm_core_utils.safe_json_loads import safe_json_loads
from litellm.types.integrations.langfuse_otel import (
LangfuseSpanAttributes,
)
@@ -197,7 +198,11 @@ class LangfuseOtelLogger(OpenTelemetry):
)
elif item_type == "function_call":
arguments_str = getattr(item, "arguments", "{}")
- arguments_obj = json.loads(arguments_str) if isinstance(arguments_str, str) else arguments_str
+ arguments_obj = (
+ safe_json_loads(arguments_str, default={})
+ if isinstance(arguments_str, str)
+ else arguments_str
+ )
langfuse_tool_call = {
"id": getattr(item, "id", ""),
"name": getattr(item, "name", ""),
diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py
index f9195db1d67..beda1a29075 100644
--- a/litellm/integrations/prometheus.py
+++ b/litellm/integrations/prometheus.py
@@ -96,7 +96,10 @@ class _PaginatedPrismaTable(Protocol[_TableRowT]):
def _paginated_table(repository: BaseRepository[_TableRowT]) -> _PaginatedPrismaTable[_TableRowT]:
"""View a repository's prisma table through the pagination surface budget metrics need."""
- return repository.table
+ return cast(
+ _PaginatedPrismaTable[_TableRowT],
+ repository.table, # cast-ok: prisma rows carry the budget columns the domain model declares
+ )
class _OrgBudgetRow(Protocol):
diff --git a/litellm/litellm_core_utils/redact_messages.py b/litellm/litellm_core_utils/redact_messages.py
index be187b091b8..064228bf8d7 100644
--- a/litellm/litellm_core_utils/redact_messages.py
+++ b/litellm/litellm_core_utils/redact_messages.py
@@ -97,16 +97,18 @@ def _redact_function_call(function_call) -> None:
def _redact_choice_content(choice):
"""Helper to redact content in a choice (message or delta)."""
if isinstance(choice, litellm.Choices):
- choice.message.content = REDACTED_BY_LITELLM
- if hasattr(choice.message, "reasoning_content"):
+ if choice.message.content is not None:
+ choice.message.content = REDACTED_BY_LITELLM
+ if getattr(choice.message, "reasoning_content", None) is not None:
choice.message.reasoning_content = REDACTED_BY_LITELLM
if hasattr(choice.message, "thinking_blocks"):
choice.message.thinking_blocks = None
_redact_tool_calls(getattr(choice.message, "tool_calls", None))
_redact_function_call(getattr(choice.message, "function_call", None))
elif isinstance(choice, litellm.utils.StreamingChoices):
- choice.delta.content = REDACTED_BY_LITELLM
- if hasattr(choice.delta, "reasoning_content"):
+ if choice.delta.content is not None:
+ choice.delta.content = REDACTED_BY_LITELLM
+ if getattr(choice.delta, "reasoning_content", None) is not None:
choice.delta.reasoning_content = REDACTED_BY_LITELLM
if hasattr(choice.delta, "thinking_blocks"):
choice.delta.thinking_blocks = None
@@ -117,19 +119,19 @@ def _redact_choice_content(choice):
def _redact_responses_api_output(output_items):
"""Helper to redact ResponsesAPIResponse output items."""
for output_item in output_items:
- if hasattr(output_item, "text"):
+ if getattr(output_item, "text", None) is not None:
output_item.text = REDACTED_BY_LITELLM
if hasattr(output_item, "content") and isinstance(output_item.content, list):
for content_part in output_item.content:
- if hasattr(content_part, "text"):
+ if getattr(content_part, "text", None) is not None:
content_part.text = REDACTED_BY_LITELLM
# Redact reasoning items in output array
if hasattr(output_item, "type") and output_item.type == "reasoning":
if hasattr(output_item, "summary") and isinstance(output_item.summary, list):
for summary_item in output_item.summary:
- if hasattr(summary_item, "text"):
+ if getattr(summary_item, "text", None) is not None:
summary_item.text = REDACTED_BY_LITELLM
if hasattr(output_item, "type") and output_item.type == "function_call" and hasattr(output_item, "arguments"):
@@ -142,17 +144,17 @@ def _redact_responses_api_output_dict(output_items, redacted_str: str):
if not isinstance(output_item, dict):
continue
- if "text" in output_item:
+ if output_item.get("text") is not None:
output_item["text"] = redacted_str
if isinstance(output_item.get("content"), list):
for content_item in output_item["content"]:
- if isinstance(content_item, dict) and "text" in content_item:
+ if isinstance(content_item, dict) and content_item.get("text") is not None:
content_item["text"] = redacted_str
if output_item.get("type") == "reasoning" and isinstance(output_item.get("summary"), list):
for summary_item in output_item["summary"]:
- if isinstance(summary_item, dict) and "text" in summary_item:
+ if isinstance(summary_item, dict) and summary_item.get("text") is not None:
summary_item["text"] = redacted_str
if output_item.get("type") == "function_call" and "arguments" in output_item:
@@ -189,40 +191,42 @@ def _redact_standard_logging_object(model_call_details: dict):
standard_logging_object["response"] = {"text": redacted_str}
-def _redact_tool_calls_dict(message: dict, redacted_str: str) -> None:
+def _redact_tool_calls_dict(message: dict) -> None:
"""Redact tool call / function_call arguments in a dict-form message or delta."""
tool_calls: Final = message.get("tool_calls")
if isinstance(tool_calls, list):
for tool_call in tool_calls:
if isinstance(tool_call, dict) and isinstance(tool_call.get("function"), dict):
- tool_call["function"]["arguments"] = redacted_str
+ tool_call["function"]["arguments"] = REDACTED_BY_LITELLM
function_call: Final = message.get("function_call")
if isinstance(function_call, dict) and "arguments" in function_call:
- function_call["arguments"] = redacted_str
+ function_call["arguments"] = REDACTED_BY_LITELLM
def _redact_model_response_dict_choices(choices, redacted_str: str):
for choice in choices:
if isinstance(choice, dict):
if "message" in choice and isinstance(choice["message"], dict):
- choice["message"]["content"] = redacted_str
- if "reasoning_content" in choice["message"]:
+ if choice["message"].get("content") is not None:
+ choice["message"]["content"] = redacted_str
+ if choice["message"].get("reasoning_content") is not None:
choice["message"]["reasoning_content"] = redacted_str
if "thinking_blocks" in choice["message"]:
choice["message"]["thinking_blocks"] = None
if "audio" in choice["message"]:
choice["message"]["audio"] = None
- _redact_tool_calls_dict(choice["message"], redacted_str)
+ _redact_tool_calls_dict(choice["message"])
elif "delta" in choice and isinstance(choice["delta"], dict):
- choice["delta"]["content"] = redacted_str
- if "reasoning_content" in choice["delta"]:
+ if choice["delta"].get("content") is not None:
+ choice["delta"]["content"] = redacted_str
+ if choice["delta"].get("reasoning_content") is not None:
choice["delta"]["reasoning_content"] = redacted_str
if "thinking_blocks" in choice["delta"]:
choice["delta"]["thinking_blocks"] = None
if "audio" in choice["delta"]:
choice["delta"]["audio"] = None
- _redact_tool_calls_dict(choice["delta"], redacted_str)
+ _redact_tool_calls_dict(choice["delta"])
else:
_redact_choice_content(choice)
@@ -263,7 +267,7 @@ def perform_redaction(model_call_details: dict, result, redact_streaming_respons
isinstance(result, (litellm.ModelResponse, litellm.ResponsesAPIResponse, litellm.EmbeddingResponse))
or (isinstance(result, dict) and ("choices" in result or "output" in result))
):
- return {"text": "redacted-by-litellm"}
+ return {"text": REDACTED_BY_LITELLM}
_result: Final = copy.deepcopy(result)
if isinstance(_result, litellm.ModelResponse):
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py
index 5c4fa4700c0..55a85c011d0 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/agentic_streaming_iterator.py
@@ -6,13 +6,41 @@ yields every chunk to the caller (preserving real streaming), collects
all bytes, and on stream exhaustion rebuilds the full Anthropic response
to run through agentic completion hooks. If an agentic hook fires, the
follow-up response is chained as Phase 2 of the same iterator.
+
+In hold-back mode (``hold_back=True``) chunks are buffered instead of yielded
+live, keepalive pings run whenever no other byte is ready, and then either the
+follow-up replaces the message or the buffer replays, except that a tool_use for
+a server-fulfilled tool fails the turn rather than reaching a client that cannot
+execute it.
"""
+import asyncio
+import contextlib
import json
from collections.abc import AsyncIterator
from typing import Any, Final, cast
from litellm._logging import verbose_logger
+from litellm.constants import STREAM_SSE_KEEPALIVE_PING_BYTES
+
+HOLD_BACK_PING_INTERVAL_SECONDS: Final = 15.0
+SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES: Final = (
+ b"event: error\n"
+ b'data: {"type": "error", "error": {"type": "api_error", "message": '
+ b'"Server-side tool retrieval failed, so this turn could not be completed. Please retry."}}\n\n'
+)
+
+
+def is_server_fulfilled_tool_leak_error(chunk: object) -> bool:
+ return chunk == SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES
+
+
+async def _anext_or_none(iterator: AsyncIterator) -> bytes | None:
+ try:
+ return await iterator.__anext__()
+ except StopAsyncIteration:
+ return None
+
# ---------------------------------------------------------------------------
# SSE parsing helpers (module-level to keep the class lean)
@@ -156,6 +184,9 @@ class AgenticAnthropicStreamingIterator:
logging_obj: Any,
custom_llm_provider: str,
kwargs: dict,
+ hold_back: bool = False,
+ server_fulfilled_tool_names: frozenset[str] = frozenset(),
+ ping_interval_seconds: float = HOLD_BACK_PING_INTERVAL_SECONDS,
):
self._inner = completion_stream.__aiter__()
self._http_handler = http_handler
@@ -166,16 +197,32 @@ class AgenticAnthropicStreamingIterator:
self._logging_obj = logging_obj
self._custom_llm_provider = custom_llm_provider
self._kwargs = kwargs
+ self._hold_back = hold_back
+ self._server_fulfilled_tool_names = server_fulfilled_tool_names
+ self._ping_interval_seconds = ping_interval_seconds
self._collected_bytes: list[bytes] = []
self._stream_exhausted = False
self._hook_processing_done = False
self._follow_up_iterator: AsyncIterator | None = None
+ self._drain_task: asyncio.Task | None = None
+ self._hook_task: asyncio.Task | None = None
+ self._follow_up_chunk_task: asyncio.Task | None = None
+ self._replay_index = 0
+ self._error_emitted = False
+
+ @property
+ def has_buffered_provider_output(self) -> bool:
+ """Whether provider output was received but withheld from the client behind keepalive pings."""
+ return self._hold_back and bool(self._collected_bytes)
def __aiter__(self):
return self
async def __anext__(self) -> bytes:
+ if self._hold_back:
+ return await self._anext_held_back()
+
# Phase 1: yield from upstream, collect bytes
if not self._stream_exhausted:
try:
@@ -194,11 +241,102 @@ class AgenticAnthropicStreamingIterator:
raise StopAsyncIteration
+ async def _drain_upstream(self) -> None:
+ try:
+ while True:
+ self._collected_bytes.append(await self._inner.__anext__())
+ except StopAsyncIteration:
+ return
+
+ async def _completed_within_ping_interval(self, task: asyncio.Task) -> bool:
+ try:
+ await asyncio.wait_for(asyncio.shield(task), timeout=self._ping_interval_seconds)
+ except asyncio.TimeoutError:
+ return False
+ return True
+
+ async def _anext_held_back(self) -> bytes:
+ if self._drain_task is None:
+ self._drain_task = asyncio.create_task(self._drain_upstream())
+ return STREAM_SSE_KEEPALIVE_PING_BYTES
+
+ if not self._stream_exhausted:
+ if not await self._completed_within_ping_interval(self._drain_task):
+ return STREAM_SSE_KEEPALIVE_PING_BYTES
+ self._stream_exhausted = True
+
+ if self._hook_task is None:
+ self._hook_task = asyncio.create_task(self._process_agentic_hooks())
+ if not await self._completed_within_ping_interval(self._hook_task):
+ return STREAM_SSE_KEEPALIVE_PING_BYTES
+
+ if self._follow_up_iterator is not None:
+ return await self._next_follow_up_chunk(self._follow_up_iterator)
+
+ if self._buffer_holds_server_fulfilled_tool_use():
+ if self._error_emitted:
+ raise StopAsyncIteration
+ self._error_emitted = True
+ verbose_logger.error(
+ "AgenticStreamingIterator: hooks did not replace a message containing a server-fulfilled "
+ "tool_use [model=%s]; emitting an SSE error instead of leaking the tool call to the client",
+ self._model,
+ )
+ return SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES
+
+ if self._replay_index < len(self._collected_bytes):
+ chunk: Final = self._collected_bytes[self._replay_index]
+ self._replay_index += 1
+ return chunk
+
+ raise StopAsyncIteration
+
+ async def _next_follow_up_chunk(self, follow_up_iterator: AsyncIterator) -> bytes:
+ if self._follow_up_chunk_task is None:
+ self._follow_up_chunk_task = asyncio.create_task(_anext_or_none(follow_up_iterator))
+ if not await self._completed_within_ping_interval(self._follow_up_chunk_task):
+ return STREAM_SSE_KEEPALIVE_PING_BYTES
+ chunk: Final = self._follow_up_chunk_task.result()
+ self._follow_up_chunk_task = None
+ if chunk is None:
+ raise StopAsyncIteration
+ return chunk
+
+ def _buffer_holds_server_fulfilled_tool_use(self) -> bool:
+ if not self._server_fulfilled_tool_names:
+ return False
+ started_blocks: Final = (
+ data.get("content_block")
+ for event_type, data in _parse_sse_events(b"".join(self._collected_bytes))
+ if event_type == "content_block_start"
+ )
+ return any(
+ isinstance(block, dict)
+ and block.get("type") == "tool_use"
+ and block.get("name") in self._server_fulfilled_tool_names
+ for block in started_blocks
+ )
+
+ @staticmethod
+ async def _settle_task(task: asyncio.Task | None) -> None:
+ if task is None:
+ return
+ if task.done():
+ if not task.cancelled():
+ task.exception()
+ return
+ task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await task
+
async def aclose(self) -> None:
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
aclose_if_supported,
)
+ await self._settle_task(self._drain_task)
+ await self._settle_task(self._hook_task)
+ await self._settle_task(self._follow_up_chunk_task)
await aclose_if_supported(self._inner)
await aclose_if_supported(self._follow_up_iterator)
@@ -217,11 +355,6 @@ class AgenticAnthropicStreamingIterator:
verbose_logger.debug("AgenticStreamingIterator: Could not rebuild response from SSE bytes")
return
- [
- (f"{b.get('type')}({b.get('name', '')})" if b.get("type") == "tool_use" else b.get("type"))
- for b in rebuilt.get("content", [])
- ]
-
result: Final = await self._http_handler._call_agentic_completion_hooks(
response=rebuilt,
model=self._model,
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/response_cache.py b/litellm/llms/anthropic/experimental_pass_through/messages/response_cache.py
index 9ac5187681b..86dfe8ff451 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/response_cache.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/response_cache.py
@@ -46,6 +46,10 @@ class AnthropicMessagesStreamCacheWriter:
stream._hidden_params if isinstance(stream, AnthropicMessagesStreamingResponse) else _EMPTY_MAPPING
)
+ @property
+ def has_buffered_provider_output(self) -> bool:
+ return getattr(self.stream, "has_buffered_provider_output", False) is True
+
def __aiter__(self) -> "AnthropicMessagesStreamCacheWriter":
return self
diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py
index 0a12bc3135f..8387dd8310d 100644
--- a/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py
+++ b/litellm/llms/anthropic/experimental_pass_through/messages/streaming_iterator.py
@@ -312,6 +312,10 @@ class AnthropicMessagesStreamingResponse:
self.completion_stream = completion_stream
self._hidden_params = hidden_params
+ @property
+ def has_buffered_provider_output(self) -> bool:
+ return getattr(self.completion_stream, "has_buffered_provider_output", False) is True
+
def __aiter__(self) -> "AnthropicMessagesStreamingResponse":
return self
diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py
index bb582f677be..e3193e8e2b0 100644
--- a/litellm/llms/custom_httpx/llm_http_handler.py
+++ b/litellm/llms/custom_httpx/llm_http_handler.py
@@ -2268,6 +2268,10 @@ class BaseLLMHTTPHandler:
AgenticAnthropicStreamingIterator,
)
+ held_back_tool_names: Final = self._server_fulfilled_tools_in_request(
+ logging_obj=logging_obj,
+ tools=anthropic_messages_optional_request_params.get("tools"),
+ )
initial_response = AgenticAnthropicStreamingIterator(
completion_stream=completion_stream,
http_handler=self,
@@ -2278,6 +2282,8 @@ class BaseLLMHTTPHandler:
logging_obj=logging_obj,
custom_llm_provider=custom_llm_provider,
kwargs={**kwargs, "api_key": api_key} if api_key else kwargs,
+ hold_back=bool(held_back_tool_names),
+ server_fulfilled_tool_names=held_back_tool_names,
)
return AnthropicMessagesStreamingResponse(
completion_stream=initial_response,
@@ -5125,6 +5131,20 @@ class BaseLLMHTTPHandler:
return True
return False
+ @staticmethod
+ def _server_fulfilled_tools_in_request(logging_obj: LiteLLMLoggingObj, tools: object) -> frozenset[str]:
+ """The request's tools that a registered callback fulfills server-side (e.g. ``headroom_retrieve``)."""
+ if not isinstance(tools, list) or not tools:
+ return frozenset()
+ from litellm.litellm_core_utils.prompt_templates.factory import has_tool_with_name
+
+ return frozenset(
+ name
+ for cb in _custom_logger_callbacks(logging_obj)
+ for name in getattr(cb, "server_fulfilled_tool_names", frozenset())
+ if has_tool_with_name(tools, name)
+ )
+
@staticmethod
def _check_agentic_loop_safety(
tool_calls: object,
diff --git a/litellm/models/team.py b/litellm/models/team.py
index 544e2cf5bbc..da526515e6e 100644
--- a/litellm/models/team.py
+++ b/litellm/models/team.py
@@ -64,8 +64,8 @@ class TeamBase(LiteLLMPydanticObjectBase):
team_alias: str | None = None
team_id: str | None = None
organization_id: str | None = None
- admins: list = []
- members: list = []
+ admins: list[str] = []
+ members: list[str] = []
members_with_roles: list[Member] = []
team_member_permissions: list[str] | None = None
metadata: dict | None = None
@@ -75,7 +75,7 @@ class TeamBase(LiteLLMPydanticObjectBase):
soft_budget: float | None = None
budget_duration: str | None = None
budget_limits: list[BudgetLimitEntry] | None = None
- models: list = []
+ models: list[str] = []
blocked: bool = False
router_settings: dict | None = None
access_group_ids: list[str] | None = None
diff --git a/litellm/proxy/_experimental/mcp_server/db.py b/litellm/proxy/_experimental/mcp_server/db.py
index 28638ed9c77..4aa08020527 100644
--- a/litellm/proxy/_experimental/mcp_server/db.py
+++ b/litellm/proxy/_experimental/mcp_server/db.py
@@ -4,7 +4,7 @@ import hashlib
import json
from collections.abc import Awaitable, Callable, Iterable, Mapping, Sequence
from datetime import datetime, timedelta, timezone
-from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict, TypeVar, cast
+from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict, cast
from litellm._logging import verbose_proxy_logger
from litellm._uuid import uuid
@@ -13,7 +13,6 @@ from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.proxy._experimental.mcp_server.oauth_utils import build_upstream_oauth2_token_request
from litellm.proxy._types import (
LiteLLM_MCPServerTable,
- LiteLLM_ObjectPermissionTable,
MCPApprovalStatus,
MCPEnvVar,
MCPEnvVarScope,
@@ -30,6 +29,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
)
from litellm.proxy.utils import PrismaClient
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
MCPServerOAuthClientRepository,
MCPServerRepository,
@@ -48,34 +48,9 @@ if TYPE_CHECKING:
from litellm.types.mcp_server.mcp_server_manager import MCPServer
-_RowT = TypeVar("_RowT")
-
-
-class _TableActions(Protocol[_RowT]):
- async def find_unique(
- self, where: Mapping[str, object], include: Mapping[str, object] | None = None
- ) -> _RowT | None: ...
-
- async def find_many(
- self,
- take: int | None = None,
- where: Mapping[str, object] | None = None,
- order: Mapping[str, object] | None = None,
- ) -> list[_RowT]: ...
-
- async def create(self, data: Mapping[str, object]) -> _RowT: ...
-
- async def upsert(self, where: Mapping[str, object], data: Mapping[str, object]) -> _RowT: ...
-
- async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> _RowT | None: ...
-
- async def delete(self, where: Mapping[str, object]) -> _RowT | None: ...
-
- async def delete_many(self, where: Mapping[str, object] | None = None) -> int: ...
-
class _UserEnvVarsTransactionClient(Protocol):
- litellm_mcpuserenvvars: "_TableActions[prisma_db_models.LiteLLM_MCPUserEnvVars]"
+ litellm_mcpuserenvvars: "TableActions[prisma_db_models.LiteLLM_MCPUserEnvVars]"
async def execute_raw(self, query: str, *args: object) -> int: ...
@@ -473,15 +448,15 @@ def _credentials_blob_to_mutable_dict(blob: str | Mapping[str, object]) -> dict[
def _mcp_server_table_actions(
prisma_client: PrismaClient,
-) -> "_TableActions[prisma_db_models.LiteLLM_MCPServerTable]":
- table: Final[_TableActions[prisma_db_models.LiteLLM_MCPServerTable]] = MCPServerRepository(prisma_client).table
+) -> "TableActions[prisma_db_models.LiteLLM_MCPServerTable]":
+ table: Final[TableActions[prisma_db_models.LiteLLM_MCPServerTable]] = MCPServerRepository(prisma_client).table
return table
def _verification_token_table_actions(
prisma_client: PrismaClient,
-) -> "_TableActions[prisma_db_models.LiteLLM_VerificationToken]":
- table: Final[_TableActions[prisma_db_models.LiteLLM_VerificationToken]] = VerificationTokenRepository(
+) -> "TableActions[prisma_db_models.LiteLLM_VerificationToken]":
+ table: Final[TableActions[prisma_db_models.LiteLLM_VerificationToken]] = VerificationTokenRepository(
prisma_client
).table
return table
@@ -489,15 +464,15 @@ def _verification_token_table_actions(
def _team_table_actions(
prisma_client: PrismaClient,
-) -> "_TableActions[prisma_db_models.LiteLLM_TeamTable]":
- table: Final[_TableActions[prisma_db_models.LiteLLM_TeamTable]] = TeamRepository(prisma_client).table
+) -> "TableActions[prisma_db_models.LiteLLM_TeamTable]":
+ table: Final[TableActions[prisma_db_models.LiteLLM_TeamTable]] = TeamRepository(prisma_client).table
return table
def _oauth_client_table_actions(
prisma_client: PrismaClient,
-) -> "_TableActions[prisma_db_models.LiteLLM_MCPServerOAuthClient]":
- table: Final[_TableActions[prisma_db_models.LiteLLM_MCPServerOAuthClient]] = MCPServerOAuthClientRepository(
+) -> "TableActions[prisma_db_models.LiteLLM_MCPServerOAuthClient]":
+ table: Final[TableActions[prisma_db_models.LiteLLM_MCPServerOAuthClient]] = MCPServerOAuthClientRepository(
prisma_client
).table
return table
@@ -511,7 +486,7 @@ def _db_transaction_manager(prisma_client: PrismaClient) -> _UserEnvVarsTransact
async def _db_find_mcp_server_rows(
prisma_client: PrismaClient,
where: "prisma_db_types.LiteLLM_MCPServerTableWhereInput | None" = None,
-) -> "list[prisma_db_models.LiteLLM_MCPServerTable]":
+) -> "Sequence[prisma_db_models.LiteLLM_MCPServerTable]":
return await _mcp_server_table_actions(prisma_client).find_many(where=where)
@@ -526,17 +501,19 @@ async def _db_update_mcp_server_row(
server_id: str,
data: "prisma_db_types.LiteLLM_MCPServerTableUpdateInput",
) -> "prisma_db_models.LiteLLM_MCPServerTable":
- row: Final[prisma_db_models.LiteLLM_MCPServerTable] = await MCPServerRepository(prisma_client).table.update(
+ row: Final[prisma_db_models.LiteLLM_MCPServerTable | None] = await _mcp_server_table_actions(prisma_client).update(
where={"server_id": server_id},
data=data,
)
+ if row is None:
+ raise ValueError(f"MCP server not found, passed server_id={server_id}")
return row
def _user_credential_actions(
prisma_client: PrismaClient,
-) -> "_TableActions[prisma_db_models.LiteLLM_MCPUserCredentials]":
- table: Final[_TableActions[prisma_db_models.LiteLLM_MCPUserCredentials]] = MCPUserCredentialsRepository(
+) -> "TableActions[prisma_db_models.LiteLLM_MCPUserCredentials]":
+ table: Final[TableActions[prisma_db_models.LiteLLM_MCPUserCredentials]] = MCPUserCredentialsRepository(
prisma_client
).table
return table
@@ -544,8 +521,8 @@ def _user_credential_actions(
def _user_env_var_actions(
prisma_client: PrismaClient,
-) -> "_TableActions[prisma_db_models.LiteLLM_MCPUserEnvVars]":
- table: Final[_TableActions[prisma_db_models.LiteLLM_MCPUserEnvVars]] = prisma_client.db.litellm_mcpuserenvvars
+) -> "TableActions[prisma_db_models.LiteLLM_MCPUserEnvVars]":
+ table: Final[TableActions[prisma_db_models.LiteLLM_MCPUserEnvVars]] = prisma_client.db.litellm_mcpuserenvvars
return table
@@ -560,7 +537,7 @@ async def _db_find_user_credential_row(
async def _db_find_user_credential_rows(
prisma_client: PrismaClient,
where: "prisma_db_types.LiteLLM_MCPUserCredentialsWhereInput | None" = None,
-) -> "list[prisma_db_models.LiteLLM_MCPUserCredentials]":
+) -> "Sequence[prisma_db_models.LiteLLM_MCPUserCredentials]":
return await _user_credential_actions(prisma_client).find_many(where=where)
@@ -583,7 +560,7 @@ async def _db_upsert_user_credential_row(
async def _db_find_user_env_var_rows(
prisma_client: PrismaClient,
where: "prisma_db_types.LiteLLM_MCPUserEnvVarsWhereInput | None" = None,
-) -> "list[prisma_db_models.LiteLLM_MCPUserEnvVars]":
+) -> "Sequence[prisma_db_models.LiteLLM_MCPUserEnvVars]":
return await _user_env_var_actions(prisma_client).find_many(where=where)
@@ -658,7 +635,7 @@ async def get_mcp_servers(prisma_client: PrismaClient, server_ids: Iterable[str]
"""
Returns the matching mcp servers from the db with the server_ids
"""
- _mcp_servers: Final[list[prisma_db_models.LiteLLM_MCPServerTable]] = await _mcp_server_table_actions(
+ _mcp_servers: Final[Sequence[prisma_db_models.LiteLLM_MCPServerTable]] = await _mcp_server_table_actions(
prisma_client
).find_many(
where={
@@ -745,13 +722,13 @@ async def get_all_mcp_servers_for_user(
async def get_objectpermissions_for_mcp_server(
prisma_client: PrismaClient, mcp_server_id: str
-) -> list[LiteLLM_ObjectPermissionTable]:
+) -> "Sequence[prisma_db_models.LiteLLM_ObjectPermissionTable]":
"""
Get all the object permissions records and the associated team and verficiationtoken records that have access to the mcp server
"""
- object_permission_records: Final[list[LiteLLM_ObjectPermissionTable]] = await ObjectPermissionRepository(
- prisma_client
- ).table.find_many(
+ object_permission_records: Final[
+ Sequence[prisma_db_models.LiteLLM_ObjectPermissionTable]
+ ] = await ObjectPermissionRepository(prisma_client).table.find_many(
where={
"mcp_servers": {"has": mcp_server_id},
},
@@ -766,19 +743,19 @@ async def get_objectpermissions_for_mcp_server(
async def get_virtualkeys_for_mcp_server(
prisma_client: PrismaClient, server_id: str
-) -> "list[prisma_db_models.LiteLLM_VerificationToken]":
+) -> "Sequence[prisma_db_models.LiteLLM_VerificationToken]":
"""
Get all the virtual keys that have access to the mcp server
"""
- virtual_keys: Final[list[prisma_db_models.LiteLLM_VerificationToken] | None] = await VerificationTokenRepository(
- prisma_client
- ).table.find_many(
+ virtual_keys: Final[
+ Sequence[prisma_db_models.LiteLLM_VerificationToken] | None
+ ] = await VerificationTokenRepository(prisma_client).table.find_many(
where={
"mcp_servers": {"has": server_id},
},
)
- if virtual_keys is None:
+ if virtual_keys is None: # pyright: ignore[reportUnnecessaryComparison] # unreachable per seam types; kept as-is
return []
return virtual_keys
@@ -860,7 +837,7 @@ async def delete_mcp_server(
invalidate_token_cache = global_mcp_server_manager.invalidate_user_oauth_token_cache
for user_id in credential_user_ids:
await invalidate_token_cache(user_id, server_id)
- return deleted_server
+ return deleted_server # pyright: ignore[reportReturnType] # prisma row, not domain LiteLLM_MCPServerTable
async def create_mcp_server(
@@ -880,7 +857,7 @@ async def create_mcp_server(
data_dict["updated_by"] = touched_by
new_mcp_server: Final[LiteLLM_MCPServerTable] = await MCPServerRepository(prisma_client).table.create(
- data=data_dict
+ data=data_dict, # pyright: ignore[reportAssignmentType] # prisma row, not domain LiteLLM_MCPServerTable
)
_decrypt_env_vars_on_returned_row(new_mcp_server)
@@ -982,7 +959,7 @@ async def update_mcp_server(
data: UpdateMCPServerRequest,
touched_by: str,
fields_set: set[str] | None = None,
-) -> LiteLLM_MCPServerTable:
+) -> LiteLLM_MCPServerTable | None:
"""
Update a new mcp server record in the db
"""
@@ -1093,9 +1070,9 @@ async def update_mcp_server(
data_dict["credentials"] = Json(None)
- updated_mcp_server: Final[LiteLLM_MCPServerTable] = await MCPServerRepository(prisma_client).table.update(
+ updated_mcp_server: Final[LiteLLM_MCPServerTable | None] = await MCPServerRepository(prisma_client).table.update(
where={"server_id": data.server_id},
- data=data_dict,
+ data=data_dict, # pyright: ignore[reportAssignmentType] # prisma row, not domain LiteLLM_MCPServerTable
)
_decrypt_env_vars_on_returned_row(updated_mcp_server)
@@ -1181,7 +1158,7 @@ async def rotate_mcp_server_credentials_master_key(prisma_client: PrismaClient,
)
updated += 1
- oauth_clients: Final[list[prisma_db_models.LiteLLM_MCPServerOAuthClient]] = await _oauth_client_table_actions(
+ oauth_clients: Final[Sequence[prisma_db_models.LiteLLM_MCPServerOAuthClient]] = await _oauth_client_table_actions(
prisma_client
).find_many()
oauth_updated = 0
@@ -1914,7 +1891,7 @@ async def get_mcp_submissions(
along with a summary count breakdown by approval_status.
Mirrors get_guardrail_submissions() from guardrail_endpoints.py.
"""
- rows: Final[list[prisma_db_models.LiteLLM_MCPServerTable]] = await _mcp_server_table_actions(
+ rows: Final[Sequence[prisma_db_models.LiteLLM_MCPServerTable]] = await _mcp_server_table_actions(
prisma_client
).find_many(
where={"submitted_at": {"not": None}},
diff --git a/litellm/proxy/agent_endpoints/agent_registry.py b/litellm/proxy/agent_endpoints/agent_registry.py
index 64de6827679..fa33a307438 100644
--- a/litellm/proxy/agent_endpoints/agent_registry.py
+++ b/litellm/proxy/agent_endpoints/agent_registry.py
@@ -4,7 +4,7 @@ import json
from collections.abc import Iterator, Mapping, Sequence
from datetime import datetime, timezone
from types import MappingProxyType
-from typing import Any, Final, NamedTuple, Protocol, TypedDict
+from typing import TYPE_CHECKING, Any, Final, NamedTuple, Protocol, TypedDict
import litellm
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
@@ -12,9 +12,13 @@ from litellm.proxy.management_helpers.object_permission_utils import (
handle_update_object_permission_common,
)
from litellm.proxy.utils import PrismaClient
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import AgentsRepository, ObjectPermissionRepository
from litellm.types.agents import AgentConfig, AgentResponse, PatchAgentRequest
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
class AgentObjectPermissionRecord(Protocol):
def model_dump(self) -> dict[str, object]: ...
@@ -42,11 +46,20 @@ class AgentRecordDump(TypedDict):
class AgentRecord(Protocol):
- agent_id: str
- agent_name: str
- object_permission_id: str | None
- object_permission: AgentObjectPermissionRecord | None
- spend: float
+ @property
+ def agent_id(self) -> str: ...
+
+ @property
+ def agent_name(self) -> str: ...
+
+ @property
+ def object_permission_id(self) -> str | None: ...
+
+ @property
+ def object_permission(self) -> AgentObjectPermissionRecord | None: ...
+
+ @property
+ def spend(self) -> float: ...
def model_dump(self) -> AgentRecordDump: ...
@@ -57,50 +70,47 @@ class AgentTableClient(Protocol):
async def create(
self,
data: Mapping[str, object],
- include: Mapping[str, bool] | None = None,
+ include: Mapping[str, object] | None = None,
) -> AgentRecord: ...
async def find_unique(
self,
where: Mapping[str, object],
- include: Mapping[str, bool] | None = None,
+ include: Mapping[str, object] | None = None,
) -> AgentRecord | None: ...
async def find_many(
self,
where: Mapping[str, object] | None = None,
order: Mapping[str, str] | None = None,
- include: Mapping[str, bool] | None = None,
+ include: Mapping[str, object] | None = None,
) -> Sequence[AgentRecord]: ...
async def update(
self,
- where: Mapping[str, object],
data: Mapping[str, object],
- include: Mapping[str, bool] | None = None,
- ) -> AgentRecord: ...
+ where: Mapping[str, object],
+ include: Mapping[str, object] | None = None,
+ ) -> AgentRecord | None: ...
- async def delete(self, where: Mapping[str, object]) -> AgentRecord: ...
+ async def delete(
+ self,
+ where: Mapping[str, object],
+ include: Mapping[str, object] | None = None,
+ ) -> AgentRecord | None: ...
def agents_table(prisma_client: PrismaClient) -> AgentTableClient:
- table: Final[AgentTableClient] = AgentsRepository(prisma_client).table
+ table: Final[AgentTableClient] = AgentsRepository(prisma_client).table # pyright: ignore[reportAssignmentType] # prisma rows type model_dump() as dict[str, Any]
return table
-class ObjectPermissionGrantRecord(Protocol):
- object_permission_id: str
- agents: list[str] | None
-
-
-class ObjectPermissionTableClient(Protocol):
- async def find_many(self, where: Mapping[str, object]) -> Sequence[ObjectPermissionGrantRecord]: ...
-
- async def update_many(self, where: Mapping[str, object], data: Mapping[str, object]) -> int: ...
-
-
-def object_permission_table(prisma_client: PrismaClient) -> ObjectPermissionTableClient:
- table: Final[ObjectPermissionTableClient] = ObjectPermissionRepository(prisma_client).table
+def object_permission_table(
+ prisma_client: PrismaClient,
+) -> "TableActions[prisma_models.LiteLLM_ObjectPermissionTable]":
+ table: Final[TableActions[prisma_models.LiteLLM_ObjectPermissionTable]] = ObjectPermissionRepository(
+ prisma_client
+ ).table
return table
@@ -222,7 +232,9 @@ class AgentRegistry:
self.load_agents_from_config(agent_config if agent_config is not None else self.config_agents)
return self.agent_list
- async def migrate_legacy_grant_ids(self, table: ObjectPermissionTableClient) -> GrantMigrationResult:
+ async def migrate_legacy_grant_ids(
+ self, table: "TableActions[prisma_models.LiteLLM_ObjectPermissionTable]"
+ ) -> GrantMigrationResult:
"""
Rewrite object_permission.agents rows holding a legacy full-entry hash to the
stable name-derived id.
@@ -360,6 +372,8 @@ class AgentRegistry:
"""
try:
deleted_agent: Final = await agents_table(prisma_client).delete(where={"agent_id": agent_id})
+ if deleted_agent is None:
+ raise ValueError(f"Agent not found, passed agent_id={agent_id}")
return dict(deleted_agent)
except Exception as e:
raise Exception(f"Error deleting agent from DB: {e}")
@@ -386,12 +400,12 @@ class AgentRegistry:
The patched agent
"""
try:
- existing_agent = await AgentsRepository(prisma_client).table.find_unique(where={"agent_id": agent_id})
- if existing_agent is not None:
- existing_agent = dict(existing_agent)
-
- if existing_agent is None:
+ existing_row: Final = await AgentsRepository(prisma_client).table.find_unique(
+ where={"agent_id": agent_id} # mutable-ok: prisma filters are plain dicts
+ )
+ if existing_row is None:
raise Exception(f"Agent with ID {agent_id} not found")
+ existing_agent: Final = dict(existing_row)
augment_agent: Final = {**existing_agent, **agent}
update_data: Final[dict[str, Any]] = {}
@@ -436,6 +450,8 @@ class AgentRegistry:
},
include={"object_permission": True},
)
+ if patched_agent is None:
+ raise ValueError(f"Agent not found, passed agent_id={agent_id}")
patched_agent_dict: Final = patched_agent.model_dump()
if patched_agent.object_permission is not None:
try:
@@ -523,6 +539,8 @@ class AgentRegistry:
include={"object_permission": True},
)
+ if updated_agent is None:
+ raise ValueError(f"Agent not found, passed agent_id={agent_id}")
updated_agent_dict: Final = updated_agent.model_dump()
if updated_agent.object_permission is not None:
try:
diff --git a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py
index 09ba2c93ea0..65bc46edfaf 100644
--- a/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py
+++ b/litellm/proxy/anthropic_endpoints/claude_code_endpoints/claude_code_marketplace.py
@@ -543,7 +543,7 @@ async def update_plugin(
manifest: Final[Mapping[str, object]] = _build_plugin_manifest(plugin_name, request)
- plugin: Final[_PluginRecord] = await ClaudeCodePluginRepository(prisma_client).table.update(
+ plugin: Final[_PluginRecord | None] = await ClaudeCodePluginRepository(prisma_client).table.update(
where={"name": plugin_name}, # mutable-ok: prisma query arguments must be plain dicts
data={ # mutable-ok: prisma query arguments must be plain dicts
"version": request.version,
@@ -553,6 +553,8 @@ async def update_plugin(
"updated_at": datetime.now(timezone.utc),
},
)
+ if plugin is None:
+ raise _error_response(404, f"Plugin '{plugin_name}' not found")
verbose_proxy_logger.info("Plugin %s updated successfully", plugin_name)
diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py
index 52857909621..366950c00a1 100644
--- a/litellm/proxy/auth/auth_checks.py
+++ b/litellm/proxy/auth/auth_checks.py
@@ -157,7 +157,12 @@ class _PrismaVectorStoreRow(Protocol):
class _PrismaUserRow(Protocol):
user_id: str
- organization_memberships: Sequence[LiteLLM_OrganizationMembershipTable | None] | None
+
+ @property
+ def organization_memberships(self) -> Sequence[_PrismaModelDumpRow | None] | None: ...
+
+ @organization_memberships.setter
+ def organization_memberships(self, value: Sequence[_PrismaModelDumpRow] | None) -> None: ...
def __iter__(self) -> Iterator[tuple[str, object]]: ...
@@ -215,9 +220,14 @@ def _user_table(repo: _PrismaTableHolder[_PrismaUserRow]) -> _PrismaAuthTable[_P
return repo.table
+class _VectorStorePermissionsRow(Protocol):
+ @property
+ def vector_stores(self) -> Sequence[str] | None: ...
+
+
def _object_permission_table(
- repo: _PrismaTableHolder[LiteLLM_ObjectPermissionTable],
-) -> _PrismaAuthTable[LiteLLM_ObjectPermissionTable]:
+ repo: _PrismaTableHolder[_VectorStorePermissionsRow],
+) -> _PrismaAuthTable[_VectorStorePermissionsRow]:
return repo.table
@@ -5376,7 +5386,7 @@ async def vector_store_access_check(
def _can_object_call_vector_stores(
object_type: Literal["key", "team", "org"],
vector_store_ids_to_run: list[str],
- object_permissions: LiteLLM_ObjectPermissionTable | None,
+ object_permissions: _VectorStorePermissionsRow | None,
):
"""
Raises ProxyException if the object (key, team, org) cannot access the specific vector store.
diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py
index 28d76e6799c..d86132e51f0 100644
--- a/litellm/proxy/auth/user_api_key_auth.py
+++ b/litellm/proxy/auth/user_api_key_auth.py
@@ -199,6 +199,15 @@ class _UserModelBudgetLimiter(Protocol):
) -> bool: ...
+class _TokenTeamModels(Protocol):
+ @property
+ def team_models(self) -> list[str]: ...
+
+
+def _token_team_models(valid_token: _TokenTeamModels) -> list[str]:
+ return valid_token.team_models
+
+
async def _read_user_model_max_budget(
user_id: str | None,
prisma_client: PrismaClient | None,
@@ -1992,7 +2001,7 @@ async def _user_api_key_auth_builder(
include={"litellm_budget_table": True},
)
if _db_member is not None:
- team_member_info = LiteLLM_TeamMembership(**_db_member.dict())
+ team_member_info = LiteLLM_TeamMembership(**_db_member.model_dump())
await user_api_key_cache.async_set_cache(
key=_cache_key,
value=team_member_info,
@@ -2149,6 +2158,7 @@ async def _user_api_key_auth_builder(
proxy_logging_obj=proxy_logging_obj,
)
except HTTPException:
+ token_team_models: Final = _token_team_models(valid_token)
_team_obj = LiteLLM_TeamTableCachedObj(
team_id=valid_token.team_id,
max_budget=valid_token.team_max_budget,
@@ -2157,7 +2167,7 @@ async def _user_api_key_auth_builder(
tpm_limit=valid_token.team_tpm_limit,
rpm_limit=valid_token.team_rpm_limit,
blocked=valid_token.team_blocked,
- models=valid_token.team_models,
+ models=token_team_models,
metadata=valid_token.team_metadata,
object_permission_id=valid_token.team_object_permission_id,
object_permission=await _resolve_object_permission_for_unresolvable_team(
@@ -2301,6 +2311,7 @@ def _team_obj_from_token(valid_token: UserAPIKeyAuth) -> LiteLLM_TeamTableCached
UserAPIKeyAuth. Only called when valid_token.team_id is known to be
non-None (the caller gates on it)."""
assert valid_token.team_id is not None
+ token_team_models: Final = _token_team_models(valid_token)
return LiteLLM_TeamTableCachedObj(
team_id=valid_token.team_id,
max_budget=valid_token.team_max_budget,
@@ -2309,7 +2320,7 @@ def _team_obj_from_token(valid_token: UserAPIKeyAuth) -> LiteLLM_TeamTableCached
tpm_limit=valid_token.team_tpm_limit,
rpm_limit=valid_token.team_rpm_limit,
blocked=valid_token.team_blocked,
- models=valid_token.team_models,
+ models=token_team_models,
metadata=valid_token.team_metadata,
object_permission_id=valid_token.team_object_permission_id,
)
diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py
index 6fd743deb44..4f56e4eb715 100644
--- a/litellm/proxy/common_request_processing.py
+++ b/litellm/proxy/common_request_processing.py
@@ -30,6 +30,7 @@ from litellm.constants import (
RETURN_RAW_MODEL_NAME_METADATA_KEY,
ROUTER_MODEL_NAME_RESPONSE_FIELD,
STREAM_SSE_DATA_PREFIX,
+ STREAM_SSE_KEEPALIVE_PING_BYTES,
UNSAFE_PROXY_RESPONSE_HEADERS,
)
from litellm.integrations.custom_guardrail import CustomGuardrail
@@ -202,6 +203,10 @@ _CLIENT_DISCONNECTED_ERROR_INFORMATION: Final[StandardLoggingPayloadErrorInforma
}
+def _withheld_provider_output(response: object) -> bool:
+ return getattr(response, "has_buffered_provider_output", False) is True
+
+
def _should_return_raw_model_name(request_data: dict[str, object]) -> bool:
return any(
isinstance(metadata, dict) and metadata.get(RETURN_RAW_MODEL_NAME_METADATA_KEY) is True
@@ -3448,8 +3453,9 @@ class ProxyBaseLLMRequestProcessing:
# so a GeneratorExit on client disconnect is raised there and any
# statement after the yield never runs. The slow-path hook is
# awaited above, so a cancellation during it still leaves this
- # False and refunds.
- delivered_chunk = True
+ # False and refunds. A keepalive ping carries no provider output,
+ # so it must not suppress that refund.
+ delivered_chunk = delivered_chunk or chunk != STREAM_SSE_KEEPALIVE_PING_BYTES
yield serialize_chunk(chunk)
stream_completed = True
except (asyncio.CancelledError, GeneratorExit):
@@ -3463,7 +3469,7 @@ class ProxyBaseLLMRequestProcessing:
# only sees GeneratorExit on GC) cannot own the refund.
if not stream_completed:
client_disconnected = True
- if not delivered_chunk:
+ if not delivered_chunk and not _withheld_provider_output(response):
from litellm.proxy.spend_tracking.budget_reservation import (
release_budget_reservation_on_cancel,
)
diff --git a/litellm/proxy/common_utils/config_sync_pubsub.py b/litellm/proxy/common_utils/config_sync_pubsub.py
index d5317fc0e02..6d781babe63 100644
--- a/litellm/proxy/common_utils/config_sync_pubsub.py
+++ b/litellm/proxy/common_utils/config_sync_pubsub.py
@@ -7,6 +7,7 @@ from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Final, Protocol, cast # noqa: TID251 # untyped prisma/redis boundary needs cast
from litellm._logging import verbose_proxy_logger
+from litellm.repositories.prisma_protocols import RowT_co, TableActions
if TYPE_CHECKING:
from litellm.caching.redis_cache import RedisCache
@@ -163,13 +164,14 @@ class _PublishOnWriteActions:
def wrap_table_actions_for_config_sync(
- actions: object,
+ actions: "TableActions[RowT_co]",
table_name: str,
publish: Callable[[str], Awaitable[None]] = publish_config_change_for_object_type,
-) -> object:
+) -> "TableActions[RowT_co]":
if table_name not in _CONFIG_SYNCED_TABLE_NAMES:
return actions
- return _PublishOnWriteActions(actions=actions, object_type=table_name, publish=publish)
+ wrapped: Final = _PublishOnWriteActions(actions=actions, object_type=table_name, publish=publish)
+ return cast("TableActions[RowT_co]", wrapped) # cast-ok: dynamic write-through proxy keeps the wrapped row type
class ConfigSyncSubscriber:
diff --git a/litellm/proxy/common_utils/expired_ui_session_key_cleanup_manager.py b/litellm/proxy/common_utils/expired_ui_session_key_cleanup_manager.py
index e314aec497f..58183eec689 100644
--- a/litellm/proxy/common_utils/expired_ui_session_key_cleanup_manager.py
+++ b/litellm/proxy/common_utils/expired_ui_session_key_cleanup_manager.py
@@ -4,8 +4,9 @@ Expired UI session key cleanup manager.
Deletes expired virtual keys created for LiteLLM dashboard sessions.
"""
+from collections.abc import Sequence
from datetime import datetime, timezone
-from typing import Any, Final
+from typing import Any, Final, Protocol
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
@@ -14,7 +15,7 @@ from litellm.constants import (
LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME,
UI_SESSION_TOKEN_TEAM_ID,
)
-from litellm.proxy._types import KeyRequest, LiteLLM_VerificationToken, UserAPIKeyAuth
+from litellm.proxy._types import KeyRequest, UserAPIKeyAuth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
from litellm.proxy.management_endpoints.key_management_endpoints import (
@@ -26,6 +27,11 @@ from litellm.repositories.verification_token_repository import (
)
+class _ExpiredSessionKeyRow(Protocol):
+ @property
+ def token(self) -> str | None: ...
+
+
class ExpiredUISessionKeyCleanupManager:
"""
Cleans up expired UI session keys.
@@ -138,7 +144,7 @@ class ExpiredUISessionKeyCleanupManager:
return len(tokens)
- async def _find_expired_ui_session_keys(self) -> list[LiteLLM_VerificationToken]:
+ async def _find_expired_ui_session_keys(self) -> Sequence[_ExpiredSessionKeyRow]:
"""
Find expired LiteLLM dashboard session keys.
"""
diff --git a/litellm/proxy/common_utils/key_rotation_manager.py b/litellm/proxy/common_utils/key_rotation_manager.py
index 839ff28c354..352d024e20e 100644
--- a/litellm/proxy/common_utils/key_rotation_manager.py
+++ b/litellm/proxy/common_utils/key_rotation_manager.py
@@ -4,8 +4,9 @@ Key Rotation Manager - Automated key rotation based on rotation schedules
Handles finding keys that need rotation based on their individual schedules.
"""
+from collections.abc import Sequence
from datetime import datetime, timezone
-from typing import Final
+from typing import TYPE_CHECKING, Final
from litellm._logging import verbose_proxy_logger
from litellm.constants import (
@@ -31,6 +32,9 @@ from litellm.repositories.verification_token_repository import (
VerificationTokenRepository,
)
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
class KeyRotationManager:
"""
@@ -106,7 +110,7 @@ class KeyRotationManager:
cronjob_id=KEY_ROTATION_JOB_NAME,
)
- async def _find_keys_needing_rotation(self) -> list[LiteLLM_VerificationToken]:
+ async def _find_keys_needing_rotation(self) -> "Sequence[prisma_models.LiteLLM_VerificationToken]":
"""
Find keys that are due for rotation based on their key_rotation_at timestamp.
@@ -156,7 +160,7 @@ class KeyRotationManager:
# Check if the rotation time has passed
return now >= key.key_rotation_at
- async def _rotate_key(self, key: LiteLLM_VerificationToken):
+ async def _rotate_key(self, key: "prisma_models.LiteLLM_VerificationToken"):
"""
Rotate a single key using existing regenerate_key_fn and call the rotation hook
"""
@@ -197,7 +201,7 @@ class KeyRotationManager:
if isinstance(response, GenerateKeyResponse):
await KeyManagementEventHooks.async_key_rotated_hook(
data=regenerate_request,
- existing_key_row=key,
+ existing_key_row=key, # pyright: ignore[reportArgumentType] # prisma row, hook wants the domain model
response=response,
user_api_key_dict=system_user,
litellm_changed_by=LITELLM_INTERNAL_JOBS_SERVICE_ACCOUNT_NAME,
diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py
index 8fcb184b26a..68999c93823 100644
--- a/litellm/proxy/common_utils/reset_budget_job.py
+++ b/litellm/proxy/common_utils/reset_budget_job.py
@@ -37,7 +37,7 @@ from litellm.proxy.db.db_transaction_queue.pod_lock_manager import PodLockManage
from litellm.proxy.db.exception_handler import call_with_db_reconnect_retry
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.organization_repository import OrganizationRepository
-from litellm.repositories.prisma_protocols import ReadOnlyTable, SpendLinkedTable
+from litellm.repositories.prisma_protocols import SpendLinkedTable
from litellm.repositories.table_repositories import (
EndUserRepository,
TagRepository,
@@ -675,7 +675,7 @@ class ResetBudgetJob:
rely on the default budget (litellm.max_end_user_budget_id) applied
in-memory during auth checks.
"""
- table: Final[ReadOnlyTable] = EndUserRepository(self.prisma_client).table
+ table: Final = EndUserRepository(self.prisma_client).table
rows: Final = await self._with_db_retry(
lambda: table.find_many(
where={
@@ -685,7 +685,7 @@ class ResetBudgetJob:
),
reason="reset_budget_read_endusers_without_budget_id_failure",
)
- return [LiteLLM_EndUserTable.model_validate(row.dict()) for row in rows]
+ return [LiteLLM_EndUserTable.model_validate(row.model_dump()) for row in rows]
async def _write_key_reset_updates(self, updated_keys: list[LiteLLM_VerificationToken]) -> None:
"""
diff --git a/litellm/proxy/common_utils/sse_keepalive.py b/litellm/proxy/common_utils/sse_keepalive.py
index 6fba9e96f6e..429fff99ae6 100644
--- a/litellm/proxy/common_utils/sse_keepalive.py
+++ b/litellm/proxy/common_utils/sse_keepalive.py
@@ -6,7 +6,9 @@ from typing import Final
import anyio
-ANTHROPIC_PING_SSE_CHUNK: Final = 'event: ping\ndata: {"type": "ping"}\n\n'
+from litellm.constants import STREAM_SSE_KEEPALIVE_PING_CHUNK
+
+ANTHROPIC_PING_SSE_CHUNK: Final = STREAM_SSE_KEEPALIVE_PING_CHUNK
SSE_COMMENT_PING: Final = ": ping\n\n"
SSE_COMMENT_PING_BYTES: Final = SSE_COMMENT_PING.encode()
# The byte form of proxy_server._SSE_FRAME_DELIMITERS, CR-only included: SSE
diff --git a/litellm/proxy/container_endpoints/ownership.py b/litellm/proxy/container_endpoints/ownership.py
index a559ab49cfa..e3088771c82 100644
--- a/litellm/proxy/container_endpoints/ownership.py
+++ b/litellm/proxy/container_endpoints/ownership.py
@@ -1,7 +1,7 @@
import json
from collections.abc import Mapping, Sequence
from collections.abc import Set as AbstractSet
-from typing import TYPE_CHECKING, Any, Final, Protocol
+from typing import TYPE_CHECKING, Any, Final
from fastapi import HTTPException
@@ -18,28 +18,11 @@ from litellm.repositories.table_repositories import ManagedObjectRepository
from litellm.responses.utils import ResponsesAPIRequestUtils
if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
from litellm.proxy.utils import PrismaClient
-class _ManagedObjectRow(Protocol):
- model_object_id: str
- unified_object_id: str | None
- file_purpose: str | None
- created_by: str | None
-
-
-class _ManagedObjectTable(Protocol):
- async def find_unique(self, *, where: Mapping[str, str]) -> _ManagedObjectRow | None: ...
-
- async def find_first(self, *, where: Mapping[str, str]) -> _ManagedObjectRow | None: ...
-
- async def find_many(self, *, where: Mapping[str, object]) -> Sequence[_ManagedObjectRow]: ...
-
- async def create(self, *, data: Mapping[str, str]) -> _ManagedObjectRow: ...
-
- async def update(self, *, where: Mapping[str, str], data: Mapping[str, str]) -> _ManagedObjectRow | None: ...
-
-
CONTAINER_OBJECT_PURPOSE: Final = "container"
# 60s LRU/TTL cache absorbs every container access check before it reaches
@@ -220,7 +203,7 @@ async def record_container_owner(
verbose_proxy_logger.warning("Skipping container ownership tracking because prisma_client is None")
return response
- table: Final[_ManagedObjectTable] = ManagedObjectRepository(prisma_client).table
+ table: Final = ManagedObjectRepository(prisma_client).table
existing: Final = await table.find_unique(where={"model_object_id": model_object_id})
if existing is not None:
if getattr(existing, "file_purpose", None) != CONTAINER_OBJECT_PURPOSE:
@@ -272,8 +255,8 @@ async def _get_container_owner(original_container_id: str, custom_llm_provider:
if prisma_client is None:
return None
- table: Final[_ManagedObjectTable] = ManagedObjectRepository(prisma_client).table
- row: Final[_ManagedObjectRow | None] = await table.find_first(
+ table: Final = ManagedObjectRepository(prisma_client).table
+ row: Final[prisma_models.LiteLLM_ManagedObjectTable | None] = await table.find_first(
where={
"model_object_id": model_object_id,
"file_purpose": CONTAINER_OBJECT_PURPOSE,
@@ -309,8 +292,8 @@ async def _get_stored_container_id(original_container_id: str, custom_llm_provid
if prisma_client is None:
return None
- table: Final[_ManagedObjectTable] = ManagedObjectRepository(prisma_client).table
- row: Final[_ManagedObjectRow | None] = await table.find_first(
+ table: Final = ManagedObjectRepository(prisma_client).table
+ row: Final[prisma_models.LiteLLM_ManagedObjectTable | None] = await table.find_first(
where={
"model_object_id": model_object_id,
"file_purpose": CONTAINER_OBJECT_PURPOSE,
@@ -394,8 +377,8 @@ async def _get_allowed_container_ids(
if prisma_client is None:
return set()
- table: Final[_ManagedObjectTable] = ManagedObjectRepository(prisma_client).table
- rows: Final[Sequence[_ManagedObjectRow]] = await table.find_many(
+ table: Final = ManagedObjectRepository(prisma_client).table
+ rows: Final[Sequence[prisma_models.LiteLLM_ManagedObjectTable]] = await table.find_many(
where={
"file_purpose": CONTAINER_OBJECT_PURPOSE,
"created_by": {"in": owner_scopes},
diff --git a/litellm/proxy/credential_endpoints/endpoints.py b/litellm/proxy/credential_endpoints/endpoints.py
index 3b3e9692eda..dc193cb8523 100644
--- a/litellm/proxy/credential_endpoints/endpoints.py
+++ b/litellm/proxy/credential_endpoints/endpoints.py
@@ -2,7 +2,10 @@
CRUD endpoints for storing reusable credentials.
"""
-from typing import Final
+from typing import (
+ Final,
+ cast, # noqa: TID251 # jsonify_object in proxy/utils.py is annotated with a bare dict
+)
from fastapi import APIRouter, Depends, HTTPException, Path, Request, Response
@@ -88,7 +91,9 @@ async def create_credential(
)
encrypted_credential: Final = CredentialHelperUtils.encrypt_credential_values(processed_credential)
credentials_dict: Final = encrypted_credential.model_dump()
- credentials_dict_jsonified: Final = jsonify_object(credentials_dict)
+ credentials_dict_jsonified: Final = cast( # cast-ok: deep-copies a model_dump, so keys are str
+ "dict[str, object]", jsonify_object(credentials_dict)
+ )
await CredentialsRepository(prisma_client).create(
data={
**credentials_dict_jsonified,
@@ -310,7 +315,9 @@ async def update_credential(
if db_credential is None:
raise HTTPException(status_code=404, detail="Credential not found in DB.")
merged_credential: Final = update_db_credential(db_credential, credential)
- credential_object_jsonified: Final = jsonify_object(merged_credential.model_dump())
+ credential_object_jsonified: Final = cast( # cast-ok: deep-copies a model_dump, so keys are str
+ "dict[str, object]", jsonify_object(merged_credential.model_dump())
+ )
await credentials_repository.update_by_name(
credential_name,
data={
diff --git a/litellm/proxy/db/tool_registry_writer.py b/litellm/proxy/db/tool_registry_writer.py
index 187a18be845..367552e783e 100644
--- a/litellm/proxy/db/tool_registry_writer.py
+++ b/litellm/proxy/db/tool_registry_writer.py
@@ -6,14 +6,15 @@ Admins use the management endpoints to read and update input_policy / output_pol
"""
import uuid
-from collections.abc import Mapping, Sequence
+from collections.abc import Mapping
from datetime import datetime, timezone
-from typing import TYPE_CHECKING, Any, Final, Protocol, TypeVar
+from typing import TYPE_CHECKING, Any, Final
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import ToolDiscoveryQueueItem
from litellm.proxy.db.exception_handler import call_with_db_reconnect_retry
from litellm.repositories.object_permission_repository import ObjectPermissionRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import ToolRepository
from litellm.types.tool_management import (
LiteLLM_ToolTableRow,
@@ -25,33 +26,16 @@ if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
-_RowT_co: Final = TypeVar("_RowT_co", covariant=True)
-
-class _TableActions(Protocol[_RowT_co]):
- async def find_unique(self, where: Mapping[str, object]) -> _RowT_co | None: ...
-
- async def find_many(
- self,
- where: Mapping[str, object] | None = None,
- order: Mapping[str, object] | None = None,
- include: Mapping[str, object] | None = None,
- ) -> Sequence[_RowT_co]: ...
-
- async def upsert(self, where: Mapping[str, object], data: Mapping[str, object]) -> _RowT_co: ...
-
- async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> _RowT_co | None: ...
-
-
-def _tool_table_actions(prisma_client: "PrismaClient") -> "_TableActions[prisma_db_models.LiteLLM_ToolTable]":
- table: Final[_TableActions[prisma_db_models.LiteLLM_ToolTable]] = ToolRepository(prisma_client).table
+def _tool_table_actions(prisma_client: "PrismaClient") -> "TableActions[prisma_db_models.LiteLLM_ToolTable]":
+ table: Final[TableActions[prisma_db_models.LiteLLM_ToolTable]] = ToolRepository(prisma_client).table
return table
def _object_permission_table_actions(
prisma_client: "PrismaClient",
-) -> "_TableActions[prisma_db_models.LiteLLM_ObjectPermissionTable]":
- table: Final[_TableActions[prisma_db_models.LiteLLM_ObjectPermissionTable]] = ObjectPermissionRepository(
+) -> "TableActions[prisma_db_models.LiteLLM_ObjectPermissionTable]":
+ table: Final[TableActions[prisma_db_models.LiteLLM_ObjectPermissionTable]] = ObjectPermissionRepository(
prisma_client
).table
return table
diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py
index e50a3a5a1e7..20efbe06ecc 100644
--- a/litellm/proxy/guardrails/guardrail_endpoints.py
+++ b/litellm/proxy/guardrails/guardrail_endpoints.py
@@ -29,6 +29,7 @@ from litellm.proxy.guardrails.guardrail_hooks.custom_code.sandbox import (
from litellm.proxy.guardrails.guardrail_registry import GuardrailRegistry
from litellm.proxy.guardrails.usage_endpoints import router as guardrails_usage_router
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import GuardrailsRepository
from litellm.types.guardrails import (
PII_ENTITY_CATEGORIES_MAP,
@@ -65,29 +66,12 @@ router: Final = APIRouter()
GUARDRAIL_REGISTRY: Final = GuardrailRegistry()
-class _GuardrailsTableActions(Protocol):
- async def create(self, data: Mapping[str, object]) -> "LiteLLM_GuardrailsTable": ...
-
- async def delete(self, where: Mapping[str, object]) -> "LiteLLM_GuardrailsTable | None": ...
-
- async def find_unique(self, where: Mapping[str, object]) -> "LiteLLM_GuardrailsTable | None": ...
-
- async def find_many(
- self, where: Mapping[str, object], order: Mapping[str, str]
- ) -> "Sequence[LiteLLM_GuardrailsTable]": ...
-
- async def update(
- self, where: Mapping[str, object], data: Mapping[str, object]
- ) -> "LiteLLM_GuardrailsTable | None": ...
-
-
def _as_str_object_mapping(mapping: Mapping[str, object]) -> Mapping[str, object]:
return mapping
-def _guardrails_table(prisma_client: "PrismaClient") -> _GuardrailsTableActions:
- table: Final[_GuardrailsTableActions] = GuardrailsRepository(prisma_client).table
- return table
+def _guardrails_table(prisma_client: "PrismaClient") -> "TableActions[LiteLLM_GuardrailsTable]":
+ return GuardrailsRepository(prisma_client).table
async def _create_guardrail_row(prisma_client: "PrismaClient", data: Mapping[str, object]) -> "LiteLLM_GuardrailsTable":
diff --git a/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py b/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py
index 8bfd5cca58a..84c6b220e62 100644
--- a/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py
+++ b/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py
@@ -339,6 +339,7 @@ def _build_responses_followup_items(
class HeadroomGuardrail(CustomGuardrail):
records_own_guardrail_information: ClassVar[bool] = True
+ server_fulfilled_tool_names: ClassVar[frozenset[str]] = frozenset({HEADROOM_RETRIEVE_TOOL_NAME})
@classmethod
def get_supported_event_hooks(cls) -> list[GuardrailEventHooks]:
diff --git a/litellm/proxy/guardrails/guardrail_registry.py b/litellm/proxy/guardrails/guardrail_registry.py
index d29ec555a80..987e7d778c7 100644
--- a/litellm/proxy/guardrails/guardrail_registry.py
+++ b/litellm/proxy/guardrails/guardrail_registry.py
@@ -3,12 +3,12 @@
import asyncio
import importlib
import os
-from collections.abc import Callable, Iterator, Mapping, Sequence
+from collections.abc import Callable, Iterator, Mapping
from datetime import datetime, timezone
from itertools import chain, count
-from typing import Final, Literal, Optional, Protocol, cast
+from typing import TYPE_CHECKING, Final, Literal, Optional, Protocol, cast
-from pydantic import BaseModel, ValidationError
+from pydantic import ValidationError
import litellm
from litellm import Router
@@ -39,6 +39,7 @@ from litellm.proxy.guardrails.guardrail_hooks.tool_permission import (
)
from litellm.proxy.types_utils.utils import get_instance_fn
from litellm.proxy.utils import PrismaClient
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import GuardrailsRepository
from litellm.secret_managers.main import get_secret
from litellm.types.guardrails import (
@@ -61,6 +62,9 @@ from .guardrail_initializers import (
initialize_tool_permission,
)
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
class _GuardrailRowLike(Protocol):
@property
@@ -68,15 +72,7 @@ class _GuardrailRowLike(Protocol):
def __iter__(self) -> Iterator[tuple[str, object]]: ...
-class _GuardrailTableActions(Protocol):
- async def create(self, *, data: Mapping[str, object]) -> _GuardrailRowLike: ...
- async def delete(self, *, where: Mapping[str, str]) -> object: ...
- async def update(self, *, where: Mapping[str, str], data: Mapping[str, object]) -> _GuardrailRowLike: ...
- async def find_many(self, *, where: Mapping[str, str], order: Mapping[str, str]) -> Sequence[BaseModel]: ...
- async def find_unique(self, *, where: Mapping[str, str]) -> BaseModel | None: ...
-
-
-def _guardrail_table(prisma_client: PrismaClient) -> _GuardrailTableActions:
+def _guardrail_table(prisma_client: PrismaClient) -> "TableActions[prisma_models.LiteLLM_GuardrailsTable]":
"""Typed view of the guardrails table actions exposed by the Prisma repository."""
return GuardrailsRepository(prisma_client).table
@@ -347,7 +343,7 @@ class GuardrailRegistry:
guardrail_info: Final[str] = safe_dumps(guardrail.get("guardrail_info", {}))
# Update in DB
- updated_guardrail: Final[_GuardrailRowLike] = await _guardrail_table(prisma_client).update(
+ updated_guardrail: Final[_GuardrailRowLike | None] = await _guardrail_table(prisma_client).update(
where={"guardrail_id": guardrail_id},
data={
"guardrail_name": guardrail_name,
@@ -356,6 +352,8 @@ class GuardrailRegistry:
"updated_at": datetime.now(timezone.utc),
},
)
+ if updated_guardrail is None:
+ raise ValueError(f"Guardrail not found, passed guardrail_id={guardrail_id}")
# Convert to dict and return
return dict(updated_guardrail)
diff --git a/litellm/proxy/guardrails/usage_endpoints.py b/litellm/proxy/guardrails/usage_endpoints.py
index 9d0d84dc2b1..7a0edbddca8 100644
--- a/litellm/proxy/guardrails/usage_endpoints.py
+++ b/litellm/proxy/guardrails/usage_endpoints.py
@@ -17,6 +17,7 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
DailyGuardrailMetricsRepository,
DailyGuardrailUsageUnitsRepository,
@@ -30,13 +31,6 @@ from litellm.repositories.table_repositories import (
if TYPE_CHECKING:
from prisma import models as prisma_models
from prisma import types as prisma_types
- from prisma.actions import (
- LiteLLM_DailyGuardrailMetricsActions,
- LiteLLM_DailyGuardrailUsageUnitsActions,
- LiteLLM_DailyPolicyMetricsActions,
- LiteLLM_GuardrailsTableActions,
- LiteLLM_PolicyTableActions,
- )
from litellm.proxy.utils import PrismaClient
from litellm.types.guardrails import Guardrail
@@ -85,8 +79,8 @@ def _resolve_usage_window(start_date: str | None, end_date: str | None) -> tuple
def _guardrails_table(
prisma_client: "PrismaClient",
-) -> "LiteLLM_GuardrailsTableActions[prisma_models.LiteLLM_GuardrailsTable]":
- guardrails_table: LiteLLM_GuardrailsTableActions[prisma_models.LiteLLM_GuardrailsTable] = GuardrailsRepository(
+) -> "TableActions[prisma_models.LiteLLM_GuardrailsTable]":
+ guardrails_table: Final[TableActions[prisma_models.LiteLLM_GuardrailsTable]] = GuardrailsRepository(
prisma_client
).table
return guardrails_table
@@ -94,28 +88,26 @@ def _guardrails_table(
def _policies_table(
prisma_client: "PrismaClient",
-) -> "LiteLLM_PolicyTableActions[prisma_models.LiteLLM_PolicyTable]":
- policies_table: Final[LiteLLM_PolicyTableActions[prisma_models.LiteLLM_PolicyTable]] = PolicyRepository(
- prisma_client
- ).table
+) -> "TableActions[prisma_models.LiteLLM_PolicyTable]":
+ policies_table: Final[TableActions[prisma_models.LiteLLM_PolicyTable]] = PolicyRepository(prisma_client).table
return policies_table
def _daily_guardrail_metrics_table(
prisma_client: "PrismaClient",
-) -> "LiteLLM_DailyGuardrailMetricsActions[prisma_models.LiteLLM_DailyGuardrailMetrics]":
- metrics_table: Final[LiteLLM_DailyGuardrailMetricsActions[prisma_models.LiteLLM_DailyGuardrailMetrics]] = (
- DailyGuardrailMetricsRepository(prisma_client).table
- )
+) -> "TableActions[prisma_models.LiteLLM_DailyGuardrailMetrics]":
+ metrics_table: Final[TableActions[prisma_models.LiteLLM_DailyGuardrailMetrics]] = DailyGuardrailMetricsRepository(
+ prisma_client
+ ).table
return metrics_table
def _daily_policy_metrics_table(
prisma_client: "PrismaClient",
-) -> "LiteLLM_DailyPolicyMetricsActions[prisma_models.LiteLLM_DailyPolicyMetrics]":
- metrics_table: Final[LiteLLM_DailyPolicyMetricsActions[prisma_models.LiteLLM_DailyPolicyMetrics]] = (
- DailyPolicyMetricsRepository(prisma_client).table
- )
+) -> "TableActions[prisma_models.LiteLLM_DailyPolicyMetrics]":
+ metrics_table: Final[TableActions[prisma_models.LiteLLM_DailyPolicyMetrics]] = DailyPolicyMetricsRepository(
+ prisma_client
+ ).table
return metrics_table
@@ -135,8 +127,8 @@ async def _find_daily_policy_metrics(
def _daily_guardrail_usage_units_table(
prisma_client: "PrismaClient",
-) -> "LiteLLM_DailyGuardrailUsageUnitsActions[prisma_models.LiteLLM_DailyGuardrailUsageUnits]":
- units_table: Final[LiteLLM_DailyGuardrailUsageUnitsActions[prisma_models.LiteLLM_DailyGuardrailUsageUnits]] = (
+) -> "TableActions[prisma_models.LiteLLM_DailyGuardrailUsageUnits]":
+ units_table: Final[TableActions[prisma_models.LiteLLM_DailyGuardrailUsageUnits]] = (
DailyGuardrailUsageUnitsRepository(prisma_client).table
)
return units_table
diff --git a/litellm/proxy/guardrails/usage_tracking.py b/litellm/proxy/guardrails/usage_tracking.py
index 820f6438aaf..b8ae09afc00 100644
--- a/litellm/proxy/guardrails/usage_tracking.py
+++ b/litellm/proxy/guardrails/usage_tracking.py
@@ -14,6 +14,8 @@ from operator import itemgetter
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Final, NamedTuple, TypeVar
+from typing_extensions import ReadOnly, TypedDict
+
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import DB_RETRY_SAFE_ERROR_TYPES
from litellm.proxy.utils import PrismaClient
@@ -47,6 +49,18 @@ class _MetricsKey(NamedTuple):
date: str
+class _UsageUnitCompoundKey(TypedDict):
+ guardrail_id: ReadOnly[str]
+ date: ReadOnly[str]
+ team_id: ReadOnly[str]
+ api_key: ReadOnly[str]
+ usage_unit: ReadOnly[str]
+
+
+class _UsageUnitWhereUnique(TypedDict):
+ guardrail_id_date_team_id_api_key_usage_unit: ReadOnly[_UsageUnitCompoundKey]
+
+
class PendingRollups:
"""Rollup rows whose connection-error retries exhausted, held for the next flush."""
@@ -229,7 +243,7 @@ async def _upsert_usage_unit_row(prisma_client: PrismaClient, key: _UsageUnitKey
"usage_unit": key.usage_unit,
"units": units,
}
- where: Final[prisma_types.LiteLLM_DailyGuardrailUsageUnitsWhereUniqueInput] = {
+ where: Final[_UsageUnitWhereUnique] = {
"guardrail_id_date_team_id_api_key_usage_unit": {
"guardrail_id": key.guardrail_id,
"date": key.date,
diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py
index c803d87e436..0357bc7dbc6 100644
--- a/litellm/proxy/management_endpoints/access_group_endpoints.py
+++ b/litellm/proxy/management_endpoints/access_group_endpoints.py
@@ -388,7 +388,7 @@ async def list_access_groups(
_require_admin_view(user_api_key_dict)
prisma_client: Final = get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value)
- table: Final[_AccessGroupTable] = AccessGroupRepository(prisma_client).table
+ table: Final = AccessGroupRepository(prisma_client).table
records: Final = await table.find_many(order={"created_at": "desc"})
return [_record_to_response(r) for r in records]
@@ -404,7 +404,7 @@ async def get_access_group(
_require_admin_view(user_api_key_dict)
prisma_client: Final = get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value)
- table: Final[_AccessGroupTable] = AccessGroupRepository(prisma_client).table
+ table: Final = AccessGroupRepository(prisma_client).table
record: Final = await table.find_unique(where={"access_group_id": access_group_id})
if record is None:
raise HTTPException(
diff --git a/litellm/proxy/management_endpoints/budget_management_endpoints.py b/litellm/proxy/management_endpoints/budget_management_endpoints.py
index 8c6195388c5..17a845d4300 100644
--- a/litellm/proxy/management_endpoints/budget_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/budget_management_endpoints.py
@@ -93,7 +93,7 @@ async def new_budget(
budget_obj.budget_reset_at = get_budget_reset_time(budget_duration=budget_obj.budget_duration)
budget_obj_json: Final = budget_obj.model_dump(exclude_none=True)
- budget_obj_jsonified: Final = jsonify_object(budget_obj_json) # json dump any dictionaries
+ budget_obj_jsonified: Final[dict[str, object]] = jsonify_object(budget_obj_json) # mutable-ok: prisma create input
try:
response: Final = await BudgetRepository(prisma_client).table.create(
data={
diff --git a/litellm/proxy/management_endpoints/cache_settings_endpoints.py b/litellm/proxy/management_endpoints/cache_settings_endpoints.py
index 385073edc90..77ff77c9a88 100644
--- a/litellm/proxy/management_endpoints/cache_settings_endpoints.py
+++ b/litellm/proxy/management_endpoints/cache_settings_endpoints.py
@@ -43,7 +43,8 @@ router: Final = APIRouter()
class _CacheConfigRow(Protocol):
- cache_settings: str | Mapping[str, object] | None
+ @property
+ def cache_settings(self) -> str | Mapping[str, object] | None: ...
class _CacheConfigTable(Protocol):
diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py
index 3d2fa798e03..d3968bf323b 100644
--- a/litellm/proxy/management_endpoints/common_daily_activity.py
+++ b/litellm/proxy/management_endpoints/common_daily_activity.py
@@ -441,7 +441,7 @@ async def get_api_key_metadata(
This ensures that key_alias and team_id are preserved in historical activity logs
even after a key is deleted or regenerated.
"""
- key_records: list[PrismaVerificationToken] = await VerificationTokenRepository(prisma_client).table.find_many(
+ key_records: Sequence[PrismaVerificationToken] = await VerificationTokenRepository(prisma_client).table.find_many(
where={"token": {"in": list(api_keys)}}
)
result: Final[dict[str, _KeyMetadataDict]] = {
@@ -452,9 +452,9 @@ async def get_api_key_metadata(
missing_keys: Final = api_keys - set(result.keys())
if missing_keys:
try:
- deleted_key_records: Final[list[PrismaDeletedVerificationToken]] = await DeletedVerificationTokenRepository(
- prisma_client
- ).table.find_many(
+ deleted_key_records: Final[
+ Sequence[PrismaDeletedVerificationToken]
+ ] = await DeletedVerificationTokenRepository(prisma_client).table.find_many(
where={"token": {"in": list(missing_keys)}},
order={"deleted_at": "desc"},
)
diff --git a/litellm/proxy/management_endpoints/config_override_endpoints.py b/litellm/proxy/management_endpoints/config_override_endpoints.py
index dde0751d98d..7f4faddf178 100644
--- a/litellm/proxy/management_endpoints/config_override_endpoints.py
+++ b/litellm/proxy/management_endpoints/config_override_endpoints.py
@@ -46,7 +46,8 @@ router: Final = APIRouter()
class _ConfigOverrideRow(Protocol):
- config_value: str | Mapping[str, object] | None
+ @property
+ def config_value(self) -> str | Mapping[str, object] | None: ...
class _ConfigOverridesTableClient(Protocol):
diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py
index c2f5b8eeb8b..9a98bdbb6b1 100644
--- a/litellm/proxy/management_endpoints/internal_user_endpoints.py
+++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py
@@ -15,9 +15,9 @@ These are members of a Team on LiteLLM
import asyncio
import json
import traceback
-from collections.abc import Mapping, Sequence
+from collections.abc import Awaitable, Mapping, Sequence
from datetime import datetime, timezone
-from typing import Any, Final, Literal, Protocol, cast
+from typing import Any, Final, Literal, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
@@ -58,6 +58,7 @@ from litellm.proxy.management_helpers.object_permission_utils import (
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
from litellm.proxy.utils import handle_exception_on_proxy, hash_password
from litellm.repositories.organization_repository import OrganizationRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
InvitationLinkRepository,
OrganizationMembershipRepository,
@@ -86,15 +87,6 @@ from litellm.types.proxy.management_endpoints.scim_v2 import (
if TYPE_CHECKING:
from prisma import models as prisma_models
from prisma import types as prisma_types
- from prisma.actions import (
- LiteLLM_InvitationLinkActions,
- LiteLLM_OrganizationMembershipActions,
- LiteLLM_OrganizationTableActions,
- LiteLLM_TeamMembershipActions,
- LiteLLM_TeamTableActions,
- LiteLLM_UserTableActions,
- LiteLLM_VerificationTokenActions,
- )
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.proxy_server import PrismaClient
@@ -105,31 +97,31 @@ router: Final = APIRouter()
def _user_table(
prisma_client: "PrismaClient | None",
-) -> "LiteLLM_UserTableActions[prisma_models.LiteLLM_UserTable]":
- user_table: Final[LiteLLM_UserTableActions[prisma_models.LiteLLM_UserTable]] = UserRepository(prisma_client).table
+) -> "TableActions[prisma_models.LiteLLM_UserTable]":
+ user_table: Final[TableActions[prisma_models.LiteLLM_UserTable]] = UserRepository(prisma_client).table
return user_table
def _team_table(
prisma_client: "PrismaClient | None",
-) -> "LiteLLM_TeamTableActions[prisma_models.LiteLLM_TeamTable]":
- team_table: Final[LiteLLM_TeamTableActions[prisma_models.LiteLLM_TeamTable]] = TeamRepository(prisma_client).table
+) -> "TableActions[prisma_models.LiteLLM_TeamTable]":
+ team_table: Final[TableActions[prisma_models.LiteLLM_TeamTable]] = TeamRepository(prisma_client).table
return team_table
def _verification_token_table(
prisma_client: "PrismaClient | None",
-) -> "LiteLLM_VerificationTokenActions[prisma_models.LiteLLM_VerificationToken]":
- token_table: Final[LiteLLM_VerificationTokenActions[prisma_models.LiteLLM_VerificationToken]] = (
- VerificationTokenRepository(prisma_client).table
- )
+) -> "TableActions[prisma_models.LiteLLM_VerificationToken]":
+ token_table: Final[TableActions[prisma_models.LiteLLM_VerificationToken]] = VerificationTokenRepository(
+ prisma_client
+ ).table
return token_table
def _organization_membership_table(
prisma_client: "PrismaClient | None",
-) -> "LiteLLM_OrganizationMembershipActions[prisma_models.LiteLLM_OrganizationMembership]":
- membership_table: Final[LiteLLM_OrganizationMembershipActions[prisma_models.LiteLLM_OrganizationMembership]] = (
+) -> "TableActions[prisma_models.LiteLLM_OrganizationMembership]":
+ membership_table: Final[TableActions[prisma_models.LiteLLM_OrganizationMembership]] = (
OrganizationMembershipRepository(prisma_client).table
)
return membership_table
@@ -137,8 +129,8 @@ def _organization_membership_table(
def _invitation_link_table(
prisma_client: "PrismaClient | None",
-) -> "LiteLLM_InvitationLinkActions[prisma_models.LiteLLM_InvitationLink]":
- invitation_table: LiteLLM_InvitationLinkActions[prisma_models.LiteLLM_InvitationLink] = InvitationLinkRepository(
+) -> "TableActions[prisma_models.LiteLLM_InvitationLink]":
+ invitation_table: Final[TableActions[prisma_models.LiteLLM_InvitationLink]] = InvitationLinkRepository(
prisma_client
).table
return invitation_table
@@ -146,19 +138,19 @@ def _invitation_link_table(
def _organization_table(
prisma_client: "PrismaClient | None",
-) -> "LiteLLM_OrganizationTableActions[prisma_models.LiteLLM_OrganizationTable]":
- organization_table: Final[LiteLLM_OrganizationTableActions[prisma_models.LiteLLM_OrganizationTable]] = (
- OrganizationRepository(prisma_client).table
- )
+) -> "TableActions[prisma_models.LiteLLM_OrganizationTable]":
+ organization_table: Final[TableActions[prisma_models.LiteLLM_OrganizationTable]] = OrganizationRepository(
+ prisma_client
+ ).table
return organization_table
def _team_membership_table(
prisma_client: "PrismaClient | None",
-) -> "LiteLLM_TeamMembershipActions[prisma_models.LiteLLM_TeamMembership]":
- team_membership_table: Final[LiteLLM_TeamMembershipActions[prisma_models.LiteLLM_TeamMembership]] = (
- TeamMembershipRepository(prisma_client).table
- )
+) -> "TableActions[prisma_models.LiteLLM_TeamMembership]":
+ team_membership_table: Final[TableActions[prisma_models.LiteLLM_TeamMembership]] = TeamMembershipRepository(
+ prisma_client
+ ).table
return team_membership_table
@@ -294,7 +286,7 @@ async def _add_user_to_organizations(
organization_member_add,
)
- tasks: Final = []
+ tasks: Final[list[Awaitable[object]]] = []
for organization_id in organizations:
tasks.append(
organization_member_add(
@@ -406,7 +398,7 @@ async def add_new_user_to_default_team(
teams: list[str] | list[NewUserRequestTeam],
prisma_client: "PrismaClient",
):
- tasks: Final = []
+ tasks: Final[list[Awaitable[object]]] = []
for team in teams:
user_role: Literal["user", "admin"] = "user"
max_budget_in_team: float | None = None
@@ -1479,7 +1471,8 @@ async def _update_single_user_helper(
# Create new user if not found
non_default_values["user_id"] = str(uuid.uuid4())
non_default_values["user_email"] = user_request.user_email
- response = await prisma_client.insert_data(data=non_default_values, table_name="user")
+ inserted_user_row: Final = await prisma_client.insert_data(data=non_default_values, table_name="user")
+ response = inserted_user_row # pyright: ignore[reportAssignmentType] # insert_data returns a prisma row
if response is not None:
await _schedule_user_update_audit_log(
@@ -1795,7 +1788,9 @@ async def bulk_user_update(
# Apply update transformations (reuse existing logic)
data_json: Final[dict] = data.user_updates.model_dump(exclude_unset=True)
- non_default_values: Final = _update_internal_user_params(data_json=data_json, data=data.user_updates)
+ non_default_values: Final[dict[str, object]] = _update_internal_user_params(
+ data_json=data_json, data=data.user_updates
+ )
# Remove user identification fields since we're updating by user_id
non_default_values.pop("user_id", None)
@@ -2149,7 +2144,7 @@ async def get_users(
_validate_sort_params(sort_by, sort_order) if sort_by is not None and isinstance(sort_by, str) else None
)
- users: Sequence[prisma_models.LiteLLM_UserTable] | None = await UserRepository(prisma_client).table.find_many(
+ users: Final[Sequence[prisma_models.LiteLLM_UserTable]] = await UserRepository(prisma_client).table.find_many(
where=where_conditions,
skip=skip,
take=page_size,
@@ -2160,10 +2155,7 @@ async def get_users(
total_count: Final[int] = await UserRepository(prisma_client).table.count(where=where_conditions)
# Get key count for each user
- if users is not None:
- user_key_counts = await get_user_key_counts(prisma_client, [user.user_id for user in users])
- else:
- user_key_counts = {}
+ user_key_counts: Final = await get_user_key_counts(prisma_client, [user.user_id for user in users])
verbose_proxy_logger.debug("Total count of users: %s", total_count)
@@ -2172,17 +2164,14 @@ async def get_users(
# Prepare response
user_list: list[LiteLLM_UserTableWithKeyCount] = []
- if users is not None:
- for user in users:
- user_dump = user.model_dump()
- user_dump["metadata"] = _redact_scim_enterprise_metadata(user_dump.get("metadata"))
- user_list.append(
- LiteLLM_UserTableWithKeyCount.model_validate(
- {**user_dump, "key_count": user_key_counts.get(user.user_id, 0)}
- )
+ for user in users:
+ user_dump = user.model_dump()
+ user_dump["metadata"] = _redact_scim_enterprise_metadata(user_dump.get("metadata"))
+ user_list.append(
+ LiteLLM_UserTableWithKeyCount.model_validate(
+ {**user_dump, "key_count": user_key_counts.get(user.user_id, 0)}
)
- else:
- user_list = []
+ )
return {
"users": user_list,
@@ -2193,13 +2182,6 @@ async def get_users(
}
-class _DeleteTeamRow(Protocol):
- team_id: str
- members_with_roles: object
-
- def model_dump(self) -> Mapping[str, object]: ...
-
-
@router.post(
"/user/delete",
tags=["Internal User management"],
@@ -2258,9 +2240,9 @@ async def delete_user(
# loop an org-admin of org-A could delete users in org-B by supplying
# {"user_ids": [victim_in_org_B], "organization_id": "org-A"}.
caller_is_proxy_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value
- caller_admin_org_ids: set = set()
+ caller_admin_org_ids: set[str] = set()
if not caller_is_proxy_admin:
- caller_memberships: Final = (
+ caller_memberships: Final[Sequence[prisma_models.LiteLLM_OrganizationMembership]] = (
await _organization_membership_table(prisma_client).find_many(
where={
"user_id": user_api_key_dict.user_id,
@@ -2279,7 +2261,7 @@ async def delete_user(
# Batch-fetch target memberships once before the per-user loop. Avoids
# an N+1 DB call when delete_user is called with a large user_ids list.
- target_org_ids_by_user: Final[dict[str, set]] = {}
+ target_org_ids_by_user: Final[dict[str, set[str]]] = {}
if not caller_is_proxy_admin:
all_target_memberships: Final = await _organization_membership_table(prisma_client).find_many(
where={"user_id": {"in": data.user_ids}}
@@ -2319,7 +2301,7 @@ async def delete_user(
# we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes
if is_audit_logging_enabled():
# make an audit log for each team deleted
- _user_row = user_row.json(exclude_none=True)
+ _user_row = user_row.model_dump_json(exclude_none=True)
asyncio.create_task(
create_audit_log_for_update(
@@ -2342,10 +2324,10 @@ async def delete_user(
)
## CLEANUP MEMBERS_WITH_ROLES
- fetch_all_teams: Sequence[_DeleteTeamRow] = await TeamRepository(prisma_client).table.find_many(
- where={"team_id": {"in": user_row.teams}}
- )
- teams_to_update = []
+ fetch_all_teams: Sequence[prisma_models.LiteLLM_TeamTable] = await TeamRepository(
+ prisma_client
+ ).table.find_many(where={"team_id": {"in": user_row.teams}})
+ teams_to_update: list[tuple[str, str]] = []
for team in fetch_all_teams:
removed_team_members, new_team_members = _cleanup_members_with_roles(
existing_team_row=LiteLLM_TeamTable.model_validate(team.model_dump()),
@@ -2357,15 +2339,14 @@ async def delete_user(
)
if removed_team_members:
_db_new_team_members: list[dict] = [m.model_dump() for m in new_team_members]
- team.members_with_roles = json.dumps(_db_new_team_members)
- teams_to_update.append(team)
+ teams_to_update.append((team.team_id, json.dumps(_db_new_team_members)))
## update teams
- for team in teams_to_update:
+ for team_id, members_with_roles in teams_to_update:
await TeamRepository(prisma_client).table.update(
- where={"team_id": team.team_id},
- data={"members_with_roles": team.members_with_roles},
+ where={"team_id": team_id},
+ data={"members_with_roles": members_with_roles},
)
# End of Audit logging
diff --git a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py
index 41e52f05c01..9f561eadfbd 100644
--- a/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py
+++ b/litellm/proxy/management_endpoints/jwt_key_mapping_endpoints.py
@@ -122,6 +122,9 @@ async def update_jwt_key_mapping(
where={"id": data.id}, data=update_data
)
+ if updated_mapping is None:
+ raise HTTPException(status_code=404, detail="Mapping not found")
+
# Invalidate new cache key if claim fields changed
cache_key = f"jwt_key_mapping:{updated_mapping.jwt_claim_name}:{updated_mapping.jwt_claim_value}"
await user_api_key_cache.async_delete_cache(cache_key)
diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py
index 54f567b7aa2..f6a4448e7c9 100644
--- a/litellm/proxy/management_endpoints/key_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/key_management_endpoints.py
@@ -123,6 +123,7 @@ from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.config_repository import ConfigParam, ConfigRepository
from litellm.repositories.credentials_repository import CredentialsRepository
from litellm.repositories.model_repository import ModelRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
DeletedVerificationTokenRepository,
DeprecatedVerificationTokenRepository,
@@ -151,65 +152,22 @@ from litellm.types.utils import (
if TYPE_CHECKING:
from prisma import Prisma
+ from prisma import models as prisma_models
-_PrismaRowT = TypeVar("_PrismaRowT")
_RepositoryModelT = TypeVar("_RepositoryModelT", bound=BaseModel)
-class _PrismaTableActions(Protocol[_PrismaRowT]):
- """Typed view of the Prisma table actions a repository exposes through its untyped ``table``."""
-
- async def find_unique(
- self,
- *,
- where: Mapping[str, object],
- include: Mapping[str, object] | None = None,
- ) -> _PrismaRowT | None: ...
-
- async def find_first(
- self,
- *,
- where: Mapping[str, object],
- include: Mapping[str, object] | None = None,
- ) -> _PrismaRowT | None: ...
-
- async def find_many(
- self,
- *,
- where: Mapping[str, object] | None = None,
- include: Mapping[str, object] | None = None,
- order: Mapping[str, object] | None = None,
- skip: int | None = None,
- take: int | None = None,
- ) -> list[_PrismaRowT]: ...
-
- async def count(self, *, where: Mapping[str, object] | None = None) -> int: ...
-
- async def create(self, *, data: Mapping[str, object]) -> _PrismaRowT: ...
-
- async def create_many(self, *, data: Sequence[Mapping[str, object]]) -> int: ...
-
- async def delete_many(self, *, where: Mapping[str, object] | None = None) -> int: ...
-
- async def update(
- self,
- *,
- where: Mapping[str, object],
- data: Mapping[str, object],
- ) -> _PrismaRowT | None: ...
-
- async def upsert(
- self,
- *,
- where: Mapping[str, object],
- data: Mapping[str, object],
- ) -> _PrismaRowT: ...
-
-
class _UserRowLike(Protocol):
- user_id: str | None
- user_email: str | None
- user_alias: str | None
+ """Read-only view of the user columns ``/key/list`` expands keys with."""
+
+ @property
+ def user_id(self) -> str | None: ...
+
+ @property
+ def user_email(self) -> str | None: ...
+
+ @property
+ def user_alias(self) -> str | None: ...
def model_dump(self) -> Mapping[str, object]: ...
@@ -217,46 +175,56 @@ class _UserRowLike(Protocol):
class _TxTables(Protocol):
- litellm_proxymodeltable: _PrismaTableActions[object]
+ litellm_proxymodeltable: TableActions[object]
-class _TableSource(Protocol[_PrismaRowT]):
- """Repository view that exposes its untyped Prisma ``table`` with a concrete row type."""
+class _ConfigTableActions(Protocol):
+ """Config table surface this module needs; the shared repository seam exposes no ``update``."""
- @property
- def table(self) -> _PrismaTableActions[_PrismaRowT]: ...
+ async def find_many(self) -> Sequence[ConfigParam]: ...
-
-def _table_of(source: _TableSource[_PrismaRowT]) -> _PrismaTableActions[_PrismaRowT]:
- return source.table
+ async def update(
+ self,
+ *,
+ where: Mapping[str, object],
+ data: Mapping[str, object],
+ ) -> ConfigParam | None: ...
def _prisma_table(
repository: BaseRepository[_RepositoryModelT],
-) -> _PrismaTableActions[_RepositoryModelT]:
- return _table_of(repository)
+) -> TableActions[_RepositoryModelT]:
+ return cast( # cast-ok: callers read only the field names the prisma row and repository model share
+ "TableActions[_RepositoryModelT]", repository.table
+ )
def _deleted_verification_token_table(
prisma_client: PrismaClient,
-) -> _PrismaTableActions[LiteLLM_DeletedVerificationToken]:
- return _table_of(DeletedVerificationTokenRepository(prisma_client))
+) -> "TableActions[prisma_models.LiteLLM_DeletedVerificationToken]":
+ return DeletedVerificationTokenRepository(prisma_client).table
-def _deprecated_verification_token_table(prisma_client: PrismaClient) -> _PrismaTableActions[object]:
- return _table_of(DeprecatedVerificationTokenRepository(prisma_client))
+def _deprecated_verification_token_table(
+ prisma_client: PrismaClient,
+) -> "TableActions[prisma_models.LiteLLM_DeprecatedVerificationToken]":
+ return DeprecatedVerificationTokenRepository(prisma_client).table
-def _user_table(prisma_client: PrismaClient) -> _PrismaTableActions[_UserRowLike]:
- return _table_of(UserRepository(prisma_client))
+def _user_table(prisma_client: PrismaClient) -> TableActions[_UserRowLike]:
+ return UserRepository(prisma_client).table
-def _credentials_table(prisma_client: PrismaClient) -> _PrismaTableActions[CredentialItem]:
- return _table_of(CredentialsRepository(prisma_client))
+def _credentials_table(prisma_client: PrismaClient) -> TableActions[CredentialItem]:
+ return cast( # cast-ok: the rotation loop reads and rewrites these rows through CredentialItem names only
+ "TableActions[CredentialItem]", CredentialsRepository(prisma_client).table
+ )
-def _config_table(prisma_client: PrismaClient) -> _PrismaTableActions[ConfigParam]:
- return _table_of(ConfigRepository(prisma_client))
+def _config_table(prisma_client: PrismaClient) -> _ConfigTableActions:
+ return cast( # cast-ok: ConfigRepository.table hides the write actions this module needs on that same object
+ "_ConfigTableActions", ConfigRepository(prisma_client).table
+ )
async def _check_custom_key_allowed(custom_key_value: str | None) -> None:
@@ -1046,7 +1014,7 @@ async def _common_key_generation_helper(
)
new_budget: Final = prisma_client.jsonify_object(budget_row.json(exclude_none=True))
- _budget: Final[LiteLLM_BudgetTable] = await BudgetRepository(prisma_client).table.create(
+ _budget: Final[prisma_models.LiteLLM_BudgetTable] = await BudgetRepository(prisma_client).table.create(
data={
**new_budget,
"created_by": user_api_key_dict.user_id or litellm_proxy_admin_name,
@@ -1252,7 +1220,7 @@ async def _common_key_generation_helper(
def _check_key_model_specific_limits(
- keys: list[LiteLLM_VerificationToken],
+ keys: Sequence[LiteLLM_VerificationToken],
data: GenerateKeyRequest | UpdateKeyRequest,
entity_rpm_limit: int | None,
entity_tpm_limit: int | None,
@@ -1323,7 +1291,7 @@ def _check_key_model_specific_limits(
def _check_key_rpm_tpm_limits(
- keys: list[LiteLLM_VerificationToken],
+ keys: Sequence[LiteLLM_VerificationToken],
data: GenerateKeyRequest | UpdateKeyRequest,
entity_rpm_limit: int | None,
entity_tpm_limit: int | None,
@@ -1361,7 +1329,7 @@ def _check_key_rpm_tpm_limits(
def check_team_key_model_specific_limits(
- keys: list[LiteLLM_VerificationToken],
+ keys: Sequence[LiteLLM_VerificationToken],
team_table: LiteLLM_TeamTableCachedObj,
data: GenerateKeyRequest | UpdateKeyRequest,
) -> None:
@@ -1386,7 +1354,7 @@ def check_team_key_model_specific_limits(
def check_team_key_rpm_tpm_limits(
- keys: list[LiteLLM_VerificationToken],
+ keys: Sequence[LiteLLM_VerificationToken],
team_table: LiteLLM_TeamTableCachedObj,
data: GenerateKeyRequest | UpdateKeyRequest,
) -> None:
@@ -1494,7 +1462,7 @@ async def _check_project_key_limits(
def check_org_key_model_specific_limits(
- keys: list[LiteLLM_VerificationToken],
+ keys: Sequence[LiteLLM_VerificationToken],
org_table: LiteLLM_OrganizationTable,
data: GenerateKeyRequest | UpdateKeyRequest,
) -> None:
@@ -1527,7 +1495,7 @@ def check_org_key_model_specific_limits(
def check_org_key_rpm_tpm_limits(
- keys: list[LiteLLM_VerificationToken],
+ keys: Sequence[LiteLLM_VerificationToken],
org_table: LiteLLM_OrganizationTable,
data: GenerateKeyRequest | UpdateKeyRequest,
) -> None:
@@ -2242,9 +2210,9 @@ async def _get_and_validate_existing_key(
code=status.HTTP_400_BAD_REQUEST,
)
- rows: list[LiteLLM_VerificationToken] = await _prisma_table(VerificationTokenRepository(prisma_client)).find_many(
- where={"key_alias": key_alias}, take=2
- )
+ rows: Sequence[LiteLLM_VerificationToken] = await _prisma_table(
+ VerificationTokenRepository(prisma_client)
+ ).find_many(where={"key_alias": key_alias}, take=2)
if len(rows) == 0:
raise ProxyException(
@@ -2407,7 +2375,10 @@ async def _process_single_key_update(
)
_data: Final = {**non_default_values, "token": update_key_request.key}
- response: Final = await prisma_client.update_data(token=update_key_request.key, data=_data)
+ response: Final[Mapping[str, object] | None] = cast( # cast-ok: every update_data branch returns a str-keyed dict
+ "Mapping[str, object] | None",
+ await prisma_client.update_data(token=update_key_request.key, data=_data),
+ )
# Delete cache
await _delete_cache_key_object(
@@ -3225,7 +3196,7 @@ async def bulk_update_team_keys(
# `blocked` is Boolean? with no default; `/key/generate` writes NULL. Prisma's `NOT`
# excludes NULLs, so explicitly OR `false` with `null` to include them.
now: Final = datetime.now(timezone.utc)
- existing_keys = await VerificationTokenRepository(prisma_client).table.find_many(
+ existing_keys = await _prisma_table(VerificationTokenRepository(prisma_client)).find_many(
where={
"team_id": data.team_id,
"AND": [
@@ -3243,7 +3214,9 @@ async def bulk_update_team_keys(
"error": f"Team {data.team_id} has more than {MAX_BATCH_SIZE} keys. Use `key_ids` to update in batches of {MAX_BATCH_SIZE}."
},
)
- requested_tokens = [row.token for row in existing_keys]
+ requested_tokens = cast( # cast-ok: token is the table's primary key, so a row read back always carries one
+ "list[str]", [row.token for row in existing_keys]
+ )
else:
if data.key_ids is None or len(data.key_ids) == 0:
raise HTTPException(
@@ -3261,7 +3234,7 @@ async def bulk_update_team_keys(
seen_hashes.add(h)
requested_tokens.append(k)
hashed_key_ids.append(h)
- existing_keys = await VerificationTokenRepository(prisma_client).table.find_many(
+ existing_keys = await _prisma_table(VerificationTokenRepository(prisma_client)).find_many(
where={"team_id": data.team_id, "token": {"in": hashed_key_ids}}
)
@@ -3698,7 +3671,7 @@ async def info_key_fn(
hashed_key: str | None = key
if key is not None:
hashed_key = _hash_token_if_needed(token=key)
- key_info = await VerificationTokenRepository(prisma_client).table.find_unique(
+ key_info = await _prisma_table(VerificationTokenRepository(prisma_client)).find_unique(
where={"token": hashed_key},
include={"litellm_budget_table": True},
)
@@ -3727,7 +3700,7 @@ async def info_key_fn(
key_info = key_info.model_dump()
except Exception:
# if using pydantic v1
- key_info = key_info.dict()
+ key_info = key_info.dict() # pyright: ignore[reportDeprecated] # deliberate pydantic v1 fallback
key_token_hash: Final = key_info.pop("token")
model_max_budget = key_info.get("model_max_budget") or {}
@@ -4012,7 +3985,10 @@ async def generate_key_helper_fn(
if table_name is None or table_name == "user": # do not auto-create users for `/key/generate`
## CREATE USER (If necessary)
if query_type == "insert_data":
- user_row = await prisma_client.insert_data(data=user_data, table_name="user")
+ user_row = cast( # cast-ok: table_name="user" is the insert_data branch returning the user row
+ "prisma_models.LiteLLM_UserTable | None",
+ await prisma_client.insert_data(data=user_data, table_name="user"),
+ )
if user_row is None:
raise Exception("Failed to create user")
@@ -4219,9 +4195,12 @@ async def delete_verification_tokens(
if prisma_client:
hashed_tokens: Final[list[str]] = [_hash_token_if_needed(token=key) for key in tokens]
tokens = hashed_tokens
- _keys_being_deleted: Final[list[LiteLLM_VerificationToken]] = await _prisma_table(
- VerificationTokenRepository(prisma_client)
- ).find_many(where={"token": {"in": hashed_tokens}})
+ _keys_being_deleted: Final[list[LiteLLM_VerificationToken]] = cast( # cast-ok: find_many returns a list
+ "list[LiteLLM_VerificationToken]",
+ await _prisma_table(VerificationTokenRepository(prisma_client)).find_many(
+ where={"token": {"in": hashed_tokens}}
+ ),
+ )
if len(_keys_being_deleted) == 0:
raise HTTPException(
@@ -4297,7 +4276,7 @@ async def delete_verification_tokens(
def _transform_verification_tokens_to_deleted_records(
- keys: list[LiteLLM_VerificationToken],
+ keys: Sequence[LiteLLM_VerificationToken],
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None = None,
) -> list[dict[str, object]]:
@@ -4372,7 +4351,7 @@ async def _save_deleted_verification_token_records(
async def _persist_deleted_verification_tokens(
- keys: list[LiteLLM_VerificationToken],
+ keys: Sequence[LiteLLM_VerificationToken],
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,
litellm_changed_by: str | None = None,
@@ -4435,7 +4414,9 @@ async def _rotate_master_key(
from litellm.proxy.proxy_server import proxy_config
try:
- models: list | None = await _prisma_table(ModelRepository(prisma_client)).find_many()
+ models: list | None = cast( # cast-ok: find_many returns a real list, which TableActions widens to Sequence
+ "list[object]", await _prisma_table(ModelRepository(prisma_client)).find_many()
+ )
except Exception:
models = None
# 2. process model table
@@ -5361,9 +5342,9 @@ async def validate_key_list_check(
if key_hash:
try:
- key_info: Final[LiteLLM_VerificationToken] = await VerificationTokenRepository(
- prisma_client
- ).table.find_unique(
+ key_info: Final[LiteLLM_VerificationToken | None] = await _prisma_table(
+ VerificationTokenRepository(prisma_client)
+ ).find_unique(
where={"token": key_hash},
)
except Exception:
@@ -5373,6 +5354,13 @@ async def validate_key_list_check(
param="key_hash",
code=status.HTTP_403_FORBIDDEN,
)
+ if key_info is None:
+ raise ProxyException(
+ message="Key Hash not found.",
+ type=ProxyErrorTypes.bad_request_error,
+ param="key_hash",
+ code=status.HTTP_403_FORBIDDEN,
+ )
can_user_query_key_info: Final = await _can_user_query_key_info(
user_api_key_dict=user_api_key_dict,
key=key_hash,
@@ -5394,8 +5382,9 @@ async def _fetch_user_team_objects(
if complete_user_info is None or not complete_user_info.teams:
return []
- teams: Final[list[BaseModel] | None] = await TeamRepository(prisma_client).table.find_many(
- where={"team_id": {"in": complete_user_info.teams}}
+ teams: Final[Sequence[BaseModel] | None] = cast( # cast-ok: the None guard below predates the non-optional seam
+ "Sequence[BaseModel] | None",
+ await TeamRepository(prisma_client).table.find_many(where={"team_id": {"in": complete_user_info.teams}}),
)
if teams is None:
return []
@@ -6130,7 +6119,7 @@ async def _list_key_helper(
key_dict = key.model_dump()
except Exception:
# Fallback for Pydantic v1 compatibility
- key_dict = key.dict()
+ key_dict = key.dict() # pyright: ignore[reportDeprecated] # deliberate pydantic v1 fallback
# Attach object_permission if object_permission_id is set (only for non-deleted keys)
if not use_deleted_table:
key_dict = await attach_object_permission_to_dict(key_dict, prisma_client)
@@ -6155,7 +6144,9 @@ async def _list_key_helper(
# Use deleted key type to preserve deleted_at, deleted_by, etc.
key_list.append(LiteLLM_DeletedVerificationToken.model_validate(key_dict))
else:
- key_list.append(UserAPIKeyAuth(**key_dict)) # Return full key object
+ key_list.append(
+ UserAPIKeyAuth(**key_dict) # pyright: ignore[reportAny] # model_dump() is dict[str, Any]
+ )
else:
_token = key_dict.get("token")
key_list.append(cast(str, _token)) # Return only the token
diff --git a/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py b/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py
index 8e8545a51cc..e1a7645e988 100644
--- a/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/model_access_group_management_endpoints.py
@@ -40,17 +40,22 @@ router: Final = APIRouter()
class _DeploymentRow(Protocol):
- model_id: str
- model_name: str
- model_info: object
+ @property
+ def model_id(self) -> str: ...
+
+ @property
+ def model_name(self) -> str: ...
+
+ @property
+ def model_info(self) -> object: ...
class _ModelTableClient(Protocol):
- async def find_many(self, where: Mapping[str, object] | None = None) -> Sequence[_DeploymentRow]: ...
+ async def find_many(self, *, where: Mapping[str, object] | None = None) -> Sequence[_DeploymentRow]: ...
- async def find_unique(self, where: Mapping[str, object]) -> _DeploymentRow | None: ...
+ async def find_unique(self, *, where: Mapping[str, object]) -> _DeploymentRow | None: ...
- async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> object: ...
+ async def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> object: ...
def _model_table(prisma_client: PrismaClient) -> _ModelTableClient:
@@ -322,7 +327,9 @@ async def get_all_access_groups_from_db(
for deployment in deployments:
model_info = deployment.model_info or {}
- access_groups = model_info.get("access_groups", [])
+ access_groups = model_info.get( # pyright: ignore[reportAttributeAccessIssue] # Json reads back as a dict
+ "access_groups", []
+ )
model_name = deployment.model_name
for access_group in access_groups:
diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py
index 217fc61a56c..3242c6f6084 100644
--- a/litellm/proxy/management_endpoints/model_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/model_management_endpoints.py
@@ -16,7 +16,7 @@ import json
from collections.abc import Awaitable, Mapping, Sequence
from json import JSONDecodeError
from types import MappingProxyType
-from typing import Final, Literal, Protocol, cast
+from typing import TYPE_CHECKING, Final, Literal, Protocol, cast
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field, ValidationError
@@ -72,6 +72,7 @@ from litellm.proxy.spend_tracking.ptu_feature_flag import (
)
from litellm.proxy.utils import PrismaClient, ProxyLogging
from litellm.repositories.model_repository import ModelRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import ModelTableRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.router import Router
@@ -100,6 +101,9 @@ from litellm.types.router import (
)
from litellm.utils import get_utc_datetime
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
router: Final = APIRouter()
@@ -120,10 +124,14 @@ class UpdatePublicModelGroupsRequest(BaseModel):
class _ProxyModelRow(Protocol):
- model_id: str
- model_name: str
- litellm_params: Mapping[str, object]
- model_info: Mapping[str, object] | None
+ @property
+ def model_id(self) -> str: ...
+
+ @property
+ def model_name(self) -> str: ...
+
+ @property
+ def model_info(self) -> object: ...
def model_dump_json(self, *, exclude_none: bool = False) -> str: ...
@@ -133,7 +141,9 @@ class _ProxyModelTable(Protocol):
def find_many(self, *, where: Mapping[str, object]) -> Awaitable[Sequence[_ProxyModelRow]]: ...
- def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> Awaitable[_ProxyModelRow]: ...
+ def update(
+ self, *, where: Mapping[str, object], data: Mapping[str, object]
+ ) -> Awaitable[_ProxyModelRow | None]: ...
def delete(self, *, where: Mapping[str, object]) -> Awaitable[_ProxyModelRow | None]: ...
@@ -144,41 +154,35 @@ class _TxModelTables(Protocol):
litellm_proxymodeltable: _ProxyModelTable
+class _ExistingModelRow(Protocol):
+ @property
+ def litellm_params(self) -> Mapping[str, object]: ...
+
+ def model_dump_json(self, *, exclude_none: bool = False) -> str: ...
+
+
class _TeamRow(Protocol):
- models: Sequence[str]
+ @property
+ def models(self) -> Sequence[str]: ...
def model_dump(self) -> Mapping[str, object]: ...
-class _TeamTable(Protocol):
+class _TeamLookupTable(Protocol):
def find_unique(self, *, where: Mapping[str, object]) -> Awaitable[_TeamRow | None]: ...
+
+class _TeamTable(_TeamLookupTable, Protocol):
def update(
self, *, where: Mapping[str, object], data: Mapping[str, object], include: Mapping[str, bool]
) -> Awaitable[LiteLLM_TeamTable]: ...
-class _TeamIdRef(Protocol):
- team_id: str
-
-
-class _ModelAliasRow(Protocol):
- id: int
- model_aliases: dict[str, str]
- team: _TeamIdRef | None
-
-
-class _ModelAliasTable(Protocol):
- def find_many(self, *, include: Mapping[str, bool]) -> Awaitable[Sequence[_ModelAliasRow]]: ...
-
- def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> Awaitable[object]: ...
-
-
def _proxy_model_table(prisma_client: PrismaClient) -> _ProxyModelTable:
return ModelRepository(prisma_client).table
-def _repo_team_table(prisma_client: PrismaClient) -> _TeamTable:
+def _repo_team_table(prisma_client: PrismaClient) -> _TeamLookupTable:
return TeamRepository(prisma_client).table
@@ -186,7 +190,7 @@ def _db_team_table(prisma_client: PrismaClient) -> _TeamTable:
return prisma_client.db.litellm_teamtable
-def _model_alias_table(prisma_client: PrismaClient) -> _ModelAliasTable:
+def _model_alias_table(prisma_client: PrismaClient) -> "TableActions[prisma_models.LiteLLM_ModelTable]":
return ModelTableRepository(prisma_client).table
@@ -677,6 +681,14 @@ async def patch_model(
data=update_data,
)
+ if updated_model is None:
+ raise ProxyException(
+ message=f"Model {model_id} not found on proxy.",
+ type=ProxyErrorTypes.not_found_error,
+ code=status.HTTP_404_NOT_FOUND,
+ param=None,
+ )
+
# Clear cache and reload models (uses config setting or defaults to preserving config models for DB updates)
live_before_reload: Final = live_model_ids_snapshot()
reload_outcome: Final = await clear_cache()
@@ -811,7 +823,7 @@ async def _set_model_blocked_status(
live_after=reload_outcome.live_after,
)
- return updated_model
+ return updated_model # pyright: ignore[reportReturnType] # prisma row, coerced by this route's response_model
except Exception as e:
verbose_proxy_logger.exception("Error in model %s: %s", action, e)
@@ -897,7 +909,7 @@ async def _add_model_to_db(
prisma_client: PrismaClient,
new_encryption_key: str | None = None,
should_create_model_in_db: bool = True,
-) -> LiteLLM_ProxyModelTable | None:
+) -> "prisma_models.LiteLLM_ProxyModelTable | LiteLLM_ProxyModelTable | None":
# encrypt litellm params #
_litellm_params_dict: Final = model_params.litellm_params.dict(exclude_none=True)
_original_litellm_model_name: Final = model_params.litellm_params.model
@@ -914,8 +926,9 @@ async def _add_model_to_db(
}
if model_params.model_info.id is not None:
_data["model_id"] = model_params.model_info.id
+ _create_data: Final = cast("Mapping[str, object]", _data) # cast-ok: str-keyed json payload built just above
if should_create_model_in_db:
- model_response = await ModelRepository(prisma_client).table.create(data=_data)
+ model_response = await ModelRepository(prisma_client).table.create(data=_create_data)
else:
model_response = LiteLLM_ProxyModelTable(**_data)
return model_response
@@ -925,7 +938,7 @@ async def _add_team_model_to_db(
model_params: Deployment,
user_api_key_dict: UserAPIKeyAuth,
prisma_client: PrismaClient,
-) -> LiteLLM_ProxyModelTable | None:
+) -> "prisma_models.LiteLLM_ProxyModelTable | LiteLLM_ProxyModelTable | None":
"""
If 'team_id' is provided,
@@ -1638,7 +1651,9 @@ async def delete_team_model_alias(
tasks: Final = []
removed_model_aliases: Final[list[tuple[str, str]]] = []
for team_model_alias in team_model_aliases:
- model_aliases = team_model_alias.model_aliases # {"alias": "public model name"}
+ model_aliases = cast( # cast-ok: prisma types Json columns as `str`; the driver hands back the parsed dict
+ "dict[str, str]", team_model_alias.model_aliases
+ )
id = team_model_alias.id
if public_model_name in model_aliases.values():
@@ -1733,7 +1748,7 @@ async def add_new_model(
existing_params=None,
)
- model_response: LiteLLM_ProxyModelTable | None = None
+ model_response: prisma_models.LiteLLM_ProxyModelTable | LiteLLM_ProxyModelTable | None = None
# update DB
incoming_model_info: Final = model_params.model_info.model_dump(exclude_none=True)
_raise_if_ptu_cost_attribution_disabled(incoming_model_info)
@@ -1902,7 +1917,10 @@ async def update_model(
# update DB
if store_model_in_db is True:
- _existing_litellm_params_dict: Final = dict(_existing_litellm_params.litellm_params)
+ existing_model_row: Final = cast( # cast-ok: prisma types Json columns as `str`; the driver parses them
+ "_ExistingModelRow", _existing_litellm_params
+ )
+ _existing_litellm_params_dict: Final = dict(existing_model_row.litellm_params)
if model_params.litellm_params is None:
raise Exception("litellm_params not provided")
@@ -1946,8 +1964,8 @@ async def update_model(
user_api_key_dict=user_api_key_dict,
table_name=LitellmTableNames.PROXY_MODEL_TABLE_NAME,
before_value=(
- _existing_litellm_params.model_dump_json(exclude_none=True)
- if isinstance(_existing_litellm_params, BaseModel)
+ existing_model_row.model_dump_json(exclude_none=True)
+ if isinstance(existing_model_row, BaseModel)
else None
),
after_value=(
diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py
index ffca858c0ce..9198aa35f3f 100644
--- a/litellm/proxy/management_endpoints/organization_endpoints.py
+++ b/litellm/proxy/management_endpoints/organization_endpoints.py
@@ -14,7 +14,14 @@ Endpoints for /organization operations
#### ORGANIZATION MANAGEMENT ####
from collections.abc import Mapping, Sequence
-from typing import TYPE_CHECKING, Annotated, Final, Protocol, overload
+from typing import (
+ TYPE_CHECKING,
+ Annotated,
+ Final,
+ Protocol,
+ cast, # noqa: TID251 # prisma types Json columns as fields.Json but reads back plain python values
+ overload,
+)
import fastapi
from fastapi import APIRouter, Depends, HTTPException, Request, status
@@ -74,6 +81,11 @@ if TYPE_CHECKING:
router: Final = APIRouter()
+class _ObjectPermissionRow(Protocol):
+ @property
+ def object_permission_id(self) -> str | None: ...
+
+
class _UserTableClient(Protocol):
async def find_unique(self, where: Mapping[str, object]) -> "PrismaUserTable | None": ...
@@ -681,7 +693,10 @@ async def update_organization(
existing_metadata: Final = existing_organization_row.metadata or {}
updated_metadata: Final = updated_organization_row_json.get("metadata", {})
merged_metadata: Final[Mapping[str, object]] = _update_dictionary(
- existing_dict=existing_metadata.copy(), new_dict=updated_metadata
+ existing_dict=cast( # cast-ok: prisma de-serializes a Json column to the plain python dict it stores
+ "dict[str, object]", existing_metadata
+ ).copy(),
+ new_dict=updated_metadata,
)
updated_organization_row_json["metadata"] = merged_metadata
@@ -720,7 +735,7 @@ async def update_organization(
async def handle_update_object_permission(
data_json: dict[str, object],
- existing_organization_row: LiteLLM_OrganizationTable,
+ existing_organization_row: _ObjectPermissionRow,
) -> dict[str, object]:
"""
Handle the update of object permission for an organization.
@@ -1276,17 +1291,20 @@ async def find_member_if_email(user_email: str, prisma_client: PrismaClient) ->
Find a member if the user_email is in LiteLLM_UserTable
"""
+ not_unique_user_email_error: Final = HTTPException(
+ status_code=400,
+ detail={
+ "error": f"Unique user not found for user_email={user_email}. Potential duplicate OR non-existent user_email in LiteLLM_UserTable. Use 'user_id' instead."
+ },
+ )
try:
- existing_user_email_row: Final[BaseModel] = await UserRepository(prisma_client).table.find_unique(
+ existing_user_email_row: Final = await UserRepository(prisma_client).table.find_unique(
where={"user_email": user_email}
)
except Exception:
- raise HTTPException(
- status_code=400,
- detail={
- "error": f"Unique user not found for user_email={user_email}. Potential duplicate OR non-existent user_email in LiteLLM_UserTable. Use 'user_id' instead."
- },
- )
+ raise not_unique_user_email_error
+ if existing_user_email_row is None:
+ raise not_unique_user_email_error
existing_user_email_row_pydantic: Final = LiteLLM_UserTable.model_validate(existing_user_email_row.model_dump())
return existing_user_email_row_pydantic
@@ -1537,7 +1555,10 @@ async def add_member_to_organization(
_returned_user = await prisma_client.insert_data(data=new_user_defaults, table_name="user")
if _returned_user is not None:
user_object = LiteLLM_UserTable.model_validate(_returned_user.model_dump())
- elif existing_user_email_row is not None and len(existing_user_email_row) > 1:
+ elif existing_user_email_row is not None and (
+ len(existing_user_email_row) # pyright: ignore[reportArgumentType] # find_unique yields a row, not a list
+ > 1
+ ):
raise HTTPException(
status_code=400,
detail={"error": "Multiple users with this email found in db. Please use 'user_id' instead."},
diff --git a/litellm/proxy/management_endpoints/scim/scim_transformations.py b/litellm/proxy/management_endpoints/scim/scim_transformations.py
index 496be05b4b0..6a67093fde8 100644
--- a/litellm/proxy/management_endpoints/scim/scim_transformations.py
+++ b/litellm/proxy/management_endpoints/scim/scim_transformations.py
@@ -33,7 +33,8 @@ class ScimTransformations:
# Get user's teams/groups
groups: Final = []
- for team_id in user.teams or []:
+ team_ids: Final[list[str]] = user.teams or [] # mutable-ok: scim reads the user row's team ids
+ for team_id in team_ids:
team = await TeamRepository(prisma_client).table.find_unique(where={"team_id": team_id})
if team:
team_alias = getattr(team, "team_alias", team.team_id)
diff --git a/litellm/proxy/management_endpoints/scim/scim_v2.py b/litellm/proxy/management_endpoints/scim/scim_v2.py
index 6658963d024..67370e3511c 100644
--- a/litellm/proxy/management_endpoints/scim/scim_v2.py
+++ b/litellm/proxy/management_endpoints/scim/scim_v2.py
@@ -2766,6 +2766,12 @@ async def patch_group(
if final_team:
updated_team = final_team
+ if updated_team is None:
+ raise HTTPException(
+ status_code=404,
+ detail={"error": f"Group not found with ID: {group_id}"}, # mutable-ok: FastAPI detail contract
+ )
+
# Convert to SCIM format and return
scim_group: Final = await ScimTransformations.transform_litellm_team_to_scim_group(
LiteLLM_TeamTable.model_validate(updated_team.model_dump())
diff --git a/litellm/proxy/management_endpoints/tag_management_endpoints.py b/litellm/proxy/management_endpoints/tag_management_endpoints.py
index 7aeb5039687..b74aa1a4e16 100644
--- a/litellm/proxy/management_endpoints/tag_management_endpoints.py
+++ b/litellm/proxy/management_endpoints/tag_management_endpoints.py
@@ -369,10 +369,10 @@ async def _add_tag_to_deployment(deployment: "Deployment", tag: str):
# Prisma returns litellm_params as dict (already parsed from JSON)
existing_params = db_model.litellm_params
- if isinstance(existing_params, str):
+ if isinstance(existing_params, str): # pyright: ignore[reportUnnecessaryIsInstance] # prisma Json stub is str
# If it's a string, parse it
existing_params = json.loads(existing_params)
- elif not isinstance(existing_params, dict):
+ elif not isinstance(existing_params, dict): # pyright: ignore[reportUnnecessaryIsInstance] # prisma Json stub
raise Exception(f"Unexpected litellm_params type: {type(existing_params)}")
# Add tag to tags array (preserve encryption of other fields)
diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py
index 14a2a8a98a5..9a2ec38d627 100644
--- a/litellm/proxy/management_endpoints/team_callback_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py
@@ -352,6 +352,9 @@ async def add_team_callbacks(
include={"object_permission": True}, # mutable-ok: prisma include takes a dict literal
)
+ if new_team_row is None:
+ raise _callback_error(400, f"Team id = {team_id} does not exist. Please use a different team id.")
+
# Without this a newly registered callback stays dormant for existing keys.
await _refresh_cached_team(
team_row=new_team_row,
diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py
index ba4fb81323f..f2327b18914 100644
--- a/litellm/proxy/management_endpoints/team_endpoints.py
+++ b/litellm/proxy/management_endpoints/team_endpoints.py
@@ -16,7 +16,7 @@ import traceback
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from types import MappingProxyType
-from typing import Annotated, Final, NamedTuple, NoReturn, Protocol, TypeVar, cast
+from typing import TYPE_CHECKING, Annotated, Final, NamedTuple, NoReturn, Protocol, TypeVar, cast
import fastapi
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
@@ -34,21 +34,17 @@ from litellm.proxy._types import (
BudgetNewRequest,
CommonProxyErrors,
DeleteTeamRequest,
- LiteLLM_AccessGroupTable,
LiteLLM_AuditLogs,
- LiteLLM_BudgetTableFull,
LiteLLM_DeletedTeamTable,
LiteLLM_ManagementEndpoint_MetadataFields,
LiteLLM_ManagementEndpoint_MetadataFields_Premium,
LiteLLM_ModelTable,
- LiteLLM_OrganizationMembershipTable,
LiteLLM_OrganizationTable,
LiteLLM_OrganizationTableWithMembers,
LiteLLM_TeamMembership,
LiteLLM_TeamTable,
LiteLLM_TeamTableCachedObj,
LiteLLM_UserTable,
- LiteLLM_VerificationToken,
LitellmTableNames,
LitellmUserRoles,
Member,
@@ -143,6 +139,7 @@ from litellm.proxy.management_helpers.utils import (
from litellm.proxy.utils import PrismaClient, ProxyLogging, handle_exception_on_proxy
from litellm.repositories.budget_repository import BudgetRepository
from litellm.repositories.organization_repository import OrganizationRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
AccessGroupRepository,
DeletedTeamRepository,
@@ -174,6 +171,10 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
UpdateTeamMemberPermissionsRequest,
)
+if TYPE_CHECKING:
+ from prisma import Prisma
+ from prisma import models as prisma_models
+
router: Final = APIRouter()
_DbRecordT = TypeVar("_DbRecordT")
@@ -188,95 +189,14 @@ class _TeamIdGroupRow(TypedDict):
_count: _TeamIdKeyCount
-class _PrismaTableActions(Protocol[_DbRecordT]):
- async def find_unique(
- self,
- where: Mapping[str, object],
- include: Mapping[str, bool] | None = None,
- ) -> _DbRecordT | None: ...
-
- async def find_first(
- self,
- where: Mapping[str, object] | None = None,
- order: Mapping[str, str] | None = None,
- ) -> _DbRecordT | None: ...
-
- async def find_many(
- self,
- where: Mapping[str, object] | None = None,
- include: Mapping[str, bool] | None = None,
- order: Mapping[str, str] | None = None,
- skip: int | None = None,
- take: int | None = None,
- cursor: Mapping[str, object] | None = None,
- ) -> list[_DbRecordT]: ...
-
- async def create(
- self,
- data: Mapping[str, object],
- include: Mapping[str, bool] | None = None,
- ) -> _DbRecordT: ...
-
- async def create_many(
- self,
- data: Sequence[Mapping[str, object]],
- skip_duplicates: bool | None = None,
- ) -> int: ...
-
- async def update(
- self,
- where: Mapping[str, object],
- data: Mapping[str, object],
- include: Mapping[str, bool] | None = None,
- ) -> _DbRecordT: ...
-
- async def update_many(
- self,
- where: Mapping[str, object],
- data: Mapping[str, object],
- ) -> int: ...
-
- async def upsert(
- self,
- where: Mapping[str, object],
- data: Mapping[str, Mapping[str, object]],
- ) -> _DbRecordT: ...
-
- async def delete_many(
- self,
- where: Mapping[str, object] | None = None,
- ) -> int: ...
-
- async def count(
- self,
- where: Mapping[str, object] | None = None,
- ) -> int: ...
-
- async def group_by(
- self,
- by: Sequence[str],
- where: Mapping[str, object] | None = None,
- count: Mapping[str, bool] | None = None,
- ) -> Sequence[_TeamIdGroupRow]: ...
-
-
-class _HasTableActions(Protocol[_DbRecordT]):
- @property
- def table(self) -> "_PrismaTableActions[_DbRecordT]": ...
-
-
-def _typed_table(
- repo: "_HasTableActions[_DbRecordT]", record_type: type[_DbRecordT]
-) -> "_PrismaTableActions[_DbRecordT]":
- return repo.table
-
-
def _as_object(value: object) -> object:
return value
-def _nullable(value: _DbRecordT | None) -> _DbRecordT | None:
- return value
+def _as_list(rows: Sequence[_DbRecordT]) -> list[_DbRecordT]: # mutable-ok: pydantic list[...] fields reject Sequence
+ return cast( # cast-ok: prisma-client-py find_many returns a list; TableActions only widens it to Sequence
+ "list[_DbRecordT]", rows
+ )
class _UserIdRow(Protocol):
@@ -284,33 +204,75 @@ class _UserIdRow(Protocol):
def user_id(self) -> str | None: ...
-class _HasUserIdTable(Protocol):
- @property
- def table(self) -> "_PrismaTableActions[_UserIdRow]": ...
-
-
-def _user_id_rows_db(repo: "_HasUserIdTable") -> "_PrismaTableActions[_UserIdRow]":
+def _user_id_rows_db(repo: UserRepository) -> "TableActions[_UserIdRow]":
return repo.table
-class _RawTeamRow(Protocol):
+class _ModelDumpRow(Protocol):
+ def model_dump(self) -> Mapping[str, object]: ...
+
+
+class _TeamIdRow(Protocol):
@property
- def members_with_roles(self) -> Sequence[Mapping[str, object]] | None: ...
+ def team_id(self) -> str: ...
-class _HasRawTeamTable(Protocol):
+class _CacheableTeamRow(_TeamIdRow, _ModelDumpRow, Protocol): ...
+
+
+class _ObjectPermissionRow(Protocol):
@property
- def table(self) -> "_PrismaTableActions[_RawTeamRow]": ...
+ def object_permission_id(self) -> str | None: ...
-def _raw_team_db(repo: "_HasRawTeamTable") -> "_PrismaTableActions[_RawTeamRow]":
- return repo.table
+class _TeamAliasBudgetRow(Protocol):
+ @property
+ def team_alias(self) -> str | None: ...
+
+ @property
+ def budget_duration(self) -> str | None: ...
+
+
+class _TeamBudgetRow(_TeamAliasBudgetRow, Protocol):
+ metadata: Mapping[str, JsonValue] | None
+
+
+class _AuditableTeamRow(Protocol):
+ def json(self, *, exclude_none: bool = False) -> str: ...
+
+
+class _RawTeamRow(_TeamIdRow, _ModelDumpRow, _ObjectPermissionRow, _TeamBudgetRow, _AuditableTeamRow, Protocol):
+ @property
+ def members_with_roles(
+ self,
+ ) -> Sequence[dict[str, object]] | None: ... # mutable-ok: prisma deserializes this JSON column into plain dicts
+
+ @property
+ def organization_id(self) -> str | None: ...
+
+ @property
+ def max_budget(self) -> float | None: ...
+
+ @property
+ def soft_budget(self) -> float | None: ...
+
+ @property
+ def model_id(self) -> int | None: ...
+
+
+def _raw_team_db(repo: TeamRepository) -> "TableActions[_RawTeamRow]":
+ return cast( # cast-ok: prisma types Json columns as str; the client hands back the deserialized value
+ "TableActions[_RawTeamRow]", repo.table
+ )
+
+
+class _BudgetIdRow(Protocol):
+ @property
+ def budget_id(self) -> str: ...
class _BudgetWriteCall(Protocol):
- async def __call__(
- self, budget_obj: BudgetNewRequest, user_api_key_dict: UserAPIKeyAuth
- ) -> LiteLLM_BudgetTableFull: ...
+ async def __call__(self, budget_obj: BudgetNewRequest, user_api_key_dict: UserAPIKeyAuth) -> _BudgetIdRow: ...
def _as_budget_write(fn: "_BudgetWriteCall") -> "_BudgetWriteCall":
@@ -343,7 +305,7 @@ class _ErrorDetail(TypedDict):
class _TeamCreateTx(AccessGroupSyncTx, Protocol):
@property
- def litellm_teamtable(self) -> "_PrismaTableActions[LiteLLM_TeamTable]": ...
+ def litellm_teamtable(self) -> "TableActions[prisma_models.LiteLLM_TeamTable]": ...
class _MemberDeleteTx(Protocol):
@@ -355,20 +317,20 @@ class _MemberDeleteTx(Protocol):
of them hold the rest of the pool."""
@property
- def litellm_usertable(self) -> "_PrismaTableActions[LiteLLM_UserTable]": ...
+ def litellm_usertable(self) -> "TableActions[prisma_models.LiteLLM_UserTable]": ...
@property
- def litellm_verificationtoken(self) -> "_PrismaTableActions[LiteLLM_VerificationToken]": ...
+ def litellm_verificationtoken(self) -> "TableActions[prisma_models.LiteLLM_VerificationToken]": ...
class _TeamDeleteTx(AccessGroupSyncTx, Protocol):
async def execute_raw(self, query: str, *args: object) -> int: ...
@property
- def litellm_teamtable(self) -> "_PrismaTableActions[LiteLLM_TeamTable]": ...
+ def litellm_teamtable(self) -> "TableActions[prisma_models.LiteLLM_TeamTable]": ...
@property
- def litellm_teammembership(self) -> "_PrismaTableActions[LiteLLM_TeamMembership]": ...
+ def litellm_teammembership(self) -> "TableActions[prisma_models.LiteLLM_TeamMembership]": ...
_STRIP_DELETED_TEAM_FROM_USERS_SQL: Final = """
@@ -378,46 +340,52 @@ UPDATE "LiteLLM_UserTable" SET teams = array_remove(teams, $1) WHERE $1 = ANY(te
_INCLUDE_MODEL_TABLE: Final = MappingProxyType({"litellm_model_table": True})
-def _team_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_TeamTable]":
- return _typed_table(TeamRepository(prisma_client), LiteLLM_TeamTable)
+def _team_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_TeamTable]":
+ return TeamRepository(prisma_client).table
-def _team_membership_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_TeamMembership]":
- return _typed_table(TeamMembershipRepository(prisma_client), LiteLLM_TeamMembership)
+def _team_tx_db(tx: "Prisma") -> "TableActions[prisma_models.LiteLLM_TeamTable]":
+ return cast( # cast-ok: generated actions type Json columns as str; TableActions widens inputs to Mapping
+ "TableActions[prisma_models.LiteLLM_TeamTable]", tx.litellm_teamtable
+ )
-def _user_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_UserTable]":
- return _typed_table(UserRepository(prisma_client), LiteLLM_UserTable)
+def _team_membership_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_TeamMembership]":
+ return TeamMembershipRepository(prisma_client).table
-def _model_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_ModelTable]":
- return _typed_table(ModelTableRepository(prisma_client), LiteLLM_ModelTable)
+def _user_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_UserTable]":
+ return UserRepository(prisma_client).table
-def _org_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_OrganizationTable]":
- return _typed_table(OrganizationRepository(prisma_client), LiteLLM_OrganizationTable)
+def _model_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_ModelTable]":
+ return ModelTableRepository(prisma_client).table
+
+
+def _org_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_OrganizationTable]":
+ return OrganizationRepository(prisma_client).table
def _org_membership_db(
prisma_client: PrismaClient | None,
-) -> "_PrismaTableActions[LiteLLM_OrganizationMembershipTable]":
- return _typed_table(OrganizationMembershipRepository(prisma_client), LiteLLM_OrganizationMembershipTable)
+) -> "TableActions[prisma_models.LiteLLM_OrganizationMembership]":
+ return OrganizationMembershipRepository(prisma_client).table
-def _budget_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_BudgetTableFull]":
- return _typed_table(BudgetRepository(prisma_client), LiteLLM_BudgetTableFull)
+def _budget_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_BudgetTable]":
+ return BudgetRepository(prisma_client).table
-def _deleted_team_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_DeletedTeamTable]":
- return _typed_table(DeletedTeamRepository(prisma_client), LiteLLM_DeletedTeamTable)
+def _deleted_team_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_DeletedTeamTable]":
+ return DeletedTeamRepository(prisma_client).table
-def _access_group_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_AccessGroupTable]":
- return _typed_table(AccessGroupRepository(prisma_client), LiteLLM_AccessGroupTable)
+def _access_group_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_AccessGroupTable]":
+ return AccessGroupRepository(prisma_client).table
-def _tokens_db(prisma_client: PrismaClient | None) -> "_PrismaTableActions[LiteLLM_VerificationToken]":
- return _typed_table(VerificationTokenRepository(prisma_client), LiteLLM_VerificationToken)
+def _tokens_db(prisma_client: PrismaClient | None) -> "TableActions[prisma_models.LiteLLM_VerificationToken]":
+ return VerificationTokenRepository(prisma_client).table
def _sanitize_for_log(value: object) -> str:
@@ -430,7 +398,7 @@ def _sanitize_for_log(value: object) -> str:
async def _refresh_cached_team(
- team_row: LiteLLM_TeamTable,
+ team_row: _CacheableTeamRow,
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
) -> None:
@@ -519,7 +487,7 @@ class TeamMemberBudgetHandler:
@staticmethod
async def create_team_member_budget_table(
- data: NewTeamRequest | LiteLLM_TeamTable,
+ data: NewTeamRequest | _TeamAliasBudgetRow,
new_team_data_json: dict,
user_api_key_dict: UserAPIKeyAuth,
team_member_budget: float | None = None,
@@ -570,7 +538,7 @@ class TeamMemberBudgetHandler:
@staticmethod
async def upsert_team_member_budget_table(
- team_table: LiteLLM_TeamTable,
+ team_table: _TeamBudgetRow,
user_api_key_dict: UserAPIKeyAuth,
updated_kv: dict,
team_member_budget: float | None = None,
@@ -641,7 +609,7 @@ class TeamMemberBudgetHandler:
@staticmethod
async def clear_team_member_budget_fields(
- team_table: LiteLLM_TeamTable,
+ team_table: _TeamBudgetRow,
user_api_key_dict: "UserAPIKeyAuth",
updated_kv: dict,
explicitly_set_fields: set,
@@ -1578,7 +1546,7 @@ async def new_team(
tx: _TeamCreateTx
async with prisma_client.db.tx() as tx:
- team_row: Final[LiteLLM_TeamTable] = await tx.litellm_teamtable.create(
+ team_row: Final[prisma_models.LiteLLM_TeamTable] = await tx.litellm_teamtable.create(
data=team_creation_data,
include=_INCLUDE_MODEL_TABLE,
)
@@ -1633,7 +1601,7 @@ async def new_team(
async def _create_team_update_audit_log(
- existing_team_row: LiteLLM_TeamTable,
+ existing_team_row: _AuditableTeamRow,
updated_kv: dict,
team_id: str,
litellm_changed_by: str | None,
@@ -1756,11 +1724,11 @@ async def _auto_add_team_members_to_organization(
async def fetch_and_validate_organization(
organization_id: str,
- existing_team_row: LiteLLM_TeamTable,
+ existing_team_row: _ModelDumpRow,
llm_router: Router | None,
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth | None = None,
-) -> LiteLLM_OrganizationTable:
+) -> "prisma_models.LiteLLM_OrganizationTable":
"""
Fetch and validate an organization for team update operations.
@@ -2034,7 +2002,9 @@ async def update_team(
validate_budget_duration(data.budget_duration)
validate_budget_duration(data.team_member_budget_duration)
- existing_team_row = await _team_db(prisma_client).find_unique(where={"team_id": data.team_id})
+ existing_team_row = await _raw_team_db(TeamRepository(prisma_client)).find_unique(
+ where={"team_id": data.team_id}
+ )
if existing_team_row is None:
raise HTTPException(
@@ -2272,18 +2242,16 @@ async def update_team(
updated_kv = prisma_client.jsonify_team_object(db_data=updated_kv)
team_update_data: Final[Mapping[str, object]] = updated_kv
- team_row: Final[LiteLLM_TeamTable | None] = _nullable(
- await _team_db(prisma_client).update(
- where={"team_id": data.team_id},
- data=team_update_data,
- # `object_permission` is included so `_refresh_cached_team`
- # doesn't write a cached team with the relation nulled out —
- # see team_model_add for the full rationale.
- include={
- "litellm_model_table": True,
- "object_permission": True,
- },
- )
+ team_row: Final = await _team_db(prisma_client).update(
+ where={"team_id": data.team_id},
+ data=team_update_data,
+ # `object_permission` is included so `_refresh_cached_team`
+ # doesn't write a cached team with the relation nulled out.
+ # See team_model_add for the full rationale.
+ include={
+ "litellm_model_table": True,
+ "object_permission": True,
+ },
)
if team_row is None or team_row.team_id is None:
@@ -2413,7 +2381,7 @@ def _set_budget_reset_at(data: UpdateTeamRequest, updated_kv: dict) -> None:
updated_kv["budget_limits"] = json.dumps(initialized_windows)
-async def handle_update_object_permission(data_json: dict, existing_team_row: LiteLLM_TeamTable) -> dict:
+async def handle_update_object_permission(data_json: dict, existing_team_row: _ObjectPermissionRow) -> dict:
"""
Handle the update of object permission for a team.
@@ -2750,7 +2718,7 @@ async def _add_team_members_to_team(
prisma_client: PrismaClient,
user_api_key_dict: UserAPIKeyAuth,
litellm_proxy_admin_name: str,
-) -> tuple[LiteLLM_TeamTable, list[LiteLLM_UserTable], list[LiteLLM_TeamMembership]]:
+) -> tuple["prisma_models.LiteLLM_TeamTable", list[LiteLLM_UserTable], list[LiteLLM_TeamMembership]]:
"""Add team members to the team, under the team's advisory lock.
The lock (``TEAM_ADVISORY_LOCK_SQL``, keyed on the team id) is taken first, and the
@@ -2765,14 +2733,12 @@ async def _add_team_members_to_team(
a waiter that can deadlock the pool, since enough concurrent adds for one team would
hold every connection waiting on the lock while the holder waits for a free one.
"""
+ gone_detail: Final[_ErrorDetail] = {"error": f"Team={data.team_id} was deleted while this member add was running"}
async with prisma_client.tx() as tx:
await tx.query_raw(TEAM_ADVISORY_LOCK_SQL, data.team_id)
locked_members: Final = await TeamRepository(prisma_client).get_members_with_roles_locked(tx, data.team_id)
if locked_members is None:
- gone_detail: Final[_ErrorDetail] = {
- "error": f"Team={data.team_id} was deleted while this member add was running"
- }
raise HTTPException(status_code=404, detail=gone_detail)
complete_team_data.members_with_roles = locked_members
@@ -2792,10 +2758,12 @@ async def _add_team_members_to_team(
)
_db_team_members: Final = [m.model_dump() for m in complete_team_data.members_with_roles]
- updated_team: Final = await tx.litellm_teamtable.update(
+ updated_team: Final = await _team_tx_db(tx).update(
where={"team_id": data.team_id},
data={"members_with_roles": json.dumps(_db_team_members)},
)
+ if updated_team is None:
+ raise HTTPException(status_code=404, detail=gone_detail)
return updated_team, updated_users, updated_team_memberships
@@ -3329,9 +3297,7 @@ async def team_member_delete(
{"user_id": {"in": sorted(removed_user_ids)}} if removed_user_ids else {"user_email": data.user_email}
)
member_tx: Final[_MemberDeleteTx] = tx
- existing_user_rows: Final[Sequence[LiteLLM_UserTable]] = await member_tx.litellm_usertable.find_many(
- where=key_val
- )
+ existing_user_rows: Final = await member_tx.litellm_usertable.find_many(where=key_val)
# Also clean up any existing team membership rows for this user and team
user_ids_to_delete: Final = removed_user_ids.union(
@@ -3342,14 +3308,14 @@ async def team_member_delete(
## DELETE KEYS CREATED BY USER FOR THIS TEAM
# Fetch keys before deletion so their audit records can be persisted alongside the delete.
# An empty user_ids_to_delete still resolves cleanly: prisma's "in": [] matches no rows.
- keys_to_delete: Final[list[LiteLLM_VerificationToken]] = await member_tx.litellm_verificationtoken.find_many(
+ keys_to_delete: Final = await member_tx.litellm_verificationtoken.find_many(
where={
"user_id": {"in": sorted(user_ids_to_delete)},
"team_id": data.team_id,
}
)
- await tx.litellm_teamtable.update(
+ await _team_tx_db(tx).update(
where={"team_id": data.team_id},
data={"members_with_roles": json.dumps(_db_new_team_members)},
)
@@ -3987,9 +3953,7 @@ async def delete_team(
_persist_deleted_verification_tokens,
)
- keys_to_delete: list[LiteLLM_VerificationToken] = await _tokens_db(prisma_client).find_many(
- where={"team_id": {"in": data.team_ids}}
- )
+ keys_to_delete: Final = await _tokens_db(prisma_client).find_many(where={"team_id": {"in": data.team_ids}})
if keys_to_delete:
await _persist_deleted_verification_tokens(
@@ -4109,7 +4073,7 @@ async def _sweep_deleted_team_references_tx(team_ids: Sequence[str], tx: _TeamDe
async def _invalidate_deleted_key_cache(
- keys: Sequence[LiteLLM_VerificationToken],
+ keys: "Sequence[prisma_models.LiteLLM_VerificationToken]",
user_api_key_cache: UserApiKeyCache,
proxy_logging_obj: ProxyLogging,
) -> None:
@@ -4294,7 +4258,7 @@ async def _hydrate_member_emails(
if not missing_user_ids:
return tuple(members)
- user_rows: Final[Sequence[LiteLLM_UserTable]] = await _user_db(prisma_client).find_many(
+ user_rows: Final[Sequence[prisma_models.LiteLLM_UserTable]] = await _user_db(prisma_client).find_many(
where={ # mutable-ok: Prisma query filters are dict-shaped
"user_id": { # mutable-ok: Prisma query filters are dict-shaped
"in": sorted(missing_user_ids)
@@ -4305,7 +4269,7 @@ async def _hydrate_member_emails(
return tuple(
m.model_copy(update={"user_email": email_by_user_id[m.user_id]}) # mutable-ok: pydantic update payload
- if not m.user_email and m.user_id in email_by_user_id
+ if not m.user_email and m.user_id is not None and m.user_id in email_by_user_id
else m
for m in members
)
@@ -4890,7 +4854,7 @@ async def _build_team_list_where_conditions(
async def _batch_resolve_access_group_resources(
all_access_group_ids: list[str],
-) -> dict[str, LiteLLM_AccessGroupTable]:
+) -> "dict[str, prisma_models.LiteLLM_AccessGroupTable]":
"""
Batch-fetch access groups in a single DB query and return them keyed by
access_group_id. Missing/invalid groups are silently omitted.
@@ -4908,7 +4872,7 @@ async def _batch_resolve_access_group_resources(
def _convert_teams_to_response_models(
- teams: list,
+ teams: Sequence,
use_deleted_table: bool,
keys_count_by_team: dict[str, int] | None = None,
) -> list[TeamListItem | LiteLLM_TeamTable | LiteLLM_DeletedTeamTable]:
@@ -4942,7 +4906,7 @@ def _convert_teams_to_response_models(
async def _get_keys_count_by_team(
prisma_client: PrismaClient,
- teams: Sequence[LiteLLM_TeamTable],
+ teams: Sequence[_TeamIdRow],
) -> dict[str, int]:
"""Aggregate virtual-key counts per team for the given page of teams.
@@ -4954,10 +4918,13 @@ async def _get_keys_count_by_team(
if not page_team_ids:
return {}
- grouped: Final = await _tokens_db(prisma_client).group_by(
- by=["team_id"],
- where={"team_id": {"in": page_team_ids}},
- count={"team_id": True},
+ grouped: Final = cast( # cast-ok: prisma group_by returns one row per `by` key with `count=` nested under "_count"
+ "Sequence[_TeamIdGroupRow]",
+ await _tokens_db(prisma_client).group_by(
+ by=["team_id"],
+ where={"team_id": {"in": page_team_ids}},
+ count={"team_id": True},
+ ),
)
return {row["team_id"]: row.get("_count", {}).get("team_id", 0) for row in grouped if row.get("team_id")}
@@ -5347,7 +5314,7 @@ async def list_team(
_team_memberships.append(tm)
# add all keys that belong to the team
- keys = await _tokens_db(prisma_client).find_many(where={"team_id": team.team_id})
+ keys = _as_list(await _tokens_db(prisma_client).find_many(where={"team_id": team.team_id}))
try:
returned_responses.append(
@@ -5582,6 +5549,11 @@ async def team_model_add(
data={"updated_at": datetime.now(timezone.utc)},
include={"object_permission": True},
)
+ if updated_team is None:
+ raise HTTPException(
+ status_code=404,
+ detail={"error": f"Team not found, passed team_id={data.team_id}"},
+ )
await _refresh_cached_team(
team_row=updated_team,
@@ -5664,6 +5636,11 @@ async def team_model_delete(
data={"models": updated_models},
include={"object_permission": True},
)
+ if updated_team is None:
+ raise HTTPException(
+ status_code=404,
+ detail={"error": f"Team not found, passed team_id={data.team_id}"},
+ )
await _refresh_cached_team(
team_row=updated_team,
@@ -5798,8 +5775,13 @@ async def update_team_member_permissions(
where={"team_id": data.team_id},
data={"team_member_permissions": data.team_member_permissions},
)
+ if updated_team is None:
+ raise HTTPException(
+ status_code=404,
+ detail={"error": f"Team not found, passed team_id={data.team_id}"},
+ )
- return updated_team
+ return updated_team # pyright: ignore[reportReturnType] # prisma row, coerced by this route's response_model
@router.post(
@@ -5864,7 +5846,9 @@ async def bulk_update_team_member_permissions(
}
-async def _compute_and_batch_updates(prisma_client, teams: Sequence[LiteLLM_TeamTable], permissions_to_add: set) -> int:
+async def _compute_and_batch_updates(
+ prisma_client, teams: "Sequence[prisma_models.LiteLLM_TeamTable]", permissions_to_add: set
+) -> int:
"""Compute merged permissions and batch-write updates. Returns count of teams updated."""
updates: Final = []
for team in teams:
diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py
index 3c135650de9..0c8240b3298 100644
--- a/litellm/proxy/management_endpoints/ui_sso.py
+++ b/litellm/proxy/management_endpoints/ui_sso.py
@@ -29,7 +29,6 @@ from typing import (
NoReturn,
Optional,
Protocol,
- TypeVar,
Union,
cast,
overload,
@@ -122,6 +121,7 @@ from litellm.proxy.utils import (
get_custom_url,
get_server_root_path,
)
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import SSOConfigRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.user_repository import UserRepository
@@ -171,51 +171,16 @@ _CLI_SSO_SECRET_KEY_FRAGMENTS: Final = frozenset(
}
)
-_DbRecordT: Final = TypeVar("_DbRecordT", covariant=True)
-
-
-class _PrismaTableActions(Protocol[_DbRecordT]):
- async def find_unique(
- self,
- where: Mapping[str, object],
- ) -> _DbRecordT | None: ...
-
- async def find_first(
- self,
- where: Mapping[str, object] | None = None,
- ) -> _DbRecordT | None: ...
-
- async def find_many(
- self,
- where: Mapping[str, object] | None = None,
- include: Mapping[str, bool] | None = None,
- ) -> Sequence[_DbRecordT]: ...
-
- async def update(
- self,
- where: Mapping[str, object],
- data: Mapping[str, object],
- ) -> _DbRecordT: ...
-
- async def update_many(
- self,
- where: Mapping[str, object],
- data: Mapping[str, object],
- ) -> int: ...
-
class _UserMetadataRow(Protocol):
@property
def metadata(self) -> Mapping[str, object] | None: ...
-class _HasUserMetadataTable(Protocol):
- @property
- def table(self) -> "_PrismaTableActions[_UserMetadataRow]": ...
-
-
-def _user_meta_db(repo: "_HasUserMetadataTable") -> "_PrismaTableActions[_UserMetadataRow]":
- return repo.table
+def _user_meta_db(repo: UserRepository) -> "TableActions[_UserMetadataRow]":
+ return cast( # cast-ok: prisma types Json columns as str; the client hands back the deserialized value
+ "TableActions[_UserMetadataRow]", repo.table
+ )
class _SsoConfigRow(Protocol):
@@ -223,25 +188,17 @@ class _SsoConfigRow(Protocol):
def sso_settings(self) -> Mapping[str, object] | None: ...
-class _HasSsoConfigTable(Protocol):
- @property
- def table(self) -> "_PrismaTableActions[_SsoConfigRow]": ...
-
-
-def _sso_config_db(repo: "_HasSsoConfigTable") -> "_PrismaTableActions[_SsoConfigRow]":
- return repo.table
+def _sso_config_db(repo: SSOConfigRepository) -> "TableActions[_SsoConfigRow]":
+ return cast( # cast-ok: prisma types Json columns as str; the client hands back the deserialized value
+ "TableActions[_SsoConfigRow]", repo.table
+ )
class _TeamDetailRow(Protocol):
def model_dump(self) -> Mapping[str, object]: ...
-class _HasTeamDetailTable(Protocol):
- @property
- def table(self) -> "_PrismaTableActions[_TeamDetailRow]": ...
-
-
-def _team_detail_db(repo: "_HasTeamDetailTable") -> "_PrismaTableActions[_TeamDetailRow]":
+def _team_detail_db(repo: TeamRepository) -> "TableActions[_TeamDetailRow]":
return repo.table
diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py
index 33b84545915..fb64914f6f5 100644
--- a/litellm/proxy/management_helpers/object_permission_utils.py
+++ b/litellm/proxy/management_helpers/object_permission_utils.py
@@ -4,7 +4,7 @@ organizations, teams, and keys.
"""
import json
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Final, Optional
@@ -19,6 +19,8 @@ from litellm.repositories.object_permission_repository import ObjectPermissionRe
from litellm.repositories.table_repositories import MCPServerRepository
if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
from litellm.proxy._types import (
LiteLLM_ObjectPermissionTable,
LiteLLM_TeamTableCachedObj,
@@ -26,7 +28,7 @@ if TYPE_CHECKING:
async def attach_object_permission_to_dict(
- data_dict: dict,
+ data_dict: dict[str, object],
prisma_client: PrismaClient,
) -> dict:
"""
@@ -61,7 +63,7 @@ async def attach_object_permission_to_dict(
try:
object_permission = object_permission.model_dump()
except Exception:
- object_permission = object_permission.dict()
+ object_permission = object_permission.dict() # pyright: ignore[reportDeprecated] # pydantic v1 fallback
data_dict["object_permission"] = object_permission
return data_dict
@@ -188,7 +190,9 @@ async def _set_object_permission(
return data_json
# Clean data: exclude None values and object_permission_id
- clean_data: Final = {k: v for k, v in permission_data.items() if v is not None and k != "object_permission_id"}
+ clean_data: Final[dict[str, object]] = {
+ k: v for k, v in permission_data.items() if v is not None and k != "object_permission_id"
+ }
# Serialize mcp_tool_permissions to JSON string for GraphQL compatibility
if "mcp_tool_permissions" in clean_data:
@@ -224,7 +228,7 @@ def _mcp_server_identifier_matches(server: Any, identifier: str) -> bool:
async def _get_db_mcp_servers_by_identifiers(
identifiers: set[str],
prisma_client: PrismaClient | None,
-) -> list[Any]:
+) -> "Sequence[prisma_models.LiteLLM_MCPServerTable]":
if prisma_client is None or not identifiers:
return []
diff --git a/litellm/proxy/memory/memory_endpoints.py b/litellm/proxy/memory/memory_endpoints.py
index 3ae8dcf64b7..98c5fdd198c 100644
--- a/litellm/proxy/memory/memory_endpoints.py
+++ b/litellm/proxy/memory/memory_endpoints.py
@@ -18,21 +18,20 @@ Scoping:
"""
import json
-from collections.abc import Mapping, Sequence
-from datetime import datetime
-from typing import TYPE_CHECKING, Final, Protocol
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Final
from fastapi import APIRouter, Depends, HTTPException, Query
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import (
CommonProxyErrors,
- LiteLLM_TeamTable,
LitellmUserRoles,
UserAPIKeyAuth,
user_api_key_has_admin_view,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import MemoryRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.types.memory_management import (
@@ -44,54 +43,17 @@ from litellm.types.memory_management import (
)
if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
from litellm.proxy.utils import PrismaClient
router: Final = APIRouter()
-class _MemoryRecord(Protocol):
- memory_id: str
- key: str
- value: str
- metadata: object
- user_id: str | None
- team_id: str | None
- created_at: datetime | None
- created_by: str | None
- updated_at: datetime | None
- updated_by: str | None
-
-
-class _MemoryTableActions(Protocol):
- async def create(self, data: Mapping[str, object]) -> _MemoryRecord: ...
-
- async def find_many(
- self,
- where: Mapping[str, object] | None = ...,
- order: Mapping[str, str] | None = ...,
- skip: int = ...,
- take: int = ...,
- ) -> Sequence[_MemoryRecord]: ...
-
- async def count(self, where: Mapping[str, object] | None = ...) -> int: ...
-
- async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> _MemoryRecord: ...
-
- async def delete(self, where: Mapping[str, object]) -> _MemoryRecord | None: ...
-
-
-def _memory_table(prisma_client: "PrismaClient") -> _MemoryTableActions:
+def _memory_table(prisma_client: "PrismaClient") -> TableActions["prisma_models.LiteLLM_MemoryTable"]:
return MemoryRepository(prisma_client).table
-class _TeamTableActions(Protocol):
- async def find_unique(self, where: Mapping[str, str]) -> LiteLLM_TeamTable | None: ...
-
-
-def _team_table(prisma_client: "PrismaClient") -> _TeamTableActions:
- return TeamRepository(prisma_client).table
-
-
def _serialize_metadata_for_prisma(metadata: object) -> str:
"""
Encode a `metadata` payload for the `Json?` column.
@@ -129,7 +91,7 @@ def _visibility_filter(user_api_key_dict: UserAPIKeyAuth) -> Mapping[str, object
return {"OR": ors}
-def _row_to_model(row: _MemoryRecord) -> LiteLLM_MemoryRow:
+def _row_to_model(row: "prisma_models.LiteLLM_MemoryTable") -> LiteLLM_MemoryRow:
return LiteLLM_MemoryRow(
memory_id=row.memory_id,
key=row.key,
@@ -163,7 +125,7 @@ def _internal_error(log_message: str, exc: Exception, default_detail: str) -> HT
async def _assert_write_access(
- prisma_client: "PrismaClient", row: _MemoryRecord, user_api_key_dict: UserAPIKeyAuth
+ prisma_client: "PrismaClient", row: "prisma_models.LiteLLM_MemoryTable", user_api_key_dict: UserAPIKeyAuth
) -> None:
"""
Enforce ownership for mutations (PUT/DELETE).
@@ -219,7 +181,7 @@ async def _is_team_admin_for(prisma_client: "PrismaClient", user_api_key_dict: U
)
try:
- team_obj: Final = await _team_table(prisma_client).find_unique(where={"team_id": team_id})
+ team_obj: Final = await TeamRepository(prisma_client).find_by_id(team_id, id_field="team_id")
except Exception as e:
verbose_proxy_logger.exception("Error loading team for write-auth check (team_id=%s): %s", team_id, e)
return False
@@ -407,7 +369,7 @@ async def list_memory(
async def _find_memory_for_caller(
prisma_client: "PrismaClient", key: str, user_api_key_dict: UserAPIKeyAuth
-) -> _MemoryRecord:
+) -> "prisma_models.LiteLLM_MemoryTable":
"""Look up a memory row by key, scoped to the caller's visibility."""
key_filter: Final[Mapping[str, object]] = {"key": key}
vis: Final = _visibility_filter(user_api_key_dict)
@@ -418,6 +380,18 @@ async def _find_memory_for_caller(
return rows[0]
+async def _find_visible_memory_or_none(
+ prisma_client: "PrismaClient", key: str, user_api_key_dict: UserAPIKeyAuth
+) -> "prisma_models.LiteLLM_MemoryTable | None":
+ """The caller-visible row for `key`, or None when nothing is visible to them."""
+ try:
+ return await _find_memory_for_caller(prisma_client, key, user_api_key_dict)
+ except HTTPException as e:
+ if e.status_code == 404:
+ return None
+ raise
+
+
@router.get(
"/v1/memory/{key:path}",
tags=["memory management"],
@@ -480,17 +454,8 @@ async def upsert_memory(
)
data["updated_by"] = user_api_key_dict.user_id
- async def _find_existing() -> _MemoryRecord | None:
- """Return the caller-visible row for `key`, or None."""
- try:
- return await _find_memory_for_caller(prisma_client, key, user_api_key_dict)
- except HTTPException as e:
- if e.status_code == 404:
- return None
- raise
-
try:
- existing: Final = await _find_existing()
+ existing: Final = await _find_visible_memory_or_none(prisma_client, key, user_api_key_dict)
if existing is not None:
# Visibility != write authority. Make sure the caller actually
# owns this row (their user_id matches, or it's a pure team row in
@@ -530,7 +495,7 @@ async def upsert_memory(
# instead of surfacing a 500 on a unique-violation.
if not _is_unique_violation(e):
raise
- existing_after_race: Final = await _find_existing()
+ existing_after_race: Final = await _find_visible_memory_or_none(prisma_client, key, user_api_key_dict)
if existing_after_race is None:
# Row exists globally but isn't visible to this caller
# (owned by someone else). Treat as conflict.
@@ -549,6 +514,8 @@ async def upsert_memory(
except Exception as e:
raise _internal_error("Error upserting memory: %s", e, "Internal error updating memory entry.")
+ if row is None:
+ raise HTTPException(status_code=404, detail=f"Memory with key '{key}' not found")
return _row_to_model(row)
@@ -568,8 +535,10 @@ async def delete_memory(
# Visibility != write authority — see the upsert handler for the rationale.
await _assert_write_access(prisma_client, row, user_api_key_dict)
try:
- await _memory_table(prisma_client).delete(where={"memory_id": row.memory_id})
+ deleted: Final = await _memory_table(prisma_client).delete(where={"memory_id": row.memory_id})
except Exception as e:
raise _internal_error("Error deleting memory: %s", e, "Internal error deleting memory entry.")
+ if deleted is None:
+ raise HTTPException(status_code=404, detail=f"Memory with key '{key}' not found")
return MemoryDeleteResponse(key=key, deleted=True)
diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py
index 142aced4a38..2c8b926dc93 100644
--- a/litellm/proxy/openai_files_endpoints/common_utils.py
+++ b/litellm/proxy/openai_files_endpoints/common_utils.py
@@ -4,7 +4,16 @@ import re
from collections.abc import Mapping
from dataclasses import dataclass, field
from types import MappingProxyType
-from typing import TYPE_CHECKING, Final, Literal, Optional, Protocol, get_args, runtime_checkable
+from typing import (
+ TYPE_CHECKING,
+ Final,
+ Literal,
+ Optional,
+ Protocol,
+ cast, # noqa: TID251 # prisma types Json columns as fields.Json but de-serializes them to plain python on read
+ get_args,
+ runtime_checkable,
+)
from litellm.proxy._types import ProxyException
from litellm.repositories.table_repositories import (
@@ -1183,7 +1192,7 @@ async def ensure_batch_response_managed_file_ids(
prisma_client,
verbose_proxy_logger,
user_api_key_dict=None,
- db_batch_object=None,
+ db_batch_object: "LiteLLM_ManagedObjectTable | None" = None,
unified_batch_id: str | Literal[False] | None = None,
) -> None:
"""Normalize batch file IDs to managed unified IDs before DB persistence."""
@@ -1270,11 +1279,10 @@ async def get_batch_from_database(
return None, None
# Parse the batch object from database
- batch_data: Final = (
- json.loads(db_batch_object.file_object)
- if isinstance(db_batch_object.file_object, str)
- else db_batch_object.file_object
+ file_object: Final = cast( # cast-ok: prisma types the Json column as str; reads return the decoded value
+ "Mapping[str, object] | str", db_batch_object.file_object
)
+ batch_data: Final = json.loads(file_object) if isinstance(file_object, str) else file_object
response: Final = LiteLLMBatch.model_validate(batch_data)
response.id = batch_id
@@ -1360,7 +1368,7 @@ async def update_batch_in_database(
managed_files_obj,
prisma_client,
verbose_proxy_logger,
- db_batch_object=None,
+ db_batch_object: "LiteLLM_ManagedObjectTable | None" = None,
operation: str = "update",
user_api_key_dict=None,
poller_owns_accounting: bool | None = None,
@@ -1427,7 +1435,7 @@ async def update_batch_in_database(
# Normalize status for database storage
db_status: Final = response.status if response.status != "completed" else "complete"
- update_data: Final[dict] = {
+ update_data: Final[dict[str, object]] = {
"status": db_status,
"file_object": response.model_dump_json(),
"updated_at": litellm.utils.get_utc_datetime(),
diff --git a/litellm/proxy/pass_through_endpoints/managed_id_rewriter.py b/litellm/proxy/pass_through_endpoints/managed_id_rewriter.py
index e08d277788f..4de6ef04d76 100644
--- a/litellm/proxy/pass_through_endpoints/managed_id_rewriter.py
+++ b/litellm/proxy/pass_through_endpoints/managed_id_rewriter.py
@@ -33,7 +33,13 @@ from __future__ import annotations
import json
import re
from collections.abc import Callable, Mapping, Sequence
-from typing import TYPE_CHECKING, Final, TypeVar, overload
+from typing import (
+ TYPE_CHECKING,
+ Final,
+ TypeVar,
+ cast, # noqa: TID251 # prisma stubs type Json columns as fields.Json but de-serialize them on read
+ overload,
+)
from urllib.parse import quote, unquote
from fastapi import HTTPException
@@ -286,11 +292,15 @@ def _canonical_path(route: str) -> str:
def _file_table(prisma_client: PrismaClient) -> ManagedFileTable:
- return ManagedFileRepository(prisma_client).table
+ return cast( # cast-ok: stub-only mismatch, prisma returns real lists and de-serialized Json
+ ManagedFileTable, ManagedFileRepository(prisma_client).table
+ )
def _object_table(prisma_client: PrismaClient) -> ManagedObjectTable:
- return ManagedObjectRepository(prisma_client).table
+ return cast( # cast-ok: stub-only mismatch, prisma returns real lists and de-serialized Json
+ ManagedObjectTable, ManagedObjectRepository(prisma_client).table
+ )
async def _resolve_one(
diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
index 3d721dead4d..60b85cb42d0 100644
--- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
+++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py
@@ -5,7 +5,7 @@ import json
import posixpath
import traceback
from base64 import b64encode
-from collections.abc import AsyncGenerator, Callable, Iterable, Mapping
+from collections.abc import AsyncGenerator, Callable, Iterable, Mapping, Sequence
from datetime import datetime
from itertools import groupby
from typing import Any, Final, TypedDict, cast
@@ -3183,13 +3183,18 @@ async def _filter_endpoints_by_team_allowed_routes(
)
# retrieve team metadata
- team_metadata: Final = team.metadata
+ team_metadata: Final = cast( # cast-ok: prisma types the Json column as str; reads hand back the decoded value
+ "Mapping[str, object] | None", team.metadata
+ )
if team_metadata is not None and team_metadata.get("allowed_passthrough_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")
+ if endpoint.path
+ in cast( # cast-ok: guarded above; team metadata stores this key as a list of route paths
+ "Sequence[str]", team_metadata.get("allowed_passthrough_routes")
+ )
]
return pass_through_endpoints
diff --git a/litellm/proxy/policy_engine/policy_registry.py b/litellm/proxy/policy_engine/policy_registry.py
index 6d7f651b9b4..f55eb4f7863 100644
--- a/litellm/proxy/policy_engine/policy_registry.py
+++ b/litellm/proxy/policy_engine/policy_registry.py
@@ -10,9 +10,20 @@ by policy_attachments (see AttachmentRegistry).
import json
from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
-from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, TypedDict, Union
+from typing import (
+ TYPE_CHECKING,
+ Any,
+ Final,
+ Literal,
+ Optional,
+ Protocol,
+ TypedDict,
+ Union,
+ cast, # noqa: TID251 # prisma types the condition/pipeline Json columns as str, but reads return decoded values
+)
from litellm._logging import verbose_proxy_logger
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import PolicyRepository
from litellm.types.proxy.policy_engine import (
GuardrailPipeline,
@@ -65,15 +76,32 @@ class _PolicyRow(Protocol):
class _PolicyVersionSourceRow(Protocol):
- policy_id: str
- policy_name: str
- version_number: int
- inherit: str | None
- description: str | None
- guardrails_add: Sequence[str] | None
- guardrails_remove: Sequence[str] | None
- condition: Mapping[str, object] | str | None
- pipeline: Mapping[str, object] | str | None
+ @property
+ def policy_id(self) -> str: ...
+
+ @property
+ def policy_name(self) -> str: ...
+
+ @property
+ def version_number(self) -> int: ...
+
+ @property
+ def inherit(self) -> str | None: ...
+
+ @property
+ def description(self) -> str | None: ...
+
+ @property
+ def guardrails_add(self) -> Sequence[str] | None: ...
+
+ @property
+ def guardrails_remove(self) -> Sequence[str] | None: ...
+
+ @property
+ def condition(self) -> Mapping[str, object] | str | None: ...
+
+ @property
+ def pipeline(self) -> Mapping[str, object] | str | None: ...
class _PolicyTableClient(Protocol):
@@ -96,23 +124,15 @@ class _PolicyTableClient(Protocol):
async def delete_many(self, where: Mapping[str, object]) -> int: ...
-class _PolicyVersionSourceTableClient(Protocol):
- async def find_unique(self, where: Mapping[str, object]) -> _PolicyVersionSourceRow | None: ...
-
- async def find_first(
- self,
- where: Mapping[str, object],
- order: Mapping[str, str] | None = None,
- ) -> _PolicyVersionSourceRow | None: ...
-
-
def _policy_table(prisma_client: "PrismaClient") -> _PolicyTableClient:
- table: Final[_PolicyTableClient] = PolicyRepository(prisma_client).table
- return table
+ table: Final = PolicyRepository(prisma_client).table
+ return cast( # cast-ok: prisma types Json columns as str; the client hands back the decoded condition/pipeline
+ "_PolicyTableClient", table
+ )
-def _policy_version_source_table(prisma_client: "PrismaClient") -> _PolicyVersionSourceTableClient:
- table: Final[_PolicyVersionSourceTableClient] = PolicyRepository(prisma_client).table
+def _policy_version_source_table(prisma_client: "PrismaClient") -> "TableActions[_PolicyVersionSourceRow]":
+ table: Final[TableActions[_PolicyVersionSourceRow]] = PolicyRepository(prisma_client).table
return table
diff --git a/litellm/proxy/policy_engine/policy_resolve_endpoints.py b/litellm/proxy/policy_engine/policy_resolve_endpoints.py
index 70b98933d0f..a8a9856b833 100644
--- a/litellm/proxy/policy_engine/policy_resolve_endpoints.py
+++ b/litellm/proxy/policy_engine/policy_resolve_endpoints.py
@@ -6,7 +6,8 @@ Policy resolve and attachment impact estimation endpoints.
"""
import json
-from typing import Final
+from collections.abc import Sequence
+from typing import TYPE_CHECKING, Final
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -30,25 +31,28 @@ from litellm.types.proxy.policy_engine import (
PolicyResolveResponse,
)
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
router: Final = APIRouter()
-def _build_alias_where(field: str, patterns: list) -> dict:
+def _build_alias_where(field: str, patterns: Sequence[str]) -> dict[str, object]:
"""Build a Prisma ``where`` clause for alias patterns.
Supports exact matches and suffix wildcards (``prefix*``).
Returns something like:
{"OR": [{"field": {"in": ["a","b"]}}, {"field": {"startsWith": "dev-"}}]}
"""
- exact: Final[list] = []
- prefix_conditions: Final[list] = []
+ exact: Final[list[str]] = []
+ prefix_conditions: Final[list[dict[str, object]]] = []
for pat in patterns:
if pat.endswith("*"):
prefix_conditions.append({field: {"startsWith": pat[:-1]}})
else:
exact.append(pat)
- conditions: Final[list] = []
+ conditions: Final[list[dict[str, object]]] = []
if exact:
conditions.append({field: {"in": exact}})
conditions.extend(prefix_conditions)
@@ -79,7 +83,7 @@ def _get_tags_from_metadata(metadata: object, json_metadata: object = None) -> l
return parsed.get("tags", []) or []
-async def _fetch_all_teams(prisma_client: object) -> list:
+async def _fetch_all_teams(prisma_client: object) -> "Sequence[prisma_models.LiteLLM_TeamTable]":
"""Fetch teams from DB once. Reuse the result across tag and alias lookups."""
return await TeamRepository(prisma_client).table.find_many(
where={},
@@ -88,13 +92,15 @@ async def _fetch_all_teams(prisma_client: object) -> list:
)
-def _filter_keys_by_tags(keys: list, tag_patterns: list) -> tuple:
+def _filter_keys_by_tags(
+ keys: "Sequence[prisma_models.LiteLLM_VerificationToken]", tag_patterns: Sequence[str]
+) -> tuple[list[str], int]:
"""Filter key rows whose metadata.tags match any of the given patterns.
Returns (named_aliases, unnamed_count).
"""
- affected: Final[list] = []
+ affected: Final[list[str]] = []
unnamed_count = 0
for key in keys:
key_alias = key.key_alias or ""
@@ -111,13 +117,15 @@ def _filter_keys_by_tags(keys: list, tag_patterns: list) -> tuple:
return affected, unnamed_count
-def _filter_teams_by_tags(teams: list, tag_patterns: list) -> tuple:
+def _filter_teams_by_tags(
+ teams: "Sequence[prisma_models.LiteLLM_TeamTable]", tag_patterns: Sequence[str]
+) -> tuple[list[str], int]:
"""Filter pre-fetched team rows whose metadata.tags match any patterns.
Returns (named_aliases, unnamed_count).
"""
- affected: Final[list] = []
+ affected: Final[list[str]] = []
unnamed_count = 0
for team in teams:
team_alias = team.team_alias or ""
@@ -136,18 +144,18 @@ def _filter_teams_by_tags(teams: list, tag_patterns: list) -> tuple:
async def _find_affected_by_team_patterns(
prisma_client: object,
- all_teams: list,
- team_patterns: list,
- existing_teams: list,
- existing_keys: list,
-) -> tuple:
+ all_teams: "Sequence[prisma_models.LiteLLM_TeamTable]",
+ team_patterns: Sequence[str],
+ existing_teams: Sequence[str],
+ existing_keys: Sequence[str],
+) -> tuple[list[str], list[str], int]:
"""Filter pre-fetched teams by alias patterns, then fetch their keys.
Returns (new_teams, new_keys, unnamed_keys_count).
"""
- new_teams: Final[list] = []
- matched_team_ids: Final[list] = []
+ new_teams: Final[list[str]] = []
+ matched_team_ids: Final[list[str]] = []
for team in all_teams:
team_alias = team.team_alias or ""
@@ -158,7 +166,7 @@ async def _find_affected_by_team_patterns(
new_teams.append(team_alias)
matched_team_ids.append(str(team.team_id))
- new_keys: Final[list] = []
+ new_keys: Final[list[str]] = []
unnamed_keys_count = 0
if matched_team_ids:
keys: Final = await VerificationTokenRepository(prisma_client).table.find_many(
@@ -177,10 +185,12 @@ async def _find_affected_by_team_patterns(
return new_teams, new_keys, unnamed_keys_count
-async def _find_affected_keys_by_alias(prisma_client: object, key_patterns: list, existing_keys: list) -> list:
+async def _find_affected_keys_by_alias(
+ prisma_client: object, key_patterns: Sequence[str], existing_keys: Sequence[str]
+) -> list[str]:
"""Find keys whose alias matches the given patterns."""
- affected: Final[list] = []
+ affected: Final[list[str]] = []
keys: Final = await VerificationTokenRepository(prisma_client).table.find_many(
where=_build_alias_where("key_alias", key_patterns),
@@ -349,8 +359,8 @@ async def estimate_attachment_impact(
sample_teams=["(global scope — affects all teams)"],
)
- affected_keys: list = []
- affected_teams: list = []
+ affected_keys: list[str] = []
+ affected_teams: list[str] = []
unnamed_keys = 0
unnamed_teams = 0
@@ -358,7 +368,7 @@ async def estimate_attachment_impact(
team_patterns: Final = request.teams or []
# Fetch teams once — reused by both tag-based and alias-based lookups
- all_teams: list = []
+ all_teams: Sequence[prisma_models.LiteLLM_TeamTable] = []
if tag_patterns or team_patterns:
all_teams = await _fetch_all_teams(prisma_client)
diff --git a/litellm/proxy/prompts/prompt_endpoints.py b/litellm/proxy/prompts/prompt_endpoints.py
index 1d71ea658e4..a289ed7cbfb 100644
--- a/litellm/proxy/prompts/prompt_endpoints.py
+++ b/litellm/proxy/prompts/prompt_endpoints.py
@@ -93,7 +93,7 @@ class _PromptTableActions(Protocol):
def create(self, *, data: Mapping[str, str | int | None]) -> Awaitable[_PromptRow]: ...
- def update(self, *, where: Mapping[str, str | int], data: Mapping[str, str]) -> Awaitable[_PromptRow]: ...
+ def update(self, *, where: Mapping[str, str | int], data: Mapping[str, str]) -> Awaitable[_PromptRow | None]: ...
def delete_many(self, *, where: Mapping[str, str]) -> Awaitable[int]: ...
@@ -1157,6 +1157,12 @@ async def patch_prompt(
data=update_data,
)
+ if updated_prompt_db_entry is None:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Prompt with ID {base_prompt_id} not found in environment {env}",
+ )
+
updated_prompt_spec: Final = create_versioned_prompt_spec(db_prompt=updated_prompt_db_entry)
return _reload_prompt_in_registry(IN_MEMORY_PROMPT_REGISTRY, versioned_id, updated_prompt_spec)
diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py
index 0abcdeaf3f6..9ae53bebf0a 100644
--- a/litellm/proxy/proxy_server.py
+++ b/litellm/proxy/proxy_server.py
@@ -133,6 +133,7 @@ if TYPE_CHECKING:
from aiohttp import ClientSession
from fastapi.routing import APIRoute
from opentelemetry.trace import Span as _Span
+ from prisma import models as prisma_models
from litellm.integrations.opentelemetry import OpenTelemetry
@@ -634,6 +635,7 @@ from litellm.proxy.utils import (
from litellm.proxy.video_endpoints.endpoints import router as video_router
from litellm.repositories.base_repository import SupportsModelDump
from litellm.repositories.credentials_repository import CredentialsRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.router import (
AssistantsTypedDict,
Deployment,
@@ -1642,12 +1644,21 @@ class _InvitationLinkRow(Protocol):
class _UserTableRow(Protocol):
user_id: str
user_email: str | None
- user_role: str
+ user_role: str | None
-class _ModelTableRow(Protocol):
- model_id: str | None
- created_by: str | None
+class _UserTeamsRow(Protocol):
+ @property
+ def teams(self) -> Sequence[str]: ...
+
+
+_ProxyModelRow: TypeAlias = "prisma_models.LiteLLM_ProxyModelTable"
+
+
+def _config_param_table(client: PrismaClient | None) -> TableActions[_ConfigParamRow]:
+ return cast( # cast-ok: this is prisma's LiteLLM_Config actions object, which parses its Json column to a mapping
+ "TableActions[_ConfigParamRow]", ConfigRepository(client).table
+ )
class _TTFTRow(TypedDict):
@@ -4376,7 +4387,7 @@ class ProxyConfig:
if prisma_client is None or not (general_settings.get("store_model_in_db", False) is True or store_model_in_db):
return
- row: Final[_ConfigParamRow | None] = await ConfigRepository(prisma_client).table.find_first(
+ row: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first(
where={"param_name": "environment_variables"}
)
existing: Final[dict] = dict(row.param_value) if row is not None and row.param_value is not None else {}
@@ -6232,7 +6243,7 @@ class ProxyConfig:
4. Update router settings
"""
if llm_router is not None and prisma_client is not None:
- db_router_settings: Final[_ConfigParamRow | None] = await ConfigRepository(prisma_client).table.find_first(
+ db_router_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first(
where={"param_name": "router_settings"}
)
@@ -6660,7 +6671,7 @@ class ProxyConfig:
def _should_load_db_object(self, object_type: str | SupportedDBObjectType) -> bool:
return should_load_db_object(object_type=object_type)
- async def _get_models_from_db(self, prisma_client: PrismaClient) -> list | None:
+ async def _get_models_from_db(self, prisma_client: PrismaClient) -> Sequence[_ProxyModelRow] | None:
"""
Fetch all model deployments from the DB.
@@ -6670,7 +6681,7 @@ class ProxyConfig:
as "all models deleted" and must not evict existing router deployments.
"""
try:
- new_models: Final[list[_ModelTableRow]] = await ModelRepository(prisma_client).table.find_many()
+ new_models: Final[Sequence[_ProxyModelRow]] = await ModelRepository(prisma_client).table.find_many()
return new_models
except Exception as e:
verbose_proxy_logger.exception(
@@ -6957,10 +6968,13 @@ class ProxyConfig:
"""
try:
- sso_settings: Final[_SSOConfigRow | None] = await call_with_db_reconnect_retry(
- prisma_client,
- lambda: SSOConfigRepository(prisma_client).table.find_unique(where={"id": "sso_config"}),
- reason="init_sso_settings_in_db_lookup_failure",
+ sso_settings: Final[_SSOConfigRow | None] = cast( # cast-ok: prisma Json stub is `str`, runtime is a dict
+ "_SSOConfigRow | None",
+ await call_with_db_reconnect_retry(
+ prisma_client,
+ lambda: SSOConfigRepository(prisma_client).table.find_unique(where={"id": "sso_config"}),
+ reason="init_sso_settings_in_db_lookup_failure",
+ ),
)
if sso_settings is not None:
sso_settings.sso_settings.pop("role_mappings", None)
@@ -6988,12 +7002,15 @@ class ProxyConfig:
)
try:
- db_record: Final[_ConfigOverridesRow | None] = await call_with_db_reconnect_retry(
- prisma_client,
- lambda: ConfigOverridesRepository(prisma_client).table.find_unique(
- where={"config_type": "hashicorp_vault"}
+ db_record: Final[_ConfigOverridesRow | None] = cast( # cast-ok: prisma Json stub is `str`, runtime dict
+ "_ConfigOverridesRow | None",
+ await call_with_db_reconnect_retry(
+ prisma_client,
+ lambda: ConfigOverridesRepository(prisma_client).table.find_unique(
+ where={"config_type": "hashicorp_vault"}
+ ),
+ reason="init_hashicorp_vault_config_override_lookup_failure",
),
- reason="init_hashicorp_vault_config_override_lookup_failure",
)
if db_record is None or db_record.config_value is None:
@@ -8841,8 +8858,9 @@ class ProxyStartupEvent:
if prisma_client is None:
return
- db_record: Final[_UISettingsRow | None] = await UISettingsRepository(prisma_client).table.find_unique(
- where={"id": "ui_settings"}
+ db_record: Final[_UISettingsRow | None] = cast( # cast-ok: prisma Json stub is `str`, runtime is a dict
+ "_UISettingsRow | None",
+ await UISettingsRepository(prisma_client).table.find_unique(where={"id": "ui_settings"}),
)
if db_record and db_record.ui_settings:
raw: Final = db_record.ui_settings
@@ -9005,7 +9023,7 @@ class ProxyStartupEvent:
# but YAML config has False.
if store_model_in_db is not True and prisma_client is not None:
try:
- _db_gs_record: Final[_ConfigParamRow | None] = await ConfigRepository(prisma_client).table.find_first(
+ _db_gs_record: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first(
where={"param_name": "general_settings"}
)
if _db_gs_record is not None and isinstance(_db_gs_record.param_value, dict):
@@ -12150,14 +12168,14 @@ async def _check_if_model_is_user_added(
id = model.get("model_info", {}).get("id", None)
if id is None:
continue
- db_model: _ModelTableRow | None = await ModelRepository(prisma_client).table.find_unique(where={"model_id": id})
+ db_model: _ProxyModelRow | None = await ModelRepository(prisma_client).table.find_unique(where={"model_id": id})
if db_model is not None:
if db_model.created_by == user_api_key_dict.user_id:
filtered_models.append(model)
return filtered_models
-def _check_if_model_is_team_model(models: list[DeploymentTypedDict], user_row: LiteLLM_UserTable) -> list[dict]:
+def _check_if_model_is_team_model(models: list[DeploymentTypedDict], user_row: _UserTeamsRow) -> list[dict]:
"""
Check if model is a team model
@@ -12210,10 +12228,11 @@ async def non_admin_all_models(
raise HTTPException(status_code=400, detail={"error": "User not found"})
# Get all models that are team models, when model team_id == user_row.teams
- all_models += _check_if_model_is_team_model(
- models=llm_router.get_model_list() or [],
- user_row=user_row,
- )
+ if user_row is not None:
+ all_models += _check_if_model_is_team_model(
+ models=llm_router.get_model_list() or [],
+ user_row=user_row,
+ )
# de-duplicate models. Only return unique model ids
unique_models: Final = _deduplicate_litellm_router_models(models=all_models)
@@ -12637,7 +12656,7 @@ async def _fetch_db_models_for_search(
db_models_total_count: Final = await ModelRepository(prisma_client).table.count(where=db_where_condition)
- db_models_raw: list = []
+ db_models_raw: Sequence[_ProxyModelRow] = []
if take_limit > 0:
db_models_raw = await ModelRepository(prisma_client).table.find_many(
where=db_where_condition,
@@ -13027,7 +13046,7 @@ async def _gather_team_accessible_model_ids(
try:
if team_object.models and SpecialModelNames.all_proxy_models.value not in team_object.models:
_resolved_names: Final = _team_models_resolve_to_names(team_object.models, access_groups)
- db_models: Final[Sequence[_ModelTableRow]] = await ModelRepository(prisma_client).table.find_many(
+ db_models: Final[Sequence[_ProxyModelRow]] = await ModelRepository(prisma_client).table.find_many(
where={"model_name": {"in": _resolved_names}}
)
for db_model in db_models:
@@ -14501,14 +14520,18 @@ async def alerting_settings(
)
## get general settings from db
- db_general_settings: Final = await ConfigRepository(prisma_client).table.find_first(
+ db_general_settings: Final = await _config_param_table(prisma_client).find_first(
where={"param_name": "general_settings"}
)
if db_general_settings is not None and db_general_settings.param_value is not None:
db_general_settings_dict: Final = dict(db_general_settings.param_value)
- alerting_args_dict: dict = db_general_settings_dict.get("alerting_args", {})
- alerting_values: list | None = db_general_settings_dict.get("alerting")
+ alerting_args_dict: dict = cast( # cast-ok: ConfigGeneralSettings validates alerting_args as a dict on write
+ dict[str, JsonValue], db_general_settings_dict.get("alerting_args", {})
+ )
+ alerting_values: list | None = cast( # cast-ok: ConfigGeneralSettings validates alerting as a list on write
+ list[JsonValue] | None, db_general_settings_dict.get("alerting")
+ )
else:
alerting_args_dict = {}
alerting_values = None
@@ -15059,7 +15082,7 @@ async def onboarding(invite_link: str, request: Request):
user_id=user_obj.user_id,
key=onboarding_token,
user_email=user_obj.user_email,
- user_role=user_obj.user_role,
+ user_role=user_obj.user_role, # pyright: ignore[reportArgumentType] # nullable DB column, no unset contract
login_method="username_password",
premium_user=premium_user,
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
@@ -15168,7 +15191,7 @@ async def _generate_onboarding_ui_session_token(user_obj: _UserTableRow) -> str:
user_id=user_obj.user_id,
key=key,
user_email=user_obj.user_email,
- user_role=user_obj.user_role,
+ user_role=user_obj.user_role, # pyright: ignore[reportArgumentType] # nullable DB column, no unset contract
login_method="username_password",
premium_user=premium_user,
auth_header_name=general_settings.get("litellm_key_header_name", "Authorization"),
@@ -15729,7 +15752,7 @@ async def update_config(
raise Exception("No DB Connected")
async def _read_section(param_name: str) -> dict:
- row: Final[_ConfigParamRow | None] = await ConfigRepository(prisma_client).table.find_first(
+ row: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first(
where={"param_name": param_name}
)
if row is None or row.param_value is None:
@@ -15986,7 +16009,7 @@ async def update_config_general_settings(
)
## get general settings from db
- db_general_settings: Final = await ConfigRepository(prisma_client).table.find_first(
+ db_general_settings: Final = await _config_param_table(prisma_client).find_first(
where={"param_name": "general_settings"}
)
### update value
@@ -16004,7 +16027,7 @@ async def update_config_general_settings(
if data.field_name == "plugins":
field_value = _preserve_redacted_plugin_keys(field_value, general_settings.get("plugins"))
- general_settings[data.field_name] = field_value
+ general_settings[data.field_name] = cast(JsonValue, field_value) # cast-ok: ConfigGeneralSettings validated it
response: Final = await ConfigRepository(prisma_client).table.upsert(
where={"param_name": "general_settings"},
@@ -16024,7 +16047,7 @@ async def update_config_general_settings(
)
if data.field_name == "plugins":
- register_plugins_from_config(general_settings)
+ register_plugins_from_config(cast(dict[str, object], general_settings)) # cast-ok: the callee only reads it
_apply_ssrf_general_settings(general_settings)
return response
@@ -16200,7 +16223,7 @@ async def get_config_general_settings(
)
## get general settings from db
- db_general_settings: Final[_ConfigParamRow | None] = await ConfigRepository(prisma_client).table.find_first(
+ db_general_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first(
where={"param_name": "general_settings"}
)
### pop the value
@@ -16389,7 +16412,7 @@ async def get_config_list(
is_full_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN
## get general settings from db
- db_general_settings: Final[_ConfigParamRow | None] = await ConfigRepository(prisma_client).table.find_first(
+ db_general_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first(
where={"param_name": "general_settings"}
)
@@ -16485,7 +16508,7 @@ async def get_config_list(
)
return_val.append(_response_obj)
- db_litellm_settings_row: Final[_ConfigParamRow | None] = await ConfigRepository(prisma_client).table.find_first(
+ db_litellm_settings_row: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first(
where={"param_name": "litellm_settings"}
)
db_litellm_settings: Final[dict] = (
@@ -16562,7 +16585,7 @@ async def delete_config_general_settings(
)
## get general settings from db
- db_general_settings: Final[_ConfigParamRow | None] = await ConfigRepository(prisma_client).table.find_first(
+ db_general_settings: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_first(
where={"param_name": "general_settings"}
)
### pop the value
@@ -17129,7 +17152,7 @@ async def reload_anthropic_beta_headers(
last_anthropic_beta_headers_reload = current_time.isoformat()
# Set force reload flag in database for other pods, preserving existing interval_hours
- existing_beta_config: Final[_ConfigParamRow | None] = await ConfigRepository(prisma_client).table.find_unique(
+ existing_beta_config: Final[_ConfigParamRow | None] = await _config_param_table(prisma_client).find_unique(
where={"param_name": "anthropic_beta_headers_reload_config"}
)
existing_beta_interval = None
@@ -17307,7 +17330,7 @@ async def get_anthropic_beta_headers_reload_status(
}
# Get reload configuration from database
- config_record: Final = await ConfigRepository(prisma_client).table.find_unique(
+ config_record: Final = await _config_param_table(prisma_client).find_unique(
where={"param_name": "anthropic_beta_headers_reload_config"}
)
@@ -17321,7 +17344,9 @@ async def get_anthropic_beta_headers_reload_status(
}
config: Final = config_record.param_value
- interval_hours: Final = config.get("interval_hours")
+ interval_hours: Final = cast( # cast-ok: every writer of this key stores `hours: int` or an explicit None
+ int | None, config.get("interval_hours")
+ )
if interval_hours is None:
verbose_proxy_logger.info("No interval configured, returning not scheduled")
diff --git a/litellm/proxy/spend_tracking/cloudzero_endpoints.py b/litellm/proxy/spend_tracking/cloudzero_endpoints.py
index 93c81f7fc67..6c0d11a2174 100644
--- a/litellm/proxy/spend_tracking/cloudzero_endpoints.py
+++ b/litellm/proxy/spend_tracking/cloudzero_endpoints.py
@@ -1,5 +1,11 @@
import json
-from typing import Final
+from collections.abc import Mapping
+from typing import (
+ TYPE_CHECKING,
+ Final,
+ Protocol,
+ cast, # noqa: TID251 # the config repository's table protocol omits find_first
+)
from fastapi import APIRouter, Depends, HTTPException
@@ -13,6 +19,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
)
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
from litellm.repositories.config_repository import ConfigRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.types.proxy.cloudzero_endpoints import (
CloudZeroExportRequest,
CloudZeroExportResponse,
@@ -22,6 +29,9 @@ from litellm.types.proxy.cloudzero_endpoints import (
CloudZeroSettingsView,
)
+if TYPE_CHECKING:
+ from litellm.proxy.proxy_server import PrismaClient
+
router: Final = APIRouter()
@@ -29,6 +39,18 @@ router: Final = APIRouter()
_sensitive_masker: Final = SensitiveDataMasker()
+class _CloudZeroConfigRow(Protocol):
+ """The ``LiteLLM_Config`` row holding ``cloudzero_settings``, as this module reads it."""
+
+ @property
+ def param_value(self) -> str | Mapping[str, str] | None: ...
+
+
+def _config_table(prisma_client: "PrismaClient") -> TableActions[_CloudZeroConfigRow]:
+ repository_table: Final = ConfigRepository(prisma_client).table
+ return cast(TableActions[_CloudZeroConfigRow], repository_table) # cast-ok: repo protocol omits find_first
+
+
async def _set_cloudzero_settings(api_key: str, connection_id: str, timezone: str):
"""
Store CloudZero settings in the database with encrypted API key.
@@ -82,9 +104,7 @@ async def _get_cloudzero_settings():
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
- cloudzero_config: Final = await ConfigRepository(prisma_client).table.find_first(
- where={"param_name": "cloudzero_settings"}
- )
+ cloudzero_config: Final = await _config_table(prisma_client).find_first(where={"param_name": "cloudzero_settings"})
if cloudzero_config is None or cloudzero_config.param_value is None:
return {}
@@ -268,7 +288,7 @@ async def is_cloudzero_setup_in_db() -> bool:
return False
# Check for CloudZero settings in database
- cloudzero_config: Final = await ConfigRepository(prisma_client).table.find_first(
+ cloudzero_config: Final = await _config_table(prisma_client).find_first(
where={"param_name": "cloudzero_settings"}
)
@@ -530,7 +550,7 @@ async def delete_cloudzero_settings(
)
# Check if CloudZero settings exist
- cloudzero_config: Final = await ConfigRepository(prisma_client).table.find_first(
+ cloudzero_config: Final = await _config_table(prisma_client).find_first(
where={"param_name": "cloudzero_settings"}
)
diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py
index ed2ecd8325a..06395a3c3cc 100644
--- a/litellm/proxy/spend_tracking/spend_management_endpoints.py
+++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py
@@ -4,10 +4,22 @@ import json
import os
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta, timezone
-from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, NamedTuple, Protocol, TypedDict, TypeVar
+from typing import (
+ TYPE_CHECKING,
+ Annotated,
+ Any,
+ Final,
+ Literal,
+ NamedTuple,
+ Protocol,
+ TypedDict,
+ TypeVar,
+ cast, # noqa: TID251 # prisma group_by returns untyped aggregate mappings
+)
import fastapi
from fastapi import APIRouter, Depends, HTTPException, Request, status
+from typing_extensions import ReadOnly
import litellm
from litellm._logging import verbose_proxy_logger
@@ -23,6 +35,7 @@ from litellm.proxy.spend_tracking.spend_tracking_utils import (
get_spend_by_team_and_customer,
)
from litellm.proxy.utils import handle_exception_on_proxy
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import SpendLogsRepository
from litellm.repositories.team_repository import TeamRepository
from litellm.repositories.verification_token_repository import (
@@ -30,6 +43,8 @@ from litellm.repositories.verification_token_repository import (
)
if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
from litellm.proxy.proxy_server import PrismaClient
from litellm.proxy.spend_tracking.cold_storage_handler import ColdStorageHandler
else:
@@ -139,6 +154,18 @@ class _SessionSpendRow(TypedDict):
mcp_tool_call_spend: float
+class _SpendSumAggregate(TypedDict, total=False):
+ spend: ReadOnly[float]
+
+
+class _SpendGroupByRow(TypedDict):
+ api_key: ReadOnly[str]
+ user: ReadOnly[str | None]
+ model: ReadOnly[str]
+ startTime: ReadOnly[object]
+ _sum: ReadOnly[_SpendSumAggregate]
+
+
async def _query_raw(prisma_client: PrismaClient, sql_query: str, *args: object) -> Sequence[_RowT]:
"""Run a raw read query and return its rows as the row type the caller declares."""
return await prisma_client.db.query_raw(sql_query, *args)
@@ -149,24 +176,6 @@ async def _query_raw_or_none(prisma_client: PrismaClient, sql_query: str, *args:
return await _query_raw(prisma_client, sql_query, *args)
-class _SpendLogsTable(Protocol):
- """The subset of the Prisma spend-logs table API this module uses."""
-
- async def find_many(
- self, *, where: Mapping[str, object], order: Mapping[str, str]
- ) -> Sequence[_SupportsModelDump]: ...
-
- async def find_unique(
- self, *, where: Mapping[str, object], include: None = None
- ) -> _SpendLogOwnershipRow | None: ...
-
- async def count(self, *, where: Mapping[str, object]) -> int: ...
-
- async def group_by(
- self, *, by: Sequence[str], where: Mapping[str, object], count: Mapping[str, bool]
- ) -> Sequence[_SessionCountRow]: ...
-
-
class _TeamTable(Protocol):
"""The subset of the Prisma team table API this module uses."""
@@ -183,7 +192,7 @@ class _VerificationTokenTable(Protocol):
async def update_many(self, *, data: Mapping[str, float], where: Mapping[str, object]) -> int: ...
-def _spend_logs_table(prisma_client: PrismaClient) -> _SpendLogsTable:
+def _spend_logs_table(prisma_client: PrismaClient) -> TableActions["prisma_models.LiteLLM_SpendLogs"]:
return SpendLogsRepository(prisma_client).table
@@ -221,11 +230,12 @@ async def _count_logs_per_session(
prisma_client: PrismaClient, session_ids: Sequence[str | None]
) -> Sequence[_SessionCountRow]:
"""Count spend log rows per session for the given session ids."""
- return await _spend_logs_table(prisma_client).group_by(
+ rows: Final = await _spend_logs_table(prisma_client).group_by(
by=["session_id"],
where={"session_id": {"in": session_ids}},
count={"session_id": True},
)
+ return cast(Sequence[_SessionCountRow], rows) # cast-ok: group_by(count=) shape is fixed by the by/count args
async def _find_team_row(prisma_client: PrismaClient, team_id: str) -> _SupportsModelDump | None:
@@ -2974,8 +2984,9 @@ async def view_spend_logs(
)
if isinstance(response, list) and len(response) > 0 and isinstance(response[0], dict):
+ spend_rows: Final = cast(Sequence[_SpendGroupByRow], response) # cast-ok: by/sum fix the shape
result: Final[dict] = {}
- for record in response:
+ for record in spend_rows:
dt_object = datetime.strptime(str(record["startTime"]), "%Y-%m-%dT%H:%M:%S.%fZ")
date = dt_object.date()
if date not in result:
diff --git a/litellm/proxy/spend_tracking/vantage_endpoints.py b/litellm/proxy/spend_tracking/vantage_endpoints.py
index 00d0554d783..c71105ad283 100644
--- a/litellm/proxy/spend_tracking/vantage_endpoints.py
+++ b/litellm/proxy/spend_tracking/vantage_endpoints.py
@@ -1,5 +1,11 @@
import json
-from typing import Final
+from collections.abc import Mapping
+from typing import (
+ TYPE_CHECKING,
+ Final,
+ Protocol,
+ cast, # noqa: TID251 # the config repository's table protocol omits find_first
+)
from fastapi import APIRouter, Depends, HTTPException
@@ -14,6 +20,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
)
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
from litellm.repositories.config_repository import ConfigRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.types.proxy.vantage_endpoints import (
VantageDryRunRequest,
VantageExportRequest,
@@ -24,6 +31,9 @@ from litellm.types.proxy.vantage_endpoints import (
VantageSettingsView,
)
+if TYPE_CHECKING:
+ from litellm.proxy.proxy_server import PrismaClient
+
router: Final = APIRouter()
_sensitive_masker: Final = SensitiveDataMasker()
@@ -31,6 +41,18 @@ _sensitive_masker: Final = SensitiveDataMasker()
VANTAGE_SETTINGS_PARAM_NAME: Final = "vantage_settings"
+class _VantageConfigRow(Protocol):
+ """The ``LiteLLM_Config`` row holding ``vantage_settings``, as this module reads it."""
+
+ @property
+ def param_value(self) -> str | Mapping[str, str] | None: ...
+
+
+def _config_table(prisma_client: "PrismaClient") -> TableActions[_VantageConfigRow]:
+ repository_table: Final = ConfigRepository(prisma_client).table
+ return cast(TableActions[_VantageConfigRow], repository_table) # cast-ok: repo protocol omits find_first
+
+
def _get_registered_vantage_logger():
"""Return the VantageLogger already registered in litellm.callbacks, if any."""
from litellm.integrations.vantage.vantage_logger import VantageLogger
@@ -82,7 +104,7 @@ async def _get_vantage_settings():
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
- vantage_config: Final = await ConfigRepository(prisma_client).table.find_first(
+ vantage_config: Final = await _config_table(prisma_client).find_first(
where={"param_name": VANTAGE_SETTINGS_PARAM_NAME}
)
if vantage_config is None or vantage_config.param_value is None:
@@ -251,7 +273,7 @@ async def is_vantage_setup_in_db() -> bool:
if prisma_client is None:
return False
- vantage_config: Final = await ConfigRepository(prisma_client).table.find_first(
+ vantage_config: Final = await _config_table(prisma_client).find_first(
where={"param_name": VANTAGE_SETTINGS_PARAM_NAME}
)
@@ -525,7 +547,7 @@ async def delete_vantage_settings(
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
- vantage_config: Final = await ConfigRepository(prisma_client).table.find_first(
+ vantage_config: Final = await _config_table(prisma_client).find_first(
where={"param_name": VANTAGE_SETTINGS_PARAM_NAME}
)
diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
index 66a8c0622fa..a1eb7ed06eb 100644
--- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
+++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
@@ -4,7 +4,12 @@ import json
import os
from collections import Counter
from collections.abc import Mapping
-from typing import Any, Final, Protocol, TypeVar
+from typing import (
+ Any,
+ Final,
+ Protocol,
+ cast, # noqa: TID251 # prisma types Json columns as fields.Json but de-serializes them to plain python on read
+)
from urllib.parse import urlparse
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
@@ -25,6 +30,7 @@ from litellm.proxy.spend_tracking.ptu_feature_flag import is_ptu_cost_attributio
from litellm.proxy.utils import invalidate_config_param
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.organization_repository import OrganizationRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import (
SSOConfigRepository,
UISettingsRepository,
@@ -37,29 +43,16 @@ from litellm.types.proxy.management_endpoints.ui_sso import (
router: Final = APIRouter()
-_DbRecordT: Final = TypeVar("_DbRecordT", covariant=True)
-
-
-class _PrismaTableActions(Protocol[_DbRecordT]):
- async def find_unique(self, where: Mapping[str, object]) -> _DbRecordT | None: ...
-
- async def update(self, where: Mapping[str, object], data: Mapping[str, object]) -> _DbRecordT: ...
-
- async def upsert(self, where: Mapping[str, object], data: Mapping[str, object]) -> _DbRecordT: ...
-
class _SsoSettingsMappingRow(Protocol):
@property
def sso_settings(self) -> Mapping[str, object] | None: ...
-class _HasSsoSettingsMappingTable(Protocol):
- @property
- def table(self) -> _PrismaTableActions[_SsoSettingsMappingRow]: ...
-
-
-def _sso_settings_mapping_db(repo: _HasSsoSettingsMappingTable) -> _PrismaTableActions[_SsoSettingsMappingRow]:
- return repo.table
+def _sso_settings_mapping_db(repo: SSOConfigRepository) -> TableActions[_SsoSettingsMappingRow]:
+ return cast( # cast-ok: prisma types Json columns as str; the client hands back the deserialized value
+ "TableActions[_SsoSettingsMappingRow]", repo.table
+ )
class _StoredSsoSettingsRow(Protocol):
@@ -67,12 +60,7 @@ class _StoredSsoSettingsRow(Protocol):
def sso_settings(self) -> object: ...
-class _HasStoredSsoSettingsTable(Protocol):
- @property
- def table(self) -> _PrismaTableActions[_StoredSsoSettingsRow]: ...
-
-
-def _stored_sso_settings_db(repo: _HasStoredSsoSettingsTable) -> _PrismaTableActions[_StoredSsoSettingsRow]:
+def _stored_sso_settings_db(repo: SSOConfigRepository) -> TableActions[_StoredSsoSettingsRow]:
return repo.table
@@ -81,13 +69,10 @@ class _UiSettingsRow(Protocol):
def ui_settings(self) -> str | Mapping[str, JsonValue] | None: ...
-class _HasUiSettingsTable(Protocol):
- @property
- def table(self) -> _PrismaTableActions[_UiSettingsRow]: ...
-
-
-def _ui_settings_db(repo: _HasUiSettingsTable) -> _PrismaTableActions[_UiSettingsRow]:
- return repo.table
+def _ui_settings_db(repo: UISettingsRepository) -> TableActions[_UiSettingsRow]:
+ return cast( # cast-ok: prisma types Json columns as str; the client hands back the deserialized value
+ "TableActions[_UiSettingsRow]", repo.table
+ )
class _ConfigParamRow(Protocol):
@@ -95,13 +80,10 @@ class _ConfigParamRow(Protocol):
def param_value(self) -> str | Mapping[str, object] | None: ...
-class _HasConfigParamTable(Protocol):
- @property
- def table(self) -> _PrismaTableActions[_ConfigParamRow]: ...
-
-
-def _config_param_db(repo: _HasConfigParamTable) -> _PrismaTableActions[_ConfigParamRow]:
- return repo.table
+def _config_param_db(repo: ConfigRepository) -> TableActions[_ConfigParamRow]:
+ return cast( # cast-ok: prisma's LiteLLM_Config actions object, whose Json column parses to a mapping
+ "TableActions[_ConfigParamRow]", repo.table
+ )
# Maps each UIThemeConfig field to the env var the UI branding path reads it
diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py
index 2bbdad9c487..8cbf5b685fd 100644
--- a/litellm/proxy/utils.py
+++ b/litellm/proxy/utils.py
@@ -177,6 +177,7 @@ from litellm.types.utils import LLMResponseTypes, LoggedLiteLLMParams
if TYPE_CHECKING:
from mcp.types import CallToolResult
from opentelemetry.trace import Span as _Span
+ from prisma import models as prisma_models
from prisma.actions import LiteLLM_DeprecatedVerificationTokenActions
from prisma.client import TransactionManager
from prisma.models import LiteLLM_DeprecatedVerificationToken
@@ -186,6 +187,7 @@ if TYPE_CHECKING:
from litellm.models.team import LiteLLM_TeamTableCachedObj
from litellm.proxy.db.autorouter_session_rollup import AutoRouterTurnTransaction
from litellm.proxy.db.spend_log_tool_index import ToolUsageTransaction
+ from litellm.repositories.prisma_protocols import TableActions
from litellm.types.proxy.policy_engine.pipeline_types import GuardrailPipeline
Span = _Span | object
@@ -3282,7 +3284,10 @@ async def prefetch_config_params(prisma_client: "PrismaClient | None", param_nam
if not param_names:
return
try:
- rows: Final = await ConfigRepository(prisma_client).table.find_many(where={"param_name": {"in": param_names}})
+ config_table: Final = cast( # cast-ok: ConfigRepository.table is prisma's litellm_config actions object
+ "TableActions[prisma_models.LiteLLM_Config]", ConfigRepository(prisma_client).table
+ )
+ rows: Final = await config_table.find_many(where={"param_name": {"in": param_names}})
except Exception as e:
verbose_proxy_logger.debug(
"prefetch_config_params failed, falling through to per-param queries: %s",
@@ -3571,8 +3576,8 @@ class PrismaClient:
return hashed_token
- def jsonify_object(self, data: dict) -> dict:
- db_data: Final = copy.deepcopy(data)
+ def jsonify_object(self, data: Mapping[str, object]) -> dict[str, object]:
+ db_data: Final[dict[str, object]] = copy.deepcopy(dict(data))
for k, v in db_data.items():
if isinstance(v, dict):
@@ -3706,7 +3711,10 @@ class PrismaClient:
elif table_name == "keys":
return await VerificationTokenRepository(self).table.find_first(where={key: value})
elif table_name == "config":
- return await ConfigRepository(self).table.find_first(where={key: value})
+ config_table: Final = cast( # cast-ok: ConfigRepository.table is prisma's litellm_config actions object
+ "TableActions[prisma_models.LiteLLM_Config]", ConfigRepository(self).table
+ )
+ return await config_table.find_first(where={key: value})
elif table_name == "spend":
return await self.db.l.find_first(where={key: value})
return None
@@ -3809,9 +3817,9 @@ class PrismaClient:
self,
token: str | list | None = None,
user_id: str | None = None,
- user_id_list: list | None = None,
+ user_id_list: Sequence[str] | None = None,
team_id: str | None = None,
- team_id_list: list | None = None,
+ team_id_list: Sequence[str] | None = None,
key_val: dict | None = None,
table_name: Literal[
"user", "key", "config", "spend", "enduser", "budget", "team", "user_notification", "combined_view"
@@ -3894,14 +3902,14 @@ class PrismaClient:
if isinstance(r.expires, datetime):
r.expires = r.expires.isoformat()
elif query_type == "find_all":
- where_filter: Final[dict] = {}
+ where_filter: Final[dict[str, dict[str, Sequence[str]]]] = {}
if token is not None:
where_filter["token"] = {}
if isinstance(token, str):
token = _hash_token_if_needed(token=token)
where_filter["token"]["in"] = [token]
elif isinstance(token, list):
- hashed_tokens: Final = []
+ hashed_tokens: Final[list[str]] = []
for t in token:
assert isinstance(t, str)
if t.startswith("sk-"):
@@ -4198,7 +4206,7 @@ class PrismaClient:
)
raise e
- def jsonify_team_object(self, db_data: dict):
+ def jsonify_team_object(self, db_data: Mapping[str, object]) -> dict[str, object]:
db_data = self.jsonify_object(data=db_data)
if db_data.get("members_with_roles", None) is not None and isinstance(db_data["members_with_roles"], list):
db_data["members_with_roles"] = json.dumps(db_data["members_with_roles"])
@@ -4216,7 +4224,7 @@ class PrismaClient:
)
async def insert_data(
self,
- data: dict,
+ data: Mapping[str, object],
table_name: Literal["user", "key", "config", "spend", "team", "user_notification"],
):
"""
@@ -4226,10 +4234,12 @@ class PrismaClient:
try:
verbose_proxy_logger.debug(
"PrismaClient: insert_data: %s",
- {**data, "token": self.hash_token(token=data["token"])} if data.get("token") is not None else data,
+ {**data, "token": self.hash_token(token=cast("str", data["token"]))} # cast-ok: a key token is a str
+ if data.get("token") is not None
+ else data,
)
if table_name == "key":
- token: Final = data["token"]
+ token: Final = cast("str", data["token"]) # cast-ok: the key table's token column is a str
hashed_token: Final = self.hash_token(token=token)
db_data = self.jsonify_object(data=data)
db_data["token"] = hashed_token
@@ -4364,14 +4374,14 @@ class PrismaClient:
async def update_data(
self,
token: str | None = None,
- data: dict = {},
+ data: Mapping[str, object] = {},
data_list: list | None = None,
user_id: str | None = None,
team_id: str | None = None,
query_type: Literal["update", "update_many"] = "update",
table_name: Literal["user", "key", "config", "spend", "team", "enduser", "budget"] | None = None,
- update_key_values: dict | None = None,
- update_key_values_custom_query: dict | None = None,
+ update_key_values: dict[str, object] | None = None,
+ update_key_values_custom_query: dict[str, object] | None = None,
):
"""
Update existing data
@@ -4397,14 +4407,14 @@ class PrismaClient:
try:
_data = response.model_dump()
except Exception:
- _data = response.dict()
+ _data = response.dict() # pyright: ignore[reportDeprecated] # pydantic-v1 row fallback
return {"token": token, "data": _data}
elif user_id is not None or (table_name is not None and table_name == "user") and query_type == "update":
"""
If data['spend'] + data['user'], update the user table with spend info as well
"""
if user_id is None:
- user_id = db_data["user_id"]
+ user_id = cast("str", db_data["user_id"]) # cast-ok: the user table's user_id column is a str
if update_key_values is None:
if update_key_values_custom_query is not None:
update_key_values = update_key_values_custom_query
@@ -4426,7 +4436,7 @@ class PrismaClient:
If data['spend'] + data['user'], update the user table with spend info as well
"""
if team_id is None:
- team_id = db_data["team_id"]
+ team_id = cast("str | None", db_data["team_id"]) # cast-ok: team_id column is a nullable str
if update_key_values is None:
update_key_values = db_data
if "team_id" not in db_data and team_id is not None:
@@ -4600,8 +4610,8 @@ class PrismaClient:
)
async def delete_data(
self,
- tokens: list | None = None,
- team_id_list: list | None = None,
+ tokens: Sequence[str | None] | None = None,
+ team_id_list: Sequence[str] | None = None,
table_name: Literal["user", "key", "config", "spend", "team"] | None = None,
user_id: str | None = None,
):
@@ -4613,14 +4623,14 @@ class PrismaClient:
start_time: Final = time.time()
try:
if tokens is not None and isinstance(tokens, list):
- hashed_tokens: Final = []
+ hashed_tokens: Final[list[str | None]] = []
for token in tokens:
if isinstance(token, str) and token.startswith("sk-"):
hashed_token = self.hash_token(token=token)
else:
hashed_token = token
hashed_tokens.append(hashed_token)
- filter_query: dict = {}
+ filter_query: dict[str, object] = {}
if user_id is not None:
filter_query = {"AND": [{"token": {"in": hashed_tokens}}, {"user_id": user_id}]}
else:
@@ -5765,12 +5775,12 @@ class PrismaClient:
limit: int = 100,
offset: int = 0,
status_filter: str | None = None,
- ):
+ ) -> "Sequence[prisma_models.LiteLLM_HealthCheckTable]":
"""
Get health check history with optional filtering
"""
try:
- where_clause: Final = {}
+ where_clause: Final[dict[str, str]] = {}
if model_name:
where_clause["model_name"] = model_name
if status_filter:
@@ -5787,7 +5797,7 @@ class PrismaClient:
verbose_proxy_logger.error("Error getting health check history: %s", e)
return []
- async def get_all_latest_health_checks(self):
+ async def get_all_latest_health_checks(self) -> "Sequence[prisma_models.LiteLLM_HealthCheckTable]":
"""
Get the latest health check for each model.
@@ -5965,15 +5975,17 @@ async def migrate_passwords_to_scrypt_async(prisma_client) -> str:
return len(s) == 64 and all(c in "0123456789abcdef" for c in s)
plaintext_users: Final = [
- u for u in all_with_pw if u.password and not u.password.startswith("scrypt:") and not _is_sha256_hex(u.password)
+ (u.user_id, u.password)
+ for u in all_with_pw
+ if u.password and not u.password.startswith("scrypt:") and not _is_sha256_hex(u.password)
]
if not plaintext_users:
return "No plaintext passwords found"
- for user in plaintext_users:
+ for user_id, plaintext_password in plaintext_users:
await UserRepository(prisma_client).table.update(
- where={"user_id": user.user_id},
- data={"password": hash_password(user.password)},
+ where={"user_id": user_id},
+ data={"password": hash_password(plaintext_password)},
)
return f"Migrated {len(plaintext_users)} plaintext passwords to scrypt"
diff --git a/litellm/proxy/vector_store_endpoints/endpoints.py b/litellm/proxy/vector_store_endpoints/endpoints.py
index b497247f576..a59d7a277cc 100644
--- a/litellm/proxy/vector_store_endpoints/endpoints.py
+++ b/litellm/proxy/vector_store_endpoints/endpoints.py
@@ -1,4 +1,9 @@
-from typing import Annotated, Any, Final
+from typing import (
+ Annotated,
+ Any, # noqa: TID251 # jsonify_object in proxy/utils.py is annotated with a bare dict
+ Final,
+ cast, # noqa: TID251 # jsonify_object in proxy/utils.py is annotated with a bare dict
+)
from fastapi import APIRouter, Depends, HTTPException, Request, Response
@@ -591,7 +596,11 @@ async def index_create(
index_data: Final = index_create_request.model_dump(exclude_none=True)
index_data["created_by"] = user_api_key_dict.user_id
index_data["updated_by"] = user_api_key_dict.user_id
- new_index = await ManagedVectorStoreIndexRepository(prisma_client).table.create(data=jsonify_object(index_data))
+ new_index = await ManagedVectorStoreIndexRepository(prisma_client).table.create(
+ data=cast( # cast-ok: jsonify_object deep-copies a model_dump, so keys are str and values plain objects
+ "dict[str, object]", jsonify_object(index_data)
+ )
+ )
return new_index.model_dump()
diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py
index 2b037bef795..183a03cc13c 100644
--- a/litellm/proxy/vector_store_endpoints/management_endpoints.py
+++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py
@@ -10,8 +10,7 @@ All /vector_store management endpoints
import copy
import json
-from collections.abc import Mapping
-from typing import TYPE_CHECKING, Any, Final, Protocol
+from typing import TYPE_CHECKING, Any, Final
from fastapi import APIRouter, Depends, HTTPException
@@ -37,6 +36,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helpe
from litellm.proxy.common_utils.rbac_utils import check_feature_access_for_user
from litellm.proxy.vector_store_endpoints.utils import can_user_access_vector_store
from litellm.repositories.model_repository import ModelRepository
+from litellm.repositories.prisma_protocols import TableActions
from litellm.repositories.table_repositories import ManagedVectorStoresRepository
from litellm.secret_managers.main import get_secret
from litellm.types.vector_stores import (
@@ -51,17 +51,7 @@ from litellm.vector_stores.vector_store_registry import VectorStoreRegistry
router: Final = APIRouter()
-class _VectorStoreTableActions(Protocol):
- async def find_unique(self, where: Mapping[str, str]) -> "_VectorStoreRow | None": ...
-
- async def create(self, data: Mapping[str, object]) -> "_VectorStoreRow": ...
-
- async def update(self, where: Mapping[str, str], data: Mapping[str, object]) -> "_VectorStoreRow": ...
-
- async def delete(self, where: Mapping[str, str]) -> "_VectorStoreRow | None": ...
-
-
-def _vector_store_table(prisma_client: "PrismaClient") -> _VectorStoreTableActions:
+def _vector_store_table(prisma_client: "PrismaClient") -> "TableActions[_VectorStoreRow]":
return ManagedVectorStoresRepository(prisma_client).table
@@ -277,7 +267,7 @@ async def _resolve_embedding_config_from_db(
if db_model and db_model.litellm_params:
# Extract litellm_params (could be dict or JSON string)
model_params = db_model.litellm_params
- if isinstance(model_params, str):
+ if isinstance(model_params, str): # pyright: ignore[reportUnnecessaryIsInstance] # prisma Json is str
model_params = json.loads(model_params)
# Decrypt values from database (similar to how proxy_server.py does it)
@@ -888,6 +878,12 @@ async def update_vector_store(
data=update_data,
)
+ if updated is None:
+ raise HTTPException(
+ status_code=404,
+ detail=f"Vector store with ID {vector_store_id} not found",
+ )
+
updated_vs: Final = _row_to_vector_store(updated)
# Immediately update in-memory registry to keep it in sync
diff --git a/litellm/repositories/base_repository.py b/litellm/repositories/base_repository.py
index 7008099fe8c..568e3b50ed2 100644
--- a/litellm/repositories/base_repository.py
+++ b/litellm/repositories/base_repository.py
@@ -8,6 +8,8 @@ from typing import Any, Final, Generic, Protocol, TypeVar, runtime_checkable
from pydantic import BaseModel
+from litellm.repositories.prisma_protocols import TableActions
+
T = TypeVar("T", bound=BaseModel)
@@ -49,7 +51,7 @@ class BaseRepository(ABC, Generic[T]):
@property
@abstractmethod
- def table(self) -> Any: # any-ok: Prisma table actions are reached through the untyped client wrapper
+ def table(self) -> TableActions[DbRecord]:
"""Return the Prisma table for this repository."""
...
@@ -76,33 +78,28 @@ class BaseRepository(ABC, Generic[T]):
async def find_many(
self,
- where: dict[str, Any] | None = None,
+ where: Mapping[str, object] | None = None,
skip: int | None = None,
take: int | None = None,
- order: dict[str, str] | None = None,
+ order: Mapping[str, str] | None = None,
) -> list[T]:
"""Find multiple records matching the criteria."""
- kwargs: Final[dict[str, Any]] = {}
- if where:
- kwargs["where"] = where
- if skip is not None:
- kwargs["skip"] = skip
- if take is not None:
- kwargs["take"] = take
- if order:
- kwargs["order"] = order
-
- records: Final = await self.table.find_many(**kwargs)
+ records: Final = await self.table.find_many(
+ take=take,
+ skip=skip,
+ where=where or None,
+ order=order or None,
+ )
return self._to_model_list(records)
- async def create(self, data: dict[str, Any]) -> T:
+ async def create(self, data: Mapping[str, object]) -> T:
"""Create a new record."""
record: Final = await self.table.create(data=data)
model: Final = self._to_model(record)
assert model is not None
return model
- async def update(self, id_value: str, data: dict[str, Any], id_field: str = "id") -> T | None:
+ async def update(self, id_value: str, data: Mapping[str, object], id_field: str = "id") -> T | None:
"""Update an existing record."""
record: Final = await self.table.update(where={id_field: id_value}, data=data)
return self._to_model(record)
@@ -112,7 +109,7 @@ class BaseRepository(ABC, Generic[T]):
record: Final = await self.table.delete(where={id_field: id_value})
return self._to_model(record)
- async def count(self, where: dict[str, Any] | None = None) -> int:
+ async def count(self, where: Mapping[str, object] | None = None) -> int:
"""Count records matching the criteria."""
return await self.table.count(where=where)
diff --git a/litellm/repositories/budget_repository.py b/litellm/repositories/budget_repository.py
index f6c47b2d639..62632ffb5f6 100644
--- a/litellm/repositories/budget_repository.py
+++ b/litellm/repositories/budget_repository.py
@@ -2,17 +2,21 @@
Budget repository for database operations on LiteLLM_BudgetTable.
"""
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final
from litellm.models.budget import LiteLLM_BudgetTable
from litellm.repositories.base_repository import BaseRepository
+from litellm.repositories.prisma_protocols import TableActions
+
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
class BudgetRepository(BaseRepository[LiteLLM_BudgetTable]):
"""Repository for budget database operations."""
@property
- def table(self) -> Any:
+ def table(self) -> TableActions["prisma_models.LiteLLM_BudgetTable"]:
return self.prisma_client.db.litellm_budgettable
@property
diff --git a/litellm/repositories/config_repository.py b/litellm/repositories/config_repository.py
index 71ae39e89c6..76b9a3a5809 100644
--- a/litellm/repositories/config_repository.py
+++ b/litellm/repositories/config_repository.py
@@ -77,7 +77,7 @@ class ConfigRepository:
return self.prisma_client.db.litellm_config
@property
- def table(self) -> Any:
+ def table(self) -> _ConfigTable:
return self._config_table
async def get_param(self, param_name: str) -> ConfigParam | None:
diff --git a/litellm/repositories/credentials_repository.py b/litellm/repositories/credentials_repository.py
index 9fdb6e4aca7..ddb9767b2b9 100644
--- a/litellm/repositories/credentials_repository.py
+++ b/litellm/repositories/credentials_repository.py
@@ -6,54 +6,77 @@ credential values is the caller's responsibility (see ``CredentialHelperUtils``)
so reads return the stored values verbatim.
"""
-from typing import Any, Final
+from collections.abc import Mapping, Sequence
+from typing import TYPE_CHECKING, Any, Final, Protocol, TypeAlias
from litellm.models.credentials import CredentialItem
from litellm.proxy.common_utils.config_sync_pubsub import wrap_table_actions_for_config_sync
+from litellm.repositories.base_repository import DbRecord, record_to_dict
+from litellm.repositories.prisma_protocols import TableActions
+
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
+
+ _CredentialsTable: TypeAlias = TableActions[prisma_models.LiteLLM_CredentialsTable]
+
+
+class _PrismaCredentialsDb(Protocol):
+ @property
+ def litellm_credentialstable(self) -> "_CredentialsTable": ...
+
+
+class _PrismaClientView(Protocol):
+ @property
+ def db(self) -> _PrismaCredentialsDb: ...
class CredentialsRepository:
"""Repository for credentials database operations, keyed by credential name."""
- def __init__(self, prisma_client: Any):
+ def __init__(self, prisma_client: Any): # any-ok: PrismaClient is an untyped runtime wrapper
self._prisma_client = prisma_client
@property
- def prisma_client(self) -> Any:
+ def prisma_client(self) -> _PrismaClientView:
if self._prisma_client is None:
raise RuntimeError("No DB Connected. See - https://docs.litellm.ai/docs/proxy/virtual_keys")
- return self._prisma_client
+ client: Final[_PrismaClientView] = self._prisma_client
+ return client
@property
- def table(self) -> Any:
+ def table(self) -> "_CredentialsTable":
return wrap_table_actions_for_config_sync(
actions=self.prisma_client.db.litellm_credentialstable,
table_name="litellm_credentialstable",
)
@staticmethod
- def _to_model(record: Any) -> CredentialItem | None:
+ def _to_model(record: DbRecord | None) -> CredentialItem | None:
if record is None:
return None
- data: Final = record.dict() if hasattr(record, "dict") else dict(record)
- return CredentialItem(
- credential_name=data["credential_name"],
- credential_values=data.get("credential_values") or {},
- credential_info=data.get("credential_info") or {},
+ data: Final = record_to_dict(record)
+ return CredentialItem.model_validate(
+ {
+ "credential_name": data["credential_name"],
+ "credential_values": data.get("credential_values") or {},
+ "credential_info": data.get("credential_info") or {},
+ }
)
- async def find_all(self) -> Any:
+ async def find_all(self) -> Sequence["prisma_models.LiteLLM_CredentialsTable"]:
return await self.table.find_many()
- async def create(self, data: dict[str, Any]) -> Any:
+ async def create(self, data: Mapping[str, object]) -> "prisma_models.LiteLLM_CredentialsTable":
return await self.table.create(data=data)
async def find_by_name(self, credential_name: str) -> CredentialItem | None:
record: Final = await self.table.find_unique(where={"credential_name": credential_name})
return self._to_model(record)
- async def update_by_name(self, credential_name: str, data: dict[str, Any]) -> Any:
+ async def update_by_name(
+ self, credential_name: str, data: Mapping[str, object]
+ ) -> "prisma_models.LiteLLM_CredentialsTable | None":
return await self.table.update(where={"credential_name": credential_name}, data=data)
- async def delete_by_name(self, credential_name: str) -> Any:
+ async def delete_by_name(self, credential_name: str) -> "prisma_models.LiteLLM_CredentialsTable | None":
return await self.table.delete(where={"credential_name": credential_name})
diff --git a/litellm/repositories/model_repository.py b/litellm/repositories/model_repository.py
index 27e23a39cc9..cc2b1f19a3c 100644
--- a/litellm/repositories/model_repository.py
+++ b/litellm/repositories/model_repository.py
@@ -3,8 +3,8 @@ Model repository for database operations on LiteLLM_ProxyModelTable.
"""
import json
-from collections.abc import Awaitable, Mapping, Sequence
-from typing import Any, Final, Protocol
+from collections.abc import Mapping
+from typing import TYPE_CHECKING, Any, Final, Protocol
from litellm.models.model import LiteLLM_ProxyModelTable
from litellm.proxy.common_utils.config_sync_pubsub import wrap_table_actions_for_config_sync
@@ -12,25 +12,21 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
decrypt_value_helper,
encrypt_value_helper,
)
-from litellm.repositories.base_repository import BaseRepository, DbRecord
+from litellm.repositories.base_repository import BaseRepository
+from litellm.repositories.prisma_protocols import TableActions
+
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
class _PrismaModelDb(Protocol):
- litellm_proxymodeltable: object
+ @property
+ def litellm_proxymodeltable(self) -> TableActions["prisma_models.LiteLLM_ProxyModelTable"]: ...
class _PrismaClientView(Protocol):
- db: _PrismaModelDb
-
-
-class _ProxyModelActions(Protocol):
- """Prisma table actions used by :class:`ModelRepository`."""
-
- def find_many(self, *, where: Mapping[str, object] | None = None) -> Awaitable[Sequence[DbRecord]]: ...
-
- def create(self, *, data: Mapping[str, object]) -> Awaitable[DbRecord]: ...
-
- def update(self, *, where: Mapping[str, object], data: Mapping[str, object]) -> Awaitable[DbRecord | None]: ...
+ @property
+ def db(self) -> _PrismaModelDb: ...
class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
@@ -41,17 +37,13 @@ class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
self._encryption_key = encryption_key
@property
- def table(self) -> Any:
+ def table(self) -> TableActions["prisma_models.LiteLLM_ProxyModelTable"]:
client: Final[_PrismaClientView] = self.prisma_client
return wrap_table_actions_for_config_sync(
actions=client.db.litellm_proxymodeltable,
table_name="litellm_proxymodeltable",
)
- @property
- def _model_table(self) -> _ProxyModelActions:
- return self.table
-
@property
def model_class(self) -> type[LiteLLM_ProxyModelTable]:
return LiteLLM_ProxyModelTable
@@ -100,17 +92,17 @@ class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
async def find_by_name(self, model_name: str) -> list[LiteLLM_ProxyModelTable]:
"""Find models by name."""
- records: Final = await self._model_table.find_many(where={"model_name": model_name})
+ records: Final = await self.table.find_many(where={"model_name": model_name})
return self._to_model_list(records)
async def find_all(self) -> list[LiteLLM_ProxyModelTable]:
"""Find all models."""
- records: Final = await self._model_table.find_many()
+ records: Final = await self.table.find_many()
return self._to_model_list(records)
async def find_unblocked(self) -> list[LiteLLM_ProxyModelTable]:
"""Find all models that are not blocked."""
- records: Final = await self._model_table.find_many(where={"blocked": False})
+ records: Final = await self.table.find_many(where={"blocked": False})
return self._to_model_list(records)
async def find_by_team_id(self, team_id: str) -> list[LiteLLM_ProxyModelTable]:
@@ -147,7 +139,7 @@ class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
if model_info is not None:
data["model_info"] = json.dumps(model_info)
- record: Final = await self._model_table.create(data=data)
+ record: Final = await self.table.create(data=data)
model: Final = self._to_model(record)
assert model is not None
return model
@@ -173,7 +165,7 @@ class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]):
if blocked is not None:
data["blocked"] = blocked
- record: Final = await self._model_table.update(where={"model_id": model_id}, data=data)
+ record: Final = await self.table.update(where={"model_id": model_id}, data=data)
return self._to_model(record)
async def delete_model(self, model_id: str) -> LiteLLM_ProxyModelTable | None:
diff --git a/litellm/repositories/object_permission_repository.py b/litellm/repositories/object_permission_repository.py
index 54a311c4a77..6b1f9c68e47 100644
--- a/litellm/repositories/object_permission_repository.py
+++ b/litellm/repositories/object_permission_repository.py
@@ -2,17 +2,21 @@
ObjectPermission repository for database operations on LiteLLM_ObjectPermissionTable.
"""
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final
from litellm.models.object_permission import LiteLLM_ObjectPermissionTable
from litellm.repositories.base_repository import BaseRepository
+from litellm.repositories.prisma_protocols import TableActions
+
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
class ObjectPermissionRepository(BaseRepository[LiteLLM_ObjectPermissionTable]):
"""Repository for object permission database operations."""
@property
- def table(self) -> Any:
+ def table(self) -> TableActions["prisma_models.LiteLLM_ObjectPermissionTable"]:
return self.prisma_client.db.litellm_objectpermissiontable
@property
diff --git a/litellm/repositories/organization_repository.py b/litellm/repositories/organization_repository.py
index 8a1350903b7..5a9bd3724e0 100644
--- a/litellm/repositories/organization_repository.py
+++ b/litellm/repositories/organization_repository.py
@@ -2,17 +2,21 @@
Organization repository for database operations on LiteLLM_OrganizationTable.
"""
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final
from litellm.models.organization import LiteLLM_OrganizationTable
from litellm.repositories.base_repository import BaseRepository
+from litellm.repositories.prisma_protocols import TableActions
+
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
class OrganizationRepository(BaseRepository[LiteLLM_OrganizationTable]):
"""Repository for organization database operations."""
@property
- def table(self) -> Any:
+ def table(self) -> TableActions["prisma_models.LiteLLM_OrganizationTable"]:
return self.prisma_client.db.litellm_organizationtable
@property
diff --git a/litellm/repositories/prisma_protocols.py b/litellm/repositories/prisma_protocols.py
index 055c68163f9..2aa1b8e0e3f 100644
--- a/litellm/repositories/prisma_protocols.py
+++ b/litellm/repositories/prisma_protocols.py
@@ -12,6 +12,93 @@ from typing import Protocol, TypeVar
RowT_co = TypeVar("RowT_co", covariant=True)
+class TableActions(Protocol[RowT_co]):
+ """The prisma-client-py per-model action surface, keyed to the row it returns.
+
+ Query inputs stay `Mapping[str, object]` rather than the generated
+ `types.*` TypedDicts so callers can keep passing plain dicts, while every
+ result carries the row type the repository is bound to.
+ """
+
+ async def find_unique(
+ self, where: Mapping[str, object], include: Mapping[str, object] | None = None
+ ) -> RowT_co | None: ...
+
+ async def find_first(
+ self,
+ skip: int | None = None,
+ where: Mapping[str, object] | None = None,
+ cursor: Mapping[str, object] | None = None,
+ include: Mapping[str, object] | None = None,
+ order: Mapping[str, object] | Sequence[Mapping[str, object]] | None = None,
+ distinct: Sequence[str] | None = None,
+ ) -> RowT_co | None: ...
+
+ async def find_many(
+ self,
+ take: int | None = None,
+ skip: int | None = None,
+ where: Mapping[str, object] | None = None,
+ cursor: Mapping[str, object] | None = None,
+ include: Mapping[str, object] | None = None,
+ order: Mapping[str, object] | Sequence[Mapping[str, object]] | None = None,
+ distinct: Sequence[str] | None = None,
+ ) -> Sequence[RowT_co]: ...
+
+ async def create(self, data: Mapping[str, object], include: Mapping[str, object] | None = None) -> RowT_co: ...
+
+ async def create_many(
+ self, data: Sequence[Mapping[str, object]], *, skip_duplicates: bool | None = None
+ ) -> int: ...
+
+ async def upsert(
+ self,
+ where: Mapping[str, object],
+ data: Mapping[str, object],
+ include: Mapping[str, object] | None = None,
+ ) -> RowT_co: ...
+
+ async def update(
+ self,
+ data: Mapping[str, object],
+ where: Mapping[str, object],
+ include: Mapping[str, object] | None = None,
+ ) -> RowT_co | None: ...
+
+ async def update_many(self, data: Mapping[str, object], where: Mapping[str, object]) -> int: ...
+
+ async def delete(
+ self, where: Mapping[str, object], include: Mapping[str, object] | None = None
+ ) -> RowT_co | None: ...
+
+ async def delete_many(self, where: Mapping[str, object] | None = None) -> int: ...
+
+ async def count(
+ self,
+ select: None = None,
+ take: int | None = None,
+ skip: int | None = None,
+ where: Mapping[str, object] | None = None,
+ cursor: Mapping[str, object] | None = None,
+ ) -> int: ...
+
+ async def group_by(
+ self,
+ by: Sequence[str],
+ *,
+ where: Mapping[str, object] | None = None,
+ take: int | None = None,
+ skip: int | None = None,
+ order: Mapping[str, object] | Sequence[Mapping[str, object]] | None = None,
+ having: Mapping[str, object] | None = None,
+ count: bool | Mapping[str, object] | None = None,
+ sum: bool | Mapping[str, object] | None = None,
+ avg: bool | Mapping[str, object] | None = None,
+ min: bool | Mapping[str, object] | None = None,
+ max: bool | Mapping[str, object] | None = None,
+ ) -> Sequence[Mapping[str, object]]: ...
+
+
class PrismaRecord(Protocol):
def dict(self) -> Mapping[str, object]: ...
diff --git a/litellm/repositories/project_repository.py b/litellm/repositories/project_repository.py
index c8b2c62f9bf..48e55efd258 100644
--- a/litellm/repositories/project_repository.py
+++ b/litellm/repositories/project_repository.py
@@ -2,17 +2,21 @@
Project repository for database operations on LiteLLM_ProjectTable.
"""
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final
from litellm.models.project import LiteLLM_ProjectTable
from litellm.repositories.base_repository import BaseRepository
+from litellm.repositories.prisma_protocols import TableActions
+
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
class ProjectRepository(BaseRepository[LiteLLM_ProjectTable]):
"""Repository for project database operations."""
@property
- def table(self) -> Any:
+ def table(self) -> TableActions["prisma_models.LiteLLM_ProjectTable"]:
return self.prisma_client.db.litellm_projecttable
@property
diff --git a/litellm/repositories/table_repositories.py b/litellm/repositories/table_repositories.py
index 131f4d377ef..e02f652caf6 100644
--- a/litellm/repositories/table_repositories.py
+++ b/litellm/repositories/table_repositories.py
@@ -7,12 +7,16 @@ These are thin wrappers for tables that do not (yet) need domain-specific query
methods; richer repositories live in their own modules.
"""
-from typing import Any
+from typing import TYPE_CHECKING, Any, Final, Generic
from litellm.proxy.common_utils.config_sync_pubsub import wrap_table_actions_for_config_sync
+from litellm.repositories.prisma_protocols import RowT_co, TableActions
+
+if TYPE_CHECKING:
+ from prisma import models as prisma_models # noqa: F401 # used by quoted base-class subscripts
-class PrismaTableRepository:
+class PrismaTableRepository(Generic[RowT_co]):
"""Base for repositories that expose a single Prisma table."""
table_name: str
@@ -27,208 +31,206 @@ class PrismaTableRepository:
return self._prisma_client
@property
- def table(self) -> Any: # any-ok: Prisma table actions are reached through the untyped client wrapper
- return wrap_table_actions_for_config_sync(
- actions=getattr(self.prisma_client.db, self.table_name),
- table_name=self.table_name,
- )
+ def table(self) -> TableActions[RowT_co]:
+ actions: Final[TableActions[RowT_co]] = getattr(self.prisma_client.db, self.table_name)
+ return wrap_table_actions_for_config_sync(actions=actions, table_name=self.table_name)
-class PolicyRepository(PrismaTableRepository):
+class PolicyRepository(PrismaTableRepository["prisma_models.LiteLLM_PolicyTable"]):
table_name = "litellm_policytable"
-class AgentsRepository(PrismaTableRepository):
+class AgentsRepository(PrismaTableRepository["prisma_models.LiteLLM_AgentsTable"]):
table_name = "litellm_agentstable"
-class ObjectPermissionRepository(PrismaTableRepository):
+class ObjectPermissionRepository(PrismaTableRepository["prisma_models.LiteLLM_ObjectPermissionTable"]):
table_name = "litellm_objectpermissiontable"
-class GuardrailsRepository(PrismaTableRepository):
+class GuardrailsRepository(PrismaTableRepository["prisma_models.LiteLLM_GuardrailsTable"]):
table_name = "litellm_guardrailstable"
-class MCPServerRepository(PrismaTableRepository):
+class MCPServerRepository(PrismaTableRepository["prisma_models.LiteLLM_MCPServerTable"]):
table_name = "litellm_mcpservertable"
-class ManagedObjectRepository(PrismaTableRepository):
+class ManagedObjectRepository(PrismaTableRepository["prisma_models.LiteLLM_ManagedObjectTable"]):
table_name = "litellm_managedobjecttable"
-class OrganizationMembershipRepository(PrismaTableRepository):
+class OrganizationMembershipRepository(PrismaTableRepository["prisma_models.LiteLLM_OrganizationMembership"]):
table_name = "litellm_organizationmembership"
-class SpendLogsRepository(PrismaTableRepository):
+class SpendLogsRepository(PrismaTableRepository["prisma_models.LiteLLM_SpendLogs"]):
table_name = "litellm_spendlogs"
-class ClaudeCodePluginRepository(PrismaTableRepository):
+class ClaudeCodePluginRepository(PrismaTableRepository["prisma_models.LiteLLM_ClaudeCodePluginTable"]):
table_name = "litellm_claudecodeplugintable"
-class TeamMembershipRepository(PrismaTableRepository):
+class TeamMembershipRepository(PrismaTableRepository["prisma_models.LiteLLM_TeamMembership"]):
table_name = "litellm_teammembership"
-class EndUserRepository(PrismaTableRepository):
+class EndUserRepository(PrismaTableRepository["prisma_models.LiteLLM_EndUserTable"]):
table_name = "litellm_endusertable"
-class ManagedVectorStoresRepository(PrismaTableRepository):
+class ManagedVectorStoresRepository(PrismaTableRepository["prisma_models.LiteLLM_ManagedVectorStoresTable"]):
table_name = "litellm_managedvectorstorestable"
-class MCPUserCredentialsRepository(PrismaTableRepository):
+class MCPUserCredentialsRepository(PrismaTableRepository["prisma_models.LiteLLM_MCPUserCredentials"]):
table_name = "litellm_mcpusercredentials"
-class MCPServerOAuthClientRepository(PrismaTableRepository):
+class MCPServerOAuthClientRepository(PrismaTableRepository["prisma_models.LiteLLM_MCPServerOAuthClient"]):
table_name = "litellm_mcpserveroauthclient"
-class PromptRepository(PrismaTableRepository):
+class PromptRepository(PrismaTableRepository["prisma_models.LiteLLM_PromptTable"]):
table_name = "litellm_prompttable"
-class TagRepository(PrismaTableRepository):
+class TagRepository(PrismaTableRepository["prisma_models.LiteLLM_TagTable"]):
table_name = "litellm_tagtable"
-class InvitationLinkRepository(PrismaTableRepository):
+class InvitationLinkRepository(PrismaTableRepository["prisma_models.LiteLLM_InvitationLink"]):
table_name = "litellm_invitationlink"
-class JWTKeyMappingRepository(PrismaTableRepository):
+class JWTKeyMappingRepository(PrismaTableRepository["prisma_models.LiteLLM_JWTKeyMapping"]):
table_name = "litellm_jwtkeymapping"
-class ManagedFileRepository(PrismaTableRepository):
+class ManagedFileRepository(PrismaTableRepository["prisma_models.LiteLLM_ManagedFileTable"]):
table_name = "litellm_managedfiletable"
-class MemoryRepository(PrismaTableRepository):
+class MemoryRepository(PrismaTableRepository["prisma_models.LiteLLM_MemoryTable"]):
table_name = "litellm_memorytable"
-class SearchToolsRepository(PrismaTableRepository):
+class SearchToolsRepository(PrismaTableRepository["prisma_models.LiteLLM_SearchToolsTable"]):
table_name = "litellm_searchtoolstable"
-class ConfigOverridesRepository(PrismaTableRepository):
+class ConfigOverridesRepository(PrismaTableRepository["prisma_models.LiteLLM_ConfigOverrides"]):
table_name = "litellm_configoverrides"
-class MCPToolsetRepository(PrismaTableRepository):
+class MCPToolsetRepository(PrismaTableRepository["prisma_models.LiteLLM_MCPToolsetTable"]):
table_name = "litellm_mcptoolsettable"
-class ToolRepository(PrismaTableRepository):
+class ToolRepository(PrismaTableRepository["prisma_models.LiteLLM_ToolTable"]):
table_name = "litellm_tooltable"
-class DeletedVerificationTokenRepository(PrismaTableRepository):
+class DeletedVerificationTokenRepository(PrismaTableRepository["prisma_models.LiteLLM_DeletedVerificationToken"]):
table_name = "litellm_deletedverificationtoken"
-class WorkflowRunRepository(PrismaTableRepository):
+class WorkflowRunRepository(PrismaTableRepository["prisma_models.LiteLLM_WorkflowRun"]):
table_name = "litellm_workflowrun"
-class ModelTableRepository(PrismaTableRepository):
+class ModelTableRepository(PrismaTableRepository["prisma_models.LiteLLM_ModelTable"]):
table_name = "litellm_modeltable"
-class AccessGroupRepository(PrismaTableRepository):
+class AccessGroupRepository(PrismaTableRepository["prisma_models.LiteLLM_AccessGroupTable"]):
table_name = "litellm_accessgrouptable"
-class SSOConfigRepository(PrismaTableRepository):
+class SSOConfigRepository(PrismaTableRepository["prisma_models.LiteLLM_SSOConfig"]):
table_name = "litellm_ssoconfig"
-class UISettingsRepository(PrismaTableRepository):
+class UISettingsRepository(PrismaTableRepository["prisma_models.LiteLLM_UISettings"]):
table_name = "litellm_uisettings"
-class DailyGuardrailMetricsRepository(PrismaTableRepository):
+class DailyGuardrailMetricsRepository(PrismaTableRepository["prisma_models.LiteLLM_DailyGuardrailMetrics"]):
table_name = "litellm_dailyguardrailmetrics"
-class DailyGuardrailUsageUnitsRepository(PrismaTableRepository):
+class DailyGuardrailUsageUnitsRepository(PrismaTableRepository["prisma_models.LiteLLM_DailyGuardrailUsageUnits"]):
table_name = "litellm_dailyguardrailusageunits"
-class PolicyAttachmentRepository(PrismaTableRepository):
+class PolicyAttachmentRepository(PrismaTableRepository["prisma_models.LiteLLM_PolicyAttachmentTable"]):
table_name = "litellm_policyattachmenttable"
-class DeletedTeamRepository(PrismaTableRepository):
+class DeletedTeamRepository(PrismaTableRepository["prisma_models.LiteLLM_DeletedTeamTable"]):
table_name = "litellm_deletedteamtable"
-class SkillsRepository(PrismaTableRepository):
+class SkillsRepository(PrismaTableRepository["prisma_models.LiteLLM_SkillsTable"]):
table_name = "litellm_skillstable"
-class CacheConfigRepository(PrismaTableRepository):
+class CacheConfigRepository(PrismaTableRepository["prisma_models.LiteLLM_CacheConfig"]):
table_name = "litellm_cacheconfig"
-class ManagedVectorStoreIndexRepository(PrismaTableRepository):
+class ManagedVectorStoreIndexRepository(PrismaTableRepository["prisma_models.LiteLLM_ManagedVectorStoreIndexTable"]):
table_name = "litellm_managedvectorstoreindextable"
-class WorkflowMessageRepository(PrismaTableRepository):
+class WorkflowMessageRepository(PrismaTableRepository["prisma_models.LiteLLM_WorkflowMessage"]):
table_name = "litellm_workflowmessage"
-class DailyTagSpendRepository(PrismaTableRepository):
+class DailyTagSpendRepository(PrismaTableRepository["prisma_models.LiteLLM_DailyTagSpend"]):
table_name = "litellm_dailytagspend"
-class SpendLogToolIndexRepository(PrismaTableRepository):
+class SpendLogToolIndexRepository(PrismaTableRepository["prisma_models.LiteLLM_SpendLogToolIndex"]):
table_name = "litellm_spendlogtoolindex"
-class DailyToolSpendRepository(PrismaTableRepository):
+class DailyToolSpendRepository(PrismaTableRepository["prisma_models.LiteLLM_DailyToolSpend"]):
table_name = "litellm_dailytoolspend"
-class SpendLogGuardrailIndexRepository(PrismaTableRepository):
+class SpendLogGuardrailIndexRepository(PrismaTableRepository["prisma_models.LiteLLM_SpendLogGuardrailIndex"]):
table_name = "litellm_spendlogguardrailindex"
-class UserNotificationsRepository(PrismaTableRepository):
+class UserNotificationsRepository(PrismaTableRepository["prisma_models.LiteLLM_UserNotifications"]):
table_name = "litellm_usernotifications"
-class HealthCheckRepository(PrismaTableRepository):
+class HealthCheckRepository(PrismaTableRepository["prisma_models.LiteLLM_HealthCheckTable"]):
table_name = "litellm_healthchecktable"
-class DeprecatedVerificationTokenRepository(PrismaTableRepository):
+class DeprecatedVerificationTokenRepository(PrismaTableRepository["prisma_models.LiteLLM_DeprecatedVerificationToken"]):
table_name = "litellm_deprecatedverificationtoken"
-class WorkflowEventRepository(PrismaTableRepository):
+class WorkflowEventRepository(PrismaTableRepository["prisma_models.LiteLLM_WorkflowEvent"]):
table_name = "litellm_workflowevent"
-class DailyPolicyMetricsRepository(PrismaTableRepository):
+class DailyPolicyMetricsRepository(PrismaTableRepository["prisma_models.LiteLLM_DailyPolicyMetrics"]):
table_name = "litellm_dailypolicymetrics"
-class AdaptiveRouterStateRepository(PrismaTableRepository):
+class AdaptiveRouterStateRepository(PrismaTableRepository["prisma_models.LiteLLM_AdaptiveRouterState"]):
table_name = "litellm_adaptiverouterstate"
-class AuditLogRepository(PrismaTableRepository):
+class AuditLogRepository(PrismaTableRepository["prisma_models.LiteLLM_AuditLog"]):
table_name = "litellm_auditlog"
-class AdaptiveRouterSessionRepository(PrismaTableRepository):
+class AdaptiveRouterSessionRepository(PrismaTableRepository["prisma_models.LiteLLM_AdaptiveRouterSession"]):
table_name = "litellm_adaptiveroutersession"
diff --git a/litellm/repositories/team_repository.py b/litellm/repositories/team_repository.py
index d636592f925..cffa08ce7e0 100644
--- a/litellm/repositories/team_repository.py
+++ b/litellm/repositories/team_repository.py
@@ -5,7 +5,7 @@ Team repository for database operations on LiteLLM_TeamTable.
import json
from collections.abc import Mapping
from datetime import datetime
-from typing import TYPE_CHECKING, Any, Final
+from typing import TYPE_CHECKING, Final
from pydantic import TypeAdapter
@@ -15,9 +15,11 @@ from litellm.repositories.base_repository import (
DbRecord,
record_to_dict,
)
+from litellm.repositories.prisma_protocols import TableActions
if TYPE_CHECKING:
from prisma import Prisma
+ from prisma import models as prisma_models
_MEMBERS_WITH_ROLES_ADAPTER: Final = TypeAdapter(list[Member])
_JSON_ENCODED_TEAM_FIELDS: Final = (
@@ -34,11 +36,11 @@ class TeamRepository(BaseRepository[LiteLLM_TeamTable]):
"""Repository for team database operations."""
@property
- def table(self) -> Any: # any-ok: PrismaClient.db is an untyped runtime wrapper
+ def table(self) -> TableActions["prisma_models.LiteLLM_TeamTable"]:
return self.prisma_client.db.litellm_teamtable
@property
- def deleted_table(self) -> Any: # any-ok: PrismaClient.db is an untyped runtime wrapper
+ def deleted_table(self) -> TableActions["prisma_models.LiteLLM_DeletedTeamTable"]:
return self.prisma_client.db.litellm_deletedteamtable
@property
diff --git a/litellm/repositories/user_banner_repository.py b/litellm/repositories/user_banner_repository.py
index 3b69e433853..c1ed977e048 100644
--- a/litellm/repositories/user_banner_repository.py
+++ b/litellm/repositories/user_banner_repository.py
@@ -1,11 +1,14 @@
-from typing import Final
+from typing import TYPE_CHECKING, Final
from litellm.repositories.table_repositories import PrismaTableRepository
+if TYPE_CHECKING:
+ from prisma import models as prisma_models # noqa: F401 # resolved only from the quoted base-class subscript below
+
USER_BANNER_ROW_ID: Final = "user_banner"
-class UserBannerRepository(PrismaTableRepository):
+class UserBannerRepository(PrismaTableRepository["prisma_models.LiteLLM_UISettings"]):
table_name = "litellm_uisettings"
async def get_raw_settings(self) -> object:
diff --git a/litellm/repositories/user_repository.py b/litellm/repositories/user_repository.py
index d0d366e1772..9df1bceac9c 100644
--- a/litellm/repositories/user_repository.py
+++ b/litellm/repositories/user_repository.py
@@ -4,10 +4,14 @@ User repository for database operations on LiteLLM_UserTable.
import json
from collections.abc import Mapping
-from typing import Any, Final
+from typing import TYPE_CHECKING, Final
from litellm.models.user import LiteLLM_UserTable
from litellm.repositories.base_repository import BaseRepository, DbRecord, record_to_dict
+from litellm.repositories.prisma_protocols import TableActions
+
+if TYPE_CHECKING:
+ from prisma import models as prisma_models
_JSON_ENCODED_COLUMNS: Final = frozenset({"metadata", "model_spend", "model_max_budget"})
@@ -16,7 +20,7 @@ class UserRepository(BaseRepository[LiteLLM_UserTable]):
"""Repository for user database operations."""
@property
- def table(self) -> Any: # any-ok: Prisma table actions are reached through the untyped client wrapper
+ def table(self) -> TableActions["prisma_models.LiteLLM_UserTable"]:
return self.prisma_client.db.litellm_usertable
@property
diff --git a/litellm/repositories/verification_token_repository.py b/litellm/repositories/verification_token_repository.py
index 3790ad25914..c0e59f9b975 100644
--- a/litellm/repositories/verification_token_repository.py
+++ b/litellm/repositories/verification_token_repository.py
@@ -3,9 +3,9 @@ VerificationToken repository for database operations on LiteLLM_VerificationToke
"""
import json
-from collections.abc import Mapping
+from collections.abc import Mapping, Sequence
from datetime import datetime
-from typing import TYPE_CHECKING, Any, Final
+from typing import TYPE_CHECKING, Final
from litellm.models.verification_token import (
LiteLLM_VerificationToken,
@@ -15,8 +15,12 @@ from litellm.repositories.base_repository import (
DbRecord,
record_to_dict,
)
+from litellm.repositories.prisma_protocols import TableActions
if TYPE_CHECKING:
+ from prisma.models import (
+ LiteLLM_DeletedVerificationToken as PrismaDeletedVerificationToken,
+ )
from prisma.models import (
LiteLLM_VerificationToken as PrismaVerificationToken,
)
@@ -45,11 +49,11 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
return prisma_client
@property
- def table(self) -> Any:
+ def table(self) -> TableActions["PrismaVerificationToken"]:
return self.prisma_client.db.litellm_verificationtoken
@property
- def deleted_table(self) -> Any:
+ def deleted_table(self) -> TableActions["PrismaDeletedVerificationToken"]:
return self.prisma_client.db.litellm_deletedverificationtoken
@property
@@ -79,29 +83,29 @@ class VerificationTokenRepository(BaseRepository[LiteLLM_VerificationToken]):
async def find_by_alias(self, key_alias: str) -> LiteLLM_VerificationToken | None:
"""Find a token by key alias."""
- records: Final[list[PrismaVerificationToken]] = await self.table.find_many(where={"key_alias": key_alias})
+ records: Final[Sequence[PrismaVerificationToken]] = await self.table.find_many(where={"key_alias": key_alias})
if records:
return self._to_model(records[0])
return None
async def find_by_user_id(self, user_id: str) -> list[LiteLLM_VerificationToken]:
"""Find all tokens belonging to a user."""
- records: Final[list[PrismaVerificationToken]] = await self.table.find_many(where={"user_id": user_id})
+ records: Final[Sequence[PrismaVerificationToken]] = await self.table.find_many(where={"user_id": user_id})
return self._to_model_list(records)
async def find_by_team_id(self, team_id: str) -> list[LiteLLM_VerificationToken]:
"""Find all tokens belonging to a team."""
- records: Final[list[PrismaVerificationToken]] = await self.table.find_many(where={"team_id": team_id})
+ records: Final[Sequence[PrismaVerificationToken]] = await self.table.find_many(where={"team_id": team_id})
return self._to_model_list(records)
async def find_by_project_id(self, project_id: str) -> list[LiteLLM_VerificationToken]:
"""Find all tokens belonging to a project."""
- records: Final[list[PrismaVerificationToken]] = await self.table.find_many(where={"project_id": project_id})
+ records: Final[Sequence[PrismaVerificationToken]] = await self.table.find_many(where={"project_id": project_id})
return self._to_model_list(records)
async def find_active_tokens(self) -> list[LiteLLM_VerificationToken]:
"""Find all active (non-expired, non-blocked) tokens."""
- records: Final[list[PrismaVerificationToken]] = await self.table.find_many(
+ records: Final[Sequence[PrismaVerificationToken]] = await self.table.find_many(
where={
"blocked": {"not": True},
"OR": [{"expires": None}, {"expires": {"gt": datetime.utcnow()}}],
diff --git a/litellm/responses/file_search/emulated_handler.py b/litellm/responses/file_search/emulated_handler.py
index e9e7ae908a5..0418f0c5e14 100644
--- a/litellm/responses/file_search/emulated_handler.py
+++ b/litellm/responses/file_search/emulated_handler.py
@@ -15,7 +15,9 @@ import json
import time
import uuid
from collections.abc import Iterable, Sequence
-from typing import TYPE_CHECKING, Any, Final, TypeAlias, cast
+from typing import TYPE_CHECKING, Any, Final, TypeAlias, cast # noqa: TID251 # see kwargs-ok / cast-ok markers
+
+from typing_extensions import NotRequired, ReadOnly, TypedDict
from litellm._internal_context import is_internal_call
from litellm._logging import verbose_logger
@@ -31,6 +33,12 @@ ToolParam: TypeAlias = object
FILE_SEARCH_FUNCTION_NAME: Final = "litellm_file_search"
+class FileSearchToolCallArgs(TypedDict):
+ queries: ReadOnly[NotRequired[object]]
+ query: ReadOnly[NotRequired[object]]
+ vector_store_id: ReadOnly[NotRequired[object]]
+
+
# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
@@ -175,13 +183,20 @@ async def _run_vector_searches(
# ---------------------------------------------------------------------------
-def _get_field(result: object, key: str, default: object = None) -> Any:
+def _get_field(result: object, key: str, default: object = None) -> object:
"""Read a field from either a dict/TypedDict or an attribute-based object."""
if isinstance(result, dict):
return result.get(key, default)
return getattr(result, key, default)
+def _joined_content_text(result: object) -> str:
+ """Concatenate the text of every content chunk on a search result."""
+ content_items: Final = cast(Iterable[object], _get_field(result, "content") or []) # cast-ok: iterated as today
+ text_chunks: Final = [c.get("text", "") if isinstance(c, dict) else getattr(c, "text", "") for c in content_items]
+ return " ".join(t for t in text_chunks if t)
+
+
def _format_search_results_as_tool_output(
results: list[VectorStoreSearchResult],
) -> str:
@@ -194,9 +209,7 @@ def _format_search_results_as_tool_output(
score = _get_field(result, "score")
file_id = _get_field(result, "file_id")
filename = _get_field(result, "filename")
- content_items = _get_field(result, "content") or []
- text_chunks = [c.get("text", "") if isinstance(c, dict) else getattr(c, "text", "") for c in content_items]
- text = " ".join(t for t in text_chunks if t)
+ text = _joined_content_text(result)
header = f"[Result {i}"
if filename:
@@ -226,9 +239,7 @@ def _build_search_results_for_include(
formatted: Final[list[dict[str, object]]] = []
for result in results:
file_id = _get_field(result, "file_id") or ""
- content_items = _get_field(result, "content") or []
- text_chunks = [c.get("text", "") if isinstance(c, dict) else getattr(c, "text", "") for c in content_items]
- text = " ".join(t for t in text_chunks if t)
+ text = _joined_content_text(result)
formatted.append(
{
"file_id": file_id,
@@ -353,14 +364,14 @@ def _synthesize_responses_api_response(
created_at=getattr(original_response, "created_at", int(time.time())),
status="completed",
model=getattr(original_response, "model", ""),
- output=cast(list[ResponseOutputItem | dict[str, Any]], synthesized_output),
+ output=cast(list[ResponseOutputItem | dict[str, object]], synthesized_output), # cast-ok: list is invariant
usage=getattr(original_response, "usage", None),
error=None,
)
if hasattr(original_response, "_hidden_params"):
hidden: Final = dict(getattr(original_response, "_hidden_params") or {})
if first_response is not None and hasattr(first_response, "_hidden_params"):
- first_hidden: Final = getattr(first_response, "_hidden_params") or {}
+ first_hidden: Final[object] = getattr(first_response, "_hidden_params") or {}
first_cost: Final = (
first_hidden.get("response_cost")
if isinstance(first_hidden, dict)
@@ -385,9 +396,10 @@ async def _call_aresponses(input, model, tools, **kwargs): # pragma: no cover
def _prepare_emulated_file_search_call(
- kwargs: dict[str, Any],
+ kwargs: dict[str, object],
) -> tuple[bool, dict[str, object]]:
- include_items: Final[list[str]] = list(kwargs.get("include") or [])
+ raw_include: Final = kwargs.get("include") or []
+ include_items: Final[list[object]] = list(cast(Iterable[object], raw_include)) # cast-ok: iterated as today
include_search_results: Final = "file_search_call.results" in include_items
original_stream: Final = kwargs.get("stream")
@@ -413,16 +425,16 @@ def _extract_tool_call_fields(tool_call: object, fallback_call_id: str) -> tuple
return call_id, raw_args
-def _resolve_queries_from_args(args: dict[str, Any], input: object) -> list[str]:
+def _resolve_queries_from_args(args: FileSearchToolCallArgs, input: object) -> list[str]:
"""Pull the queries list out of parsed tool-call arguments, with backward-compat fallbacks."""
queries_from_call: Final = args.get("queries")
if not queries_from_call:
# Fallback: check for single "query" field (backward compat)
single_query: Final = args.get("query")
- return [single_query] if single_query else [str(input)]
+ return [cast(str, single_query)] if single_query else [str(input)] # cast-ok: model-supplied, as today
if not isinstance(queries_from_call, list):
return [str(queries_from_call)]
- return queries_from_call
+ return cast(list[str], queries_from_call) # cast-ok: model-supplied elements, forwarded unchecked as today
async def _execute_file_search_tool_calls(
@@ -440,14 +452,14 @@ async def _execute_file_search_tool_calls(
call_id, raw_args = _extract_tool_call_fields(tool_call, fallback_call_id=file_search_call_id)
try:
- args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
+ args: FileSearchToolCallArgs = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
except json.JSONDecodeError:
args = {}
queries_from_call = _resolve_queries_from_args(args, input)
vs_id_arg = args.get("vector_store_id")
- vs_ids_for_call = [vs_id_arg] if vs_id_arg else all_vs_ids
+ vs_ids_for_call = [cast(str, vs_id_arg)] if vs_id_arg else all_vs_ids # cast-ok: model-supplied, as today
queries, results = await _run_vector_searches(
queries=queries_from_call,
@@ -481,7 +493,7 @@ def _build_follow_up_input(
original_input_items: Final[list[object]] = (
list(input) if isinstance(input, (list, tuple)) else [{"role": "user", "content": str(input)}]
)
- first_response_output_items: Final[list[Any]] = []
+ first_response_output_items: Final[list[object]] = []
for _item in first_response.output:
if isinstance(_item, dict):
first_response_output_items.append(_item)
@@ -498,7 +510,7 @@ async def aresponses_with_emulated_file_search(
model: str,
tools: Iterable[ToolParam] | None = None,
# Pass-through params — forwarded as-is to the underlying aresponses call
- **kwargs: Any,
+ **kwargs: Any, # kwargs-ok: `object` would surface the caller's partially-unknown dict at its call site
) -> ResponsesAPIResponse:
"""
Emulated file_search for providers that don't support it natively.
@@ -507,7 +519,7 @@ async def aresponses_with_emulated_file_search(
runs vector search, and synthesizes an OpenAI-format response.
"""
# Determine whether caller wants search_results populated in the output.
- _include_search_results, kwargs = _prepare_emulated_file_search_call(kwargs=kwargs)
+ _include_search_results, call_kwargs = _prepare_emulated_file_search_call(kwargs=kwargs)
# 1. Replace file_search tools with function tool
transformed_tools, all_vs_ids = _replace_file_search_tools(tools)
@@ -524,7 +536,7 @@ async def aresponses_with_emulated_file_search(
input=input,
model=model,
tools=transformed_tools or None,
- **kwargs,
+ **call_kwargs,
),
)
finally:
@@ -588,7 +600,7 @@ async def aresponses_with_emulated_file_search(
input=follow_up_input,
model=model,
tools=None, # no tools needed for the answer step
- **kwargs,
+ **call_kwargs,
),
)
finally:
diff --git a/litellm/responses/litellm_completion_transformation/custom_tools.py b/litellm/responses/litellm_completion_transformation/custom_tools.py
index fa4ed73a1d6..cccae06c74b 100644
--- a/litellm/responses/litellm_completion_transformation/custom_tools.py
+++ b/litellm/responses/litellm_completion_transformation/custom_tools.py
@@ -16,8 +16,8 @@ logic.
"""
import json
-from collections.abc import Mapping
-from typing import Any, Final
+from collections.abc import Mapping, Sequence
+from typing import Final
from pydantic import BaseModel, TypeAdapter, ValidationError
@@ -29,7 +29,7 @@ from litellm.types.llms.openai import (
_MAX_ARGUMENTS_LEN: Final = 1_000_000
-def extract_custom_tool_names(tools: list[Any] | None) -> set[str]:
+def extract_custom_tool_names(tools: Sequence[object] | None) -> set[str]:
"""Extract names of tools originally defined as ``type: "custom"``."""
if not tools:
return set()
@@ -73,7 +73,7 @@ def build_tool_call_item_kwargs(
arguments_or_input: str,
status: str,
custom_tool_names: set[str],
-) -> dict[str, Any]:
+) -> dict[str, str]:
"""Build kwargs for an output item dict that is either a ``function_call``
or a ``custom_tool_call`` depending on whether *name* is in
*custom_tool_names*.
@@ -86,7 +86,7 @@ def build_tool_call_item_kwargs(
"""
custom: Final = is_custom_tool_call(name, custom_tool_names)
item_type: Final = "custom_tool_call" if custom else "function_call"
- kwargs: Final[dict[str, Any]] = {
+ kwargs: Final[dict[str, str]] = {
"type": item_type,
"id": call_id,
"call_id": call_id,
diff --git a/litellm/responses/litellm_completion_transformation/handler.py b/litellm/responses/litellm_completion_transformation/handler.py
index 555e3258773..a0e8cd278e6 100644
--- a/litellm/responses/litellm_completion_transformation/handler.py
+++ b/litellm/responses/litellm_completion_transformation/handler.py
@@ -2,8 +2,8 @@
Handler for transforming responses api requests to litellm.completion requests
"""
-from collections.abc import Coroutine
-from typing import Any, Final
+from collections.abc import Coroutine, Mapping
+from typing import Final
import litellm
from litellm.responses.litellm_completion_transformation.streaming_iterator import (
@@ -30,12 +30,12 @@ class LiteLLMCompletionTransformationHandler:
custom_llm_provider: str | None = None,
_is_async: bool = False,
stream: bool | None = None,
- extra_headers: dict[str, Any] | None = None,
+ extra_headers: Mapping[str, object] | None = None,
**kwargs,
) -> (
ResponsesAPIResponse
| BaseResponsesAPIStreamingIterator
- | Coroutine[Any, Any, ResponsesAPIResponse | BaseResponsesAPIStreamingIterator]
+ | Coroutine[object, object, ResponsesAPIResponse | BaseResponsesAPIStreamingIterator]
):
litellm_completion_request: Final[dict] = (
LiteLLMCompletionResponsesConfig.transform_responses_api_request_to_chat_completion_request(
diff --git a/litellm/responses/litellm_completion_transformation/session_handler.py b/litellm/responses/litellm_completion_transformation/session_handler.py
index 1566bb1bdd7..f749977eb82 100644
--- a/litellm/responses/litellm_completion_transformation/session_handler.py
+++ b/litellm/responses/litellm_completion_transformation/session_handler.py
@@ -4,7 +4,8 @@ from typing import TYPE_CHECKING, Any, Final, cast
import litellm
from litellm._logging import verbose_proxy_logger
-from litellm.proxy._types import SpendLogsPayload
+from litellm.constants import REDACTED_BY_LITELLM, REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER
+from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload
from litellm.proxy.spend_tracking.cold_storage_handler import ColdStorageHandler
from litellm.responses.utils import ResponsesAPIRequestUtils
from litellm.types.llms.openai import (
@@ -29,6 +30,17 @@ COLD_STORAGE_HANDLER: Final = ColdStorageHandler()
########################################################
+def _normalize_redacted_tool_call_arguments(message: Message) -> None:
+ """Redaction stores the bare sentinel (invalid JSON) in tool-call arguments;
+ normalize replayed history to "{}" so provider converters can parse it."""
+ for tool_call in message.tool_calls or []:
+ if (function := getattr(tool_call, "function", None)) is not None and function.arguments == REDACTED_BY_LITELLM:
+ function.arguments = REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER
+ function_call: Final = message.function_call
+ if function_call is not None and function_call.arguments == REDACTED_BY_LITELLM:
+ function_call.arguments = REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER
+
+
class ResponsesSessionHandler:
@staticmethod
async def get_chat_completion_message_history_for_previous_response_id(
@@ -143,7 +155,8 @@ class ResponsesSessionHandler:
model_response: Final = ModelResponse(**_response_output)
for choice in model_response.choices:
if hasattr(choice, "message"):
- chat_completion_message_history.append(getattr(choice, "message"))
+ _normalize_redacted_tool_call_arguments(choice.message)
+ chat_completion_message_history.append(choice.message)
return chat_completion_message_history
@staticmethod
@@ -195,7 +208,7 @@ class ResponsesSessionHandler:
try:
metadata_str: Final = spend_log.get("metadata", "{}")
if isinstance(metadata_str, str):
- metadata_dict: Final = json.loads(metadata_str)
+ metadata_dict: Final[SpendLogsMetadata] = json.loads(metadata_str)
return metadata_dict.get("cold_storage_object_key")
elif isinstance(metadata_str, dict):
return metadata_str.get("cold_storage_object_key")
diff --git a/litellm/responses/litellm_completion_transformation/streaming_iterator.py b/litellm/responses/litellm_completion_transformation/streaming_iterator.py
index 92bbca9ee5b..8b1eeb30306 100644
--- a/litellm/responses/litellm_completion_transformation/streaming_iterator.py
+++ b/litellm/responses/litellm_completion_transformation/streaming_iterator.py
@@ -1,5 +1,6 @@
import time
import uuid
+from collections.abc import Sequence
from typing import Any, Final, cast
import litellm
@@ -48,14 +49,18 @@ from litellm.types.utils import (
)
+def _index_of_output_item_type(items: Sequence[object], item_type: str) -> int | None:
+ return next(
+ (index for index, item in enumerate(items) if getattr(item, "type", None) == item_type),
+ None,
+ )
+
+
def _output_items_with_id(items: tuple[Any, ...], item_type: str, item_id: str | None) -> tuple[Any, ...]:
if item_id is None:
return items
- target_index: Final = next(
- (index for index, item in enumerate(items) if getattr(item, "type", None) == item_type),
- None,
- )
+ target_index: Final = _index_of_output_item_type(items, item_type)
if target_index is None:
return items
@@ -86,7 +91,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self.litellm_metadata: dict | None = litellm_metadata or {}
# Store lightweight dict snapshots for stream_chunk_builder to reduce
# repeated Pydantic attribute access in end-of-stream assembly.
- self.collected_chat_completion_chunks: list[dict[str, Any]] = []
+ self.collected_chat_completion_chunks: list[dict[str, object]] = []
self.finished: bool = False
self.litellm_logging_obj = litellm_custom_stream_wrapper.logging_obj
self.sent_response_created_event: bool = False
@@ -98,7 +103,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self.sent_output_item_done_event: bool = False
self.sent_annotation_events: bool = False
self.litellm_model_response: ModelResponse | TextCompletionResponse | None = None
- self.completed_response: Any = None
+ self.completed_response = None
self.final_text: str = ""
self._cached_item_id: str | None = None
self._cached_response_id: str | None = None
@@ -123,7 +128,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
self._reasoning_done_emitted = False
self._reasoning_item_id: str | None = None
self._accumulated_reasoning_content_parts: list[str] = []
- self._accumulated_provider_specific_fields: dict[str, Any] = {}
+ self._accumulated_provider_specific_fields: dict[str, object] = {}
self._custom_tool_names: set[str] = extract_custom_tool_names(self.responses_api_request.get("tools"))
self._namespace_tool_names = LiteLLMCompletionResponsesConfig.namespace_tool_name_map(
self.responses_api_request.get("tools")
@@ -543,7 +548,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
@staticmethod
def _snapshot_chunk_for_stream_chunk_builder(
chunk: ModelResponseStream,
- ) -> dict[str, Any]:
+ ) -> dict[str, object]:
"""
Convert a streaming chunk into a plain dict for end-of-stream assembly.
Keep _hidden_params so downstream usage/header behavior is preserved.
@@ -1161,7 +1166,7 @@ class LiteLLMCompletionStreamingIterator(ResponsesAPIStreamingIterator):
if litellm_model_response:
# Add cost to usage object if include_cost_in_streaming_usage is True
if litellm.include_cost_in_streaming_usage and self.litellm_logging_obj is not None:
- usage: Final = getattr(litellm_model_response, "usage", None)
+ usage: Final[object] = getattr(litellm_model_response, "usage", None)
if usage is not None:
setattr(
usage,
diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py
index b8d7b726a28..ec0b425dee6 100644
--- a/litellm/responses/litellm_completion_transformation/transformation.py
+++ b/litellm/responses/litellm_completion_transformation/transformation.py
@@ -5,7 +5,7 @@ Handles transforming from Responses API -> LiteLLM completion (Chat Completion
import json
import re
import uuid
-from collections.abc import Iterator, Mapping, Sequence
+from collections.abc import Iterable, Iterator, Mapping, Sequence
from types import MappingProxyType
from typing import (
TYPE_CHECKING,
@@ -28,10 +28,11 @@ 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 TypeAdapter
-from typing_extensions import TypedDict
+from typing_extensions import ReadOnly, TypedDict
from litellm._logging import verbose_logger
from litellm.caching import InMemoryCache
+from litellm.constants import REDACTED_BY_LITELLM, REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER
from litellm.litellm_core_utils.get_supported_openai_params import (
get_supported_openai_params,
)
@@ -46,6 +47,7 @@ from litellm.types.llms.openai import (
ChatCompletionRedactedThinkingBlock,
ChatCompletionResponseMessage,
ChatCompletionSystemMessage,
+ ChatCompletionTextObject,
ChatCompletionThinkingBlock,
ChatCompletionToolCallChunk,
ChatCompletionToolCallFunctionChunk,
@@ -129,6 +131,30 @@ class _HasId(Protocol):
id: object
+class _ResponsesToolCallItem(Protocol):
+ name: object
+ arguments: object
+
+ def get(self, key: str, /) -> object: ...
+
+
+class _ToolFunctionDefinition(TypedDict, total=False):
+ name: ReadOnly[str]
+ description: ReadOnly[str]
+ parameters: ReadOnly[dict[str, object]]
+ strict: ReadOnly[bool | None]
+
+
+def _attribute_fields(value: object) -> dict[str, object]:
+ if not hasattr(value, "__dict__"):
+ return {} # mutable-ok: provider_specific_fields payload
+ return dict(cast("Iterable[tuple[str, object]]", value)) # cast-ok: dict() raises on non-pair values, as before
+
+
+def _input_item_role(input_item: Mapping[str, object]) -> str:
+ return cast(str, input_item.get("role") or "user") # cast-ok: client-supplied role forwarded verbatim, unvalidated
+
+
class ChatCompletionSession(TypedDict, total=False):
messages: list[
AllMessageValues
@@ -677,7 +703,7 @@ class LiteLLMCompletionResponsesConfig:
existing_text: Final = _reasoning_text(msg)
combined: Final = "\n".join(pending_texts + ((existing_text,) if existing_text else ()))
if isinstance(msg, dict):
- cast(dict[str, Any], msg)["reasoning_content"] = combined # cast-ok: mutable reasoning carrier
+ cast(dict[str, object], msg)["reasoning_content"] = combined # cast-ok: mutable reasoning carrier
else:
setattr(msg, "reasoning_content", combined) # noqa: B010 # attribute name is fixed, not dynamic
if pending_blocks:
@@ -685,7 +711,7 @@ class LiteLLMCompletionResponsesConfig:
pending_blocks + (_thinking_blocks(msg) or ())
)
if isinstance(msg, dict):
- cast(dict[str, Any], msg)["thinking_blocks"] = replayed # cast-ok: mutable reasoning carrier
+ cast(dict[str, object], msg)["thinking_blocks"] = replayed # cast-ok: mutable reasoning carrier
else:
setattr(msg, "thinking_blocks", replayed) # noqa: B010 # attribute name is fixed, not dynamic
@@ -1034,7 +1060,7 @@ class LiteLLMCompletionResponsesConfig:
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: Final = cast(dict[str, Any], assistant_message)
+ prev_assistant_dict: Final = cast(dict[str, object], assistant_message)
if "tool_calls" not in prev_assistant_dict:
prev_assistant_dict["tool_calls"] = []
tool_calls_list: Final = prev_assistant_dict["tool_calls"]
@@ -1119,7 +1145,7 @@ class LiteLLMCompletionResponsesConfig:
# 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 = cast(dict[str, object], message)
message_dict["tool_call_id"] = tool_call_id
elif hasattr(message, "tool_call_id"):
setattr(message, "tool_call_id", tool_call_id)
@@ -1171,7 +1197,7 @@ class LiteLLMCompletionResponsesConfig:
@staticmethod
def _transform_responses_api_input_item_to_chat_completion_message(
- input_item: Any,
+ input_item: Mapping[str, object],
replay_reasoning: bool = False,
) -> list[AllMessageValues | GenericChatCompletionMessage | ChatCompletionResponseMessage]:
"""
@@ -1199,7 +1225,9 @@ class LiteLLMCompletionResponsesConfig:
elif LiteLLMCompletionResponsesConfig._is_input_item_function_call(input_item):
# handle function call input items
return LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
- function_call=input_item
+ function_call=cast( # cast-ok: callee coerces every field it reads with `or ""` / str()
+ Mapping[str, str], input_item
+ )
)
elif input_item.get("type") == "reasoning":
# A ResponseReasoningItemParam carries the prior-turn chain-of-thought.
@@ -1224,7 +1252,7 @@ class LiteLLMCompletionResponsesConfig:
return [] # mutable-ok: empty drop result
return [ # mutable-ok: single message result
GenericChatCompletionMessage(
- role=input_item.get("role") or "user",
+ role=_input_item_role(input_item),
content=LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(
inspectable
),
@@ -1252,7 +1280,7 @@ class LiteLLMCompletionResponsesConfig:
return []
return [
GenericChatCompletionMessage(
- role=input_item.get("role") or "user",
+ role=_input_item_role(input_item),
content=LiteLLMCompletionResponsesConfig._transform_responses_api_content_to_chat_completion_content(
content
),
@@ -1339,7 +1367,7 @@ class LiteLLMCompletionResponsesConfig:
if not isinstance(encrypted_content, str) or not encrypted_content.strip():
return None
try:
- decoded: Final[object] = json.loads(encrypted_content)
+ decoded: Final[object] = cast(object, json.loads(encrypted_content)) # cast-ok: json.loads returns Any
except ValueError:
return None
if not isinstance(decoded, list):
@@ -1406,7 +1434,7 @@ class LiteLLMCompletionResponsesConfig:
def _normalize_function_call_output_to_tool_content(
output: object,
- ) -> Any:
+ ) -> str | list[ChatCompletionTextObject | ChatCompletionImageObject]:
"""
Normalize Responses API function_call_output.output into a shape that downstream
chat adapters (esp. Gemini) can reliably consume.
@@ -1428,7 +1456,7 @@ class LiteLLMCompletionResponsesConfig:
# Some adapters represent tool output as a list of "input_*" parts
if isinstance(output, list):
- normalized_blocks: Final[list[dict[str, object]]] = []
+ normalized_blocks: Final[list[ChatCompletionTextObject | ChatCompletionImageObject]] = []
text_acc: Final[list[str]] = []
for part in output:
if not isinstance(part, dict):
@@ -1551,6 +1579,9 @@ class LiteLLMCompletionResponsesConfig:
# store their payload in "input" (raw string) rather than
# "arguments" (JSON string), so normalize to arguments here.
raw_arguments = function_call.get("arguments")
+ if raw_arguments == REDACTED_BY_LITELLM:
+ # redaction stores the bare sentinel (invalid JSON) in arguments
+ raw_arguments = REDACTED_TOOL_CALL_ARGUMENTS_PLACEHOLDER
if not raw_arguments and function_call.get("type") == "custom_tool_call":
raw_input: Final = function_call.get("input") or ""
raw_arguments = json.dumps({"content": raw_input}) if raw_input else ""
@@ -1899,7 +1930,7 @@ class LiteLLMCompletionResponsesConfig:
result.append(tool)
continue
if tool.get("type") == "function":
- fn = cast(dict[str, Any], tool.get("function") or {})
+ fn = cast(_ToolFunctionDefinition, tool.get("function") or {})
parameters = dict(fn.get("parameters", {}) or {})
if not parameters or "type" not in parameters:
parameters["type"] = "object"
@@ -2095,7 +2126,7 @@ 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, object]:
"""
@@ -2108,24 +2139,25 @@ class LiteLLMCompletionResponsesConfig:
Returns:
Dictionary in ChatCompletionToolCallChunk format
"""
+ item: Final = cast( # cast-ok: duck-typed tool call item, .get access guarded by hasattr below
+ _ResponsesToolCallItem, tool_call_item
+ )
# Extract provider_specific_fields if present
- provider_specific_fields = getattr(tool_call_item, "provider_specific_fields", None)
+ provider_specific_fields: object = 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):
- provider_fields: Final = tool_call_item.get("provider_specific_fields")
+ provider_specific_fields = _attribute_fields(provider_specific_fields)
+ elif hasattr(tool_call_item, "get") and callable(item.get):
+ provider_fields: Final = item.get("provider_specific_fields")
if provider_fields:
provider_specific_fields = (
- provider_fields
+ cast("dict[str, object]", provider_fields) # cast-ok: passed through as-is, keys unvalidated
if isinstance(provider_fields, dict)
- else (dict(provider_fields) if hasattr(provider_fields, "__dict__") else {})
+ else _attribute_fields(provider_fields)
)
function_dict: Final[dict[str, object]] = {
- "name": tool_call_item.name,
- "arguments": tool_call_item.arguments,
+ "name": item.name,
+ "arguments": item.arguments,
}
if provider_specific_fields:
@@ -2306,7 +2338,7 @@ class LiteLLMCompletionResponsesConfig:
"""
output_items: Final[list] = []
for choice in chat_completion_response.choices or []:
- message = getattr(choice, "message", None)
+ message: object = getattr(choice, "message", None)
if not message:
continue
psf = getattr(message, "provider_specific_fields", None)
@@ -2338,7 +2370,7 @@ class LiteLLMCompletionResponsesConfig:
for choice in choices:
if hasattr(choice, "message") and choice.message:
message = choice.message
- reasoning_content = getattr(message, "reasoning_content", None) or ""
+ reasoning_content: str = getattr(message, "reasoning_content", None) or ""
encrypted_content = LiteLLMCompletionResponsesConfig._encode_thinking_blocks(message)
if reasoning_content or encrypted_content:
# Only check the first choice for reasoning content
diff --git a/litellm/responses/main.py b/litellm/responses/main.py
index 34058e8eca7..d6ebc44ac52 100644
--- a/litellm/responses/main.py
+++ b/litellm/responses/main.py
@@ -697,7 +697,7 @@ def _apply_managed_file_id_mapping(
tools = cast(
Iterable[ToolParam] | None,
update_responses_tools_with_model_file_ids(
- tools=cast(list[dict[str, Any]] | None, tools),
+ tools=cast(list[dict[str, object]] | None, tools),
model_id=model_info_id,
model_file_id_mapping=model_file_id_mapping,
),
@@ -734,7 +734,7 @@ def _responses_try_dispatch_mcp_gateway(
extra_body: dict[str, object] | None,
timeout: float | httpx.Timeout | None,
custom_llm_provider: str | None,
- kwargs: dict[str, Any],
+ kwargs: dict[str, object],
_is_async: bool,
) -> Any | None:
"""Return a response when MCP gateway handles the call; otherwise None."""
diff --git a/litellm/responses/mcp/chat_completions_handler.py b/litellm/responses/mcp/chat_completions_handler.py
index 2a0406f9a4d..a75b3768636 100644
--- a/litellm/responses/mcp/chat_completions_handler.py
+++ b/litellm/responses/mcp/chat_completions_handler.py
@@ -1,7 +1,9 @@
"""Helpers for handling MCP-aware `/chat/completions` requests."""
import logging
-from typing import TYPE_CHECKING, Any, Final, cast
+from typing import TYPE_CHECKING, Final, cast
+
+from typing_extensions import TypedDict, Unpack
from litellm.responses.mcp.litellm_proxy_mcp_handler import (
LiteLLM_Proxy_MCP_Handler,
@@ -14,6 +16,10 @@ if TYPE_CHECKING:
from litellm.proxy._types import UserAPIKeyAuth
+class _MCPCompletionKwargs(TypedDict, total=False, extra_items=object):
+ """Extra keywords forwarded verbatim to ``litellm.acompletion``, which owns their contract."""
+
+
def _add_mcp_metadata_to_response(
response: ModelResponse | CustomStreamWrapper,
openai_tools: list | None,
@@ -79,7 +85,7 @@ async def acompletion_with_mcp(
model: str,
messages: list,
tools: list | None = None,
- **kwargs: Any,
+ **kwargs: Unpack[_MCPCompletionKwargs], # kwargs-ok: forwarded verbatim to litellm.acompletion, which owns them
) -> ModelResponse | CustomStreamWrapper:
"""
Async completion with MCP integration.
@@ -126,7 +132,7 @@ async def acompletion_with_mcp(
) = await LiteLLM_Proxy_MCP_Handler._process_mcp_tools_without_openai_transform(
user_api_key_auth=user_api_key_auth,
mcp_tools_with_litellm_proxy=mcp_tools_with_litellm_proxy,
- litellm_trace_id=kwargs.get("litellm_trace_id"),
+ litellm_trace_id=context.litellm_trace_id,
mcp_auth_header=mcp_auth_header,
mcp_server_auth_headers=mcp_server_auth_headers,
request_tags=request_tags,
@@ -168,7 +174,7 @@ async def acompletion_with_mcp(
return response
# For auto-execute: handle streaming vs non-streaming differently
- stream: Final[bool] = kwargs.get("stream", False)
+ stream: Final[object] = kwargs.get("stream", False)
mock_tool_calls: Final = base_call_args.pop("mock_tool_calls", None)
if stream:
@@ -490,8 +496,8 @@ async def acompletion_with_mcp(
mcp_server_auth_headers=mcp_server_auth_headers,
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
- litellm_call_id=kwargs.get("litellm_call_id"),
- litellm_trace_id=kwargs.get("litellm_trace_id"),
+ litellm_call_id=context.litellm_call_id,
+ litellm_trace_id=context.litellm_trace_id,
openai_tools=openai_tools,
base_call_args=base_call_args,
request_tags=request_tags,
@@ -604,8 +610,8 @@ async def acompletion_with_mcp(
mcp_server_auth_headers=mcp_server_auth_headers,
oauth2_headers=oauth2_headers,
raw_headers=raw_headers,
- litellm_call_id=kwargs.get("litellm_call_id"),
- litellm_trace_id=kwargs.get("litellm_trace_id"),
+ litellm_call_id=context.litellm_call_id,
+ litellm_trace_id=context.litellm_trace_id,
request_tags=request_tags,
)
diff --git a/litellm/responses/mcp/mcp_streaming_iterator.py b/litellm/responses/mcp/mcp_streaming_iterator.py
index c7471518398..8f5dc926c68 100644
--- a/litellm/responses/mcp/mcp_streaming_iterator.py
+++ b/litellm/responses/mcp/mcp_streaming_iterator.py
@@ -22,6 +22,8 @@ from litellm.types.llms.openai import (
)
if TYPE_CHECKING:
+ from collections.abc import AsyncIterator, Iterator
+
from mcp.types import Tool as MCPTool
from litellm.proxy._types import UserAPIKeyAuth
@@ -511,7 +513,9 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
if self.base_iterator:
if hasattr(self.base_iterator, "__anext__"):
try:
- chunk: Final[ResponsesAPIStreamingResponse] = await cast(Any, self.base_iterator).__anext__()
+ chunk: Final[ResponsesAPIStreamingResponse] = await cast( # cast-ok: hasattr __anext__ checked
+ "AsyncIterator[ResponsesAPIStreamingResponse]", self.base_iterator
+ ).__anext__()
# Capture the response ID from the first event to ensure consistency
if self._cached_response_id is None and hasattr(chunk, "response"):
@@ -569,7 +573,9 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
if not self.base_iterator or not hasattr(self.base_iterator, "__anext__"):
raise StopAsyncIteration
- chunk: Final[ResponsesAPIStreamingResponse] = await cast(Any, self.base_iterator).__anext__()
+ chunk: Final[ResponsesAPIStreamingResponse] = await cast( # cast-ok: hasattr __anext__ checked above
+ "AsyncIterator[ResponsesAPIStreamingResponse]", self.base_iterator
+ ).__anext__()
if self._cached_response_id is None and hasattr(chunk, "response"):
new_response: Final[ResponsesAPIResponse | None] = getattr(chunk, "response", None)
@@ -834,7 +840,9 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator):
if not self.is_async:
try:
if self.base_iterator and hasattr(self.base_iterator, "__next__"):
- return next(cast(Any, self.base_iterator))
+ return next(
+ cast("Iterator[ResponsesAPIStreamingResponse]", self.base_iterator) # cast-ok: hasattr-checked
+ )
else:
raise StopIteration
except StopIteration:
diff --git a/litellm/responses/mcp/request_context.py b/litellm/responses/mcp/request_context.py
index 0689c041a95..22869dcd502 100644
--- a/litellm/responses/mcp/request_context.py
+++ b/litellm/responses/mcp/request_context.py
@@ -10,14 +10,25 @@ still executes the tool, just with no credentials.
from collections.abc import Iterable, Mapping, Sequence
from dataclasses import dataclass
-from typing import Any, Final
+from typing import TYPE_CHECKING, Any, Final
+
+from typing_extensions import NotRequired, ReadOnly, TypedDict
+
+if TYPE_CHECKING:
+ from litellm.proxy._types import UserAPIKeyAuth
+
+
+class _AuthCarryingMetadata(TypedDict):
+ """The one key this module reads out of a request's ``metadata`` / ``litellm_metadata``."""
+
+ user_api_key_auth: ReadOnly[NotRequired["UserAPIKeyAuth | None"]]
@dataclass(frozen=True, slots=True)
class MCPRequestContext:
"""Everything a gateway handler must forward to MCP tool listing and execution."""
- user_api_key_auth: Any # any-ok: UserAPIKeyAuth is proxy-only; importing it here would create a cycle
+ user_api_key_auth: "UserAPIKeyAuth | None"
mcp_auth_header: str | None = None
mcp_server_auth_headers: Mapping[str, Mapping[str, str]] | None = None
oauth2_headers: Mapping[str, str] | None = None
@@ -30,7 +41,7 @@ class MCPRequestContext:
def resolve(
cls,
kwargs: Mapping[str, Any],
- tools: Iterable[Any] | None,
+ tools: Iterable[object] | None,
) -> "MCPRequestContext":
"""
Build the context from a gateway handler's kwargs.
@@ -44,9 +55,9 @@ class MCPRequestContext:
)
from litellm.responses.utils import ResponsesAPIRequestUtils
- litellm_metadata: Final = kwargs.get("litellm_metadata") or {}
- metadata: Final = kwargs.get("metadata") or {}
- user_api_key_auth: Final = (
+ litellm_metadata: Final[_AuthCarryingMetadata] = kwargs.get("litellm_metadata") or {}
+ metadata: Final[_AuthCarryingMetadata] = kwargs.get("metadata") or {}
+ user_api_key_auth: Final[UserAPIKeyAuth | None] = (
kwargs.get("user_api_key_auth")
or litellm_metadata.get("user_api_key_auth")
or metadata.get("user_api_key_auth")
diff --git a/litellm/responses/sse_output_recovery.py b/litellm/responses/sse_output_recovery.py
index 208dec10c62..adc6a30319c 100644
--- a/litellm/responses/sse_output_recovery.py
+++ b/litellm/responses/sse_output_recovery.py
@@ -8,14 +8,17 @@ caller automatically applies to all of them.
"""
import json
-from typing import Any, Final
+from collections.abc import Mapping
+from typing import Final, SupportsInt, TypeAlias, cast # noqa: TID251 # int() re-checks the cast below at runtime
from litellm.constants import STREAM_SSE_DONE_STRING
_MAX_CONTENT_INDEX: Final = 1024
+_ConvertibleToInt: TypeAlias = SupportsInt | str
-def parse_sse_json_chunk(chunk: str) -> dict[str, Any] | None:
+
+def parse_sse_json_chunk(chunk: str) -> dict[str, object] | None:
"""Parse a single raw SSE line into a JSON object dict.
Returns ``None`` for empty lines, ``event:`` lines, ``[DONE]`` markers,
@@ -30,7 +33,7 @@ def parse_sse_json_chunk(chunk: str) -> dict[str, Any] | None:
if not stripped_chunk or stripped_chunk == STREAM_SSE_DONE_STRING or stripped_chunk.startswith("event:"):
return None
try:
- parsed_chunk: Final = json.loads(stripped_chunk)
+ parsed_chunk: Final[object] = json.loads(stripped_chunk)
except json.JSONDecodeError:
return None
if not isinstance(parsed_chunk, dict):
@@ -38,9 +41,19 @@ def parse_sse_json_chunk(chunk: str) -> dict[str, Any] | None:
return parsed_chunk
+def _chunk_index(parsed_chunk: Mapping[str, object], key: str, fallback: int) -> int:
+ raw_index: Final = parsed_chunk.get(key)
+ if raw_index is None:
+ return fallback
+ try:
+ return int(cast(_ConvertibleToInt, raw_index)) # cast-ok: int() raises TypeError otherwise, caught below
+ except (TypeError, ValueError):
+ return fallback
+
+
def record_output_item_chunk(
- parsed_chunk: dict[str, Any],
- output_items: dict[int, dict[str, Any]],
+ parsed_chunk: Mapping[str, object],
+ output_items: dict[int, dict[str, object]],
) -> None:
"""Record an OUTPUT_ITEM_DONE chunk into ``output_items`` keyed by
``output_index`` (falling back to the next free slot when missing).
@@ -48,20 +61,14 @@ def record_output_item_chunk(
item: Final = parsed_chunk.get("item")
if not isinstance(item, dict):
return
- try:
- output_index_raw: Final = parsed_chunk.get("output_index")
- if output_index_raw is None:
- raise ValueError("missing output_index")
- output_index = int(output_index_raw)
- except (TypeError, ValueError):
- output_index = len(output_items)
+ output_index: Final = _chunk_index(parsed_chunk, "output_index", len(output_items))
output_items[output_index] = item
def record_output_text_chunk(
- parsed_chunk: dict[str, Any],
- output_items: dict[int, dict[str, Any]],
- text_only_items: dict[int, dict[str, Any]],
+ parsed_chunk: Mapping[str, object],
+ output_items: Mapping[int, dict[str, object]],
+ text_only_items: dict[int, dict[str, object]],
) -> None:
"""Record an OUTPUT_TEXT_DONE chunk as a synthetic message item in
``text_only_items``. Real OUTPUT_ITEM_DONE events already captured in
@@ -71,13 +78,7 @@ def record_output_text_chunk(
if not isinstance(text, str):
return
- try:
- output_index_raw: Final = parsed_chunk.get("output_index")
- if output_index_raw is None:
- raise ValueError("missing output_index")
- output_index = int(output_index_raw)
- except (TypeError, ValueError):
- output_index = len(text_only_items)
+ output_index: Final = _chunk_index(parsed_chunk, "output_index", len(text_only_items))
if output_index in output_items:
return
@@ -97,13 +98,7 @@ def record_output_text_chunk(
if not isinstance(content, list):
return
- try:
- content_index_raw: Final = parsed_chunk.get("content_index")
- if content_index_raw is None:
- raise ValueError("missing content_index")
- content_index = int(content_index_raw)
- except (TypeError, ValueError):
- content_index = len(content)
+ content_index: Final = _chunk_index(parsed_chunk, "content_index", len(content))
if content_index < 0 or content_index > _MAX_CONTENT_INDEX:
return
diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py
index a6924c1d87a..5c0d6fc536e 100644
--- a/litellm/responses/streaming_iterator.py
+++ b/litellm/responses/streaming_iterator.py
@@ -49,6 +49,21 @@ if TYPE_CHECKING:
ResponsesClientWebSocket,
)
+ class _StreamCachingHandler(Protocol):
+ """The ``_llm_caching_handler`` attached to a logging object, as this module uses it."""
+
+ original_function: Callable[..., object]
+
+ def _should_store_result_in_cache(
+ self, original_function: Callable[..., object], kwargs: Mapping[str, object]
+ ) -> bool: ...
+
+ class PiiUnmaskingGuardrailCallback(PresidioGuardrailCallback, Protocol):
+ """Guardrail callback that can also reverse its own masking, selected by
+ ``llm_http_handler`` on exactly this attribute."""
+
+ def _unmask_pii_text(self, text: str, pii_tokens: Mapping[str, str]) -> str: ...
+
class ProjectQuotaCallback(Protocol):
async def enforce_project_io_token_quota_for_frame(
@@ -84,6 +99,11 @@ def _load_json_object(payload: str | bytes) -> dict[str, object]:
return json.loads(payload)
+def _load_json_value(payload: str | bytes) -> object:
+ """Parse a JSON payload whose top-level shape the caller narrows itself."""
+ return json.loads(payload)
+
+
def _model_id_from_metadata(litellm_metadata: dict[str, object] | None) -> str | None:
model_info: Final = litellm_metadata.get("model_info") if litellm_metadata else None
model_id: Final = model_info.get("id") if _is_json_object(model_info) else None
@@ -243,10 +263,10 @@ class BaseResponsesAPIStreamingIterator:
try:
# Parse the JSON chunk
- parsed_chunk: Final = json.loads(chunk)
+ parsed_chunk: Final = _load_json_value(chunk)
# Format as ResponsesAPIStreamingResponse
- if isinstance(parsed_chunk, dict):
+ if _is_json_object(parsed_chunk):
if self.responses_api_provider_config is None:
raise ValueError("responses_api_provider_config is required to process live streaming chunks")
openai_responses_api_chunk: Final = self.responses_api_provider_config.transform_streaming_response(
@@ -529,7 +549,7 @@ class BaseResponsesAPIStreamingIterator:
if response_obj is None:
return
- caching_handler: Final = getattr(self.logging_obj, "_llm_caching_handler", None)
+ caching_handler: Final[_StreamCachingHandler | None] = getattr(self.logging_obj, "_llm_caching_handler", None)
if caching_handler is None:
return
@@ -547,7 +567,7 @@ class BaseResponsesAPIStreamingIterator:
if preset_cache_key is not None:
request_kwargs["cache_key"] = preset_cache_key
- if not caching_handler._should_store_result_in_cache(
+ if not caching_handler._should_store_result_in_cache( # pyright: ignore[reportPrivateUsage] # no public API
original_function=caching_handler.original_function,
kwargs=request_kwargs,
):
@@ -1401,7 +1421,7 @@ async def _enforce_frame_project_quota(
if not quota_callbacks:
return
try:
- msg_obj = json.loads(raw_message)
+ msg_obj: Final = _load_json_value(raw_message)
except (json.JSONDecodeError, TypeError):
return
if not _is_json_object(msg_obj) or msg_obj.get("type") != "response.create":
@@ -1451,7 +1471,7 @@ class ResponsesWebSocketStreaming:
user_api_key_dict: UserAPIKeyAuth | None = None,
request_data: dict[str, object] | None = None,
first_message: str | None = None,
- guardrail_callbacks: list[Any] | None = None,
+ guardrail_callbacks: list[PiiUnmaskingGuardrailCallback] | None = None,
output_guardrail_callbacks: list[PresidioGuardrailCallback] | None = None,
quota_callbacks: Sequence[ProjectQuotaCallback] | None = None,
authorized_model: str | None = None,
@@ -1464,7 +1484,7 @@ class ResponsesWebSocketStreaming:
self.messages: list[dict[str, object]] = []
self.input_messages: list[dict[str, object]] = []
self.first_message = first_message
- self.guardrail_callbacks: list[Any] = guardrail_callbacks or []
+ self.guardrail_callbacks: list[PiiUnmaskingGuardrailCallback] = guardrail_callbacks or []
self.output_guardrail_callbacks: list[PresidioGuardrailCallback] = output_guardrail_callbacks or []
self.quota_callbacks: tuple[ProjectQuotaCallback, ...] = tuple(quota_callbacks) if quota_callbacks else ()
# Model name authorized at connection time; enforced on every
@@ -1780,7 +1800,9 @@ class ResponsesWebSocketStreaming:
continue
text = content_block.get("text")
if isinstance(text, str):
- unmasked = cb._unmask_pii_text(text, pii_tokens)
+ unmasked = cb._unmask_pii_text( # pyright: ignore[reportPrivateUsage] # no public unmasker
+ text, pii_tokens
+ )
if unmasked != text:
content_block["text"] = unmasked
modified = True
@@ -1789,7 +1811,9 @@ class ResponsesWebSocketStreaming:
if event_type in self._DELTA_EVENT_TYPES:
delta: Final = evt_obj.get("delta")
if isinstance(delta, str):
- unmasked = cb._unmask_pii_text(delta, pii_tokens)
+ unmasked = cb._unmask_pii_text( # pyright: ignore[reportPrivateUsage] # no public unmasker
+ delta, pii_tokens
+ )
if unmasked != delta:
evt_obj["delta"] = unmasked
return json.dumps(evt_obj)
diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py
index 716a815547d..0ff6bc8a7d2 100644
--- a/litellm/responses/utils.py
+++ b/litellm/responses/utils.py
@@ -1,15 +1,17 @@
import base64
import re
-from collections.abc import Iterable, Mapping
+from collections.abc import Iterable, Mapping, Sequence
from typing import Any, Final, Optional, Union, cast, get_type_hints, overload
from pydantic import BaseModel
+from typing_extensions import TypeIs # noqa: TID251 # narrows untyped wire payloads without a runtime conversion
import litellm
from litellm._logging import verbose_logger
from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig
from litellm.types.llms.openai import (
AllMessageValues,
+ OutputTokensDetails,
ResponseAPIUsage,
ResponseInputParam,
ResponsesAPIOptionalRequestParams,
@@ -26,6 +28,16 @@ from litellm.types.utils import (
)
+def _is_object_sequence(value: object) -> TypeIs[Sequence[object]]: # guard-ok: a list is a Sequence of anything
+ return isinstance(value, list)
+
+
+def _is_object_dict(
+ value: object,
+) -> TypeIs[dict[str, object]]: # guard-ok: wire dicts have str keys # mutable-ok: callers rewrite ids in place
+ return isinstance(value, dict)
+
+
def normalize_responses_api_stream_options(
stream_options: object,
) -> ResponsesAPIStreamOptions | None:
@@ -703,12 +715,12 @@ class ResponsesAPIRequestUtils:
@staticmethod
def _encode_container_ids_in_annotations(
- annotations: Any,
+ annotations: object,
custom_llm_provider: str | None,
model_id: str | None,
) -> None:
"""Encode ``container_id`` on each annotation (e.g. ``container_file_citation``)."""
- if not annotations or not isinstance(annotations, list):
+ if not annotations or not _is_object_sequence(annotations):
return
for ann in annotations:
ResponsesAPIRequestUtils._encode_container_id_on_output_item(
@@ -719,16 +731,16 @@ class ResponsesAPIRequestUtils:
@staticmethod
def _encode_container_ids_in_message_content(
- content: Any,
+ content: object,
custom_llm_provider: str | None,
model_id: str | None,
) -> None:
"""Walk message ``content`` parts and encode citation ``container_id`` values."""
if not content:
return
- if isinstance(content, list):
+ if _is_object_sequence(content):
for part in content:
- if isinstance(part, dict):
+ if _is_object_dict(part):
ResponsesAPIRequestUtils._encode_container_ids_in_annotations(
part.get("annotations"),
custom_llm_provider,
@@ -743,7 +755,7 @@ class ResponsesAPIRequestUtils:
@staticmethod
def _encode_container_id_on_output_item(
- item: Any,
+ item: object,
custom_llm_provider: str | None,
model_id: str | None,
) -> None:
@@ -770,14 +782,14 @@ class ResponsesAPIRequestUtils:
container_id=container_id,
)
- if isinstance(item, dict):
+ if _is_object_dict(item):
cid: Final = item.get("container_id")
if isinstance(cid, str):
enc = _maybe_encode(cid)
if enc is not None:
- item["container_id"] = enc
+ item["container_id"] = enc # rebind-ok: this helper's contract is to rewrite the item in place
nested: Final = item.get("code_interpreter_call")
- if isinstance(nested, dict):
+ if _is_object_dict(nested):
nc: Final = nested.get("container_id")
if isinstance(nc, str):
enc = _maybe_encode(nc)
@@ -803,7 +815,7 @@ class ResponsesAPIRequestUtils:
exc_info=True,
)
- nested_obj: Final = getattr(item, "code_interpreter_call", None)
+ nested_obj: Final[object] = getattr(item, "code_interpreter_call", None)
if nested_obj is not None:
ResponsesAPIRequestUtils._encode_container_id_on_output_item(
nested_obj,
@@ -820,24 +832,24 @@ class ResponsesAPIRequestUtils:
@staticmethod
def _collect_container_ids_from_annotations(
- annotations: Any,
+ annotations: object,
collected: set[str],
) -> None:
- if not annotations or not isinstance(annotations, list):
+ if not annotations or not _is_object_sequence(annotations):
return
for ann in annotations:
ResponsesAPIRequestUtils._collect_container_ids_from_output_item(ann, collected)
@staticmethod
def _collect_container_ids_from_message_content(
- content: Any,
+ content: object,
collected: set[str],
) -> None:
if not content:
return
- if isinstance(content, list):
+ if _is_object_sequence(content):
for part in content:
- if isinstance(part, dict):
+ if _is_object_dict(part):
ResponsesAPIRequestUtils._collect_container_ids_from_annotations(
part.get("annotations"),
collected,
@@ -850,19 +862,19 @@ class ResponsesAPIRequestUtils:
@staticmethod
def _collect_container_ids_from_output_item(
- item: Any,
+ item: object,
collected: set[str],
) -> None:
"""Collect managed or raw ``container_id`` values from one output item."""
if item is None:
return
- if isinstance(item, dict):
+ if _is_object_dict(item):
cid: Final = item.get("container_id")
if isinstance(cid, str) and cid:
collected.add(cid)
nested: Final = item.get("code_interpreter_call")
- if isinstance(nested, dict):
+ if _is_object_dict(nested):
nc: Final = nested.get("container_id")
if isinstance(nc, str) and nc:
collected.add(nc)
@@ -877,7 +889,7 @@ class ResponsesAPIRequestUtils:
if isinstance(cid_attr, str) and cid_attr:
collected.add(cid_attr)
- nested_obj: Final = getattr(item, "code_interpreter_call", None)
+ nested_obj: Final[object] = getattr(item, "code_interpreter_call", None)
if nested_obj is not None:
ResponsesAPIRequestUtils._collect_container_ids_from_output_item(nested_obj, collected)
@@ -1108,7 +1120,9 @@ class ResponseAPILoggingUtils:
cache_write_tokens=getattr(response_api_usage.input_tokens_details, "cache_write_tokens", None),
)
completion_tokens_details: CompletionTokensDetailsWrapper | None = None
- output_tokens_details: Final = getattr(response_api_usage, "output_tokens_details", None)
+ output_tokens_details: Final[OutputTokensDetails | None] = getattr(
+ response_api_usage, "output_tokens_details", None
+ )
if output_tokens_details:
completion_tokens_details = CompletionTokensDetailsWrapper(
reasoning_tokens=getattr(output_tokens_details, "reasoning_tokens", None),
diff --git a/litellm/router.py b/litellm/router.py
index 3d6230e0428..de60d46e01d 100644
--- a/litellm/router.py
+++ b/litellm/router.py
@@ -384,10 +384,31 @@ def _anthropic_stream_should_drop_pre_content_ping(chunk: object, has_generated_
return is_anthropic_ping_chunk(chunk)
+def _anthropic_stream_forwards_ping_live(chunk: object, has_generated_content: bool, buffered_chunk_count: int) -> bool:
+ """A `ping` that no lifecycle frame precedes reaches the client live: a fallback's own message_start can still
+ follow it without overlapping lifecycles, and AgenticAnthropicStreamingIterator's hold-back keepalive is exactly
+ such a ping."""
+ from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import is_anthropic_ping_chunk
+
+ if has_generated_content or buffered_chunk_count:
+ return False
+ return is_anthropic_ping_chunk(chunk)
+
+
def _is_retriable_anthropic_status(status_code: int) -> bool:
return status_code == 429 or status_code >= 500
+def _anthropic_stream_error_is_gateway_verdict(chunk: object) -> bool:
+ """AgenticAnthropicStreamingIterator's own retrieval-failure frame is the gateway's verdict, not a provider
+ failure: another deployment would rerun the same failed hook, so it reaches the client instead of falling back."""
+ from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import (
+ is_server_fulfilled_tool_leak_error,
+ )
+
+ return is_server_fulfilled_tool_leak_error(chunk)
+
+
def _anthropic_stream_should_decline_fallback(has_generated_content: bool, error: "MidStreamFallbackError") -> bool:
"""
A MidStreamFallbackError raised directly by the source iterator (the
@@ -427,10 +448,18 @@ class FallbackAwareAnthropicMessagesStream:
def __init__(self, async_generator: AsyncGenerator[bytes, None], source_iterator: object) -> None:
self._async_generator = async_generator
+ self._source_iterator = source_iterator
self._hidden_params = dict( # mutable-ok: mutated in place by merge_fallback_hidden_params
getattr(source_iterator, "_hidden_params", None) or {}
)
+ @property
+ def has_buffered_provider_output(self) -> bool:
+ return getattr(self._source_iterator, "has_buffered_provider_output", False) is True
+
+ def adopt_fallback_source(self, fallback_response: object) -> None:
+ self._source_iterator = fallback_response
+
def __aiter__(self) -> "FallbackAwareAnthropicMessagesStream":
return self
@@ -4974,7 +5003,9 @@ class Router:
# arrives - at that point the primary attempt has committed and a
# clean retry is no longer possible anyway - or once the primary
# stream ends without ever producing content. A `ping` keepalive
- # is dropped outright rather than buffered, since it can recur
+ # that nothing precedes is forwarded live (it is how a hold-back
+ # turn keeps its connection alive); one behind buffered frames is
+ # dropped outright rather than buffered, since it can recur
# indefinitely on a slow-starting connection and carries nothing
# worth preserving; hitting MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS
# forces the same early commit as real content arriving, so a
@@ -4984,6 +5015,11 @@ class Router:
model: Final = cast(str, initial_kwargs.get("model")) # cast-ok: kwargs always carries the model group
try:
async for chunk in source_iterator:
+ if _anthropic_stream_forwards_ping_live(
+ chunk, has_generated_content, len(buffered_lifecycle_chunks)
+ ):
+ yield chunk
+ continue
if _anthropic_stream_should_drop_pre_content_ping(chunk, has_generated_content):
continue
if _anthropic_stream_commits_now(chunk, has_generated_content, len(buffered_lifecycle_chunks)):
@@ -4993,6 +5029,7 @@ class Router:
not has_generated_content
and error_event is not None
and _is_retriable_anthropic_status(error_event[2])
+ and not _anthropic_stream_error_is_gateway_verdict(chunk)
)
if not has_generated_content and not retriable_pending_error and error_event is None:
buffered_lifecycle_chunks = (*buffered_lifecycle_chunks, chunk)
@@ -5087,6 +5124,7 @@ class Router:
)
fallback_hidden_params, fallback_headers = Router._prepare_fallback_hidden_params(fallback_response)
wrapper.merge_fallback_hidden_params(fallback_hidden_params, fallback_headers)
+ wrapper.adopt_fallback_source(fallback_response)
if hasattr(fallback_response, "__aiter__"):
async for fallback_item in fallback_response:
yield fallback_item
diff --git a/litellm/vector_stores/vector_store_registry.py b/litellm/vector_stores/vector_store_registry.py
index bd9a7bff101..22d27bc3266 100644
--- a/litellm/vector_stores/vector_store_registry.py
+++ b/litellm/vector_stores/vector_store_registry.py
@@ -1,7 +1,14 @@
# litellm/proxy/vector_stores/vector_store_registry.py
import json
+from collections.abc import Mapping
from datetime import datetime, timezone
-from typing import TYPE_CHECKING, Any, Final, get_args
+from typing import (
+ TYPE_CHECKING,
+ Any, # noqa: TID251 # untyped non_default_params dict is the only source of the unknown key type
+ Final,
+ cast, # noqa: TID251 # untyped non_default_params dict is the only source of the unknown key type
+ get_args,
+)
from litellm._logging import verbose_logger
from litellm.litellm_core_utils.core_helpers import remove_items_at_indices
@@ -336,7 +343,9 @@ class VectorStoreRegistry:
try:
# Check if it still exists in database
db_vector_store = await ManagedVectorStoresRepository(prisma_client).table.find_unique(
- where={"vector_store_id": vector_store_id}
+ where=cast( # cast-ok: every value is already an object, only the popped id is stub-untyped
+ "Mapping[str, object]", {"vector_store_id": vector_store_id}
+ )
)
if db_vector_store is None:
# Vector store was deleted from database, remove from cache
diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json
index bbfbbbc1cfb..97484851715 100644
--- a/ruff-strict-budget.json
+++ b/ruff-strict-budget.json
@@ -1,6 +1,6 @@
{
"ANN001": {
- "limit": 3016
+ "limit": 3014
},
"ANN002": {
"limit": 71
@@ -9,7 +9,7 @@
"limit": 827
},
"ANN201": {
- "limit": 2015
+ "limit": 2012
},
"ANN202": {
"limit": 852
@@ -24,7 +24,7 @@
"limit": 133
},
"ANN401": {
- "limit": 1188
+ "limit": 1157
},
"ASYNC230": {
"limit": 11
@@ -33,13 +33,13 @@
"limit": 2
},
"B006": {
- "limit": 177
+ "limit": 176
},
"B008": {
"limit": 503
},
"B009": {
- "limit": 59
+ "limit": 58
},
"B010": {
"limit": 190
@@ -231,7 +231,7 @@
"limit": 5
},
"TID251": {
- "limit": 1212
+ "limit": 1201
},
"TRY002": {
"limit": 524
diff --git a/ruff-tests.toml b/ruff-tests.toml
index 7d28d6d838a..e036fb4946c 100644
--- a/ruff-tests.toml
+++ b/ruff-tests.toml
@@ -51,6 +51,10 @@
# F632 `is` against a literal. It compares identity, so it passes only where CPython
# happens to intern the value and stops meaning what it says the moment the
# value is built at runtime
+# B003 `os.environ = {...}` rebinds the mapping instead of mutating it, so `putenv`
+# never fires and a subprocess still reads the real keys the test believes it
+# cleared. The manual restore underneath is skipped whenever the body raises,
+# so every later test in that worker inherits a plain dict for an environment
#
# No target-version here on purpose: it resolves from requires-python (>=3.10), so
# 3.11-only builtins like BaseExceptionGroup are correctly flagged in a tree that
@@ -78,4 +82,5 @@ lint.select = [
"B023",
"B025",
"F632",
+ "B003",
]
diff --git a/tests/code_coverage_tests/router_code_coverage.py b/tests/code_coverage_tests/router_code_coverage.py
index 014f3a16b59..c541c035db7 100644
--- a/tests/code_coverage_tests/router_code_coverage.py
+++ b/tests/code_coverage_tests/router_code_coverage.py
@@ -80,6 +80,7 @@ ignored_function_names = [
"_override_vector_store_methods_for_router", # No-op placeholder, called during Router init
"_merge_tools_from_deployment", # Tested indirectly via _update_kwargs_with_deployment (test files lack "router" in name)
"_invalidate_access_groups_cache", # Tested indirectly via set_model_list, upsert_model etc. (test files lack "router" in name)
+ "has_buffered_provider_output", # Property, so its reads in test_router.py are never an ast.Call
]
diff --git a/tests/litellm_utils_tests/test_utils.py b/tests/litellm_utils_tests/test_utils.py
index 67f2e1ce06d..0ccfae55290 100644
--- a/tests/litellm_utils_tests/test_utils.py
+++ b/tests/litellm_utils_tests/test_utils.py
@@ -328,34 +328,23 @@ def test_trimming_with_untokenizable_field(caplog: pytest.LogCaptureFixture) ->
def test_aget_valid_models():
- old_environ = os.environ
- os.environ = {"OPENAI_API_KEY": "temp"} # mock set only openai key in environ
+ with mock.patch.dict(os.environ, {"OPENAI_API_KEY": "temp"}, clear=True):
+ valid_models = get_valid_models()
+ print(valid_models)
- valid_models = get_valid_models()
- print(valid_models)
+ # list of openai supported llms on litellm
+ expected_models = (
+ litellm.open_ai_chat_completion_models | litellm.open_ai_text_completion_models
+ )
- # list of openai supported llms on litellm
- expected_models = (
- litellm.open_ai_chat_completion_models | litellm.open_ai_text_completion_models
- )
-
- assert set(valid_models) == set(expected_models)
-
- # reset replicate env key
- os.environ = old_environ
+ assert set(valid_models) == set(expected_models)
# GEMINI
- expected_models = litellm.gemini_models
- old_environ = os.environ
- os.environ = {"GEMINI_API_KEY": "temp"} # mock set only openai key in environ
+ with mock.patch.dict(os.environ, {"GEMINI_API_KEY": "temp"}, clear=True):
+ valid_models = get_valid_models()
- valid_models = get_valid_models()
-
- print(valid_models)
- assert set(valid_models) == set(expected_models)
-
- # reset replicate env key
- os.environ = old_environ
+ print(valid_models)
+ assert set(valid_models) == set(litellm.gemini_models)
@pytest.mark.parametrize("custom_llm_provider", ["anthropic", "xai"])
diff --git a/tests/proxy_unit_tests/test_jwt_key_mapping.py b/tests/proxy_unit_tests/test_jwt_key_mapping.py
index 65d075b7f99..4b50f83e9eb 100644
--- a/tests/proxy_unit_tests/test_jwt_key_mapping.py
+++ b/tests/proxy_unit_tests/test_jwt_key_mapping.py
@@ -416,6 +416,34 @@ async def test_update_returns_404_when_not_found():
assert exc_info.value.status_code == 404
+@pytest.mark.asyncio
+async def test_update_returns_404_when_row_deleted_before_write():
+ """A mapping deleted between the read and the write must 404, not 500.
+
+ Prisma's update returns None when the row is gone, and the endpoint used to
+ dereference it for the cache key.
+ """
+ from litellm.proxy._types import UpdateJWTKeyMappingRequest
+
+ mock_prisma = _mock_prisma()
+ mock_prisma.db.litellm_jwtkeymapping.find_unique.return_value = _mock_mapping()
+ mock_prisma.db.litellm_jwtkeymapping.update.return_value = None
+ mock_cache = AsyncMock()
+
+ data = UpdateJWTKeyMappingRequest(id="mapping-1", description="test")
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch("litellm.proxy.proxy_server.user_api_key_cache", mock_cache), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await update_jwt_key_mapping(
+ data=data, user_api_key_dict=_make_admin_auth()
+ )
+ assert exc_info.value.status_code == 404
+ assert exc_info.value.detail == "Mapping not found"
+
+
@pytest.mark.asyncio
async def test_info_returns_404_when_not_found():
"""Getting info for non-existent mapping should return 404."""
diff --git a/tests/test_litellm/integrations/test_langfuse_otel.py b/tests/test_litellm/integrations/test_langfuse_otel.py
index 417921c166b..89607494920 100644
--- a/tests/test_litellm/integrations/test_langfuse_otel.py
+++ b/tests/test_litellm/integrations/test_langfuse_otel.py
@@ -933,6 +933,52 @@ class TestLangfuseOtelResponsesAPI:
assert output_data[0]["arguments"]["location"] == "San Francisco"
assert output_data[0]["arguments"]["unit"] == "celsius"
+ def test_responses_api_function_call_with_redacted_arguments(self):
+ """Sentinel arguments (invalid JSON) must not kill the whole observation output."""
+ from openai.types.responses import ResponseFunctionToolCall
+
+ from litellm.types.integrations.langfuse_otel import LangfuseSpanAttributes
+
+ response_obj = ResponsesAPIResponse(
+ id="response-redacted",
+ created_at=1625247700,
+ output=[
+ ResponseFunctionToolCall(
+ id="fc-redacted",
+ type="function_call",
+ name="get_weather",
+ call_id="call-redacted",
+ arguments="redacted-by-litellm",
+ status="completed",
+ )
+ ],
+ )
+
+ kwargs = {
+ "call_type": "responses",
+ "messages": [{"role": "user", "content": "What's the weather?"}],
+ "model": "gpt-4o",
+ "optional_params": {},
+ }
+
+ mock_span = MagicMock()
+
+ with patch( # test-quality-ok: the span attribute sink is the observable boundary; sibling tests in this class stub the same seam
+ "litellm.integrations.arize._utils.safe_set_attribute"
+ ) as mock_safe_set_attribute:
+ LangfuseOtelLogger._set_langfuse_specific_attributes(mock_span, kwargs, response_obj)
+
+ output_calls = [
+ call
+ for call in mock_safe_set_attribute.call_args_list
+ if call.args[1] == LangfuseSpanAttributes.OBSERVATION_OUTPUT.value
+ ]
+
+ assert len(output_calls) > 0, "observation.output should still be set"
+ output_data = json.loads(output_calls[0].args[2])
+ assert output_data[0]["name"] == "get_weather"
+ assert output_data[0]["arguments"] == {}
+
if __name__ == "__main__":
pytest.main([__file__])
diff --git a/tests/test_litellm/litellm_core_utils/test_redact_messages.py b/tests/test_litellm/litellm_core_utils/test_redact_messages.py
index 8fa6d44dd8a..3be0bae4120 100644
--- a/tests/test_litellm/litellm_core_utils/test_redact_messages.py
+++ b/tests/test_litellm/litellm_core_utils/test_redact_messages.py
@@ -350,7 +350,7 @@ class TestPerformRedaction:
redacted = perform_redaction({}, result)
message = redacted["choices"][0]["message"]
- assert message["content"] == "redacted-by-litellm"
+ assert message["content"] is None
tool_call = message["tool_calls"][0]
assert tool_call["function"]["arguments"] == "redacted-by-litellm"
assert tool_call["function"]["name"] == "get_weather"
@@ -491,6 +491,76 @@ class TestPerformRedaction:
assert redacted["output"][0]["arguments"] == "redacted-by-litellm"
assert redacted["output"][0]["name"] == "get_weather"
+ def test_redacts_every_tool_call_in_multi_element_list(self):
+ result = litellm.ModelResponse(
+ id="resp-multi",
+ choices=[
+ litellm.Choices(
+ message=litellm.Message(
+ content=None,
+ role="assistant",
+ tool_calls=[
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": '{"city": "a"}'},
+ },
+ {
+ "id": "call_2",
+ "type": "function",
+ "function": {"name": "get_time", "arguments": '{"tz": "b"}'},
+ },
+ ],
+ )
+ )
+ ],
+ model="gpt-4o",
+ )
+
+ redacted = perform_redaction({}, result)
+
+ tool_calls = redacted.choices[0].message.tool_calls
+ assert tool_calls[0].function.arguments == "redacted-by-litellm"
+ assert tool_calls[1].function.arguments == "redacted-by-litellm"
+
+ def test_preserves_none_content_on_tool_call_only_message(self):
+ result = litellm.ModelResponse(
+ id="resp-none",
+ choices=[
+ litellm.Choices(
+ message=litellm.Message(
+ content=None,
+ role="assistant",
+ tool_calls=[
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": '{"city": "a"}'},
+ }
+ ],
+ )
+ )
+ ],
+ model="gpt-4o",
+ )
+
+ redacted = perform_redaction({}, result)
+
+ assert redacted.choices[0].message.content is None
+
+ def test_redacts_responses_api_function_call_arguments_object(self):
+ output_item = SimpleNamespace(
+ type="function_call",
+ name="get_weather",
+ arguments='{"city": "sensitive-city"}',
+ call_id="call_1",
+ )
+
+ _redact_responses_api_output([output_item])
+
+ assert output_item.arguments == "redacted-by-litellm"
+ assert output_item.name == "get_weather"
+
def test_redacts_response_output_objects_with_top_level_text(self):
output_items = [
SimpleNamespace(text="top-level output"),
@@ -502,6 +572,29 @@ class TestPerformRedaction:
assert output_items[0].text == "redacted-by-litellm"
assert output_items[1] == "non-dict output item"
+ def test_preserves_none_text_in_responses_output(self):
+ from litellm.litellm_core_utils.redact_messages import _redact_responses_api_output_dict
+
+ none_item = SimpleNamespace(type="output_text", text=None, content=[SimpleNamespace(text=None)])
+ real_item = SimpleNamespace(type="output_text", text="real answer", content=[SimpleNamespace(text="real part")])
+
+ _redact_responses_api_output([none_item, real_item])
+
+ assert none_item.text is None
+ assert none_item.content[0].text is None
+ assert real_item.text == "redacted-by-litellm"
+ assert real_item.content[0].text == "redacted-by-litellm"
+
+ none_dict = {"type": "output_text", "text": None, "content": [{"text": None}]}
+ real_dict = {"type": "output_text", "text": "real answer", "content": [{"text": "real part"}]}
+
+ _redact_responses_api_output_dict([none_dict, real_dict], "redacted-by-litellm")
+
+ assert none_dict["text"] is None
+ assert none_dict["content"][0]["text"] is None
+ assert real_dict["text"] == "redacted-by-litellm"
+ assert real_dict["content"][0]["text"] == "redacted-by-litellm"
+
def test_skips_non_dict_response_output_items(self):
result = {
"output": [
diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_agentic_streaming_iterator.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_agentic_streaming_iterator.py
index db8aae6702f..015b5754c6e 100644
--- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_agentic_streaming_iterator.py
+++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_agentic_streaming_iterator.py
@@ -2,6 +2,7 @@
Tests for AgenticAnthropicStreamingIterator and SSE rebuild helpers.
"""
+import asyncio
import json
from typing import Any, Dict, List, Optional, Tuple
from unittest.mock import AsyncMock, MagicMock
@@ -9,7 +10,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
+from litellm.constants import STREAM_SSE_KEEPALIVE_PING_BYTES
from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import (
+ SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES,
AgenticAnthropicStreamingIterator,
_handle_content_block_delta,
_handle_content_block_start,
@@ -227,6 +230,53 @@ class MockAsyncStream:
return chunk
+class MockSlowAsyncStream(MockAsyncStream):
+ """Async iterator that sleeps before every chunk."""
+
+ def __init__(self, chunks: List[bytes], delay_seconds: float):
+ super().__init__(chunks)
+ self._delay_seconds = delay_seconds
+
+ async def __anext__(self) -> bytes:
+ await asyncio.sleep(self._delay_seconds)
+ return await super().__anext__()
+
+
+class MockFailingAsyncStream(MockAsyncStream):
+ """Async iterator that raises after yielding its chunks."""
+
+ def __init__(self, chunks: List[bytes], error: Exception):
+ super().__init__(chunks)
+ self._error = error
+
+ async def __anext__(self) -> bytes:
+ if self._idx >= len(self._chunks):
+ raise self._error
+ return await super().__anext__()
+
+
+def _build_hold_back_iterator(
+ stream: MockAsyncStream,
+ mock_handler: MagicMock,
+ ping_interval_seconds: float = 15.0,
+ server_fulfilled_tool_names: frozenset = frozenset({"litellm_content_retrieve"}),
+) -> AgenticAnthropicStreamingIterator:
+ return AgenticAnthropicStreamingIterator(
+ completion_stream=stream,
+ http_handler=mock_handler,
+ model="claude-sonnet-4-20250514",
+ messages=[],
+ anthropic_messages_provider_config=MagicMock(),
+ anthropic_messages_optional_request_params={},
+ logging_obj=MagicMock(),
+ custom_llm_provider="anthropic",
+ kwargs={},
+ hold_back=True,
+ server_fulfilled_tool_names=server_fulfilled_tool_names,
+ ping_interval_seconds=ping_interval_seconds,
+ )
+
+
# ---------------------------------------------------------------------------
# Tests for _parse_sse_events
# ---------------------------------------------------------------------------
@@ -787,3 +837,269 @@ class TestAgenticStreamingIteratorErrorHandling:
call_kwargs = mock_handler._call_agentic_completion_hooks.call_args
assert call_kwargs.kwargs["stream"] is True
+
+
+class TestAgenticStreamingIteratorHoldBack:
+ @pytest.mark.asyncio
+ async def test_should_not_leak_intercepted_message_when_follow_up_fires(self):
+ """The buffered tool_use message must be dropped: only pings and follow-up bytes reach the client."""
+ phase1_chunks = _build_tool_use_stream()
+ phase2_chunks = [b"follow-up-chunk-1", b"follow-up-chunk-2"]
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=MockAsyncStream(phase2_chunks))
+
+ iterator = _build_hold_back_iterator(MockAsyncStream(phase1_chunks), mock_handler)
+
+ collected = []
+ async for chunk in iterator:
+ collected.append(chunk)
+
+ non_ping = [c for c in collected if c != STREAM_SSE_KEEPALIVE_PING_BYTES]
+ assert non_ping == phase2_chunks
+ assert b"litellm_content_retrieve" not in b"".join(collected)
+ assert collected[0] == STREAM_SSE_KEEPALIVE_PING_BYTES
+
+ @pytest.mark.asyncio
+ async def test_should_replay_buffer_verbatim_when_no_hook_fires(self):
+ """Without interception the buffered message is replayed byte-identical after the pings."""
+ chunks = _build_simple_text_stream()
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=None)
+
+ iterator = _build_hold_back_iterator(MockAsyncStream(chunks), mock_handler)
+
+ collected = []
+ async for chunk in iterator:
+ collected.append(chunk)
+
+ assert [c for c in collected if c != STREAM_SSE_KEEPALIVE_PING_BYTES] == chunks
+ mock_handler._call_agentic_completion_hooks.assert_awaited_once()
+
+ @pytest.mark.asyncio
+ async def test_should_emit_pings_while_upstream_is_slow(self):
+ """Pings keep the client connection alive while the upstream message is buffered."""
+ chunks = _build_simple_text_stream()
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=None)
+
+ iterator = _build_hold_back_iterator(
+ MockSlowAsyncStream(chunks, delay_seconds=0.05),
+ mock_handler,
+ ping_interval_seconds=0.02,
+ )
+
+ collected = []
+ async for chunk in iterator:
+ collected.append(chunk)
+
+ assert collected.count(STREAM_SSE_KEEPALIVE_PING_BYTES) >= 2
+ assert [c for c in collected if c != STREAM_SSE_KEEPALIVE_PING_BYTES] == chunks
+
+ @pytest.mark.asyncio
+ async def test_should_propagate_upstream_error_instead_of_partial_message(self):
+ """An upstream failure surfaces as an error; the client never receives a truncated message."""
+ chunks = _build_simple_text_stream()[:2]
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=None)
+
+ iterator = _build_hold_back_iterator(
+ MockFailingAsyncStream(chunks, RuntimeError("upstream died")),
+ mock_handler,
+ )
+
+ collected = []
+
+ async def _drain():
+ async for chunk in iterator:
+ collected.append(chunk)
+
+ with pytest.raises(RuntimeError, match="upstream died"):
+ await _drain()
+
+ assert all(c == STREAM_SSE_KEEPALIVE_PING_BYTES for c in collected)
+ mock_handler._call_agentic_completion_hooks.assert_not_awaited()
+
+ @pytest.mark.asyncio
+ async def test_should_emit_pings_while_hooks_are_slow(self):
+ """Retrieval and follow-up generation can outlast a client's idle timeout, so hooks get keepalives too."""
+ chunks = _build_tool_use_stream()
+ phase2_chunks = [b"follow-up-chunk"]
+
+ async def slow_hooks(**_kwargs):
+ await asyncio.sleep(0.12)
+ return MockAsyncStream(phase2_chunks)
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(side_effect=slow_hooks)
+
+ iterator = _build_hold_back_iterator(
+ MockAsyncStream(chunks),
+ mock_handler,
+ ping_interval_seconds=0.02,
+ )
+
+ collected = []
+ async for chunk in iterator:
+ collected.append(chunk)
+
+ assert collected.count(STREAM_SSE_KEEPALIVE_PING_BYTES) >= 4
+ assert [c for c in collected if c != STREAM_SSE_KEEPALIVE_PING_BYTES] == phase2_chunks
+
+ @pytest.mark.asyncio
+ async def test_should_error_instead_of_replaying_server_fulfilled_tool_use_when_hook_crashes(self):
+ """A hook crash must not replay the buffered retrieval tool_use: that is the unknown-tool bug."""
+ chunks = _build_tool_use_stream()
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(side_effect=RuntimeError("hook exploded"))
+
+ iterator = _build_hold_back_iterator(MockAsyncStream(chunks), mock_handler)
+
+ collected = []
+ async for chunk in iterator:
+ collected.append(chunk)
+
+ assert [c for c in collected if c != STREAM_SSE_KEEPALIVE_PING_BYTES] == [SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES]
+ assert b"litellm_content_retrieve" not in b"".join(collected)
+
+ @pytest.mark.asyncio
+ async def test_should_error_instead_of_replaying_when_no_hook_fires_on_tool_use(self):
+ """Hooks returning None on a retrieval tool_use is still a leak, so the turn fails loudly."""
+ chunks = _build_tool_use_stream()
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=None)
+
+ iterator = _build_hold_back_iterator(MockAsyncStream(chunks), mock_handler)
+
+ collected = []
+ async for chunk in iterator:
+ collected.append(chunk)
+
+ assert [c for c in collected if c != STREAM_SSE_KEEPALIVE_PING_BYTES] == [SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES]
+
+ @pytest.mark.asyncio
+ async def test_should_replay_client_owned_tool_use_verbatim(self):
+ """Only server-fulfilled tools are withheld: a client's own tool_use still reaches it byte-identical."""
+ chunks = _build_tool_use_stream()
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=None)
+
+ iterator = _build_hold_back_iterator(
+ MockAsyncStream(chunks),
+ mock_handler,
+ server_fulfilled_tool_names=frozenset({"headroom_retrieve"}),
+ )
+
+ collected = []
+ async for chunk in iterator:
+ collected.append(chunk)
+
+ assert [c for c in collected if c != STREAM_SSE_KEEPALIVE_PING_BYTES] == chunks
+
+ @pytest.mark.asyncio
+ async def test_should_emit_pings_while_the_follow_up_stream_is_slow(self):
+ """The corrected answer can be slow to generate, so the follow-up stream gets keepalives too."""
+ chunks = _build_tool_use_stream()
+ phase2_chunks = [b"follow-up-chunk-1", b"follow-up-chunk-2"]
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(
+ return_value=MockSlowAsyncStream(phase2_chunks, delay_seconds=0.06)
+ )
+
+ iterator = _build_hold_back_iterator(
+ MockAsyncStream(chunks),
+ mock_handler,
+ ping_interval_seconds=0.02,
+ )
+
+ collected = []
+ async for chunk in iterator:
+ collected.append(chunk)
+
+ first_follow_up_index = collected.index(phase2_chunks[0])
+ assert collected[first_follow_up_index + 1] == STREAM_SSE_KEEPALIVE_PING_BYTES
+ assert [c for c in collected if c != STREAM_SSE_KEEPALIVE_PING_BYTES] == phase2_chunks
+
+ @pytest.mark.asyncio
+ async def test_should_propagate_follow_up_stream_error(self):
+ """A failing follow-up stream surfaces its error instead of hanging on pings forever."""
+ chunks = _build_tool_use_stream()
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(
+ return_value=MockFailingAsyncStream([b"follow-up-chunk"], RuntimeError("follow-up died"))
+ )
+
+ iterator = _build_hold_back_iterator(MockAsyncStream(chunks), mock_handler, ping_interval_seconds=0.02)
+
+ with pytest.raises(RuntimeError, match="follow-up died"):
+ async for _ in iterator:
+ pass
+
+ @pytest.mark.asyncio
+ async def test_aclose_cancels_in_flight_follow_up_chunk_task(self):
+ """Closing while a follow-up chunk is pending must not orphan that task."""
+ chunks = _build_tool_use_stream()
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(
+ return_value=MockSlowAsyncStream([b"follow-up-chunk"], delay_seconds=5.0)
+ )
+
+ iterator = _build_hold_back_iterator(MockAsyncStream(chunks), mock_handler, ping_interval_seconds=0.02)
+
+ while iterator._follow_up_chunk_task is None:
+ await iterator.__anext__()
+
+ await iterator.aclose()
+ assert iterator._follow_up_chunk_task.cancelled()
+
+ @pytest.mark.asyncio
+ async def test_aclose_cancels_drain_task(self):
+ """Closing the iterator mid-buffer must cancel the background drain task."""
+ chunks = _build_simple_text_stream()
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(return_value=None)
+
+ iterator = _build_hold_back_iterator(
+ MockSlowAsyncStream(chunks, delay_seconds=5.0),
+ mock_handler,
+ )
+
+ first = await iterator.__anext__()
+ assert first == STREAM_SSE_KEEPALIVE_PING_BYTES
+ assert iterator._drain_task is not None
+
+ await iterator.aclose()
+ assert iterator._drain_task.cancelled()
+
+ @pytest.mark.asyncio
+ async def test_aclose_cancels_in_flight_hook_task(self):
+ """Closing while hooks are running must not leave the retrieval follow-up task orphaned."""
+ chunks = _build_tool_use_stream()
+
+ async def never_finishing_hooks(**_kwargs):
+ await asyncio.sleep(5.0)
+
+ mock_handler = MagicMock()
+ mock_handler._call_agentic_completion_hooks = AsyncMock(side_effect=never_finishing_hooks)
+
+ iterator = _build_hold_back_iterator(
+ MockAsyncStream(chunks),
+ mock_handler,
+ ping_interval_seconds=0.02,
+ )
+
+ while iterator._hook_task is None:
+ await iterator.__anext__()
+
+ await iterator.aclose()
+ assert iterator._hook_task.cancelled()
diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_response_cache.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_response_cache.py
index fe0bcfa4f30..22d14614108 100644
--- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_response_cache.py
+++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_response_cache.py
@@ -4,9 +4,15 @@ from typing import Any, AsyncIterator, Dict, List
import pytest
+import datetime
+
import litellm
from litellm.caching.caching import Cache, LiteLLMCacheType
+from litellm.caching.caching_handler import LLMCachingHandler
from litellm.llms.anthropic.experimental_pass_through.messages import handler
+from litellm.llms.anthropic.experimental_pass_through.messages.response_cache import (
+ AnthropicMessagesStreamCacheWriter,
+)
STREAM_EVENTS: List[bytes] = [
b'event: message_start\ndata: {"type": "message_start", "message": {"id": "msg_stream_1", "type": "message", '
@@ -262,3 +268,25 @@ async def test_cached_stream_replay_logs_once_when_polled_after_exhaustion():
await asyncio.sleep(0)
mock_route.assert_called_once()
+class _HeldBackStream:
+ has_buffered_provider_output = True
+
+ def __aiter__(self) -> "_HeldBackStream":
+ return self
+
+ async def __anext__(self) -> bytes:
+ raise StopAsyncIteration
+
+
+def test_cache_writer_forwards_has_buffered_provider_output(request_kwargs):
+ caching_handler = LLMCachingHandler(
+ original_function=handler.anthropic_messages,
+ request_kwargs=dict(request_kwargs),
+ start_time=datetime.datetime.now(),
+ )
+ held_back = AnthropicMessagesStreamCacheWriter(stream=_HeldBackStream(), caching_handler=caching_handler)
+ assert held_back.has_buffered_provider_output is True
+ replayable = AnthropicMessagesStreamCacheWriter(
+ stream=_byte_stream(STREAM_EVENTS), caching_handler=caching_handler
+ )
+ assert replayable.has_buffered_provider_output is False
diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_streaming_iterator.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_streaming_iterator.py
index 652c1f077a9..cb31280c2d5 100644
--- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_streaming_iterator.py
+++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_streaming_iterator.py
@@ -8,6 +8,8 @@ import pytest
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
INCOMPLETE_STREAM_ERROR_MESSAGE,
+ AnthropicMessagesStreamHiddenParams,
+ AnthropicMessagesStreamingResponse,
BaseAnthropicMessagesStreamingIterator,
_incomplete_stream_error_sse_event,
_is_message_stop_chunk,
@@ -511,6 +513,32 @@ def test_anthropic_messages_response_as_sse_events_multiple_blocks_are_indexed()
assert [d["delta"]["text"] for d in deltas] == ["a", "b"]
+def test_anthropic_messages_streaming_response_reports_withheld_output_of_its_stream():
+ class _HoldingBack:
+ has_buffered_provider_output = True
+
+ def __aiter__(self):
+ return self
+
+ async def __anext__(self) -> bytes:
+ raise StopAsyncIteration
+
+ async def _bare_stream():
+ yield b""
+
+ hidden_params: AnthropicMessagesStreamHiddenParams = {"additional_headers": {}}
+ assert (
+ AnthropicMessagesStreamingResponse(completion_stream=_HoldingBack(), hidden_params=hidden_params)
+ .has_buffered_provider_output
+ is True
+ )
+ assert (
+ AnthropicMessagesStreamingResponse(completion_stream=_bare_stream(), hidden_params=hidden_params)
+ .has_buffered_provider_output
+ is False
+ )
+
+
def test_anthropic_messages_response_as_sse_events_no_content_blocks():
response = {"id": "msg_4", "content": [], "stop_reason": "end_turn"}
decoded = _decode_sse_events(anthropic_messages_response_as_sse_events(response))
diff --git a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py
index 694a01cda5f..177c71eeae7 100644
--- a/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py
+++ b/tests/test_litellm/llms/custom_httpx/test_llm_http_handler.py
@@ -2146,6 +2146,77 @@ async def test_anthropic_invalid_thinking_signature_retry_resigns_bedrock_reques
assert retry_authorization != first_attempt_headers["Authorization"]
+class TestServerFulfilledToolsInRequest:
+ """_server_fulfilled_tools_in_request gates the buffered (non-leaking) streaming
+ mode for server-fulfilled tools like headroom_retrieve."""
+
+ @staticmethod
+ def _logging_obj_with(callbacks):
+ logging_obj = Mock()
+ logging_obj.dynamic_success_callbacks = callbacks
+ return logging_obj
+
+ def test_should_hold_back_when_callback_owns_tool_in_request(self):
+ from litellm.integrations.custom_logger import CustomLogger
+
+ class RetrievalCallback(CustomLogger):
+ server_fulfilled_tool_names = frozenset({"headroom_retrieve"})
+
+ tools = [
+ {"name": "Bash", "input_schema": {"type": "object"}},
+ {"name": "headroom_retrieve", "input_schema": {"type": "object"}},
+ ]
+ assert BaseLLMHTTPHandler._server_fulfilled_tools_in_request(
+ logging_obj=self._logging_obj_with([RetrievalCallback()]), tools=tools
+ ) == frozenset({"headroom_retrieve"})
+
+ def test_should_stream_live_when_tool_absent_from_request(self):
+ from litellm.integrations.custom_logger import CustomLogger
+
+ class RetrievalCallback(CustomLogger):
+ server_fulfilled_tool_names = frozenset({"headroom_retrieve"})
+
+ tools = [{"name": "Bash", "input_schema": {"type": "object"}}]
+ assert (
+ BaseLLMHTTPHandler._server_fulfilled_tools_in_request(
+ logging_obj=self._logging_obj_with([RetrievalCallback()]), tools=tools
+ )
+ == frozenset()
+ )
+
+ def test_should_stream_live_when_no_callback_declares_tool_names(self):
+ from litellm.integrations.custom_logger import CustomLogger
+
+ tools = [{"name": "headroom_retrieve", "input_schema": {"type": "object"}}]
+ assert (
+ BaseLLMHTTPHandler._server_fulfilled_tools_in_request(
+ logging_obj=self._logging_obj_with([CustomLogger()]), tools=tools
+ )
+ == frozenset()
+ )
+
+ def test_should_stream_live_without_tools(self):
+ assert (
+ BaseLLMHTTPHandler._server_fulfilled_tools_in_request(logging_obj=self._logging_obj_with([]), tools=None)
+ == frozenset()
+ )
+
+ def test_interception_callbacks_declare_their_retrieval_tools(self):
+ from litellm.integrations.compression_interception.handler import (
+ LITELLM_CONTENT_RETRIEVE_TOOL_NAME,
+ CompressionInterceptionLogger,
+ )
+ from litellm.proxy.guardrails.guardrail_hooks.headroom.headroom import (
+ HEADROOM_RETRIEVE_TOOL_NAME,
+ HeadroomGuardrail,
+ )
+
+ assert HeadroomGuardrail.server_fulfilled_tool_names == frozenset({HEADROOM_RETRIEVE_TOOL_NAME})
+ assert CompressionInterceptionLogger.server_fulfilled_tool_names == frozenset(
+ {LITELLM_CONTENT_RETRIEVE_TOOL_NAME}
+ )
+
+
def _make_stub_direct_vector_store_config(response):
from litellm.llms.base_llm.vector_store.transformation import (
BaseDirectVectorStoreConfig,
diff --git a/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py b/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py
index 4573b1012a1..bee8ab3a9ac 100644
--- a/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py
+++ b/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py
@@ -1,6 +1,6 @@
import json
import logging
-from collections.abc import Mapping, Sequence
+from collections.abc import Iterator, Mapping, Sequence
from unittest.mock import MagicMock
import httpx
@@ -9,6 +9,7 @@ import pytest
import litellm
from litellm.exceptions import UnsupportedParamsError
from litellm.llms.base_llm.chat.transformation import LiteLLMLoggingObj
+from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler
from litellm.llms.openai.chat.gpt_transformation import (
OpenAIChatCompletionStreamingHandler,
)
@@ -46,6 +47,11 @@ def force_local_model_cost(monkeypatch):
monkeypatch.setattr(litellm, "model_cost", get_model_cost_map(url=litellm.model_cost_map_url))
+@pytest.fixture(autouse=True)
+def isolate_together_api_base_env(monkeypatch):
+ monkeypatch.delenv("TOGETHER_AI_API_BASE", raising=False)
+
+
@pytest.fixture
def registry_disables_function_calling(monkeypatch):
monkeypatch.setitem(
@@ -258,26 +264,20 @@ def _transform_response(message: dict) -> ModelResponse:
def test_transform_response_maps_reasoning_to_reasoning_content():
- result = _transform_response(
- {"role": "assistant", "content": "4", "reasoning": "2+2 equals 4"}
- )
+ result = _transform_response({"role": "assistant", "content": "4", "reasoning": "2+2 equals 4"})
assert result.choices[0].message.content == "4"
assert result.choices[0].message.reasoning_content == "2+2 equals 4"
def test_transform_response_preserves_reasoning_content_field():
- result = _transform_response(
- {"role": "assistant", "content": "4", "reasoning_content": "adding 2 and 2"}
- )
+ result = _transform_response({"role": "assistant", "content": "4", "reasoning_content": "adding 2 and 2"})
assert result.choices[0].message.reasoning_content == "adding 2 and 2"
def test_streaming_chunk_maps_delta_reasoning_to_reasoning_content():
- iterator = TogetherAIChatConfig().get_model_response_iterator(
- streaming_response=iter(()), sync_stream=True
- )
+ iterator = TogetherAIChatConfig().get_model_response_iterator(streaming_response=iter(()), sync_stream=True)
assert isinstance(iterator, OpenAIChatCompletionStreamingHandler)
parsed = iterator.chunk_parser(
@@ -293,9 +293,7 @@ def test_streaming_chunk_maps_delta_reasoning_to_reasoning_content():
def test_streaming_chunk_preserves_tool_call_index_and_id():
- iterator = TogetherAIChatConfig().get_model_response_iterator(
- streaming_response=iter(()), sync_stream=True
- )
+ iterator = TogetherAIChatConfig().get_model_response_iterator(streaming_response=iter(()), sync_stream=True)
def parse_tool_call_chunk(tool_call: dict):
parsed = iterator.chunk_parser(
@@ -426,9 +424,7 @@ def test_together_ai_config_alias_points_at_chat_config():
def test_provider_config_manager_returns_together_chat_config():
from litellm.utils import ProviderConfigManager
- config = ProviderConfigManager.get_provider_chat_config(
- model=REASONING_MODEL, provider=LlmProviders.TOGETHER_AI
- )
+ config = ProviderConfigManager.get_provider_chat_config(model=REASONING_MODEL, provider=LlmProviders.TOGETHER_AI)
assert isinstance(config, TogetherAIChatConfig)
@@ -600,3 +596,351 @@ def test_completion_regex_response_format_sends_pattern_to_together():
request_body = _capture_completion_request(TOOL_CALLING_MODEL, response_format=REGEX_RESPONSE_FORMAT)
assert request_body["response_format"] == REGEX_RESPONSE_FORMAT
+
+
+TOGETHER_CHAT_URL = "https://api.together.ai/v1/chat/completions"
+
+WEATHER_AND_TIME_TOOLS = [
+ *WEATHER_TOOLS,
+ {"type": "function", "function": {"name": "get_time", "parameters": {}}},
+]
+
+ANTHROPIC_WEATHER_TOOL = {
+ "name": "get_weather",
+ "description": "Get the weather",
+ "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}},
+}
+
+
+def _chat_completion(message: Mapping[str, object], finish_reason: str = "stop") -> dict:
+ return {
+ "id": "chatcmpl-together",
+ "object": "chat.completion",
+ "created": 1234567890,
+ "model": UNMAPPED_MODEL,
+ "choices": [{"index": 0, "message": dict(message), "finish_reason": finish_reason}],
+ "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
+ }
+
+
+def _chunk(delta: Mapping[str, object], finish_reason: str | None = None) -> dict:
+ return {
+ "id": "chatcmpl-together-stream",
+ "object": "chat.completion.chunk",
+ "created": 1234567890,
+ "model": UNMAPPED_MODEL,
+ "choices": [{"index": 0, "delta": dict(delta), "finish_reason": finish_reason}],
+ }
+
+
+def _sse(*events: Mapping[str, object]) -> bytes:
+ return b"".join(f"data: {json.dumps(event)}\n\n".encode() for event in events) + b"data: [DONE]\n\n"
+
+
+def _sse_response(*events: Mapping[str, object]) -> httpx.Response:
+ return httpx.Response(200, content=_sse(*events), headers={"Content-Type": "text/event-stream"})
+
+
+def _sync_client(captured_requests: list[httpx.Request], response: httpx.Response) -> HTTPHandler:
+ def respond(request: httpx.Request) -> httpx.Response:
+ captured_requests.append(request)
+ return response
+
+ return HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond)))
+
+
+async def _async_client(captured_requests: list[httpx.Request], response: httpx.Response) -> AsyncHTTPHandler:
+ def respond(request: httpx.Request) -> httpx.Response:
+ captured_requests.append(request)
+ return response
+
+ handler = AsyncHTTPHandler()
+ await handler.close()
+ handler.client = httpx.AsyncClient(transport=httpx.MockTransport(respond))
+ return handler
+
+
+PARALLEL_TOOL_CALL_STREAM = (
+ _chunk({"role": "assistant", "reasoning": "Need weather "}),
+ _chunk({"reasoning": "and time."}),
+ _chunk(
+ {
+ "tool_calls": [
+ {
+ "index": 0,
+ "id": "call_weather",
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": ""},
+ }
+ ]
+ }
+ ),
+ _chunk({"tool_calls": [{"index": 0, "function": {"arguments": '{"city": "San'}}]}),
+ _chunk({"tool_calls": [{"index": 0, "function": {"arguments": ' Francisco"}'}}]}),
+ _chunk(
+ {
+ "tool_calls": [
+ {"index": 1, "id": "call_time", "type": "function", "function": {"name": "get_time", "arguments": ""}}
+ ]
+ }
+ ),
+ _chunk({"tool_calls": [{"index": 1, "function": {"arguments": '{"tz": "PST"}'}}]}, finish_reason="tool_calls"),
+)
+
+
+def test_streaming_completion_rebuilds_reasoning_and_parallel_tool_calls():
+ captured_requests: list[httpx.Request] = []
+ client = _sync_client(captured_requests, _sse_response(*PARALLEL_TOOL_CALL_STREAM))
+
+ chunks = list(
+ litellm.completion(
+ model=f"together_ai/{UNMAPPED_MODEL}",
+ messages=[{"role": "user", "content": "Weather and time in San Francisco?"}],
+ tools=WEATHER_AND_TIME_TOOLS,
+ stream=True,
+ api_key="fake-key",
+ client=client,
+ )
+ )
+
+ request_body = json.loads(captured_requests[0].content)
+ assert str(captured_requests[0].url) == TOGETHER_CHAT_URL
+ assert request_body["stream"] is True
+ assert request_body["tools"] == WEATHER_AND_TIME_TOOLS
+
+ streamed_reasoning = "".join(getattr(chunk.choices[0].delta, "reasoning_content", None) or "" for chunk in chunks)
+ assert streamed_reasoning == "Need weather and time."
+
+ rebuilt = litellm.stream_chunk_builder(chunks)
+ message = rebuilt.choices[0].message
+ assert message.reasoning_content == "Need weather and time."
+ assert rebuilt.choices[0].finish_reason == "tool_calls"
+ calls = {call.id: call for call in message.tool_calls}
+ assert calls["call_weather"].function.name == "get_weather"
+ assert json.loads(calls["call_weather"].function.arguments) == {"city": "San Francisco"}
+ assert calls["call_time"].function.name == "get_time"
+ assert json.loads(calls["call_time"].function.arguments) == {"tz": "PST"}
+
+
+async def test_async_streaming_completion_strips_internal_fields_and_streams_reasoning():
+ captured_requests: list[httpx.Request] = []
+ client = await _async_client(
+ captured_requests,
+ _sse_response(
+ _chunk({"role": "assistant", "reasoning": "Recalling 47."}),
+ _chunk({"content": "47"}, finish_reason="stop"),
+ ),
+ )
+
+ try:
+ stream = await litellm.acompletion(
+ model=f"together_ai/{REASONING_MODEL}",
+ messages=[dict(message) for message in PRESERVED_THINKING_MESSAGES],
+ chat_template_kwargs={"clear_thinking": False},
+ stream=True,
+ api_key="fake-key",
+ client=client,
+ )
+ chunks = [chunk async for chunk in stream]
+ finally:
+ await client.client.aclose()
+
+ request_body = json.loads(captured_requests[0].content)
+ assert str(captured_requests[0].url) == TOGETHER_CHAT_URL
+ assert request_body["chat_template_kwargs"] == {"clear_thinking": False}
+ _assert_internal_fields_stripped_reasoning_kept(request_body["messages"])
+
+ rebuilt = litellm.stream_chunk_builder(chunks)
+ assert rebuilt.choices[0].message.reasoning_content == "Recalling 47."
+ assert rebuilt.choices[0].message.content == "47"
+
+
+@pytest.mark.parametrize("api_base", ["https://api.together.ai/v1", "https://api.together.xyz/v1"])
+def test_completion_bare_model_with_together_api_base_uses_together_config(api_base):
+ captured_requests: list[httpx.Request] = []
+ client = _sync_client(
+ captured_requests,
+ httpx.Response(200, json=_chat_completion({"role": "assistant", "content": "4", "reasoning": "2+2"})),
+ )
+
+ response = litellm.completion(
+ model=UNMAPPED_MODEL,
+ messages=[{"role": "user", "content": "What is 2+2?"}],
+ api_base=api_base,
+ api_key="fake-key",
+ client=client,
+ )
+
+ assert str(captured_requests[0].url) == f"{api_base}/chat/completions"
+ assert captured_requests[0].headers["authorization"] == "Bearer fake-key"
+ assert response._hidden_params["custom_llm_provider"] == "together_ai"
+ assert response.choices[0].message.reasoning_content == "2+2"
+
+
+def test_completion_honors_together_ai_api_base_env(monkeypatch):
+ monkeypatch.setenv("TOGETHER_AI_API_BASE", "https://together.internal.example/v1")
+ captured_requests: list[httpx.Request] = []
+ client = _sync_client(
+ captured_requests,
+ httpx.Response(200, json=_chat_completion({"role": "assistant", "content": "4"})),
+ )
+
+ litellm.completion(
+ model=f"together_ai/{REASONING_MODEL}",
+ messages=[{"role": "user", "content": "What is 2+2?"}],
+ api_key="fake-key",
+ client=client,
+ )
+
+ assert str(captured_requests[0].url) == "https://together.internal.example/v1/chat/completions"
+
+
+def test_responses_api_sends_tools_and_maps_reasoning_and_function_call():
+ captured_requests: list[httpx.Request] = []
+ client = _sync_client(
+ captured_requests,
+ httpx.Response(
+ 200,
+ json=_chat_completion(
+ {
+ "role": "assistant",
+ "content": None,
+ "reasoning": "Need the weather tool.",
+ "tool_calls": [
+ {
+ "id": "call_abc123",
+ "type": "function",
+ "function": {"name": "get_weather", "arguments": '{"city": "San Francisco"}'},
+ }
+ ],
+ },
+ finish_reason="tool_calls",
+ ),
+ ),
+ )
+
+ response = litellm.responses(
+ model=f"together_ai/{UNMAPPED_MODEL}",
+ input="What is the weather in San Francisco?",
+ tools=[{"type": "function", "name": "get_weather", "parameters": {}}],
+ api_key="fake-key",
+ client=client,
+ )
+
+ request_body = json.loads(captured_requests[0].content)
+ assert str(captured_requests[0].url) == TOGETHER_CHAT_URL
+ assert [tool["function"]["name"] for tool in request_body["tools"]] == ["get_weather"]
+ outputs = {item.type: item for item in response.output}
+ assert outputs["reasoning"].content[0].text == "Need the weather tool."
+ assert outputs["function_call"].name == "get_weather"
+ assert json.loads(outputs["function_call"].arguments) == {"city": "San Francisco"}
+
+
+ANTHROPIC_TOOL_LOOP_MESSAGES = [
+ {"role": "user", "content": "What is the weather in San Francisco?"},
+ {
+ "role": "assistant",
+ "content": [
+ {"type": "thinking", "thinking": "I should call get_weather.", "signature": ""},
+ {"type": "tool_use", "id": "toolu_01", "name": "get_weather", "input": {"city": "San Francisco"}},
+ ],
+ },
+ {
+ "role": "user",
+ "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": "Sunny, 18C"}],
+ },
+]
+
+
+def test_anthropic_messages_replays_tool_loop_and_maps_reasoning_to_thinking_block():
+ captured_requests: list[httpx.Request] = []
+ client = _sync_client(
+ captured_requests,
+ httpx.Response(
+ 200,
+ json=_chat_completion({"role": "assistant", "content": "Sunny in SF.", "reasoning": "Tool said sunny."}),
+ ),
+ )
+
+ response = litellm.anthropic.messages.create(
+ model=f"together_ai/{UNMAPPED_MODEL}",
+ max_tokens=100,
+ messages=[dict(message) for message in ANTHROPIC_TOOL_LOOP_MESSAGES],
+ tools=[ANTHROPIC_WEATHER_TOOL],
+ api_key="fake-key",
+ client=client,
+ )
+
+ request_body = json.loads(captured_requests[0].content)
+ assert str(captured_requests[0].url) == TOGETHER_CHAT_URL
+ assert [tool["function"]["name"] for tool in request_body["tools"]] == ["get_weather"]
+ assistant_turn = request_body["messages"][1]
+ assert assistant_turn["role"] == "assistant"
+ assert assistant_turn["reasoning_content"] == "I should call get_weather."
+ assert "thinking_blocks" not in assistant_turn
+ replayed_call = assistant_turn["tool_calls"][0]
+ assert replayed_call["id"] == "toolu_01"
+ assert replayed_call["function"]["name"] == "get_weather"
+ assert json.loads(replayed_call["function"]["arguments"]) == {"city": "San Francisco"}
+ tool_turn = request_body["messages"][2]
+ assert tool_turn["role"] == "tool"
+ assert tool_turn["tool_call_id"] == "toolu_01"
+ assert tool_turn["content"] == "Sunny, 18C"
+
+ blocks = {block["type"]: block for block in response["content"]}
+ assert blocks["thinking"]["thinking"] == "Tool said sunny."
+ assert blocks["text"]["text"] == "Sunny in SF."
+ assert response["stop_reason"] == "end_turn"
+
+
+def _anthropic_sse_events(stream: Iterator[bytes]) -> list[dict]:
+ return [
+ json.loads(line.removeprefix("data: "))
+ for raw in stream
+ for line in raw.decode().splitlines()
+ if line.startswith("data: ")
+ ]
+
+
+def test_anthropic_messages_streams_together_tool_call_as_input_json_delta():
+ captured_requests: list[httpx.Request] = []
+ client = _sync_client(captured_requests, _sse_response(*PARALLEL_TOOL_CALL_STREAM))
+
+ events = _anthropic_sse_events(
+ litellm.anthropic.messages.create(
+ model=f"together_ai/{UNMAPPED_MODEL}",
+ max_tokens=100,
+ messages=[{"role": "user", "content": "Weather and time in San Francisco?"}],
+ tools=[ANTHROPIC_WEATHER_TOOL, {"name": "get_time", "input_schema": {"type": "object"}}],
+ stream=True,
+ api_key="fake-key",
+ client=client,
+ )
+ )
+
+ assert json.loads(captured_requests[0].content)["stream"] is True
+ tool_starts = {
+ event["index"]: event["content_block"]
+ for event in events
+ if event["type"] == "content_block_start" and event["content_block"]["type"] == "tool_use"
+ }
+ input_json_deltas = [
+ event
+ for event in events
+ if event["type"] == "content_block_delta" and event["delta"]["type"] == "input_json_delta"
+ ]
+ tool_inputs = {
+ block["name"]: json.loads(
+ "".join(delta["delta"]["partial_json"] for delta in input_json_deltas if delta["index"] == index)
+ )
+ for index, block in tool_starts.items()
+ }
+ assert {block["id"] for block in tool_starts.values()} == {"call_weather", "call_time"}
+ assert tool_inputs == {"get_weather": {"city": "San Francisco"}, "get_time": {"tz": "PST"}}
+ thinking_text = "".join(
+ event["delta"]["thinking"]
+ for event in events
+ if event["type"] == "content_block_delta" and event["delta"]["type"] == "thinking_delta"
+ )
+ assert thinking_text == "Need weather and time."
+ assert [event["delta"]["stop_reason"] for event in events if event["type"] == "message_delta"] == ["tool_use"]
diff --git a/tests/test_litellm/proxy/agent_endpoints/test_agent_registry.py b/tests/test_litellm/proxy/agent_endpoints/test_agent_registry.py
index a87f5384c6f..7ce62fdf648 100644
--- a/tests/test_litellm/proxy/agent_endpoints/test_agent_registry.py
+++ b/tests/test_litellm/proxy/agent_endpoints/test_agent_registry.py
@@ -428,3 +428,60 @@ async def test_migrate_legacy_grant_ids_no_ops_without_config_agents():
assert await registry.migrate_legacy_grant_ids(table=table) == GrantMigrationResult(rewritten=0, missed=0)
table.find_many.assert_not_awaited()
+
+
+@pytest.mark.asyncio
+async def test_update_agent_in_db_raises_when_row_deleted_mid_update():
+ """Prisma's update returns None when the row vanished between read and write. Without a
+ guard the code dereferences None and reports an opaque AttributeError instead of the id."""
+ registry: Final = AgentRegistry()
+ mock_prisma: Final = MagicMock()
+ mock_prisma.db.litellm_agentstable.update = AsyncMock(return_value=None)
+
+ with pytest.raises(Exception, match="Error updating agent in DB") as exc_info:
+ await registry.update_agent_in_db(
+ agent_id="agent-123",
+ agent={
+ "agent_name": "Updated Agent",
+ "agent_card_params": _sample_agent_card_params(),
+ "litellm_params": {},
+ },
+ prisma_client=mock_prisma,
+ updated_by="test-user",
+ )
+
+ assert str(exc_info.value) == "Error updating agent in DB: Agent not found, passed agent_id=agent-123"
+
+
+@pytest.mark.asyncio
+async def test_patch_agent_in_db_raises_when_row_deleted_mid_update():
+ """Same race on PATCH: the existing row is read, then deleted before the update lands."""
+ registry: Final = AgentRegistry()
+ mock_prisma: Final = MagicMock()
+ mock_prisma.db.litellm_agentstable.find_unique = AsyncMock(
+ return_value={"agent_id": "agent-123", "agent_name": "Old Agent", "object_permission_id": None}
+ )
+ mock_prisma.db.litellm_agentstable.update = AsyncMock(return_value=None)
+
+ with pytest.raises(Exception, match="Error patching agent in DB") as exc_info:
+ await registry.patch_agent_in_db(
+ agent_id="agent-123",
+ agent={"agent_name": "Patched Agent"},
+ prisma_client=mock_prisma,
+ updated_by="test-user",
+ )
+
+ assert str(exc_info.value) == "Error patching agent in DB: Agent not found, passed agent_id=agent-123"
+
+
+@pytest.mark.asyncio
+async def test_delete_agent_from_db_raises_when_row_already_gone():
+ """Prisma's delete returns None for a missing row, which dict() cannot consume."""
+ registry: Final = AgentRegistry()
+ mock_prisma: Final = MagicMock()
+ mock_prisma.db.litellm_agentstable.delete = AsyncMock(return_value=None)
+
+ with pytest.raises(Exception, match="Error deleting agent from DB") as exc_info:
+ await registry.delete_agent_from_db(agent_id="agent-123", prisma_client=mock_prisma)
+
+ assert str(exc_info.value) == "Error deleting agent from DB: Agent not found, passed agent_id=agent-123"
diff --git a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py
index d1ccd86044c..99b09f48fe5 100644
--- a/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py
+++ b/tests/test_litellm/proxy/anthropic_endpoints/test_claude_code_marketplace.py
@@ -231,6 +231,30 @@ async def test_update_plugin_db_error_maps_to_structured_500():
assert "connection lost" in exc_info.value.detail["error"]
+@pytest.mark.asyncio
+async def test_update_plugin_deleted_mid_update_returns_404():
+ """A concurrent delete between the find_unique pre-check and the update makes prisma's
+ update return None; that must surface the same 404 as a plain miss, not an AttributeError."""
+ name = "my-monorepo-plugin"
+ await register_plugin(
+ request=RegisterPluginRequest(name=name, source=_GIT_SUBDIR_SOURCE, version="1.0.0"),
+ user_api_key_dict=_USER,
+ )
+
+ table = litellm.proxy.proxy_server.prisma_client.db.litellm_claudecodeplugintable
+ table.update = AsyncMock(return_value=None)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await update_plugin(
+ plugin_name=name,
+ request=UpdatePluginRequest(source={"source": "github", "repo": "org/replacement"}),
+ user_api_key_dict=_USER,
+ )
+
+ assert exc_info.value.status_code == 404
+ assert exc_info.value.detail == {"error": f"Plugin '{name}' not found"}
+
+
@pytest.mark.asyncio
async def test_get_marketplace_skips_plugin_with_null_manifest():
await register_plugin(
diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py
index 25c177a308d..c575265ed91 100644
--- a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py
+++ b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py
@@ -695,7 +695,7 @@ def test_reset_budget_resets_endusers_with_null_budget_id(reset_budget_job, mock
"object_permission_id": None,
"object_permission": None,
"litellm_budget_table": None,
- "dict": lambda self=None: {
+ "model_dump": lambda self=None: {
"spend": 25.0,
"user_id": "enduser-implicit",
"blocked": False,
diff --git a/tests/test_litellm/proxy/db/mcp_server/test_db.py b/tests/test_litellm/proxy/db/mcp_server/test_db.py
index aa40ec0d76c..e2440e49f19 100644
--- a/tests/test_litellm/proxy/db/mcp_server/test_db.py
+++ b/tests/test_litellm/proxy/db/mcp_server/test_db.py
@@ -4,7 +4,11 @@ from unittest.mock import AsyncMock, MagicMock
import pytest
-from litellm.proxy._experimental.mcp_server.db import get_mcp_servers_by_team
+from litellm.proxy._experimental.mcp_server.db import (
+ approve_mcp_server,
+ get_mcp_servers_by_team,
+ reject_mcp_server,
+)
def _prisma_client_returning(team_record: object) -> MagicMock:
@@ -38,3 +42,30 @@ async def test_fetch_mcp_servers_by_team(team_record, expected):
where={"team_id": "team-123"},
include={"object_permission": True},
)
+
+
+def _prisma_client_with_missing_mcp_server_row() -> MagicMock:
+ prisma_client = MagicMock()
+ prisma_client.db.litellm_mcpservertable.update = AsyncMock(return_value=None)
+ return prisma_client
+
+
+@pytest.mark.asyncio
+async def test_approve_mcp_server_raises_value_error_when_row_missing():
+ prisma_client = _prisma_client_with_missing_mcp_server_row()
+
+ with pytest.raises(ValueError, match=r"^MCP server not found, passed server_id=server-gone$"):
+ await approve_mcp_server(prisma_client, "server-gone", touched_by="admin")
+
+
+@pytest.mark.asyncio
+async def test_reject_mcp_server_raises_value_error_when_row_missing():
+ prisma_client = _prisma_client_with_missing_mcp_server_row()
+
+ with pytest.raises(ValueError, match=r"^MCP server not found, passed server_id=server-gone$"):
+ await reject_mcp_server(
+ prisma_client,
+ "server-gone",
+ touched_by="admin",
+ review_notes="spam",
+ )
diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py
index 729dbce6b9a..26b3890464e 100644
--- a/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py
+++ b/tests/test_litellm/proxy/guardrails/test_guardrail_registry.py
@@ -1,8 +1,11 @@
+from unittest.mock import AsyncMock, MagicMock
+
import pytest
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.proxy.guardrails.guardrail_registry import (
get_guardrail_initializer_from_hooks,
+ GuardrailRegistry,
InMemoryGuardrailHandler,
)
from litellm.types.guardrails import GuardrailEventHooks, Guardrail, LitellmParams
@@ -657,3 +660,22 @@ class TestScanOnlyToolResultsInitRefusal:
"scan_only_tool_results": True,
},
)
+
+
+@pytest.mark.asyncio
+async def test_update_guardrail_in_db_raises_when_row_missing():
+ prisma_client = MagicMock()
+ prisma_client.db.litellm_guardrailstable.update = AsyncMock(return_value=None)
+
+ with pytest.raises(
+ Exception,
+ match=r"^Error updating guardrail in DB: Guardrail not found, passed guardrail_id=missing-guardrail$",
+ ):
+ await GuardrailRegistry().update_guardrail_in_db(
+ guardrail_id="missing-guardrail",
+ guardrail=Guardrail(
+ guardrail_name="missing-guardrail",
+ litellm_params=LitellmParams(guardrail="bedrock", mode="pre_call"),
+ ),
+ prisma_client=prisma_client,
+ )
diff --git a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py
index d51e3abde83..50a057c9b73 100644
--- a/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/scim/test_scim_v2_endpoints.py
@@ -5513,3 +5513,46 @@ async def test_handle_group_membership_changes_already_in_team_is_noop(mocker):
)
assert mock_team_member_add.await_count == 2
+
+
+@pytest.mark.asyncio
+async def test_patch_group_404s_when_team_deleted_mid_request(mocker):
+ """A group deleted between the existence check and the write must 404.
+
+ Prisma returns None from both the update and the refresh reads once the row is
+ gone, and patch_group used to dereference that None while building the response.
+ """
+ group_id = "team-gone"
+
+ snapshot_team = LiteLLM_TeamTable(
+ team_id=group_id,
+ team_alias="Group",
+ members_with_roles=[Member(user_id="zed", role="user")],
+ metadata={"externalId": "grp-ext"},
+ )
+
+ patch_ops = SCIMPatchOp(
+ schemas=["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
+ Operations=[SCIMPatchOperation(op="replace", path="displayName", value="Renamed")],
+ )
+
+ mock_prisma_client = mocker.MagicMock()
+ mock_prisma_client.db = mocker.MagicMock()
+ mock_prisma_client.db.litellm_teamtable = mocker.MagicMock()
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(side_effect=[snapshot_team, None, None])
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=None)
+
+ mocker.patch( # test-quality-ok: stubs the collaborator so the test pins the endpoint's own error contract
+ "litellm.proxy.management_endpoints.scim.scim_v2._get_prisma_client_or_raise_exception",
+ AsyncMock(return_value=mock_prisma_client),
+ )
+ mocker.patch( # test-quality-ok: stubs the collaborator so the test pins the endpoint's own error contract
+ "litellm.proxy.management_endpoints.scim.scim_v2._recompute_scim_member_roles",
+ AsyncMock(),
+ )
+
+ with pytest.raises(ProxyException) as exc_info:
+ await patch_group(group_id=group_id, patch_ops=patch_ops)
+
+ assert exc_info.value.code == "404"
+ assert f"Group not found with ID: {group_id}" in exc_info.value.message
diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
index f86e17c61b0..8bce967b316 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py
@@ -2398,7 +2398,7 @@ async def test_delete_user_cleans_up_created_by_invitation_links(mocker):
mock_user_row.user_id = "admin-creator"
mock_user_row.user_email = "admin@example.com"
mock_user_row.teams = []
- mock_user_row.json.return_value = "{}"
+ mock_user_row.model_dump_json.return_value = "{}"
mock_user_row.model_dump.return_value = {
"user_id": "admin-creator",
"user_email": "admin@example.com",
diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py
index 0c615cbaa32..a37c4f72b3d 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py
@@ -7766,6 +7766,45 @@ async def test_validate_key_list_check_key_hash_not_found():
assert "Key Hash not found" in exc_info.value.message
+@pytest.mark.asyncio
+async def test_validate_key_list_check_key_hash_row_missing():
+ """A key_hash with no row reaches the same 'Key Hash not found' 403 as a failed
+ lookup, instead of blowing up inside the ownership check on a None row."""
+ mock_prisma_client = AsyncMock()
+ mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
+ return_value=LiteLLM_UserTable(
+ user_id="test-user",
+ user_email="test@example.com",
+ teams=[],
+ organization_memberships=[],
+ )
+ )
+ mock_prisma_client.db.litellm_verificationtoken.find_unique = AsyncMock(
+ return_value=None
+ )
+
+ user_api_key_dict = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.INTERNAL_USER,
+ user_id="test-user",
+ api_key="sk-caller",
+ )
+
+ with pytest.raises(ProxyException) as exc_info:
+ await validate_key_list_check(
+ user_api_key_dict=user_api_key_dict,
+ user_id=None,
+ team_id=None,
+ organization_id=None,
+ key_alias=None,
+ key_hash="hash-of-a-deleted-key",
+ prisma_client=mock_prisma_client,
+ )
+
+ assert exc_info.value.code == "403" or exc_info.value.code == 403
+ assert exc_info.value.param == "key_hash"
+ assert "Key Hash not found" in exc_info.value.message
+
+
@pytest.mark.asyncio
async def test_validate_key_list_check_proxy_admin_viewer_skips_db_lookup():
"""proxy_admin_viewer takes the same unscoped read fast-path as proxy_admin, so no
diff --git a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
index 097230108d4..eeb1b2d50e6 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_model_management_endpoints.py
@@ -3312,6 +3312,61 @@ class TestPatchModelBlockedAuthGate:
mock_prisma.db.litellm_proxymodeltable.update.assert_awaited_once()
+class TestPatchModelRowDeletedBeforeWrite:
+ """A row deleted between the read and the update makes prisma's `update`
+ return None. That must surface patch_model's own 404 not-found contract,
+ not a 500 from dereferencing the missing row."""
+
+ @pytest.mark.asyncio
+ async def test_patch_model_404s_when_update_returns_none(self):
+ from litellm.proxy.management_endpoints.model_management_endpoints import (
+ patch_model,
+ )
+ from litellm.proxy.proxy_server import ProxyException
+
+ admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
+ existing_row = MagicMock()
+ existing_row.litellm_params = {"model": "openai/gpt-4o-mini"}
+ existing_row.model_dump.return_value = {
+ "model_name": "gpt-4o-mini",
+ "litellm_params": existing_row.litellm_params,
+ "model_info": {"id": "m1"},
+ }
+ existing_row.model_dump_json.return_value = "{}"
+
+ mock_prisma = MagicMock()
+ mock_prisma.db.litellm_proxymodeltable.find_unique = AsyncMock(
+ return_value=existing_row
+ )
+ mock_prisma.db.litellm_proxymodeltable.update = AsyncMock(return_value=None)
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch("litellm.proxy.proxy_server.llm_router", MagicMock(**{"get_model_ids.return_value": ["m1"]})), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch( # test-quality-ok: stubs the auth gate so the test exercises the not-found branch under test
+ "litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call",
+ new=AsyncMock(return_value=None),
+ ),
+ patch( # test-quality-ok: stubs the cache write so the test observes only the DB result handling
+ "litellm.proxy.management_endpoints.model_management_endpoints.clear_cache",
+ new=AsyncMock(
+ return_value=ReconcileOutcome(still_desired=None, live_after=None)
+ ),
+ ),
+ ):
+ with pytest.raises(ProxyException) as exc_info:
+ await patch_model(
+ model_id="m1",
+ patch_data=updateDeployment(blocked=True),
+ user_api_key_dict=admin,
+ )
+
+ assert exc_info.value.code == "404"
+ assert exc_info.value.message == "Model m1 not found on proxy."
+
+
class TestWriteSurfacesReloadDrop:
"""A model-write endpoint may report success only if every row it wrote is, after the
reload it triggered, live in this pod's router or deliberately environment-inactive."""
diff --git a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py
index a62c98e56a7..e2d89a660c2 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py
@@ -1037,3 +1037,29 @@ async def test_get_organization_daily_activity_non_admin_without_org_admin_role_
assert get_daily_activity_mock.call_args.kwargs["entity_id"] == []
assert org_table_find_many.call_args.kwargs["where"] == {"organization_id": {"in": []}}
+
+
+@pytest.mark.asyncio
+async def test_find_member_if_email_missing_row_raises_documented_400():
+ """A user_email lookup that matches nothing returns None instead of raising, so the
+ only failure the surrounding try/except models is never entered. Without an explicit
+ None guard the next line dereferences None and /organization/member_add answers with
+ an AttributeError-driven 500 rather than the documented 400.
+ """
+ from litellm.proxy.management_endpoints.organization_endpoints import (
+ find_member_if_email,
+ )
+
+ prisma_client = AsyncMock()
+ prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await find_member_if_email("missing@example.com", prisma_client)
+
+ assert exc_info.value.status_code == 400
+ assert exc_info.value.detail == {
+ "error": (
+ "Unique user not found for user_email=missing@example.com. Potential duplicate OR "
+ "non-existent user_email in LiteLLM_UserTable. Use 'user_id' instead."
+ )
+ }
diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py
index c2610d88927..08e931e6405 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py
@@ -1169,6 +1169,44 @@ async def test_delete_team_callback_404s_for_unknown_team():
mock_prisma.db.litellm_teamtable.update.assert_not_called()
+@pytest.mark.asyncio
+async def test_add_team_callbacks_rejects_team_deleted_before_write():
+ """A team deleted between the existence check and the write must be rejected.
+
+ Prisma's update returns None for a row that is gone, and add_team_callbacks
+ used to hand that None to the cache refresh and report success with a null
+ body. The rejection reuses this endpoint's own missing-team contract, so a
+ caller sees the same 400 whether the team vanished before or after the read.
+ """
+ mock_prisma = _patch_prisma(_team_row(team_id="team-1", metadata={}))
+ mock_prisma.db.litellm_teamtable.update = AsyncMock(return_value=None)
+
+ data = AddTeamCallback(
+ callback_name="langfuse",
+ callback_type="success",
+ callback_vars={
+ "langfuse_public_key": "pk-demo",
+ "langfuse_secret_key": "sk-demo",
+ },
+ )
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", mock_prisma), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch("litellm.proxy.proxy_server.master_key", None), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ ):
+ with pytest.raises(HTTPException) as exc:
+ await add_team_callbacks(
+ data=data,
+ http_request=MagicMock(spec=Request),
+ team_id="team-1",
+ user_api_key_dict=_admin_auth(),
+ )
+
+ mock_prisma.db.litellm_teamtable.update.assert_called_once()
+ assert exc.value.status_code == 400
+ assert exc.value.detail == {"error": "Team id = team-1 does not exist. Please use a different team id."}
+
+
@pytest.mark.asyncio
async def test_delete_team_callback_keeps_last_removal_from_reviving_legacy_shape():
"""Removing the last entry must leave metadata["logging"] present and empty.
diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
index f33854eeb81..6461245bb2c 100644
--- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
+++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py
@@ -3,7 +3,7 @@ import json
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from types import SimpleNamespace
-from typing import Optional, cast
+from typing import Final, Optional, cast
from unittest.mock import AsyncMock, MagicMock, PropertyMock, call, patch
import pytest
@@ -2204,6 +2204,100 @@ async def test_team_model_add_delete_refresh_team_cache(endpoint_name):
assert update_call_kwargs.get("include", {}).get("object_permission") is True
+@pytest.mark.asyncio
+@pytest.mark.parametrize(
+ "endpoint_name",
+ ["team_model_add", "team_model_delete", "update_team_member_permissions"],
+)
+async def test_team_write_404s_when_row_vanishes_before_update(endpoint_name):
+ """A team deleted between the read and the write must 404.
+
+ Prisma's `update` returns None when no row matches `where`, and the team
+ row can be deleted between the read these endpoints do first and the
+ update that follows it. Without the guard, `team_model_add` /
+ `team_model_delete` hand that None to `_refresh_cached_team` (which
+ reads `team_row.team_id`) and `/team/permissions_update` returns None
+ out of a route declared to return a team, so a plain race turns into a
+ 500 instead of the 404 every other not-found path in this file raises.
+ """
+ from unittest.mock import AsyncMock, MagicMock, Mock, patch
+
+ from fastapi import Request
+
+ from litellm.proxy._types import (
+ LitellmUserRoles,
+ TeamModelAddRequest,
+ TeamModelDeleteRequest,
+ UserAPIKeyAuth,
+ )
+ from litellm.proxy.management_endpoints.team_endpoints import (
+ team_model_add,
+ team_model_delete,
+ update_team_member_permissions,
+ )
+
+ mock_request = Mock(spec=Request)
+ mock_user_api_key_dict = UserAPIKeyAuth(
+ user_role=LitellmUserRoles.PROXY_ADMIN, user_id="test_user_id"
+ )
+
+ existing_team = MagicMock()
+ existing_team.team_id = "team-1234"
+ existing_team.model_dump.return_value = {
+ "team_id": "team-1234",
+ "models": ["bedrock-claude-sonnet-4", "openai/*"],
+ "team_member_permissions": [],
+ "spend": 0.0,
+ }
+
+ call_endpoint_under_test: Final = {
+ "team_model_add": lambda: team_model_add(
+ data=TeamModelAddRequest(team_id="team-1234", models=["team-byok-1"]),
+ http_request=mock_request,
+ user_api_key_dict=mock_user_api_key_dict,
+ ),
+ "team_model_delete": lambda: team_model_delete(
+ data=TeamModelDeleteRequest(team_id="team-1234", models=["openai/*"]),
+ http_request=mock_request,
+ user_api_key_dict=mock_user_api_key_dict,
+ ),
+ "update_team_member_permissions": lambda: update_team_member_permissions(
+ data=UpdateTeamMemberPermissionsRequest(
+ team_id="team-1234",
+ team_member_permissions=["/key/generate"],
+ ),
+ http_request=mock_request,
+ user_api_key_dict=mock_user_api_key_dict,
+ ),
+ }[endpoint_name]
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma_client, # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch("litellm.proxy.proxy_server.user_api_key_cache"), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch("litellm.proxy.proxy_server.proxy_logging_obj"), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch( # test-quality-ok: stubs the cache write so the test observes only the DB result handling
+ "litellm.proxy.management_endpoints.team_endpoints._cache_team_object",
+ new_callable=AsyncMock,
+ ),
+ patch( # test-quality-ok: stubs the collaborator so the test pins the endpoint's own error contract
+ "litellm.proxy.management_endpoints.team_endpoints.get_team_object",
+ new_callable=AsyncMock,
+ return_value=existing_team,
+ ),
+ ):
+ mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(
+ return_value=existing_team
+ )
+ mock_prisma_client.db.litellm_teamtable.update = AsyncMock(return_value=None)
+ mock_prisma_client.db.execute_raw = AsyncMock(return_value=None)
+
+ with pytest.raises(HTTPException) as exc_info:
+ await call_endpoint_under_test()
+
+ assert exc_info.value.status_code == 404
+ assert exc_info.value.detail == {"error": "Team not found, passed team_id=team-1234"}
+
+
@pytest.mark.asyncio
async def test_update_team_team_member_budget_not_passed_to_db(
disable_audit_logging_for_mocked_team,
diff --git a/tests/test_litellm/proxy/memory/test_memory_endpoints.py b/tests/test_litellm/proxy/memory/test_memory_endpoints.py
index 3d99a600a73..be75d980d9d 100644
--- a/tests/test_litellm/proxy/memory/test_memory_endpoints.py
+++ b/tests/test_litellm/proxy/memory/test_memory_endpoints.py
@@ -7,6 +7,7 @@ We patch the endpoint module's `_require_prisma` helper so we never need the
real proxy_server import chain (which pulls heavy optional deps).
"""
+import json
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from unittest.mock import MagicMock, patch
@@ -176,14 +177,36 @@ class _InMemoryTeamTable:
return None
-def _make_team(team_id: str, *, admin_user_ids: List[str]) -> MagicMock:
- """Build a team-row stub with `members_with_roles` shaped like Prisma."""
- members = [MagicMock(user_id=uid, role="admin") for uid in admin_user_ids]
- team = MagicMock()
- team.team_id = team_id
- team.organization_id = None # skip org-admin path in tests
- team.members_with_roles = members
- return team
+def _make_team(team_id: str, *, admin_user_ids: List[str]) -> Any:
+ """Build a real Prisma team row.
+
+ `members_with_roles` is a JSON column, so Prisma deserializes it into plain
+ dicts, not `Member` objects. A stub that hands back attribute-style members
+ would let the router read `member.role` off something Prisma never returns.
+ """
+ from prisma import models as prisma_models
+
+ now = datetime.now(timezone.utc)
+ return prisma_models.LiteLLM_TeamTable(
+ team_id=team_id,
+ organization_id=None,
+ members_with_roles=json.dumps([{"user_id": uid, "role": "admin"} for uid in admin_user_ids]),
+ metadata="{}",
+ models=[],
+ blocked=False,
+ created_at=now,
+ updated_at=now,
+ spend=0.0,
+ model_spend="{}",
+ model_max_budget="{}",
+ admins=[],
+ members=[],
+ team_member_permissions=[],
+ access_group_ids=[],
+ policies=[],
+ default_team_member_models=[],
+ allow_team_guardrail_config=False,
+ )
def _make_prisma() -> MagicMock:
@@ -653,6 +676,39 @@ class TestMemoryEndpoints:
assert resp.json()["value"] == "new"
assert len(table.rows) == 1
+ def test_put_memory_row_deleted_mid_update_returns_404(self):
+ """
+ A concurrent DELETE landing between the visibility read and the write
+ makes Prisma's `update` return None. That must surface the same 404 the
+ read path uses, not an AttributeError bubbling out as an unhandled 500.
+ """
+ table = self.prisma.db.litellm_memorytable
+ table.rows.append(
+ _make_row(
+ memory_id="m1",
+ key="notes",
+ value="old",
+ user_id="user-a",
+ team_id="team-a",
+ )
+ )
+
+ async def vanished(*_args, **_kwargs):
+ return None
+
+ original_update = table.update
+ table.update = vanished
+
+ client = _make_client(_user_auth("user-a", "team-a"))
+ try:
+ with _patch_prisma(self.prisma):
+ resp = client.put("/v1/memory/notes", json={"value": "new"})
+ finally:
+ table.update = original_update
+
+ assert resp.status_code == 404, resp.text
+ assert resp.json()["detail"] == "Memory with key 'notes' not found"
+
def test_put_memory_explicit_null_metadata_clears_field(self):
"""
prisma-client-python can't write a true SQL NULL to a `Json?` column
@@ -919,6 +975,28 @@ class TestMemoryEndpoints:
resp = client.delete("/v1/memory/notes")
assert resp.status_code == 404
+ def test_delete_memory_row_deleted_mid_delete_returns_404(self):
+ table = self.prisma.db.litellm_memorytable
+ table.rows.append(
+ _make_row(memory_id="m1", key="notes", user_id="user-a", team_id="team-a")
+ )
+
+ async def vanished(*_args, **_kwargs):
+ return None
+
+ original_delete = table.delete
+ table.delete = vanished
+
+ client = _make_client(_user_auth("user-a", "team-a"))
+ try:
+ with _patch_prisma(self.prisma):
+ resp = client.delete("/v1/memory/notes")
+ finally:
+ table.delete = original_delete
+
+ assert resp.status_code == 404, resp.text
+ assert resp.json()["detail"] == "Memory with key 'notes' not found"
+
def test_visibility_filter_unscoped_for_admin_viewer(self):
"""
proxy_admin_viewer reads with the same unscoped filter as proxy_admin;
diff --git a/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py b/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py
index 4fb8e54e68d..3e8e1e9dff8 100644
--- a/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py
+++ b/tests/test_litellm/proxy/prompts/test_prompt_endpoints_crud.py
@@ -191,3 +191,58 @@ async def test_get_prompt_info_by_base_id():
response.prompt_spec.prompt_id == "test_prompt"
) # Should return base ID in spec response
assert response.prompt_spec.version == 3 # Should identify it as version 3
+
+
+@pytest.mark.asyncio
+async def test_patch_prompt_row_deleted_mid_update_returns_404():
+ """
+ A concurrent delete between the version lookup and the write makes Prisma's
+ `update` return None. That must reuse the endpoint's existing not-found 404
+ contract rather than blowing up into an opaque 500.
+ """
+ from fastapi import HTTPException
+
+ from litellm.proxy.prompts.prompt_endpoints import PatchPromptRequest, patch_prompt
+
+ mock_user_auth = UserAPIKeyAuth(
+ api_key="sk-1234", user_role=LitellmUserRoles.PROXY_ADMIN
+ )
+
+ target_row = MagicMock()
+ target_row.id = "row-1"
+ target_row.version = 1
+
+ mock_prisma_client = MagicMock()
+ mock_prisma_client.db.litellm_prompttable.find_many = AsyncMock(
+ return_value=[target_row]
+ )
+ mock_prisma_client.db.litellm_prompttable.update = AsyncMock(return_value=None)
+
+ existing_prompt = PromptSpec(
+ prompt_id="test_prompt.v1",
+ litellm_params=PromptLiteLLMParams(
+ prompt_id="test_prompt", prompt_integration="dotprompt"
+ ),
+ prompt_info=PromptInfo(prompt_type="db"),
+ )
+
+ with (
+ patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch( # test-quality-ok: stubs the collaborator so the test pins the endpoint's own error contract
+ "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY"
+ ) as mock_registry,
+ ):
+ mock_registry.get_prompt_by_id.return_value = existing_prompt
+
+ with pytest.raises(HTTPException) as exc_info:
+ await patch_prompt(
+ prompt_id="test_prompt",
+ request=PatchPromptRequest(prompt_info=PromptInfo(prompt_type="db")),
+ user_api_key_dict=mock_user_auth,
+ )
+
+ assert exc_info.value.status_code == 404
+ assert (
+ exc_info.value.detail
+ == "Prompt with ID test_prompt not found in environment development"
+ )
diff --git a/tests/test_litellm/proxy/test_budget_reservation.py b/tests/test_litellm/proxy/test_budget_reservation.py
index 2388654bf4b..38a346e7fb7 100644
--- a/tests/test_litellm/proxy/test_budget_reservation.py
+++ b/tests/test_litellm/proxy/test_budget_reservation.py
@@ -9,6 +9,13 @@ from fastapi import HTTPException
import litellm
from litellm.caching.dual_cache import DualCache
+from litellm.constants import STREAM_SSE_KEEPALIVE_PING_BYTES
+from litellm.llms.anthropic.experimental_pass_through.messages.streaming_iterator import (
+ AnthropicMessagesStreamingResponse,
+)
+from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import (
+ AgenticAnthropicStreamingIterator,
+)
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_EndUserTable,
@@ -2379,6 +2386,11 @@ async def _reserve_for_stream(counter_cache, key_cache, proxy_logging_obj, token
return valid_token, reservation
+async def _never_ending_stream():
+ yield b'event: message_start\ndata: {"type": "message_start"}\n\n'
+ await asyncio.sleep(30)
+
+
def _drive_streaming_cancel(valid_token, iterator_hook):
streaming_logging_obj = MagicMock()
streaming_logging_obj.async_post_call_streaming_iterator_hook = iterator_hook
@@ -2463,6 +2475,108 @@ async def test_streaming_cancel_after_chunk_keeps_reservation(
streaming_logging_obj._arelease_max_parallel_requests_on_disconnect.assert_awaited_once()
+@pytest.mark.asyncio
+async def test_streaming_cancel_after_only_keepalive_pings_reconciles_to_input_cost(
+ spend_counter_state,
+):
+ counter_cache, key_cache = spend_counter_state
+ proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache)
+ valid_token, reservation = await _reserve_for_stream(
+ counter_cache, key_cache, proxy_logging_obj, "key-cancel-after-ping"
+ )
+
+ async def cancel_after_ping(user_api_key_dict, response, request_data):
+ yield STREAM_SSE_KEEPALIVE_PING_BYTES
+ raise asyncio.CancelledError()
+
+ generator, streaming_logging_obj = _drive_streaming_cancel(valid_token, cancel_after_ping)
+ received = []
+
+ async def _drain():
+ async for chunk in generator:
+ received.append(chunk)
+
+ with pytest.raises(asyncio.CancelledError):
+ await _drain()
+
+ assert received == [STREAM_SSE_KEEPALIVE_PING_BYTES]
+ assert counter_cache.in_memory_cache.get_cache(
+ key="spend:key:key-cancel-after-ping"
+ ) == pytest.approx(0.5)
+ assert reservation["finalized"] is True
+
+
+@pytest.mark.asyncio
+async def test_streaming_cancel_while_holding_back_provider_output_keeps_reservation(
+ spend_counter_state,
+):
+ counter_cache, key_cache = spend_counter_state
+ proxy_logging_obj = ProxyLogging(user_api_key_cache=key_cache)
+ valid_token, reservation = await _reserve_for_stream(
+ counter_cache, key_cache, proxy_logging_obj, "key-cancel-held-back"
+ )
+
+ held_back = AgenticAnthropicStreamingIterator(
+ completion_stream=_never_ending_stream(),
+ http_handler=MagicMock(),
+ model="claude-haiku-4-5",
+ messages=[],
+ anthropic_messages_provider_config=MagicMock(),
+ anthropic_messages_optional_request_params={},
+ logging_obj=MagicMock(),
+ custom_llm_provider="anthropic",
+ kwargs={},
+ hold_back=True,
+ server_fulfilled_tool_names=frozenset({"headroom_retrieve"}),
+ ping_interval_seconds=0.01,
+ )
+ router = Router(
+ model_list=[
+ {
+ "model_name": "claude-haiku-4-5",
+ "litellm_params": {"model": "anthropic/claude-haiku-4-5", "api_key": "sk-test"},
+ }
+ ]
+ )
+ response = await router._aanthropic_messages_streaming_iterator(
+ response=AnthropicMessagesStreamingResponse(completion_stream=held_back, hidden_params={"additional_headers": {}}),
+ initial_kwargs={"model": "claude-haiku-4-5"},
+ )
+
+ async def ping_then_cancel(user_api_key_dict, response, request_data):
+ yield await response.__anext__()
+ while not response.has_buffered_provider_output:
+ yield await response.__anext__()
+ raise asyncio.CancelledError()
+
+ streaming_logging_obj = MagicMock()
+ streaming_logging_obj.async_post_call_streaming_iterator_hook = ping_then_cancel
+ streaming_logging_obj._arelease_max_parallel_requests_on_disconnect = AsyncMock()
+ generator = ProxyBaseLLMRequestProcessing.async_streaming_data_generator(
+ response=response,
+ user_api_key_dict=valid_token,
+ request_data=_request_body(),
+ proxy_logging_obj=streaming_logging_obj,
+ serialize_chunk=lambda chunk: chunk,
+ serialize_error=lambda exc: str(exc),
+ )
+
+ received = []
+
+ async def _drain():
+ async for chunk in generator:
+ received.append(chunk)
+
+ with pytest.raises(asyncio.CancelledError):
+ await asyncio.wait_for(_drain(), timeout=5)
+
+ assert received and received == [STREAM_SSE_KEEPALIVE_PING_BYTES] * len(received)
+ assert counter_cache.in_memory_cache.get_cache(
+ key="spend:key:key-cancel-held-back"
+ ) == pytest.approx(2.0)
+ assert reservation.get("finalized") is not True
+
+
@pytest.mark.asyncio
async def test_release_budget_reservation_on_cancel_swallows_release_errors():
# If the release itself fails (e.g. Redis unavailable) it must not escape
diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py
index b9a31acca96..afc42e8db45 100644
--- a/tests/test_litellm/proxy/test_proxy_server.py
+++ b/tests/test_litellm/proxy/test_proxy_server.py
@@ -1860,6 +1860,38 @@ def test_add_team_models_to_all_models_excludes_other_teams_byok_with_shared_nam
assert result == {"model-a-id": {"team-a"}}
+@pytest.mark.asyncio
+async def test_non_admin_all_models_returns_user_models_when_user_row_missing():
+ """
+ Regression test: /key/generate mints keys without a LiteLLM_UserTable row, so
+ find_unique returns None for such a user. That miss must neither raise (a 400
+ here, or the AttributeError on `user_row.teams` that used to surface as a 500)
+ nor leak team models: the user belongs to no team, so only the models they
+ added themselves come back.
+ """
+ from litellm.proxy.proxy_server import non_admin_all_models
+
+ user_added_model = {"model_name": "my-model", "model_info": {"id": "user-model-1"}}
+ prisma_client = MagicMock()
+ prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
+ prisma_client.db.litellm_proxymodeltable.find_unique = AsyncMock(return_value=MagicMock(created_by="ghost-user"))
+
+ llm_router = MagicMock()
+ llm_router.get_model_list.return_value = [
+ user_added_model,
+ {"model_name": "team-model", "model_info": {"id": "team-model-1", "team_id": "team-a"}},
+ ]
+
+ result = await non_admin_all_models(
+ all_models=[user_added_model],
+ llm_router=llm_router,
+ user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", user_id="ghost-user"),
+ prisma_client=prisma_client,
+ )
+
+ assert result == [user_added_model]
+
+
@pytest.mark.asyncio
async def test_apply_search_filter_matches_team_public_model_name():
"""
diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
index b85c70cae12..dc256ccf718 100644
--- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
+++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py
@@ -2770,7 +2770,7 @@ def mock_team_lookup(monkeypatch):
existing_team_ids: set = set()
- async def _find_many(where):
+ async def _find_many(where, **_):
requested = where["team_id"]["in"]
return [{"team_id": team_id} for team_id in requested if team_id in existing_team_ids]
diff --git a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_get_data.py b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_get_data.py
index ed1317e647d..ce6ecc2ea65 100644
--- a/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_get_data.py
+++ b/tests/test_litellm/proxy/utils/prisma_and_spend/test_prisma_client_get_data.py
@@ -84,7 +84,7 @@ def test_jsonify_object_fallback_for_unserializable_dict(
def test_jsonify_object_error_on_non_dict(prisma_client: PrismaClient) -> None:
- with pytest.raises(AttributeError):
+ with pytest.raises(TypeError):
prisma_client.jsonify_object(None) # type: ignore[arg-type]
@@ -134,7 +134,7 @@ def test_jsonify_team_object_converts_budget_limits_to_json_string(
def test_jsonify_team_object_error_on_non_dict(prisma_client: PrismaClient) -> None:
- with pytest.raises(AttributeError):
+ with pytest.raises(TypeError):
prisma_client.jsonify_team_object(None) # type: ignore[arg-type]
diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py
index 905928428b7..eae6f90863a 100644
--- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py
+++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py
@@ -2924,6 +2924,57 @@ class TestUpdateVectorStoreAccessControlAndRedaction:
assert params["api_key"] == REDACTED_BY_LITELM_STRING
assert params["api_base"] == "https://api.openai.com/v1"
+ @pytest.mark.asyncio
+ async def test_update_row_deleted_mid_update_returns_404(self):
+ """A concurrent delete between the authorization read and the write makes Prisma's
+ ``update`` return None. That must reuse the not-found 404 contract instead of
+ turning an AttributeError into an opaque 500."""
+ from unittest.mock import AsyncMock, MagicMock, patch
+
+ from litellm.proxy._types import UserAPIKeyAuth
+ from litellm.proxy.vector_store_endpoints.management_endpoints import (
+ update_vector_store,
+ )
+ from litellm.types.vector_stores import VectorStoreUpdateRequest
+
+ existing_row = MagicMock()
+ existing_row.model_dump = MagicMock(
+ return_value={"vector_store_id": "vs_owned", "team_id": "team-A"}
+ )
+
+ mock_prisma_client = MagicMock()
+ mock_prisma_client.db.litellm_managedvectorstorestable.find_unique = AsyncMock(
+ return_value=existing_row
+ )
+ mock_prisma_client.db.litellm_managedvectorstorestable.update = AsyncMock(
+ return_value=None
+ )
+
+ with (
+ patch( # test-quality-ok: stubs the auth gate so the test exercises the not-found branch under test
+ "litellm.proxy.vector_store_endpoints.management_endpoints.check_feature_access_for_user",
+ new_callable=AsyncMock,
+ ),
+ patch( # test-quality-ok: stubs the auth gate so the test exercises the not-found branch under test
+ "litellm.proxy.vector_store_endpoints.management_endpoints._check_vector_store_access",
+ new_callable=AsyncMock,
+ return_value=True,
+ ),
+ patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), # test-quality-ok: proxy_server module global is the endpoint's only injection point
+ patch("litellm.vector_store_registry", None), # test-quality-ok: litellm module global is the only injection point for the registry
+ ):
+ with pytest.raises(HTTPException) as exc_info:
+ await update_vector_store(
+ data=VectorStoreUpdateRequest(
+ vector_store_id="vs_owned",
+ vector_store_description="new desc",
+ ),
+ user_api_key_dict=UserAPIKeyAuth(user_id="owner", team_id="team-A"),
+ )
+
+ assert exc_info.value.status_code == 404
+ assert exc_info.value.detail == "Vector store with ID vs_owned not found"
+
class TestAzureAIDocumentWritePassthroughPermission:
"""Regression tests for the Azure AI Search passthrough write mapping.
diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py
index 2273f23b1cc..b96d2eb5322 100644
--- a/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py
+++ b/tests/test_litellm/responses/litellm_completion_transformation/test_litellm_completion_responses.py
@@ -953,6 +953,19 @@ class TestFunctionCallTransformation:
assert function.get("name") == "get_weather"
assert function.get("arguments") == '{"location": "São Paulo, Brazil"}'
+ def test_function_call_transformation_normalizes_redacted_arguments(self):
+ """Redacted rows hold the bare sentinel in arguments, which is invalid JSON."""
+ result = LiteLLMCompletionResponsesConfig._transform_responses_api_function_call_to_chat_completion_message(
+ function_call={
+ "type": "function_call",
+ "name": "get_weather",
+ "arguments": "redacted-by-litellm",
+ "call_id": "call_123",
+ }
+ )
+
+ assert result[0]["tool_calls"][0]["function"]["arguments"] == "{}"
+
def test_complete_input_transformation_with_function_calls(self):
"""Test the complete transformation with the exact input from the issue"""
test_input = [
diff --git a/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py b/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py
index df477f6d01e..901fa8f57ff 100644
--- a/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py
+++ b/tests/test_litellm/responses/litellm_completion_transformation/test_session_handler.py
@@ -9,8 +9,10 @@ import litellm
from litellm.responses.litellm_completion_transformation import session_handler
from litellm.responses.litellm_completion_transformation.session_handler import (
ResponsesSessionHandler,
+ _normalize_redacted_tool_call_arguments,
)
from litellm.responses.utils import ResponsesAPIRequestUtils
+from litellm.types.utils import Message
@pytest.mark.asyncio
@@ -638,3 +640,81 @@ async def test_session_lookup_does_not_retry_when_spend_logs_are_disabled(
assert spend_logs == []
assert fake_prisma_client.db.calls == [("chatcmpl-does-not-exist",)]
+
+
+def test_normalize_redacted_arguments_skips_custom_tool_calls():
+ """Custom tool calls have no .function; the normalizer must skip them, not crash (session replay path)."""
+ message = Message(
+ content=None,
+ tool_calls=[
+ {"id": "call_c", "type": "custom", "custom": {"name": "run_code", "input": "print(1)"}},
+ {"id": "call_f", "type": "function", "function": {"name": "get_weather", "arguments": "redacted-by-litellm"}},
+ ],
+ )
+
+ _normalize_redacted_tool_call_arguments(message)
+
+ assert message.tool_calls[0].custom.input == "print(1)"
+ assert message.tool_calls[1].function.arguments == "{}"
+
+
+@pytest.mark.asyncio
+async def test_message_history_normalizes_redacted_tool_call_arguments():
+ """Sessions stored with turn_off_message_logging hold the bare sentinel
+ in tool-call arguments; replay must normalize it to valid JSON."""
+ mock_spend_logs = [
+ {
+ "request_id": "chatcmpl-redacted-1",
+ "call_type": "aresponses",
+ "session_id": "sess-redacted",
+ "proxy_server_request": {
+ "input": "what is the weather in sf",
+ "model": "gpt-4o",
+ },
+ "response": {
+ "id": "chatcmpl-redacted-1",
+ "model": "gpt-4o",
+ "object": "chat.completion",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": None,
+ "tool_calls": [
+ {
+ "id": "call_1",
+ "type": "function",
+ "function": {
+ "name": "get_weather",
+ "arguments": "redacted-by-litellm",
+ },
+ }
+ ],
+ "function_call": None,
+ },
+ "finish_reason": "tool_calls",
+ }
+ ],
+ "created": 1748575031,
+ "usage": {"total_tokens": 10, "prompt_tokens": 5, "completion_tokens": 5},
+ },
+ "status": "success",
+ }
+ ]
+
+ with patch.object( # test-quality-ok: the handler has no DI seam for the spend-log fetch; every test in this file stubs this same boundary
+ ResponsesSessionHandler,
+ "get_all_spend_logs_for_previous_response_id",
+ new_callable=AsyncMock,
+ ) as mock_get_spend_logs:
+ mock_get_spend_logs.return_value = mock_spend_logs
+
+ result = await ResponsesSessionHandler.get_chat_completion_message_history_for_previous_response_id(
+ "chatcmpl-redacted-1"
+ )
+
+ assistant_message = result["messages"][-1]
+ tool_call = assistant_message.tool_calls[0]
+ assert tool_call.function.arguments == "{}"
+ assert json.loads(tool_call.function.arguments) == {}
diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py
index e59b00f53ff..5ee6160fbdb 100644
--- a/tests/test_litellm/test_router.py
+++ b/tests/test_litellm/test_router.py
@@ -15,11 +15,16 @@ import litellm
from litellm import Router
from litellm.exceptions import MidStreamFallbackError
from litellm.integrations.custom_logger import CustomLogger
+from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import (
+ SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES,
+)
from litellm.router import (
MAX_BUFFERED_PRE_CONTENT_ANTHROPIC_CHUNKS,
FallbackAwareAnthropicMessagesStream,
_anthropic_stream_commits_now,
_anthropic_stream_should_decline_fallback,
+ _anthropic_stream_error_is_gateway_verdict,
+ _anthropic_stream_forwards_ping_live,
_anthropic_stream_should_drop_pre_content_ping,
_is_retriable_anthropic_status,
)
@@ -9469,21 +9474,103 @@ async def test_anthropic_messages_content_coalesced_with_error_in_one_physical_c
@pytest.mark.asyncio
-async def test_anthropic_messages_ping_keepalive_never_buffered_or_forwarded():
- """Bugbot regression: a `ping` keepalive carries no content and must be
- dropped outright before any real content arrives, rather than buffered -
+async def test_anthropic_messages_ping_behind_buffered_lifecycle_frame_is_dropped():
+ """Bugbot regression: a `ping` keepalive behind buffered lifecycle frames
+ carries no content and is dropped outright rather than buffered -
otherwise a slow-starting connection sending many pings could grow the
pre-content buffer without bound."""
router = _anthropic_messages_make_router()
source = _AnthropicMessagesFakeByteStream(
- [_anthropic_messages_ping_chunk(), _anthropic_messages_content_chunk("hi")]
+ [
+ _anthropic_messages_message_start_chunk(),
+ _anthropic_messages_ping_chunk(),
+ _anthropic_messages_content_chunk("hi"),
+ ]
)
wrapped = await router._aanthropic_messages_streaming_iterator(response=source, initial_kwargs={"model": "primary"})
collected = [chunk async for chunk in wrapped]
- assert _anthropic_messages_ping_chunk() not in collected
- assert collected == [_anthropic_messages_content_chunk("hi")]
+ assert collected == [_anthropic_messages_message_start_chunk(), _anthropic_messages_content_chunk("hi")]
+
+
+@pytest.mark.asyncio
+async def test_anthropic_messages_leading_ping_keepalive_is_forwarded_live():
+ """A `ping` that no lifecycle frame precedes is how a hold-back turn keeps
+ its connection alive (AgenticAnthropicStreamingIterator), so it must reach
+ the client at once rather than wait behind the pre-content buffer."""
+ router = _anthropic_messages_make_router()
+ content_released = asyncio.Event()
+
+ async def source():
+ yield _anthropic_messages_ping_chunk()
+ await content_released.wait()
+ yield _anthropic_messages_message_start_chunk()
+ yield _anthropic_messages_content_chunk("hi")
+
+ wrapped = await router._aanthropic_messages_streaming_iterator(response=source(), initial_kwargs={"model": "primary"})
+
+ assert await asyncio.wait_for(wrapped.__anext__(), timeout=1) == _anthropic_messages_ping_chunk()
+ content_released.set()
+ assert [chunk async for chunk in wrapped] == [
+ _anthropic_messages_message_start_chunk(),
+ _anthropic_messages_content_chunk("hi"),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_anthropic_messages_leading_ping_does_not_disqualify_fallback():
+ """A live-forwarded leading `ping` commits nothing: a retriable error after
+ it still falls back, and the fallback's own lifecycle follows the ping cleanly."""
+ router = _anthropic_messages_make_router()
+ source = _AnthropicMessagesFakeByteStream(
+ [_anthropic_messages_ping_chunk(), _anthropic_messages_overloaded_error_chunk()]
+ )
+ fallback_message_start = b'event: message_start\ndata: {"type": "message_start", "message": {"id": "msg_2"}}\n\n'
+ fallback_stream = _AnthropicMessagesFallbackByteStream(
+ [fallback_message_start, _anthropic_messages_content_chunk("fallback answer")]
+ )
+
+ with patch.object(
+ router,
+ "async_function_with_fallbacks_common_utils",
+ new=AsyncMock(return_value=fallback_stream),
+ ):
+ wrapped = await router._aanthropic_messages_streaming_iterator(
+ response=source,
+ initial_kwargs={"model": "primary"},
+ )
+ collected = [chunk async for chunk in wrapped]
+
+ assert collected == [
+ _anthropic_messages_ping_chunk(),
+ fallback_message_start,
+ _anthropic_messages_content_chunk("fallback answer"),
+ ]
+
+
+@pytest.mark.asyncio
+async def test_anthropic_messages_hold_back_retrieval_failure_reaches_client_without_fallback():
+ """The hold-back iterator's own retrieval-failure frame is the gateway's verdict, not a
+ provider failure: a configured fallback stays untouched and the client reads the error
+ right after the live keepalive."""
+ router = _anthropic_messages_make_router()
+ source = _AnthropicMessagesFakeByteStream(
+ [_anthropic_messages_ping_chunk(), SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES]
+ )
+ fallback = AsyncMock(
+ return_value=_AnthropicMessagesFallbackByteStream([_anthropic_messages_content_chunk("fallback answer")])
+ )
+
+ with patch.object(router, "async_function_with_fallbacks_common_utils", new=fallback):
+ wrapped = await router._aanthropic_messages_streaming_iterator(
+ response=source,
+ initial_kwargs={"model": "primary"},
+ )
+ collected = [chunk async for chunk in wrapped]
+
+ fallback.assert_not_called()
+ assert collected == [_anthropic_messages_ping_chunk(), SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES]
@pytest.mark.asyncio
@@ -9815,6 +9902,36 @@ def test_anthropic_stream_should_drop_pre_content_ping_direct_call():
assert _anthropic_stream_should_drop_pre_content_ping(content, has_generated_content=False) is False
+def test_anthropic_stream_forwards_ping_live_direct_call():
+ ping = _anthropic_messages_ping_chunk()
+ content = _anthropic_messages_content_chunk("hi")
+ assert _anthropic_stream_forwards_ping_live(ping, has_generated_content=False, buffered_chunk_count=0) is True
+ assert _anthropic_stream_forwards_ping_live(ping, has_generated_content=False, buffered_chunk_count=1) is False
+ assert _anthropic_stream_forwards_ping_live(ping, has_generated_content=True, buffered_chunk_count=0) is False
+ assert _anthropic_stream_forwards_ping_live(content, has_generated_content=False, buffered_chunk_count=0) is False
+
+
+def test_anthropic_stream_error_is_gateway_verdict_direct_call():
+ assert _anthropic_stream_error_is_gateway_verdict(SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES) is True
+ assert _anthropic_stream_error_is_gateway_verdict(_anthropic_messages_overloaded_error_chunk()) is False
+ assert _anthropic_stream_error_is_gateway_verdict(_anthropic_messages_ping_chunk()) is False
+
+
+def test_fallback_aware_stream_reports_withheld_output_of_its_current_source():
+ """The proxy's cancel-refund guard reads this flag off the router wrapper, so it
+ must reflect the stream actually being drained: the primary, then the fallback."""
+
+ class _HoldingBack:
+ _hidden_params = {"additional_headers": {}}
+ has_buffered_provider_output = True
+
+ wrapper = FallbackAwareAnthropicMessagesStream(_anthropic_messages_empty_generator(), _HoldingBack())
+ assert wrapper.has_buffered_provider_output is True
+
+ wrapper.adopt_fallback_source(_AnthropicMessagesFakeByteStream([]))
+ assert wrapper.has_buffered_provider_output is False
+
+
def test_is_retriable_anthropic_status_direct_call():
assert _is_retriable_anthropic_status(429) is True
assert _is_retriable_anthropic_status(503) is True
diff --git a/type-discipline-budget.json b/type-discipline-budget.json
index 7165bb53812..31f3b29286a 100644
--- a/type-discipline-budget.json
+++ b/type-discipline-budget.json
@@ -1,9 +1,9 @@
{
"LIT001": {
- "limit": 22804
+ "limit": 22749
},
"LIT002": {
- "limit": 26872
+ "limit": 26866
},
"LIT003": {
"limit": 269
@@ -15,22 +15,22 @@
"limit": 0
},
"LIT006": {
- "limit": 1069
+ "limit": 1066
},
"LIT007": {
"limit": 0
},
"LIT008": {
- "limit": 950
+ "limit": 948
},
"LIT009": {
"limit": 0
},
"LIT010": {
- "limit": 16673
+ "limit": 16655
},
"LIT011": {
- "limit": 5587
+ "limit": 5585
},
"LIT012": {
"limit": 4510
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx
index 59bd63fd0ed..3af5b31c41f 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.test.tsx
@@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest";
import { fireEvent, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { renderWithProviders } from "@/../tests/test-utils";
-import PipelineFlowBuilder, { PipelineInfoDisplay } from "./pipeline_flow_builder";
+import PipelineFlowBuilder, { FlowBuilderPage, PipelineInfoDisplay } from "./pipeline_flow_builder";
import { GuardrailPipeline, PipelineStep } from "@/components/policies/types";
import { Guardrail } from "@/components/guardrails/types";
@@ -167,3 +167,24 @@ describe("PipelineFlowBuilder", () => {
expect(screen.getAllByRole("combobox").length).toBeGreaterThan(0);
});
});
+
+describe("FlowBuilderPage", () => {
+ it("stacks its full-screen shell below the portalled popup layer", () => {
+ const portalLayerZIndex = 50;
+ const { container } = renderWithProviders(
+ ,
+ );
+
+ const shell = container.firstElementChild as HTMLElement;
+
+ expect(shell).toHaveStyle({ position: "fixed" });
+ expect(Number(shell.style.zIndex)).toBeLessThan(portalLayerZIndex);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx
index 8af8ac7a414..c649bb4844b 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/pipeline_flow_builder.tsx
@@ -1631,7 +1631,7 @@ export const FlowBuilderPage: React.FC = ({
right: 0,
bottom: 0,
backgroundColor: "var(--color-muted)",
- zIndex: 1000,
+ zIndex: 40,
display: "flex",
flexDirection: "column",
overflow: "hidden",
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 67ec35d36a0..e0b8cf19159 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -26712,7 +26712,7 @@ export interface components {
* Admins
* @default []
*/
- admins: unknown[];
+ admins: string[];
/**
* Allow Team Guardrail Config
* @default false
@@ -26752,7 +26752,7 @@ export interface components {
* Members
* @default []
*/
- members: unknown[];
+ members: string[];
/**
* Members With Roles
* @default []
@@ -26782,7 +26782,7 @@ export interface components {
* Models
* @default []
*/
- models: unknown[];
+ models: string[];
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionTable"] | null;
/** Object Permission Id */
object_permission_id?: string | null;
@@ -27988,7 +27988,7 @@ export interface components {
* Admins
* @default []
*/
- admins: unknown[];
+ admins: string[];
/**
* Allow Team Guardrail Config
* @default false
@@ -28018,7 +28018,7 @@ export interface components {
* Members
* @default []
*/
- members: unknown[];
+ members: string[];
/**
* Members With Roles
* @default []
@@ -28048,7 +28048,7 @@ export interface components {
* Models
* @default []
*/
- models: unknown[];
+ models: string[];
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionTable"] | null;
/** Object Permission Id */
object_permission_id?: string | null;
@@ -30094,7 +30094,7 @@ export interface components {
* Admins
* @default []
*/
- admins: unknown[];
+ admins: string[];
/** Allowed Passthrough Routes */
allowed_passthrough_routes?: unknown[] | null;
/** Allowed Vector Store Indexes */
@@ -30138,7 +30138,7 @@ export interface components {
* Members
* @default []
*/
- members: unknown[];
+ members: string[];
/**
* Members With Roles
* @default []
@@ -30164,7 +30164,7 @@ export interface components {
* Models
* @default []
*/
- models: unknown[];
+ models: string[];
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionBase"] | null;
/** Organization Id */
organization_id?: string | null;
@@ -33947,7 +33947,7 @@ export interface components {
* Admins
* @default []
*/
- admins: unknown[];
+ admins: string[];
/**
* Allow Team Guardrail Config
* @default false
@@ -33977,7 +33977,7 @@ export interface components {
* Members
* @default []
*/
- members: unknown[];
+ members: string[];
/**
* Members With Roles
* @default []
@@ -34007,7 +34007,7 @@ export interface components {
* Models
* @default []
*/
- models: unknown[];
+ models: string[];
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionTable"] | null;
/** Object Permission Id */
object_permission_id?: string | null;
@@ -34077,7 +34077,7 @@ export interface components {
* Admins
* @default []
*/
- admins: unknown[];
+ admins: string[];
/**
* Allow Team Guardrail Config
* @default false
@@ -34112,7 +34112,7 @@ export interface components {
* Members
* @default []
*/
- members: unknown[];
+ members: string[];
/**
* Members Count
* @default 0
@@ -34147,7 +34147,7 @@ export interface components {
* Models
* @default []
*/
- models: unknown[];
+ models: string[];
object_permission?: components["schemas"]["LiteLLM_ObjectPermissionTable"] | null;
/** Object Permission Id */
object_permission_id?: string | null;