From 902a93d10d2159f66b9f8b5cacbde527d50e88b0 Mon Sep 17 00:00:00 2001 From: Shivam Rawat Date: Tue, 4 Aug 2026 16:51:21 -0700 Subject: [PATCH 001/410] fix(router): honor ServiceUnavailableErrorRetries and InternalServerErrorRetries in retry policy --- litellm/router_utils/get_retry_from_policy.py | 8 ++ litellm/types/router.py | 1 + .../test_get_retry_from_policy.py | 102 ++++++++++++++++++ tests/test_litellm/test_router.py | 31 ++++++ .../components/ModelRetrySettingsTab.tsx | 1 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 6 files changed, 145 insertions(+) create mode 100644 tests/test_litellm/router_utils/test_get_retry_from_policy.py diff --git a/litellm/router_utils/get_retry_from_policy.py b/litellm/router_utils/get_retry_from_policy.py index 1645e6776fc..fbcc3de83c0 100644 --- a/litellm/router_utils/get_retry_from_policy.py +++ b/litellm/router_utils/get_retry_from_policy.py @@ -8,7 +8,9 @@ from litellm.exceptions import ( AuthenticationError, BadRequestError, ContentPolicyViolationError, + InternalServerError, RateLimitError, + ServiceUnavailableError, Timeout, ) from litellm.types.router import RetryPolicy @@ -26,6 +28,8 @@ def get_num_retries_from_retry_policy( TimeoutErrorRetries: Optional[int] = None RateLimitErrorRetries: Optional[int] = None ContentPolicyViolationErrorRetries: Optional[int] = None + InternalServerErrorRetries: Optional[int] = None + ServiceUnavailableErrorRetries: Optional[int] = None """ # if we can find the exception then in the retry policy -> return the number of retries @@ -48,6 +52,10 @@ def get_num_retries_from_retry_policy( and retry_policy.ContentPolicyViolationErrorRetries is not None ): return retry_policy.ContentPolicyViolationErrorRetries + if isinstance(exception, ServiceUnavailableError) and retry_policy.ServiceUnavailableErrorRetries is not None: + return retry_policy.ServiceUnavailableErrorRetries + if isinstance(exception, InternalServerError) and retry_policy.InternalServerErrorRetries is not None: + return retry_policy.InternalServerErrorRetries if isinstance(exception, BadRequestError) and retry_policy.BadRequestErrorRetries is not None: return retry_policy.BadRequestErrorRetries diff --git a/litellm/types/router.py b/litellm/types/router.py index 21bed84a3a1..e0952d9dd02 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -95,6 +95,7 @@ class RetryPolicy(BaseModel): RateLimitErrorRetries: Optional[int] = None ContentPolicyViolationErrorRetries: Optional[int] = None InternalServerErrorRetries: Optional[int] = None + ServiceUnavailableErrorRetries: Optional[int] = None class UpdateRouterConfig(BaseModel): diff --git a/tests/test_litellm/router_utils/test_get_retry_from_policy.py b/tests/test_litellm/router_utils/test_get_retry_from_policy.py new file mode 100644 index 00000000000..a5e239b8595 --- /dev/null +++ b/tests/test_litellm/router_utils/test_get_retry_from_policy.py @@ -0,0 +1,102 @@ +import litellm +from litellm.router_utils.get_retry_from_policy import ( + get_num_retries_from_retry_policy, +) +from litellm.types.router import RetryPolicy + + +def _service_unavailable_error() -> litellm.ServiceUnavailableError: + return litellm.ServiceUnavailableError( + message="model is down", + llm_provider="openai", + model="gpt-5.6", + ) + + +def _internal_server_error() -> litellm.InternalServerError: + return litellm.InternalServerError( + message="upstream 500", + llm_provider="openai", + model="gpt-5.6", + ) + + +def test_service_unavailable_error_retries_honored(): + policy = RetryPolicy(ServiceUnavailableErrorRetries=0) + + assert ( + get_num_retries_from_retry_policy( + exception=_service_unavailable_error(), + retry_policy=policy, + ) + == 0 + ) + + +def test_service_unavailable_error_retries_nonzero(): + policy = RetryPolicy(ServiceUnavailableErrorRetries=4) + + assert ( + get_num_retries_from_retry_policy( + exception=_service_unavailable_error(), + retry_policy=policy, + ) + == 4 + ) + + +def test_internal_server_error_retries_honored(): + policy = RetryPolicy(InternalServerErrorRetries=0) + + assert ( + get_num_retries_from_retry_policy( + exception=_internal_server_error(), + retry_policy=policy, + ) + == 0 + ) + + +def test_service_unavailable_not_covered_by_internal_server_error_retries(): + policy = RetryPolicy(InternalServerErrorRetries=0) + + assert ( + get_num_retries_from_retry_policy( + exception=_service_unavailable_error(), + retry_policy=policy, + ) + is None + ) + + +def test_internal_server_error_not_covered_by_service_unavailable_retries(): + policy = RetryPolicy(ServiceUnavailableErrorRetries=0) + + assert ( + get_num_retries_from_retry_policy( + exception=_internal_server_error(), + retry_policy=policy, + ) + is None + ) + + +def test_service_unavailable_error_retries_from_dict_policy(): + assert ( + get_num_retries_from_retry_policy( + exception=_service_unavailable_error(), + retry_policy={"ServiceUnavailableErrorRetries": 0}, + ) + == 0 + ) + + +def test_service_unavailable_error_retries_from_model_group_policy(): + assert ( + get_num_retries_from_retry_policy( + exception=_service_unavailable_error(), + model_group="gpt-5.6", + model_group_retry_policy={"gpt-5.6": RetryPolicy(ServiceUnavailableErrorRetries=1)}, + ) + == 1 + ) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 46b5ce65c3f..c98be9c7d80 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -6574,3 +6574,34 @@ def test_model_info_is_active_for_environment_matrix(monkeypatch): monkeypatch.delenv("LITELLM_ENVIRONMENT") with pytest.raises(ValueError, match="LITELLM_ENVIRONMENT"): model_info_is_active_for_environment(model_info={"supported_environments": ["production"]}) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("policy_retries,expected_calls", [(0, 1), (1, 2)]) +async def test_router_retry_policy_service_unavailable_retries(policy_retries, expected_calls): + from litellm.types.router import RetryPolicy + + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-5.6", + "litellm_params": {"model": "openai/gpt-5.6", "api_key": "fake-key"}, + } + ], + retry_policy=RetryPolicy(ServiceUnavailableErrorRetries=policy_retries), + disable_cooldowns=True, + ) + + error = litellm.ServiceUnavailableError( + message="model is down", + llm_provider="openai", + model="gpt-5.6", + ) + with patch.object(litellm, "acompletion", AsyncMock(side_effect=error)) as mock_acompletion: + with pytest.raises(litellm.ServiceUnavailableError): + await router.acompletion( + model="gpt-5.6", + messages=[{"role": "user", "content": "hi"}], + ) + + assert mock_acompletion.call_count == expected_calls diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx index a4e3c4b958c..a9e0b8eb051 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx @@ -30,6 +30,7 @@ const retryPolicyMap: Record = { "RateLimitError (429)": "RateLimitErrorRetries", "ContentPolicyViolationError (400)": "ContentPolicyViolationErrorRetries", "InternalServerError (500)": "InternalServerErrorRetries", + "ServiceUnavailableError (503)": "ServiceUnavailableErrorRetries", }; const ModelRetrySettingsTab = ({ diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 9133bfb5cf4..cf20f86a28d 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -30947,6 +30947,8 @@ export interface components { InternalServerErrorRetries?: number | null; /** Ratelimiterrorretries */ RateLimitErrorRetries?: number | null; + /** Serviceunavailableerrorretries */ + ServiceUnavailableErrorRetries?: number | null; /** Timeouterrorretries */ TimeoutErrorRetries?: number | null; }; From 7b181ef1978cfc5d4169ff1397ebef65229bb595 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 11 Aug 2026 21:53:25 +0000 Subject: [PATCH 002/410] fix(responses/mcp): make MCP follow-up calls stateless when store=false Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/responses/main.py | 9 +- .../mcp/litellm_proxy_mcp_handler.py | 31 +++- .../responses/mcp/mcp_streaming_iterator.py | 7 + .../mcp/test_litellm_proxy_mcp_handler.py | 149 ++++++++++++++++++ .../mcp/test_mcp_streaming_iterator.py | 78 +++++++++ 5 files changed, 270 insertions(+), 4 deletions(-) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index e0af363b1a5..0f6119ce94c 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -326,8 +326,13 @@ async def aresponses_api_with_mcp( ) if tool_results: + persistence_disabled: Final = LiteLLM_Proxy_MCP_Handler._is_persistence_disabled(call_params) + follow_up_input: Final = LiteLLM_Proxy_MCP_Handler._create_follow_up_input( - response=response, tool_results=tool_results, original_input=input + response=response, + tool_results=tool_results, + original_input=input, + preserve_reasoning=persistence_disabled, ) # Prepare parameters for follow-up call (restores original stream setting) @@ -346,7 +351,7 @@ async def aresponses_api_with_mcp( follow_up_input=follow_up_input, model=model, all_tools=all_tools, - response_id=response.id, + response_id=None if persistence_disabled else response.id, **follow_up_call_params, ) diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py index c6e17502e5d..6271c5888db 100644 --- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py +++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py @@ -951,11 +951,30 @@ class LiteLLM_Proxy_MCP_Handler: return follow_up_messages + @staticmethod + def _is_persistence_disabled(call_params: Mapping[str, object]) -> bool: + """Whether the caller opted out of server-side response persistence (store=false). + + Zero data retention callers send store=false, so the provider never persisted the + first response and previous_response_id cannot be used to link the follow-up call. + """ + return call_params.get("store") is False + + @staticmethod + def _extract_reasoning_items(response: ResponsesAPIResponse) -> tuple[Mapping[str, object], ...]: + """Reasoning output items, kept whole so reasoning.encrypted_content survives replay.""" + normalized: Final = tuple( + output_item if isinstance(output_item, dict) else output_item.model_dump(exclude_none=True) + for output_item in response.output + ) + return tuple(item for item in normalized if item.get("type") == "reasoning") + @staticmethod def _create_follow_up_input( response: ResponsesAPIResponse, tool_results: Sequence[Mapping[str, object]], original_input: str | ResponseInputParam | None = None, + preserve_reasoning: bool = False, ) -> list[object]: """Create follow-up input with tool results in proper format.""" follow_up_input: Final[list[object]] = [] @@ -1013,6 +1032,10 @@ class LiteLLM_Proxy_MCP_Handler: } ) + # Reasoning items must precede the function calls they produced + if preserve_reasoning: + follow_up_input.extend(LiteLLM_Proxy_MCP_Handler._extract_reasoning_items(response)) + # Add function calls (these can come directly after user message for LLM) for function_call in function_calls: follow_up_input.append(function_call) @@ -1034,10 +1057,14 @@ class LiteLLM_Proxy_MCP_Handler: follow_up_input: list[Any], model: str, all_tools: Sequence[ResponsesToolParam] | None, - response_id: str, + response_id: str | None, **call_params: Any, ) -> ResponsesAPIResponse | BaseResponsesAPIStreamingIterator: - """Make follow-up response API call with tool results.""" + """Make follow-up response API call with tool results. + + response_id is None for stateless (store=false) requests, where the whole prior + turn is replayed in follow_up_input instead of linked by previous_response_id. + """ return await aresponses( input=follow_up_input, model=model, diff --git a/litellm/responses/mcp/mcp_streaming_iterator.py b/litellm/responses/mcp/mcp_streaming_iterator.py index 186852f91c2..f1560e7ec84 100644 --- a/litellm/responses/mcp/mcp_streaming_iterator.py +++ b/litellm/responses/mcp/mcp_streaming_iterator.py @@ -774,10 +774,15 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): try: # Create follow-up input if self.collected_response is not None: + persistence_disabled: Final = LiteLLM_Proxy_MCP_Handler._is_persistence_disabled( + self.original_request_params + ) + follow_up_input: Final = LiteLLM_Proxy_MCP_Handler._create_follow_up_input( response=self.collected_response, tool_results=self.tool_results, original_input=self.original_request_params.get("input"), + preserve_reasoning=persistence_disabled, ) # Make follow-up call with streaming @@ -788,6 +793,8 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): "stream": True, } ) + if persistence_disabled: + follow_up_params.pop("previous_response_id", None) else: return # Remove tool_choice to avoid forcing more tool calls diff --git a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py index d60fff66c44..f08666f7b0d 100644 --- a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py +++ b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py @@ -9,10 +9,13 @@ from fastapi import HTTPException import importlib from litellm.proxy._experimental.mcp_server.faults.list_outcomes import AggregateToolListing +from litellm.responses import main as responses_main +from litellm.responses.mcp import litellm_proxy_mcp_handler as mcp_handler_module from litellm.responses.mcp.litellm_proxy_mcp_handler import ( LiteLLM_Proxy_MCP_Handler, ) from typing import Any, cast +from litellm.types.llms.openai import ResponsesAPIResponse from litellm.types.utils import ModelResponse from litellm.types.responses.main import OutputFunctionToolCall @@ -648,3 +651,149 @@ def test_extract_tool_call_details_still_prefers_openai_arguments(): assert name == "get_weather" assert call_id == "call_123" assert arguments == '{"city": "Paris"}' + + +def _response_with_reasoning_and_tool_call() -> Any: + """A first-turn response as a reasoning model returns it: reasoning item, then a function call.""" + return ResponsesAPIResponse( + id="resp_first", + created_at=1234567890, + model="gpt-5", + object="response", + status="completed", + output=[ + { + "type": "reasoning", + "id": "rs_1", + "summary": [], + "encrypted_content": "gAAAAA-opaque-blob", + }, + { + "type": "function_call", + "id": "fc_1", + "call_id": "call-1", + "name": "foo", + "arguments": "{}", + "status": "completed", + }, + ], + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ) + + +def test_create_follow_up_input_preserves_reasoning_when_stateless(): + """ + Regression test (LIT-5427): a store=false follow-up has to replay the reasoning + item, including reasoning.encrypted_content, since the provider kept no state. + """ + follow_up = LiteLLM_Proxy_MCP_Handler._create_follow_up_input( + response=_response_with_reasoning_and_tool_call(), + tool_results=[{"tool_call_id": "call-1", "name": "foo", "result": "done"}], + original_input="hi", + preserve_reasoning=True, + ) + + assert follow_up[1] == { + "type": "reasoning", + "id": "rs_1", + "summary": [], + "encrypted_content": "gAAAAA-opaque-blob", + } + # the reasoning item has to come before the function call it produced + assert follow_up[2] == { + "type": "function_call", + "call_id": "call-1", + "name": "foo", + "arguments": "{}", + } + assert follow_up[3] == { + "type": "function_call_output", + "call_id": "call-1", + "output": "done", + } + + +def test_create_follow_up_input_omits_reasoning_when_stateful(): + """With store=true the provider still holds the reasoning item, so don't resend it.""" + follow_up = LiteLLM_Proxy_MCP_Handler._create_follow_up_input( + response=_response_with_reasoning_and_tool_call(), + tool_results=[{"tool_call_id": "call-1", "name": "foo", "result": "done"}], + original_input="hi", + ) + + assert not [item for item in follow_up if isinstance(item, dict) and item.get("type") == "reasoning"] + + +@pytest.mark.parametrize( + "call_params, expected", + [ + ({"store": False}, True), + ({"store": True}, False), + ({"store": None}, False), + ({}, False), + ], +) +def test_is_persistence_disabled(call_params: dict[str, Any], expected: bool): + assert LiteLLM_Proxy_MCP_Handler._is_persistence_disabled(call_params) is expected + + +@pytest.mark.parametrize( + "store, expected_previous_response_id", + [(False, None), (True, "resp_first")], +) +@pytest.mark.asyncio +async def test_mcp_follow_up_call_is_stateless_when_store_is_false( + monkeypatch: pytest.MonkeyPatch, store: bool, expected_previous_response_id: str | None +): + """ + Regression test (LIT-5427): linking the MCP follow-up call with + previous_response_id fails for zero data retention callers, because store=false + means the first response was never persisted. + """ + captured_calls: list[dict[str, Any]] = [] + first_response = _response_with_reasoning_and_tool_call() + + async def fake_aresponses(**kwargs: Any) -> ResponsesAPIResponse: + captured_calls.append(kwargs) + return first_response if len(captured_calls) == 1 else ResponsesAPIResponse( + id="resp_follow_up", + created_at=1234567891, + model="gpt-5", + object="response", + status="completed", + output=[], + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ) + + async def fake_process(**kwargs: Any) -> tuple[list[Any], dict[str, str]]: + return ([], {"foo": "litellm_proxy"}) + + async def fake_execute(**kwargs: Any) -> list[dict[str, Any]]: + return [{"tool_call_id": "call-1", "name": "foo", "result": "done"}] + + monkeypatch.setattr(responses_main, "aresponses", fake_aresponses) + monkeypatch.setattr(mcp_handler_module, "aresponses", fake_aresponses) + monkeypatch.setattr( + LiteLLM_Proxy_MCP_Handler, "_process_mcp_tools_without_openai_transform", staticmethod(fake_process) + ) + monkeypatch.setattr(LiteLLM_Proxy_MCP_Handler, "_execute_tool_calls", staticmethod(fake_execute)) + + await responses_main.aresponses_api_with_mcp( + input="hi", + model="gpt-5", + tools=[{"type": "mcp", "server_url": "litellm_proxy", "require_approval": "never"}], + store=store, + ) + + assert len(captured_calls) == 2 + follow_up_call = captured_calls[1] + assert follow_up_call["previous_response_id"] == expected_previous_response_id + + reasoning_items = [ + item for item in follow_up_call["input"] if isinstance(item, dict) and item.get("type") == "reasoning" + ] + assert bool(reasoning_items) is (store is False) diff --git a/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py index 24edf12fffe..040ee26d796 100644 --- a/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py +++ b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py @@ -257,3 +257,81 @@ async def test_initial_call_failure_is_stashed_for_eager_reraise(monkeypatch): assert iterator._initial_creation_error is not None assert "initial boom" in str(iterator._initial_creation_error) + + +def _reasoning_item(encrypted_content: str): + return {"type": "reasoning", "id": "rs_1", "summary": [], "encrypted_content": encrypted_content} + + +@pytest.mark.asyncio +async def test_streaming_follow_up_is_stateless_when_store_is_false(monkeypatch): + """ + Regression test (LIT-5427): with store=false the provider persisted nothing, so the + streaming follow-up must drop previous_response_id and replay the reasoning item + (carrying reasoning.encrypted_content) instead of pointing at a response id. + """ + _mock_mcp_environment(monkeypatch) + + aresponses_mock = AsyncMock(side_effect=[_text_only_stream("done")]) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + iterator = MCPEnhancedStreamingIterator( + base_iterator=_FakeAsyncStream( + [ + _output_item_added_chunk(), + _completed_chunk([_reasoning_item("gAAAAA-opaque-blob"), _function_call("call_1", "read_wiki_contents")]), + ] + ), + mcp_events=[], + tool_server_map={"read_wiki_contents": "deepwiki"}, + mcp_tools_with_litellm_proxy=[{"require_approval": "never"}], + user_api_key_auth=None, + original_request_params={ + "model": "gpt-5", + "input": "what is berriai/litellm?", + "tools": [{"type": "mcp"}], + "store": False, + "previous_response_id": "resp_prev", + }, + ) + + _ = [chunk async for chunk in iterator] + + assert aresponses_mock.call_count == 1 + follow_up_kwargs = aresponses_mock.call_args_list[0].kwargs + assert "previous_response_id" not in follow_up_kwargs + assert _reasoning_item("gAAAAA-opaque-blob") in follow_up_kwargs["input"] + + +@pytest.mark.asyncio +async def test_streaming_follow_up_keeps_previous_response_id_when_stored(monkeypatch): + """The stateful default is unchanged: previous_response_id still links the follow-up.""" + _mock_mcp_environment(monkeypatch) + + aresponses_mock = AsyncMock(side_effect=[_text_only_stream("done")]) + monkeypatch.setattr(responses_main_module, "aresponses", aresponses_mock) + + iterator = MCPEnhancedStreamingIterator( + base_iterator=_FakeAsyncStream( + [ + _output_item_added_chunk(), + _completed_chunk([_reasoning_item("gAAAAA-opaque-blob"), _function_call("call_1", "read_wiki_contents")]), + ] + ), + mcp_events=[], + tool_server_map={"read_wiki_contents": "deepwiki"}, + mcp_tools_with_litellm_proxy=[{"require_approval": "never"}], + user_api_key_auth=None, + original_request_params={ + "model": "gpt-5", + "input": "what is berriai/litellm?", + "tools": [{"type": "mcp"}], + "previous_response_id": "resp_prev", + }, + ) + + _ = [chunk async for chunk in iterator] + + follow_up_kwargs = aresponses_mock.call_args_list[0].kwargs + assert follow_up_kwargs["previous_response_id"] == "resp_prev" + assert not [item for item in follow_up_kwargs["input"] if item.get("type") == "reasoning"] From fae91c2a1137c57f64106ceec02c1a6d8ad3938f Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 11 Aug 2026 22:27:41 +0000 Subject: [PATCH 003/410] chore(responses/mcp): drop explanatory comments per repo policy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/responses/mcp/litellm_proxy_mcp_handler.py | 1 - .../test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py | 1 - 2 files changed, 2 deletions(-) diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py index 6271c5888db..33ca99c1a7e 100644 --- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py +++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py @@ -1032,7 +1032,6 @@ class LiteLLM_Proxy_MCP_Handler: } ) - # Reasoning items must precede the function calls they produced if preserve_reasoning: follow_up_input.extend(LiteLLM_Proxy_MCP_Handler._extract_reasoning_items(response)) diff --git a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py index f08666f7b0d..dbb3ad9fd44 100644 --- a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py +++ b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py @@ -701,7 +701,6 @@ def test_create_follow_up_input_preserves_reasoning_when_stateless(): "summary": [], "encrypted_content": "gAAAAA-opaque-blob", } - # the reasoning item has to come before the function call it produced assert follow_up[2] == { "type": "function_call", "call_id": "call-1", From 4e280ecc344f67f2f04d791b8990886acbfbb83f Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 13 Aug 2026 15:33:36 -0700 Subject: [PATCH 004/410] feat(cli): add lite pi to run the pi coding agent through the proxy --- litellm/proxy/client/cli/README.md | 7 +- litellm/proxy/client/cli/commands/agents.py | 71 ++++++- litellm/proxy/client/cli/commands/pi.py | 178 ++++++++++++++++ .../proxy/client/cli/test_agents.py | 147 ++++++++++++- .../test_litellm/proxy/client/cli/test_pi.py | 201 ++++++++++++++++++ 5 files changed, 591 insertions(+), 13 deletions(-) create mode 100644 litellm/proxy/client/cli/commands/pi.py create mode 100644 tests/test_litellm/proxy/client/cli/test_pi.py diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index de9d38963c1..66beecbd2e6 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -467,6 +467,7 @@ Launch a coding agent with all of its LLM traffic routed through your LiteLLM pr lite claude lite codex lite opencode +lite pi ``` Anything you type after the agent name is forwarded to it untouched, so the usual flags keep working: @@ -480,17 +481,19 @@ Each command resolves your LiteLLM key (logging in via SSO when none is stored a The right variables are picked per agent. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`. Codex ignores `OPENAI_BASE_URL`, so it is additionally pointed at the proxy through a custom provider passed as `-c` config overrides (HTTP/SSE Responses transport, since the proxy does not speak the Responses WebSocket protocol). +pi ignores base-URL environment variables entirely, so `lite pi` wires it up differently: before handoff it fetches the models your key can use from the proxy's `/v1/models` (plus each model's context window and output cap from `/model_group/info`, when available) and syncs them into a `litellm` provider entry in pi's `~/.pi/agent/models.json` (honoring `PI_CODING_AGENT_DIR`), then starts pi on that provider's first model via an injected `--model litellm/`. Only that one provider entry is rewritten; the rest of the file, including any other custom providers, is left alone. The entry references the key as `$LITELLM_PROXY_API_KEY`, which the wrapper exports for the session, so the token itself never lands on disk and plain `pi` outside the wrapper simply shows the litellm models as unavailable. Your own flags come after the injected pin, so `lite pi --model litellm/` wins, and inside the TUI the `/model` picker lists every synced litellm model. + Options (these belong to the wrapper, so put them before the agent's own flags): - `--skip-verify`: Skip the pre-launch key check (useful offline or with non-standard auth). -To pin the model, pass the agent's own model flag (for example `lite claude --model my-proxy-model` or `lite codex -m my-proxy-model`), or export the variable the agent reads (`ANTHROPIC_MODEL` / `ANTHROPIC_SMALL_FAST_MODEL` for Claude Code); the wrapper preserves anything you already have set. Whatever model the agent ends up requesting must exist on the proxy, since requests land on the proxy's `/v1/messages` (Anthropic) or `/v1/chat/completions` and `/v1/responses` (OpenAI) endpoints. +To pin the model, pass the agent's own model flag (for example `lite claude --model my-proxy-model`, `lite codex -m my-proxy-model`, or `lite pi --model my-proxy-model`), or export the variable the agent reads (`ANTHROPIC_MODEL` / `ANTHROPIC_SMALL_FAST_MODEL` for Claude Code); the wrapper preserves anything you already have set. Whatever model the agent ends up requesting must exist on the proxy, since requests land on the proxy's `/v1/messages` (Anthropic) or `/v1/chat/completions` and `/v1/responses` (OpenAI) endpoints. #### About the `lite login` credential The token minted by `lite login` is a short-lived, per-session agent credential, not a managed virtual key. It is scoped to the user and team you authenticated as, inherits that user's and team's models and budgets, and is enforced on the proxy exactly like a virtual key on the same team (guardrails, routing, logging, spend). Spend is tracked against the shared team and user budgets, so running several agents (or logging in more than once) does not hand each session its own separate budget; they all draw down the same team/user allowance. There is no separate per-session cap, so sustained agent use is not capped at a small chat-session limit. -The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while it's still fresh and fails once it expires -- there is no silent renewal, so a long-running session needs a fresh `lite login` once a day. `lite claude`, `lite codex`, and `lite opencode` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead. +The credential is short-lived by design (default 24h, configurable via `LITELLM_CLI_JWT_EXPIRATION_HOURS`); run `lite login` again to refresh it, which also re-reads your latest team and user settings. It does not appear in the Keys UI and cannot be rotated or revoked mid-session. `lite auth print-token` (usable as Claude Code's `apiKeyHelper`) prints it while it's still fresh and fails once it expires -- there is no silent renewal, so a long-running session needs a fresh `lite login` once a day. `lite claude`, `lite codex`, `lite opencode`, and `lite pi` work with it on a default deployment; `EXPERIMENTAL_UI_LOGIN` is not required. If you need a long-lived, rotatable key that shows up in the Keys UI, create a dedicated virtual key in the dashboard and pass it via `--api-key` or `LITELLM_PROXY_API_KEY` instead. ### Route Every Claude Code Session Through the Proxy diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index dfc70a8df7c..f55cf9893e7 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -2,12 +2,22 @@ import os import shutil import sys from collections.abc import Callable, Mapping, Sequence +from types import MappingProxyType from typing import Final import click import requests from .auth import get_stored_api_key, login +from .pi import ( + LITELLM_PROXY_API_KEY_ENV, + PI_PROVIDER_NAME, + PiSyncError, + fetch_model_ids, + fetch_model_limits, + models_json_path, + sync_models_json, +) ANTHROPIC_BASE_URL_ENV: Final = "ANTHROPIC_BASE_URL" ANTHROPIC_AUTH_TOKEN_ENV: Final = "ANTHROPIC_AUTH_TOKEN" @@ -17,17 +27,20 @@ OPENAI_API_KEY_ENV: Final = "OPENAI_API_KEY" PROFILE_ANTHROPIC: Final = "anthropic" PROFILE_OPENAI: Final = "openai" +PROFILE_LITELLM: Final = "litellm" _KNOWN_AGENTS: Final[dict[str, tuple[str, frozenset[str]]]] = { "claude": ("Claude Code", frozenset({PROFILE_ANTHROPIC})), "codex": ("Codex", frozenset({PROFILE_OPENAI})), "opencode": ("OpenCode", frozenset({PROFILE_OPENAI})), + "pi": ("pi", frozenset({PROFILE_LITELLM})), } _INSTALL_DOCS: Final[dict[str, str]] = { "claude": "https://docs.claude.com/en/docs/claude-code/setup", "codex": "https://developers.openai.com/codex/cli", "opencode": "https://opencode.ai/docs", + "pi": "https://pi.dev", } CODEX_PROXY_PROVIDER: Final = "litellm" @@ -60,7 +73,9 @@ def build_agent_env( Anthropic clients (Claude Code) append /v1/messages to ANTHROPIC_BASE_URL, so it stays the bare proxy root; OpenAI clients (Codex, OpenCode) expect the /v1 suffix on OPENAI_BASE_URL. ANTHROPIC_API_KEY is dropped so a stray - Anthropic key cannot win over the bearer token we set. + Anthropic key cannot win over the bearer token we set. pi ignores both base + URL variables and instead resolves $LITELLM_PROXY_API_KEY from its synced + models.json provider entry. """ env: Final = dict(base_env) root: Final = base_url.rstrip("/") @@ -71,6 +86,8 @@ def build_agent_env( if PROFILE_OPENAI in profiles: env[OPENAI_BASE_URL_ENV] = root + "/v1" env[OPENAI_API_KEY_ENV] = api_key + if PROFILE_LITELLM in profiles: + env[LITELLM_PROXY_API_KEY_ENV] = api_key return env @@ -106,6 +123,38 @@ _PROXY_ARGS: Final[dict[str, Callable[[str], list[str]]]] = { } +def prepare_pi( + base_url: str, + api_key: str, + base_env: Mapping[str, str], + *, + get: Callable[..., requests.Response] = requests.get, +) -> list[str]: + """Sync the proxy's model list into pi's models.json before handoff. + + pi has no base-URL env vars, so this file is the only way to point it at the + proxy. Only the litellm provider entry is touched; the synced entry references + the key as $LITELLM_PROXY_API_KEY, which build_agent_env exports. The returned + --model pin is needed because pi ignores a bare --provider when picking the + interactive startup model; a user-supplied --model comes later in argv and wins. + """ + ids: Final = fetch_model_ids(base_url, api_key, get=get) + if isinstance(ids, PiSyncError): + raise AgentRunError(ids.message) + limits: Final = fetch_model_limits(base_url, api_key, get=get) + path: Final = models_json_path(base_env) + error: Final = sync_models_json(path, base_url, ids, limits) + if error is not None: + raise AgentRunError(error.message) + click.echo(f"litellm: synced {len(ids)} proxy models into {path}") + return ["--model", f"{PI_PROVIDER_NAME}/{ids[0]}"] + + +_PREPARERS: Final[dict[str, Callable[[str, str, Mapping[str, str]], Sequence[str]]]] = { + "pi": prepare_pi, +} + + def agent_launch_args(command: str, base_url: str) -> list[str]: """Extra CLI args an agent needs to actually honor the proxy. @@ -177,12 +226,14 @@ def run_agent( verify: Callable[[str, str], None] = verify_proxy_key, launcher: Callable[[str, Sequence[str], Mapping[str, str]], None] = _exec, reattach_terminal: Callable[[], None] | None = None, + preparers: Mapping[str, Callable[[str, str, Mapping[str, str]], Sequence[str]]] = MappingProxyType(_PREPARERS), ) -> None: """Validate, wire the environment, and hand off to the agent. On success this replaces the current process and never returns. Raises - AgentRunError for missing binaries, an unreachable proxy, or a rejected key. - reattach_terminal, when given, runs just before handoff to restore stdin. + AgentRunError for missing binaries, an unreachable proxy, a rejected key, or + a failed pre-launch config sync (pi). reattach_terminal, when given, runs + just before handoff to restore stdin. """ if not command: raise AgentRunError("Nothing to run.") @@ -197,13 +248,12 @@ def run_agent( if not skip_verify: verify(base_url, api_key) - env: Final = build_agent_env( - base_env if base_env is not None else os.environ, - base_url, - api_key, - profiles, - ) - extra_args: Final = agent_launch_args(command[0], base_url) + source_env: Final = base_env if base_env is not None else os.environ + prepare: Final = preparers.get(os.path.basename(command[0])) + prepared_args: Final = list(prepare(base_url, api_key, source_env)) if prepare is not None else [] + + env: Final = build_agent_env(source_env, base_url, api_key, profiles) + extra_args: Final = [*agent_launch_args(command[0], base_url), *prepared_args] if reattach_terminal is not None: reattach_terminal() launcher(binary, [command[0], *extra_args, *command[1:]], env) @@ -288,6 +338,7 @@ __all__ = [ "agent_launch_args", "agent_profile", "build_agent_env", + "prepare_pi", "resolve_api_key", "run_agent", "verify_proxy_key", diff --git a/litellm/proxy/client/cli/commands/pi.py b/litellm/proxy/client/cli/commands/pi.py new file mode 100644 index 00000000000..bd933874a10 --- /dev/null +++ b/litellm/proxy/client/cli/commands/pi.py @@ -0,0 +1,178 @@ +"""Sync a LiteLLM provider into pi's models.json. + +pi ignores ANTHROPIC_BASE_URL/OPENAI_BASE_URL, so `lite pi` routes it through the +proxy by writing a provider entry instead. The key is stored as a $-reference so +the short-lived login token never lands on disk. +""" + +import json +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from types import MappingProxyType +from typing import Final + +import requests +from pydantic import BaseModel, JsonValue, TypeAdapter, ValidationError + +PI_CONFIG_DIR_ENV: Final = "PI_CODING_AGENT_DIR" +PI_PROVIDER_NAME: Final = "litellm" +LITELLM_PROXY_API_KEY_ENV: Final = "LITELLM_PROXY_API_KEY" + + +@dataclass(frozen=True, slots=True) +class PiSyncError: + message: str + + +@dataclass(frozen=True, slots=True) +class ModelLimits: + context_window: int | None + max_tokens: int | None + + +class _Model(BaseModel): + id: str + + +class _ModelList(BaseModel): + data: list[_Model] + + +class _ModelGroup(BaseModel): + model_group: str + max_input_tokens: float | None = None + max_output_tokens: float | None = None + + +class _ModelGroupList(BaseModel): + data: list[_ModelGroup] + + +def fetch_model_ids( + base_url: str, + api_key: str, + *, + get: Callable[..., requests.Response] = requests.get, +) -> tuple[str, ...] | PiSyncError: + url: Final = base_url.rstrip("/") + "/v1/models" + try: + resp: Final = get(url, headers={"Authorization": f"Bearer {api_key}"}, timeout=10) + except requests.RequestException as e: + return PiSyncError(f"Could not list models from the proxy: {e}") + if resp.status_code != 200: + return PiSyncError(f"The proxy returned HTTP {resp.status_code} for /v1/models; cannot build pi's model list.") + try: + listing: Final = _ModelList.model_validate(resp.json()) + except (ValueError, ValidationError) as e: + return PiSyncError(f"Unexpected /v1/models response from the proxy: {e}") + ids: Final = tuple(dict.fromkeys(model.id for model in listing.data)) + if not ids: + return PiSyncError("The proxy returned no models for your key, so pi would have nothing to run.") + return ids + + +_NO_LIMITS: Final[Mapping[str, ModelLimits]] = MappingProxyType({}) + + +def fetch_model_limits( + base_url: str, + api_key: str, + *, + get: Callable[..., requests.Response] = requests.get, +) -> Mapping[str, ModelLimits]: + """Best effort: pi falls back to its own defaults for models without limits, + so an unavailable /model_group/info must not block the launch.""" + url: Final = base_url.rstrip("/") + "/model_group/info" + try: + resp: Final = get(url, headers={"Authorization": f"Bearer {api_key}"}, timeout=10) + if resp.status_code != 200: + return _NO_LIMITS + listing: Final = _ModelGroupList.model_validate(resp.json()) + except (requests.RequestException, ValueError, ValidationError): + return _NO_LIMITS + return MappingProxyType( + { + group.model_group: ModelLimits( + context_window=int(group.max_input_tokens) if group.max_input_tokens else None, + max_tokens=int(group.max_output_tokens) if group.max_output_tokens else None, + ) + for group in listing.data + } + ) + + +def models_json_path(env: Mapping[str, str]) -> Path: + override: Final = env.get(PI_CONFIG_DIR_ENV) + root: Final = Path(override) if override else Path.home() / ".pi" / "agent" + return root / "models.json" + + +def _model_entry(model_id: str, limits: Mapping[str, ModelLimits]) -> dict[str, JsonValue]: + limit: Final = limits.get(model_id) + context: Final[dict[str, JsonValue]] = ( + {"contextWindow": limit.context_window} if limit and limit.context_window else {} + ) + output: Final[dict[str, JsonValue]] = {"maxTokens": limit.max_tokens} if limit and limit.max_tokens else {} + return {"id": model_id, **context, **output} + + +def provider_block( + base_url: str, + model_ids: tuple[str, ...], + limits: Mapping[str, ModelLimits] = _NO_LIMITS, +) -> dict[str, JsonValue]: + """openai-completions is the one API shape every LiteLLM model serves. + + Real contextWindow/maxTokens matter: pi otherwise assumes 128k/16384, which + breaks compaction thresholds and over-asks models with smaller output caps. + """ + return { + "baseUrl": base_url.rstrip("/") + "/v1", + "api": "openai-completions", + "apiKey": f"${LITELLM_PROXY_API_KEY_ENV}", + "models": [_model_entry(model_id, limits) for model_id in model_ids], + } + + +_MODELS_FILE_ADAPTER: Final = TypeAdapter(dict[str, JsonValue]) + + +def sync_models_json( + path: Path, + base_url: str, + model_ids: tuple[str, ...], + limits: Mapping[str, ModelLimits] = _NO_LIMITS, +) -> PiSyncError | None: + """Replace only the litellm provider entry, leaving the rest of the file intact.""" + try: + current: Final = _MODELS_FILE_ADAPTER.validate_json(path.read_text()) if path.exists() else {} + except (OSError, ValidationError) as e: + return PiSyncError(f"Could not read {path} as a JSON object: {e}. Fix or move the file, then retry.") + existing_providers: Final = current.get("providers", {}) + if not isinstance(existing_providers, dict): + return PiSyncError(f'"providers" in {path} is not an object; fix or move the file, then retry.') + updated: Final = { + **current, + "providers": {**existing_providers, PI_PROVIDER_NAME: provider_block(base_url, model_ids, limits)}, + } + try: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(updated, indent=2) + "\n") + except OSError as e: + return PiSyncError(f"Could not write {path}: {e}") + return None + + +__all__ = [ + "LITELLM_PROXY_API_KEY_ENV", + "PI_CONFIG_DIR_ENV", + "PI_PROVIDER_NAME", + "ModelLimits", + "PiSyncError", + "fetch_model_ids", + "fetch_model_limits", + "models_json_path", + "provider_block", + "sync_models_json", +] diff --git a/tests/test_litellm/proxy/client/cli/test_agents.py b/tests/test_litellm/proxy/client/cli/test_agents.py index afd1696a89f..fad0d3842fa 100644 --- a/tests/test_litellm/proxy/client/cli/test_agents.py +++ b/tests/test_litellm/proxy/client/cli/test_agents.py @@ -34,6 +34,15 @@ class _FakeResponse: self.status_code = status_code +class _FakeJsonResponse: + def __init__(self, status_code, payload=None): + self.status_code = status_code + self._payload = payload + + def json(self): + return self._payload + + class TestAgentProfile: def test_claude_is_anthropic(self): name, profiles = agent_profile("claude") @@ -49,6 +58,9 @@ class TestAgentProfile: assert agent_profile("codex") == ("Codex", frozenset({"openai"})) assert agent_profile("opencode") == ("OpenCode", frozenset({"openai"})) + def test_pi_is_litellm(self): + assert agent_profile("pi") == ("pi", frozenset({"litellm"})) + def test_unknown_command_gets_both_profiles(self): name, profiles = agent_profile("mytool") assert name == "mytool" @@ -91,6 +103,15 @@ class TestBuildAgentEnv: assert env["ANTHROPIC_AUTH_TOKEN"] == "sk-key" assert env["OPENAI_API_KEY"] == "sk-key" + def test_litellm_profile_exports_only_the_proxy_key(self): + env = build_agent_env( + {}, "http://localhost:4000/", "sk-key", frozenset({"litellm"}) + ) + assert env["LITELLM_PROXY_API_KEY"] == "sk-key" + assert "ANTHROPIC_BASE_URL" not in env + assert "OPENAI_BASE_URL" not in env + assert "OPENAI_API_KEY" not in env + def test_preserves_unrelated_env_and_does_not_mutate_input(self): base = {"PATH": "/usr/bin", "ANTHROPIC_API_KEY": "real-key"} env = build_agent_env( @@ -123,6 +144,9 @@ class TestAgentLaunchArgs: agent_launch_args("codex", "http://localhost:4000") ) + def test_pi_gets_no_static_args(self): + assert agent_launch_args("pi", "http://localhost:4000") == [] + class TestVerifyProxyKey: def test_ok_status_passes_and_uses_models_endpoint(self): @@ -223,6 +247,127 @@ class TestRunAgent: # overrides must precede the codex subcommand so codex parses them assert args.index('model_provider="litellm"') < args.index("exec") + def test_pi_preparer_runs_after_verify_and_before_launch(self): + order = [] + captured = {} + + def fake_prepare(base_url, api_key, base_env): + order.append("prepare") + captured["args"] = (base_url, api_key, dict(base_env)) + return [] + + run_agent( + "http://localhost:4000", + "sk-key", + ["pi"], + base_env={"HOME": "/home/u"}, + which=lambda name: "/usr/local/bin/pi", + verify=lambda *a: order.append("verify"), + launcher=lambda *a: order.append("launch"), + preparers={"pi": fake_prepare}, + ) + assert order == ["verify", "prepare", "launch"] + assert captured["args"] == ( + "http://localhost:4000", + "sk-key", + {"HOME": "/home/u"}, + ) + + def test_pi_prepared_args_precede_user_args_and_env_has_proxy_key(self): + calls = {} + run_agent( + "http://localhost:4000", + "sk-key", + ["pi", "-p", "hello"], + base_env={}, + which=lambda name: "/usr/local/bin/pi", + verify=lambda *a: None, + launcher=lambda p, a, e: calls.update(args=tuple(a), env=dict(e)), + preparers={"pi": lambda *a: ["--model", "litellm/m-1"]}, + ) + # user args come last so a user-supplied --model wins in pi's parser + assert calls["args"] == ("pi", "--model", "litellm/m-1", "-p", "hello") + assert calls["env"]["LITELLM_PROXY_API_KEY"] == "sk-key" + assert "OPENAI_API_KEY" not in calls["env"] + assert "ANTHROPIC_BASE_URL" not in calls["env"] + + def test_failed_preparer_aborts_before_launch(self): + launched = [] + + def boom(*a): + raise AgentRunError("sync failed") + + with pytest.raises(AgentRunError, match="sync failed"): + run_agent( + "http://localhost:4000", + "sk-key", + ["pi"], + base_env={}, + which=lambda name: "/usr/local/bin/pi", + verify=lambda *a: None, + launcher=lambda *a: launched.append(a), + preparers={"pi": boom}, + ) + assert launched == [] + + def test_prepare_pi_syncs_models_json_and_pins_first_model(self, tmp_path): + from litellm.proxy.client.cli.commands.agents import prepare_pi + + def fake_get(url, headers, timeout): + if url.endswith("/model_group/info"): + return _FakeJsonResponse( + 200, + {"data": [{"model_group": "m-first", "max_input_tokens": 131072, "max_output_tokens": 8192}]}, + ) + return _FakeJsonResponse(200, {"data": [{"id": "m-first"}, {"id": "m-second"}]}) + + pin = prepare_pi( + "http://localhost:4000", + "sk-key", + {"PI_CODING_AGENT_DIR": str(tmp_path)}, + get=fake_get, + ) + + assert pin == ["--model", "litellm/m-first"] + import json + + written = json.loads((tmp_path / "models.json").read_text()) + assert written["providers"]["litellm"]["apiKey"] == "$LITELLM_PROXY_API_KEY" + assert written["providers"]["litellm"]["models"] == [ + {"id": "m-first", "contextWindow": 131072, "maxTokens": 8192}, + {"id": "m-second"}, + ] + + def test_prepare_pi_surfaces_fetch_failure_as_agent_error(self, tmp_path): + from litellm.proxy.client.cli.commands.agents import prepare_pi + + with pytest.raises(AgentRunError, match="HTTP 500"): + prepare_pi( + "http://localhost:4000", + "sk-key", + {"PI_CODING_AGENT_DIR": str(tmp_path)}, + get=lambda *a, **k: _FakeJsonResponse(500), + ) + + def test_claude_has_no_preparer(self): + prepared = [] + + def fake_prepare(*a): + prepared.append(a) + return [] + + run_agent( + "http://localhost:4000", + "sk-key", + ["claude"], + base_env={}, + which=lambda name: "/usr/local/bin/claude", + verify=lambda *a: None, + launcher=lambda *a: None, + preparers={"pi": fake_prepare}, + ) + assert prepared == [] + def test_claude_launches_without_injected_args(self): calls = {} run_agent( @@ -319,7 +464,7 @@ class TestAgentCommands: self.runner = CliRunner() def test_one_command_per_known_agent(self): - assert {c.name for c in agent_commands()} == {"claude", "codex", "opencode"} + assert {c.name for c in agent_commands()} == {"claude", "codex", "opencode", "pi"} def test_claude_launches_with_stored_key_and_forwards_args(self): captured = {} diff --git a/tests/test_litellm/proxy/client/cli/test_pi.py b/tests/test_litellm/proxy/client/cli/test_pi.py new file mode 100644 index 00000000000..99ed2734b76 --- /dev/null +++ b/tests/test_litellm/proxy/client/cli/test_pi.py @@ -0,0 +1,201 @@ +import json +from pathlib import Path + +import requests + +from litellm.proxy.client.cli.commands.pi import ( + ModelLimits, + PiSyncError, + fetch_model_ids, + fetch_model_limits, + models_json_path, + provider_block, + sync_models_json, +) + + +class _FakeResponse: + def __init__(self, status_code, payload=None): + self.status_code = status_code + self._payload = payload + + def json(self): + if self._payload is None: + raise ValueError("not json") + return self._payload + + +class TestFetchModelIds: + def test_returns_ids_in_proxy_order_deduped(self): + captured = {} + + def fake_get(url, headers, timeout): + captured["url"] = url + captured["headers"] = headers + return _FakeResponse( + 200, + {"data": [{"id": "m-b"}, {"id": "m-a"}, {"id": "m-b"}]}, + ) + + assert fetch_model_ids("http://localhost:4000/", "sk-key", get=fake_get) == ("m-b", "m-a") + assert captured["url"] == "http://localhost:4000/v1/models" + assert captured["headers"] == {"Authorization": "Bearer sk-key"} + + def test_network_error_is_a_value(self): + def boom(*a, **k): + raise requests.ConnectionError("refused") + + result = fetch_model_ids("http://localhost:4000", "sk-key", get=boom) + assert isinstance(result, PiSyncError) + assert "Could not list models" in result.message + + def test_non_200_is_a_value(self): + result = fetch_model_ids( + "http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(500) + ) + assert isinstance(result, PiSyncError) + assert "HTTP 500" in result.message + + def test_malformed_body_is_a_value(self): + result = fetch_model_ids( + "http://localhost:4000", + "sk-key", + get=lambda *a, **k: _FakeResponse(200, {"data": "nope"}), + ) + assert isinstance(result, PiSyncError) + + def test_empty_model_list_is_a_value(self): + result = fetch_model_ids( + "http://localhost:4000", + "sk-key", + get=lambda *a, **k: _FakeResponse(200, {"data": []}), + ) + assert isinstance(result, PiSyncError) + assert "no models" in result.message + + +class TestFetchModelLimits: + def test_maps_group_limits_and_hits_model_group_info(self): + captured = {} + + def fake_get(url, headers, timeout): + captured["url"] = url + return _FakeResponse( + 200, + { + "data": [ + {"model_group": "m-a", "max_input_tokens": 131072, "max_output_tokens": 8192}, + {"model_group": "m-b", "max_input_tokens": None, "max_output_tokens": None}, + ] + }, + ) + + limits = fetch_model_limits("http://localhost:4000/", "sk-key", get=fake_get) + assert captured["url"] == "http://localhost:4000/model_group/info" + assert limits["m-a"] == ModelLimits(context_window=131072, max_tokens=8192) + assert limits["m-b"] == ModelLimits(context_window=None, max_tokens=None) + + def test_non_200_degrades_to_no_limits(self): + assert fetch_model_limits("http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(403)) == {} + + def test_network_error_degrades_to_no_limits(self): + def boom(*a, **k): + raise requests.ConnectionError("refused") + + assert fetch_model_limits("http://localhost:4000", "sk-key", get=boom) == {} + + def test_malformed_body_degrades_to_no_limits(self): + assert ( + fetch_model_limits( + "http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(200, {"data": "nope"}) + ) + == {} + ) + + +class TestModelsJsonPath: + def test_env_override_wins(self): + assert models_json_path({"PI_CODING_AGENT_DIR": "/custom/dir"}) == Path("/custom/dir/models.json") + + def test_defaults_to_home_pi_agent(self): + assert models_json_path({}) == Path.home() / ".pi" / "agent" / "models.json" + + +class TestProviderBlock: + def test_points_pi_at_proxy_with_env_interpolated_key(self): + block = provider_block("http://localhost:4000/", ("m-1", "m-2")) + assert block == { + "baseUrl": "http://localhost:4000/v1", + "api": "openai-completions", + "apiKey": "$LITELLM_PROXY_API_KEY", + "models": [{"id": "m-1"}, {"id": "m-2"}], + } + + def test_known_limits_become_context_window_and_max_tokens(self): + block = provider_block( + "http://localhost:4000", + ("m-1", "m-2"), + { + "m-1": ModelLimits(context_window=131072, max_tokens=8192), + "m-2": ModelLimits(context_window=None, max_tokens=None), + }, + ) + assert block["models"] == [ + {"id": "m-1", "contextWindow": 131072, "maxTokens": 8192}, + {"id": "m-2"}, + ] + + +class TestSyncModelsJson: + def test_creates_file_and_parent_dirs(self, tmp_path): + path = tmp_path / "agent" / "models.json" + assert sync_models_json(path, "http://localhost:4000", ("m-1",)) is None + written = json.loads(path.read_text()) + assert written["providers"]["litellm"]["baseUrl"] == "http://localhost:4000/v1" + assert written["providers"]["litellm"]["models"] == [{"id": "m-1"}] + + def test_preserves_other_providers_and_top_level_keys(self, tmp_path): + path = tmp_path / "models.json" + path.write_text( + json.dumps( + { + "somethingElse": True, + "providers": { + "ollama": {"baseUrl": "http://localhost:11434/v1"}, + "litellm": {"baseUrl": "http://stale:1234/v1", "models": []}, + }, + } + ) + ) + assert sync_models_json(path, "http://localhost:4000", ("m-1",)) is None + written = json.loads(path.read_text()) + assert written["somethingElse"] is True + assert written["providers"]["ollama"] == {"baseUrl": "http://localhost:11434/v1"} + assert written["providers"]["litellm"]["baseUrl"] == "http://localhost:4000/v1" + assert written["providers"]["litellm"]["models"] == [{"id": "m-1"}] + + def test_invalid_json_is_a_value_and_file_untouched(self, tmp_path): + path = tmp_path / "models.json" + path.write_text("{not json") + result = sync_models_json(path, "http://localhost:4000", ("m-1",)) + assert isinstance(result, PiSyncError) + assert path.read_text() == "{not json" + + def test_non_object_providers_is_a_value(self, tmp_path): + path = tmp_path / "models.json" + path.write_text(json.dumps({"providers": ["nope"]})) + result = sync_models_json(path, "http://localhost:4000", ("m-1",)) + assert isinstance(result, PiSyncError) + + def test_top_level_non_object_is_a_value(self, tmp_path): + path = tmp_path / "models.json" + path.write_text(json.dumps(["nope"])) + result = sync_models_json(path, "http://localhost:4000", ("m-1",)) + assert isinstance(result, PiSyncError) + + def test_unwritable_path_is_a_value(self, tmp_path): + blocker = tmp_path / "agent" + blocker.write_text("i am a file, not a directory") + result = sync_models_json(blocker / "models.json", "http://localhost:4000", ("m-1",)) + assert isinstance(result, PiSyncError) + assert "Could not" in result.message From d3dc6f6b12324186f15963f8eb12f02662461784 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 13 Aug 2026 16:15:51 -0700 Subject: [PATCH 005/410] fix(cli): write pi models.json atomically and hide lite pi from --help --- litellm/proxy/client/cli/README.md | 2 +- litellm/proxy/client/cli/commands/agents.py | 11 ++++++++--- litellm/proxy/client/cli/commands/pi.py | 4 +++- tests/test_litellm/proxy/client/cli/test_agents.py | 4 ++++ tests/test_litellm/proxy/client/cli/test_pi.py | 5 +++++ 5 files changed, 21 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 66beecbd2e6..468b8d96123 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -481,7 +481,7 @@ Each command resolves your LiteLLM key (logging in via SSO when none is stored a The right variables are picked per agent. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`. Codex ignores `OPENAI_BASE_URL`, so it is additionally pointed at the proxy through a custom provider passed as `-c` config overrides (HTTP/SSE Responses transport, since the proxy does not speak the Responses WebSocket protocol). -pi ignores base-URL environment variables entirely, so `lite pi` wires it up differently: before handoff it fetches the models your key can use from the proxy's `/v1/models` (plus each model's context window and output cap from `/model_group/info`, when available) and syncs them into a `litellm` provider entry in pi's `~/.pi/agent/models.json` (honoring `PI_CODING_AGENT_DIR`), then starts pi on that provider's first model via an injected `--model litellm/`. Only that one provider entry is rewritten; the rest of the file, including any other custom providers, is left alone. The entry references the key as `$LITELLM_PROXY_API_KEY`, which the wrapper exports for the session, so the token itself never lands on disk and plain `pi` outside the wrapper simply shows the litellm models as unavailable. Your own flags come after the injected pin, so `lite pi --model litellm/` wins, and inside the TUI the `/model` picker lists every synced litellm model. +pi ignores base-URL environment variables entirely, so `lite pi` (kept out of the `lite --help` command listing for now, but fully functional) wires it up differently: before handoff it fetches the models your key can use from the proxy's `/v1/models` (plus each model's context window and output cap from `/model_group/info`, when available) and syncs them into a `litellm` provider entry in pi's `~/.pi/agent/models.json` (honoring `PI_CODING_AGENT_DIR`), then starts pi on that provider's first model via an injected `--model litellm/`. Only that one provider entry is rewritten; the rest of the file, including any other custom providers, is left alone. The entry references the key as `$LITELLM_PROXY_API_KEY`, which the wrapper exports for the session, so the token itself never lands on disk and plain `pi` outside the wrapper simply shows the litellm models as unavailable. Your own flags come after the injected pin, so `lite pi --model litellm/` wins, and inside the TUI the `/model` picker lists every synced litellm model. Options (these belong to the wrapper, so put them before the agent's own flags): diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index f55cf9893e7..ba410673ec9 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -3,7 +3,7 @@ import shutil import sys from collections.abc import Callable, Mapping, Sequence from types import MappingProxyType -from typing import Final +from typing import Final, TypeAlias import click import requests @@ -43,6 +43,8 @@ _INSTALL_DOCS: Final[dict[str, str]] = { "pi": "https://pi.dev", } +_HIDDEN_AGENTS: Final = frozenset({"pi"}) + CODEX_PROXY_PROVIDER: Final = "litellm" @@ -150,7 +152,9 @@ def prepare_pi( return ["--model", f"{PI_PROVIDER_NAME}/{ids[0]}"] -_PREPARERS: Final[dict[str, Callable[[str, str, Mapping[str, str]], Sequence[str]]]] = { +_Preparer: TypeAlias = Callable[[str, str, Mapping[str, str]], Sequence[str]] + +_PREPARERS: Final[dict[str, _Preparer]] = { "pi": prepare_pi, } @@ -226,7 +230,7 @@ def run_agent( verify: Callable[[str, str], None] = verify_proxy_key, launcher: Callable[[str, Sequence[str], Mapping[str, str]], None] = _exec, reattach_terminal: Callable[[], None] | None = None, - preparers: Mapping[str, Callable[[str, str, Mapping[str, str]], Sequence[str]]] = MappingProxyType(_PREPARERS), + preparers: Mapping[str, _Preparer] = MappingProxyType(_PREPARERS), ) -> None: """Validate, wire the environment, and hand off to the agent. @@ -311,6 +315,7 @@ def _make_agent_command(binary: str, display_name: str) -> click.Command: name=binary, context_settings={"ignore_unknown_options": True}, short_help=f"Run {display_name} through your LiteLLM proxy", + hidden=binary in _HIDDEN_AGENTS, ) @click.option("--skip-verify", is_flag=True, default=False, help=_SKIP_VERIFY_HELP) @click.argument("args", nargs=-1, type=click.UNPROCESSED) diff --git a/litellm/proxy/client/cli/commands/pi.py b/litellm/proxy/client/cli/commands/pi.py index bd933874a10..668b803a33d 100644 --- a/litellm/proxy/client/cli/commands/pi.py +++ b/litellm/proxy/client/cli/commands/pi.py @@ -156,9 +156,11 @@ def sync_models_json( **current, "providers": {**existing_providers, PI_PROVIDER_NAME: provider_block(base_url, model_ids, limits)}, } + staging: Final = path.with_name(path.name + ".tmp") try: path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(json.dumps(updated, indent=2) + "\n") + staging.write_text(json.dumps(updated, indent=2) + "\n") + staging.replace(path) except OSError as e: return PiSyncError(f"Could not write {path}: {e}") return None diff --git a/tests/test_litellm/proxy/client/cli/test_agents.py b/tests/test_litellm/proxy/client/cli/test_agents.py index fad0d3842fa..99b2ff234e2 100644 --- a/tests/test_litellm/proxy/client/cli/test_agents.py +++ b/tests/test_litellm/proxy/client/cli/test_agents.py @@ -466,6 +466,10 @@ class TestAgentCommands: def test_one_command_per_known_agent(self): assert {c.name for c in agent_commands()} == {"claude", "codex", "opencode", "pi"} + def test_pi_is_hidden_from_help_but_still_registered(self): + hidden_by_name = {c.name: c.hidden for c in agent_commands()} + assert hidden_by_name == {"claude": False, "codex": False, "opencode": False, "pi": True} + def test_claude_launches_with_stored_key_and_forwards_args(self): captured = {} diff --git a/tests/test_litellm/proxy/client/cli/test_pi.py b/tests/test_litellm/proxy/client/cli/test_pi.py index 99ed2734b76..ee3222a77e3 100644 --- a/tests/test_litellm/proxy/client/cli/test_pi.py +++ b/tests/test_litellm/proxy/client/cli/test_pi.py @@ -174,6 +174,11 @@ class TestSyncModelsJson: assert written["providers"]["litellm"]["baseUrl"] == "http://localhost:4000/v1" assert written["providers"]["litellm"]["models"] == [{"id": "m-1"}] + def test_write_leaves_no_staging_file_behind(self, tmp_path): + path = tmp_path / "models.json" + assert sync_models_json(path, "http://localhost:4000", ("m-1",)) is None + assert [p.name for p in tmp_path.iterdir()] == ["models.json"] + def test_invalid_json_is_a_value_and_file_untouched(self, tmp_path): path = tmp_path / "models.json" path.write_text("{not json") From e5582b65c9b69adf636c8a9c739a9fa1b96e01a7 Mon Sep 17 00:00:00 2001 From: milan Date: Fri, 28 Aug 2026 20:33:53 +0000 Subject: [PATCH 006/410] fix(team_endpoints): let member_delete clear a team left on the user row Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/team_endpoints.py | 38 +++--- .../test_team_endpoints.py | 118 ++++++++++++++++++ 2 files changed, 138 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index c6d7975b75e..88c58b8887a 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3303,9 +3303,6 @@ async def team_member_delete( data=data, ) - if not removed_team_members: - raise HTTPException(status_code=400, detail={"error": "User not found in team"}) - existing_team_row.members_with_roles = new_team_members _db_new_team_members: Final[list[dict]] = [m.model_dump() for m in new_team_members] @@ -3313,17 +3310,22 @@ async def team_member_delete( ## DELETE TEAM ID from USER ROW, IF EXISTS ## # get user row removed_user_ids: Final = frozenset(m.user_id for m in removed_team_members if m.user_id is not None) + addressed_user_ids: Final = removed_user_ids.union((data.user_id,) if data.user_id is not None else ()) key_val: Final[Mapping[str, object]] = ( - {"user_id": {"in": sorted(removed_user_ids)}} if removed_user_ids else {"user_email": data.user_email} + {"user_id": {"in": sorted(addressed_user_ids)}} if addressed_user_ids else {"user_email": data.user_email} ) member_tx: Final[_MemberDeleteTx] = tx 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( - (data.user_id,) if data.user_id is not None else (), - (user.user_id for user in existing_user_rows if user.user_id), - ) + user_ids_to_delete: Final = addressed_user_ids.union(user.user_id for user in existing_user_rows if user.user_id) + + # A user row can outlive its roster entry, and until the team is off user.teams the user + # still sees it and still fails key creation against it, so removal has to clear it too + stale_user_rows: Final = tuple(user for user in existing_user_rows if data.team_id in user.teams) + + if not removed_team_members and not stale_user_rows: + raise HTTPException(status_code=400, detail={"error": "User not found in team"}) ## DELETE KEYS CREATED BY USER FOR THIS TEAM # Fetch keys before deletion so their audit records can be persisted alongside the delete. @@ -3335,17 +3337,17 @@ async def team_member_delete( } ) - await _team_tx_db(tx).update( - where={"team_id": data.team_id}, - data={"members_with_roles": json.dumps(_db_new_team_members)}, - ) + if removed_team_members: + await _team_tx_db(tx).update( + where={"team_id": data.team_id}, + data={"members_with_roles": json.dumps(_db_new_team_members)}, + ) - for existing_user in existing_user_rows: - if data.team_id in existing_user.teams: - await tx.litellm_usertable.update( - where={"user_id": existing_user.user_id}, - data={"teams": {"set": [team for team in existing_user.teams if team != data.team_id]}}, - ) + for existing_user in stale_user_rows: + await tx.litellm_usertable.update( + where={"user_id": existing_user.user_id}, + data={"teams": {"set": [team for team in existing_user.teams if team != data.team_id]}}, + ) for _uid in sorted(user_ids_to_delete): await tx.litellm_teammembership.delete_many(where={"team_id": data.team_id, "user_id": _uid}) 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 ffa6bc601e9..e5b58f4b38e 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -4775,6 +4775,124 @@ async def test_team_member_delete_by_email_the_user_row_does_not_carry( ) +@pytest.mark.asyncio +async def test_team_member_delete_clears_team_left_on_the_user_row_without_a_roster_entry( + mock_db_client, mock_admin_auth +): + """ + A user row can keep a team (several times over, from older duplicate-prone adds) after the + roster entry is gone, which leaves the team listed on the user, offered in the key creation + dropdown, and rejected by key creation itself. Reporting "User not found in team" left that + residue unremovable, so the delete now cleans every copy of the team off the user row. + """ + from litellm.proxy._types import TeamMemberDeleteRequest + from litellm.proxy.management_endpoints.team_endpoints import team_member_delete + + test_team_id = "team-del-orphan-123" + test_user_id = "user-del-orphan-123" + + mock_team_row = MagicMock() + mock_team_row.model_dump.return_value = { + "team_id": test_team_id, + "members_with_roles": [], + "team_member_permissions": [], + "metadata": {}, + "models": [], + "spend": 0.0, + } + mock_db_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team_row + ) + mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row) + + mock_user_row = MagicMock() + mock_user_row.user_id = test_user_id + mock_user_row.user_email = None + mock_user_row.teams = [test_team_id, "other-team", test_team_id] + mock_db_client.db.litellm_usertable.find_many = AsyncMock( + return_value=[mock_user_row] + ) + mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock()) + + mock_db_client.db.litellm_teammembership = MagicMock() + mock_db_client.db.litellm_teammembership.delete_many = AsyncMock( + return_value=MagicMock() + ) + + mock_db_client.db.litellm_verificationtoken = MagicMock() + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock( + return_value=MagicMock() + ) + + _wire_member_delete_tx(mock_db_client) + + await team_member_delete( + data=TeamMemberDeleteRequest(team_id=test_team_id, user_id=test_user_id), + user_api_key_dict=mock_admin_auth, + ) + + mock_db_client.db.litellm_usertable.update.assert_awaited_once_with( + where={"user_id": test_user_id}, + data={"teams": {"set": ["other-team"]}}, + ) + mock_db_client.db.litellm_teammembership.delete_many.assert_awaited_once_with( + where={"team_id": test_team_id, "user_id": test_user_id} + ) + + +@pytest.mark.asyncio +async def test_team_member_delete_still_rejects_a_user_the_team_has_no_trace_of( + mock_db_client, mock_admin_auth +): + from litellm.proxy._types import TeamMemberDeleteRequest + from litellm.proxy.management_endpoints.team_endpoints import team_member_delete + + test_team_id = "team-del-absent-123" + test_user_id = "user-del-absent-123" + + mock_team_row = MagicMock() + mock_team_row.model_dump.return_value = { + "team_id": test_team_id, + "members_with_roles": [], + "team_member_permissions": [], + "metadata": {}, + "models": [], + "spend": 0.0, + } + mock_db_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team_row + ) + mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row) + + mock_user_row = MagicMock() + mock_user_row.user_id = test_user_id + mock_user_row.user_email = None + mock_user_row.teams = ["other-team"] + mock_db_client.db.litellm_usertable.find_many = AsyncMock( + return_value=[mock_user_row] + ) + mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock()) + + mock_db_client.db.litellm_teammembership = MagicMock() + mock_db_client.db.litellm_teammembership.delete_many = AsyncMock( + return_value=MagicMock() + ) + + _wire_member_delete_tx(mock_db_client) + + with pytest.raises(HTTPException) as exc_info: + await team_member_delete( + data=TeamMemberDeleteRequest(team_id=test_team_id, user_id=test_user_id), + user_api_key_dict=mock_admin_auth, + ) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == {"error": "User not found in team"} + mock_db_client.db.litellm_usertable.update.assert_not_awaited() + mock_db_client.db.litellm_teammembership.delete_many.assert_not_awaited() + + class _InjectedMemberDeleteFailure(Exception): pass From df9b9f9ffb486561cd4cbbacffd2402b9b43ae16 Mon Sep 17 00:00:00 2001 From: milan Date: Fri, 28 Aug 2026 21:12:05 +0000 Subject: [PATCH 007/410] style(team_endpoints): apply ruff format Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/management_endpoints/team_endpoints.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 88c58b8887a..fce109c0c8b 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3318,7 +3318,9 @@ async def team_member_delete( 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 = addressed_user_ids.union(user.user_id for user in existing_user_rows if user.user_id) + user_ids_to_delete: Final = addressed_user_ids.union( + user.user_id for user in existing_user_rows if user.user_id + ) # A user row can outlive its roster entry, and until the team is off user.teams the user # still sees it and still fails key creation against it, so removal has to clear it too From 676f841534e7c83bcf5d9afb65f5c37bf741af44 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 03:47:38 -0700 Subject: [PATCH 008/410] feat(mistral): add text-to-speech support for /v1/audio/speech --- .../mistral/audio_speech/transformation.py | 209 ++++++++++++++++++ litellm/main.py | 28 +++ ...odel_prices_and_context_window_backup.json | 4 +- litellm/router.py | 4 +- litellm/utils.py | 6 + model_prices_and_context_window.json | 4 +- ...est_mistral_audio_speech_transformation.py | 198 +++++++++++++++++ tests/test_litellm/test_cost_calculator.py | 12 + tests/test_litellm/test_main.py | 28 +++ tests/test_litellm/test_router.py | 26 +++ 10 files changed, 513 insertions(+), 6 deletions(-) create mode 100644 litellm/llms/mistral/audio_speech/transformation.py create mode 100644 tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py diff --git a/litellm/llms/mistral/audio_speech/transformation.py b/litellm/llms/mistral/audio_speech/transformation.py new file mode 100644 index 00000000000..6d1a693268a --- /dev/null +++ b/litellm/llms/mistral/audio_speech/transformation.py @@ -0,0 +1,209 @@ +""" +Support for Mistral Voxtral text-to-speech via ``/v1/audio/speech``. + +API reference: https://docs.mistral.ai/api/#tag/audio/operation/audio_speech_v1_audio_speech_post +""" + +import base64 +import json +from collections.abc import Mapping +from types import MappingProxyType +from typing import TYPE_CHECKING, Final + +import httpx + +from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.text_to_speech.transformation import ( + BaseTextToSpeechConfig, + TextToSpeechRequestData, +) +from litellm.secret_managers.main import get_secret_str + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.types.llms.openai import HttpxBinaryResponseContent + + +class MistralTextToSpeechException(BaseLLMException): + pass + + +class MistralTextToSpeechConfig(BaseTextToSpeechConfig): + TTS_BASE_URL: Final[str] = "https://api.mistral.ai/v1" + AUDIO_CONTENT_TYPES: Final[MappingProxyType[str, str]] = MappingProxyType( + { + "mp3": "audio/mpeg", + "wav": "audio/wav", + "pcm": "audio/pcm", + "flac": "audio/flac", + "opus": "audio/ogg", + } + ) + DROPPED_RESPONSE_HEADERS: Final[frozenset[str]] = frozenset( + {"content-encoding", "transfer-encoding", "content-length", "content-type"} + ) + OPENAI_VOICE_ALIASES: Final[MappingProxyType[str, str]] = MappingProxyType( + { + "alloy": "en_paul_neutral", + "echo": "gb_oliver_neutral", + "fable": "en_paul_cheerful", + "onyx": "en_paul_confident", + "nova": "gb_jane_sarcasm", + "shimmer": "gb_jane_sarcasm", + } + ) + + def get_supported_openai_params(self, model: str) -> list: # mutable-ok: base class contract returns a plain list + return ["voice", "response_format"] # mutable-ok: base class contract returns a plain list + + def _map_openai_voice(self, voice_id: str) -> str: + return self.OPENAI_VOICE_ALIASES.get(voice_id.lower(), voice_id) + + def _resolve_voice_id(self, voice: object) -> str | None: + if isinstance(voice, str) and voice.strip(): + return self._map_openai_voice(voice.strip()) + if isinstance(voice, Mapping): + candidates: Final = (voice.get(key) for key in ("voice_id", "id", "name")) + resolved: Final = next( + (candidate.strip() for candidate in candidates if isinstance(candidate, str) and candidate.strip()), + None, + ) + return self._map_openai_voice(resolved) if resolved else None + return None + + def map_openai_params( + self, + model: str, + optional_params: Mapping[str, object], + voice: object = None, + drop_params: bool = False, + kwargs: Mapping[str, object] | None = None, + ) -> tuple[str | None, dict]: # mutable-ok: base class contract returns a plain dict + response_format: Final = optional_params.get("response_format") + ref_audio: Final = kwargs.get("ref_audio") if kwargs else None + voice_id_kwarg: Final = kwargs.get("voice_id") if kwargs else None + mapped_voice: Final = self._resolve_voice_id(voice) or self._resolve_voice_id(voice_id_kwarg) + mapped_params: Final = { # mutable-ok: base class contract returns a plain dict + key: value + for key, value in (("response_format", response_format), ("ref_audio", ref_audio)) + if isinstance(value, str) + } + return mapped_voice, mapped_params + + def validate_environment( + self, + headers: Mapping[str, str], + model: str, + api_key: str | None = None, + api_base: str | None = None, + ) -> dict: # mutable-ok: base class contract returns a plain dict + resolved_key: Final = api_key or get_secret_str("MISTRAL_API_KEY") + if resolved_key is None: + raise MistralTextToSpeechException( + status_code=401, + message="Mistral API key is required. Set MISTRAL_API_KEY or pass api_key.", + ) + return { # mutable-ok: base class contract returns a plain dict + **headers, + "Authorization": f"Bearer {resolved_key}", + "Content-Type": "application/json", + } + + def get_complete_url( + self, + model: str, + api_base: str | None, + litellm_params: Mapping[str, object], + ) -> str: + base_url: Final = api_base or get_secret_str("MISTRAL_API_BASE") or self.TTS_BASE_URL + return f"{base_url.rstrip('/')}/audio/speech" + + def transform_text_to_speech_request( + self, + model: str, + input: str, + voice: str | None, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object], + headers: Mapping[str, str], + ) -> TextToSpeechRequestData: + response_format: Final = optional_params.get("response_format") + ref_audio: Final = optional_params.get("ref_audio") + request_data: Final[TextToSpeechRequestData] = { + "dict_body": { + "model": model, + "input": input, + **({"voice_id": voice} if voice else {}), + **({"response_format": response_format} if isinstance(response_format, str) else {}), + **({"ref_audio": ref_audio} if isinstance(ref_audio, str) else {}), + }, + "headers": {"Content-Type": "application/json"}, + } + return request_data + + def _requested_content_type(self, request: httpx.Request) -> str: + request_body: Final = json.loads(request.content or b"{}") + requested_format: Final = request_body.get("response_format") + if not isinstance(requested_format, str): + return "audio/mpeg" + return self.AUDIO_CONTENT_TYPES.get(requested_format, "audio/mpeg") + + def transform_text_to_speech_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: "LiteLLMLoggingObj", + ) -> "HttpxBinaryResponseContent": + from litellm.types.llms.openai import HttpxBinaryResponseContent + + try: + response_json: Final = raw_response.json() + except (json.JSONDecodeError, ValueError): + raise MistralTextToSpeechException( + status_code=raw_response.status_code, + message=f"Non-JSON response from Mistral speech API: {raw_response.text[:500]}", + headers=raw_response.headers, + ) + audio_b64: Final = response_json.get("audio_data") + if not isinstance(audio_b64, str) or not audio_b64: + raise MistralTextToSpeechException( + status_code=500, + message=f"No audio_data in Mistral speech response. Response keys: {tuple(response_json.keys())}", + headers=raw_response.headers, + ) + try: + audio_bytes: Final = base64.b64decode(audio_b64) + except ValueError: + raise MistralTextToSpeechException( + status_code=500, + message="Invalid base64 audio_data in Mistral speech response.", + headers=raw_response.headers, + ) + retained_headers: Final = tuple( + (key, value) + for key, value in raw_response.headers.items() + if key.lower() not in self.DROPPED_RESPONSE_HEADERS + ) + response_headers: Final = retained_headers + ( + ("content-length", str(len(audio_bytes))), + ("content-type", self._requested_content_type(raw_response.request)), + ) + binary_response: Final = httpx.Response( + status_code=200, + headers=response_headers, + content=audio_bytes, + request=raw_response.request, + ) + return HttpxBinaryResponseContent(binary_response) + + def get_error_class( + self, + error_message: str, + status_code: int, + headers: dict | httpx.Headers, # mutable-ok: BaseLLMException takes a plain dict or httpx.Headers + ) -> BaseLLMException: + return MistralTextToSpeechException( + message=error_message, + status_code=status_code, + headers=headers, + ) diff --git a/litellm/main.py b/litellm/main.py index cafa1e4718f..692df23b3f9 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8367,6 +8367,34 @@ def speech( client=client, _is_async=aspeech or False, ) + elif custom_llm_provider == "mistral": + from litellm.llms.mistral.audio_speech.transformation import ( + MistralTextToSpeechConfig, + ) + + mistral_tts_config: Final = text_to_speech_provider_config or MistralTextToSpeechConfig() + + if api_base is not None: + litellm_params_dict["api_base"] = api_base + if api_key is not None: + litellm_params_dict["api_key"] = api_key + + mistral_voice: Final[str | None] = voice if isinstance(voice, str) else None + + response = base_llm_http_handler.text_to_speech_handler( + model=model, + input=input, + voice=mistral_voice, + text_to_speech_provider_config=mistral_tts_config, + text_to_speech_optional_params=optional_params, + custom_llm_provider=custom_llm_provider, + litellm_params=litellm_params_dict, + logging_obj=logging_obj, + timeout=timeout, + extra_headers=extra_headers, + client=client, + _is_async=aspeech or False, + ) elif custom_llm_provider == "aws_polly": from litellm.llms.aws_polly.text_to_speech.transformation import ( AWSPollyTextToSpeechConfig, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bebbcc32181..620fe2f8030 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -31126,9 +31126,9 @@ "supports_audio_input": true }, "mistral/voxtral-mini-tts-latest": { + "input_cost_per_character": 1.6e-05, "litellm_provider": "mistral", "mode": "audio_speech", - "output_cost_per_character": 1.6e-05, "source": "https://docs.mistral.ai/models/model-cards/voxtral-tts-26-03", "supported_endpoints": [ "/v1/audio/speech" @@ -51677,9 +51677,9 @@ "supports_audio_input": true }, "mistral/voxtral-mini-tts-2603": { + "input_cost_per_character": 1.6e-05, "litellm_provider": "mistral", "mode": "audio_speech", - "output_cost_per_character": 1.6e-05, "source": "https://docs.mistral.ai/models/model-cards/voxtral-tts-26-03", "supported_endpoints": [ "/v1/audio/speech" diff --git a/litellm/router.py b/litellm/router.py index c93c1753f0e..d6ec5e57467 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -4270,7 +4270,7 @@ class Router: self.fail_calls[model_name] += 1 raise e - async def aspeech(self, model: str, input: str, voice: str, **kwargs): + async def aspeech(self, model: str, input: str, voice: str | None = None, **kwargs): """ Example Usage: @@ -4322,7 +4322,7 @@ class Router: ) raise e - async def _aspeech(self, model: str, input: str, voice: str, **kwargs): + async def _aspeech(self, model: str, input: str, voice: str | None = None, **kwargs): model_name: Final = model try: verbose_router_logger.debug("Inside _aspeech()- model: %s; kwargs: %s", model, kwargs) diff --git a/litellm/utils.py b/litellm/utils.py index 5cd9bfc5f32..74a9b4ce935 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9408,6 +9408,12 @@ class ProviderConfigManager: ) return MinimaxTextToSpeechConfig() + elif litellm.LlmProviders.MISTRAL == provider: + from litellm.llms.mistral.audio_speech.transformation import ( + MistralTextToSpeechConfig, + ) + + return MistralTextToSpeechConfig() elif litellm.LlmProviders.AWS_POLLY == provider: from litellm.llms.aws_polly.text_to_speech.transformation import ( AWSPollyTextToSpeechConfig, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index bebbcc32181..620fe2f8030 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -31126,9 +31126,9 @@ "supports_audio_input": true }, "mistral/voxtral-mini-tts-latest": { + "input_cost_per_character": 1.6e-05, "litellm_provider": "mistral", "mode": "audio_speech", - "output_cost_per_character": 1.6e-05, "source": "https://docs.mistral.ai/models/model-cards/voxtral-tts-26-03", "supported_endpoints": [ "/v1/audio/speech" @@ -51677,9 +51677,9 @@ "supports_audio_input": true }, "mistral/voxtral-mini-tts-2603": { + "input_cost_per_character": 1.6e-05, "litellm_provider": "mistral", "mode": "audio_speech", - "output_cost_per_character": 1.6e-05, "source": "https://docs.mistral.ai/models/model-cards/voxtral-tts-26-03", "supported_endpoints": [ "/v1/audio/speech" diff --git a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py new file mode 100644 index 00000000000..20d07699cb8 --- /dev/null +++ b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py @@ -0,0 +1,198 @@ +import base64 +from typing import Final +from unittest.mock import MagicMock + +import httpx +import pytest + +import litellm +from litellm.llms.base_llm.text_to_speech.transformation import BaseTextToSpeechConfig +from litellm.llms.mistral.audio_speech.transformation import ( + MistralTextToSpeechConfig, + MistralTextToSpeechException, +) +from litellm.utils import ProviderConfigManager + +SPEECH_URL: Final = "https://api.mistral.ai/v1/audio/speech" + + +def test_mistral_text_to_speech_config_installed(): + config: Final = ProviderConfigManager.get_provider_text_to_speech_config( + model="voxtral-mini-tts-2603", + provider=litellm.LlmProviders.MISTRAL, + ) + assert isinstance(config, BaseTextToSpeechConfig) + assert isinstance(config, MistralTextToSpeechConfig) + + +def test_map_openai_params_drops_speed_and_instructions(): + config: Final = MistralTextToSpeechConfig() + voice, params = config.map_openai_params( + model="voxtral-mini-tts-2603", + optional_params={"response_format": "wav", "speed": 1.5, "instructions": "sound cheerful"}, + voice="en_paul_neutral", + ) + assert voice == "en_paul_neutral" + assert params == {"response_format": "wav"} + + +def test_map_openai_params_accepts_voice_dict_and_ref_audio(): + config: Final = MistralTextToSpeechConfig() + voice, params = config.map_openai_params( + model="voxtral-mini-tts-2603", + optional_params={}, + voice={"voice_id": "1f3a8b0c-voice-uuid"}, + kwargs={"ref_audio": "bXktdm9pY2Utc2FtcGxl"}, + ) + assert voice == "1f3a8b0c-voice-uuid" + assert params == {"ref_audio": "bXktdm9pY2Utc2FtcGxl"} + + +def test_transform_request_builds_mistral_body(): + config: Final = MistralTextToSpeechConfig() + data: Final = config.transform_text_to_speech_request( + model="voxtral-mini-tts-2603", + input="hello from litellm", + voice="en_paul_neutral", + optional_params={"response_format": "wav"}, + litellm_params={}, + headers={}, + ) + assert data["dict_body"] == { + "model": "voxtral-mini-tts-2603", + "input": "hello from litellm", + "voice_id": "en_paul_neutral", + "response_format": "wav", + } + assert data["headers"] == {"Content-Type": "application/json"} + + +def test_transform_request_omits_voice_for_ref_audio_cloning(): + config: Final = MistralTextToSpeechConfig() + data: Final = config.transform_text_to_speech_request( + model="voxtral-mini-tts-2603", + input="clone me", + voice=None, + optional_params={"ref_audio": "bXktdm9pY2Utc2FtcGxl"}, + litellm_params={}, + headers={}, + ) + assert data["dict_body"] == { + "model": "voxtral-mini-tts-2603", + "input": "clone me", + "ref_audio": "bXktdm9pY2Utc2FtcGxl", + } + + +def test_get_complete_url_default_base(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("MISTRAL_API_BASE", raising=False) + config: Final = MistralTextToSpeechConfig() + url: Final = config.get_complete_url(model="voxtral-mini-tts-2603", api_base=None, litellm_params={}) + assert url == SPEECH_URL + + +def test_get_complete_url_custom_base(): + config: Final = MistralTextToSpeechConfig() + url: Final = config.get_complete_url( + model="voxtral-mini-tts-2603", + api_base="https://custom.api.example.com/v1/", + litellm_params={}, + ) + assert url == "https://custom.api.example.com/v1/audio/speech" + + +def test_validate_environment_sets_bearer_header(): + config: Final = MistralTextToSpeechConfig() + headers: Final = config.validate_environment( + headers={"x-custom": "1"}, + model="voxtral-mini-tts-2603", + api_key="sk-mistral-test", + ) + assert headers == { + "x-custom": "1", + "Authorization": "Bearer sk-mistral-test", + "Content-Type": "application/json", + } + + +def test_validate_environment_requires_key(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("MISTRAL_API_KEY", raising=False) + config: Final = MistralTextToSpeechConfig() + with pytest.raises(MistralTextToSpeechException, match="MISTRAL_API_KEY"): + config.validate_environment(headers={}, model="voxtral-mini-tts-2603") + + +def test_transform_response_decodes_base64_audio(): + config: Final = MistralTextToSpeechConfig() + audio_bytes: Final = b"RIFF-fake-wav-bytes" + raw_response: Final = httpx.Response( + 200, + json={"audio_data": base64.b64encode(audio_bytes).decode()}, + headers={"x-request-id": "req-123"}, + request=httpx.Request( + "POST", + SPEECH_URL, + json={"model": "voxtral-mini-tts-2603", "input": "hi", "response_format": "wav"}, + ), + ) + result: Final = config.transform_text_to_speech_response( + model="voxtral-mini-tts-2603", + raw_response=raw_response, + logging_obj=MagicMock(), + ) + assert result.content == audio_bytes + assert result.response.headers["content-type"] == "audio/wav" + assert result.response.headers["content-length"] == str(len(audio_bytes)) + assert result.response.headers["x-request-id"] == "req-123" + + +def test_transform_response_missing_audio_data_raises(): + config: Final = MistralTextToSpeechConfig() + raw_response: Final = httpx.Response( + 200, + json={"detail": "unexpected"}, + request=httpx.Request("POST", SPEECH_URL, json={"model": "voxtral-mini-tts-2603", "input": "hi"}), + ) + with pytest.raises(MistralTextToSpeechException, match="audio_data"): + config.transform_text_to_speech_response( + model="voxtral-mini-tts-2603", + raw_response=raw_response, + logging_obj=MagicMock(), + ) + + +def test_map_openai_params_maps_openai_voice_aliases(): + config: Final = MistralTextToSpeechConfig() + alloy_voice, _ = config.map_openai_params( + model="voxtral-mini-tts-2603", + optional_params={}, + voice="alloy", + ) + nova_voice, _ = config.map_openai_params( + model="voxtral-mini-tts-2603", + optional_params={}, + voice="Nova", + ) + passthrough_voice, _ = config.map_openai_params( + model="voxtral-mini-tts-2603", + optional_params={}, + voice="en_paul_happy", + ) + assert alloy_voice == "en_paul_neutral" + assert nova_voice == "gb_jane_sarcasm" + assert passthrough_voice == "en_paul_happy" + + +def test_transform_response_invalid_base64_raises(): + config: Final = MistralTextToSpeechConfig() + raw_response: Final = httpx.Response( + status_code=200, + json={"audio_data": "!!!not-base64!!!"}, + request=httpx.Request("POST", SPEECH_URL), + ) + with pytest.raises(MistralTextToSpeechException, match="base64"): + config.transform_text_to_speech_response( + model="voxtral-mini-tts-2603", + raw_response=raw_response, + logging_obj=MagicMock(), + ) diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 7c2174018e8..db08ee486bf 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -4473,3 +4473,15 @@ def test_explicit_pricing_precedes_private_provider_response_model( ) assert selected == expected + + +def test_cost_per_token_mistral_voxtral_tts_bills_per_input_character(_local_model_cost_map): + prompt_usd, completion_usd = cost_per_token( + model="voxtral-mini-tts-2603", + custom_llm_provider="mistral", + call_type="speech", + prompt_characters=1000, + ) + + assert prompt_usd == pytest.approx(1000 * 1.6e-05) + assert completion_usd == 0.0 diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 8cf878d05d9..47293d9c413 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -3181,3 +3181,31 @@ def test_stream_chunk_builder_defers_cost_to_logging_obj_when_usage_cost_absent( assert response is not None assert response._hidden_params.get("response_cost") is None + + +def test_speech_mistral_dispatches_and_decodes_audio(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test") + audio_bytes: Final = b"ID3-fake-mp3-bytes" + mock_route: Final = respx_mock.post("https://api.mistral.ai/v1/audio/speech").mock( + return_value=httpx.Response(200, json={"audio_data": base64.b64encode(audio_bytes).decode()}) + ) + + response: Final = litellm.speech( + model="mistral/voxtral-mini-tts-2603", + input="hello from litellm", + voice="en_paul_neutral", + response_format="wav", + speed=2, + instructions="sound cheerful", + ) + + assert mock_route.called + request_body: Final = json.loads(mock_route.calls.last.request.content) + assert request_body == { + "model": "voxtral-mini-tts-2603", + "input": "hello from litellm", + "voice_id": "en_paul_neutral", + "response_format": "wav", + } + assert mock_route.calls.last.request.headers["authorization"] == "Bearer sk-mistral-test" + assert response.content == audio_bytes diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 97286017ffe..388011b7d5d 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -11530,3 +11530,29 @@ class TestTierParamsTheTargetAccepts: accepted = router._tier_params_the_target_accepts("no-such-group", {"reasoning_effort": "max"}, {}) assert accepted == {"reasoning_effort": "max"} + + +@pytest.mark.asyncio +async def test_router_aspeech_without_voice_dispatches_ref_audio_cloning(respx_mock, monkeypatch): + import base64 + + monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + audio_bytes = b"RIFFfake-wav-bytes" + respx_mock.post("https://api.mistral.ai/v1/audio/speech").respond( + json={"audio_data": base64.b64encode(audio_bytes).decode()} + ) + router = Router( + model_list=[ + { + "model_name": "voxtral-tts", + "litellm_params": {"model": "mistral/voxtral-mini-tts-2603"}, + } + ] + ) + + response = await router.aspeech(model="voxtral-tts", input="clone me", ref_audio="ZmFrZQ==") + + request_body = json.loads(respx_mock.calls.last.request.content) + assert request_body == {"model": "voxtral-mini-tts-2603", "input": "clone me", "ref_audio": "ZmFrZQ=="} + assert response.content == audio_bytes From 7c4cf2dcffbbff32b087d7756d4c0c0a4c590a91 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 11:41:24 -0700 Subject: [PATCH 009/410] fix(mistral): reject malformed base64 audio_data with strict validation --- litellm/llms/mistral/audio_speech/transformation.py | 2 +- .../audio_speech/test_mistral_audio_speech_transformation.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/llms/mistral/audio_speech/transformation.py b/litellm/llms/mistral/audio_speech/transformation.py index 6d1a693268a..e7f7d510346 100644 --- a/litellm/llms/mistral/audio_speech/transformation.py +++ b/litellm/llms/mistral/audio_speech/transformation.py @@ -172,7 +172,7 @@ class MistralTextToSpeechConfig(BaseTextToSpeechConfig): headers=raw_response.headers, ) try: - audio_bytes: Final = base64.b64decode(audio_b64) + audio_bytes: Final = base64.b64decode(audio_b64, validate=True) except ValueError: raise MistralTextToSpeechException( status_code=500, diff --git a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py index 20d07699cb8..6d250901e50 100644 --- a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py +++ b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py @@ -187,7 +187,7 @@ def test_transform_response_invalid_base64_raises(): config: Final = MistralTextToSpeechConfig() raw_response: Final = httpx.Response( status_code=200, - json={"audio_data": "!!!not-base64!!!"}, + json={"audio_data": "QUJD!QUJD"}, request=httpx.Request("POST", SPEECH_URL), ) with pytest.raises(MistralTextToSpeechException, match="base64"): From 318b6a4b36d31c4255c66d7b6289bf782fd37d20 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:26:48 -0700 Subject: [PATCH 010/410] fix(mcp): forward staged credentials on /mcp-rest/test/connection like /test/tools/list The connection preview built its temporary MCP client without the credentials the not-yet-saved server config carries: the Authorization bearer an OAuth2 authorization_code server had just been granted, the auth_value of an api_key, bearer_token, basic, or authorization server, and the stored credentials of a saved server being edited. The tools preview forwarded all three, so the same request succeeded there and failed on the connection test with the generic "Failed to connect to MCP server" message Both previews now resolve those credentials through one shared staging step, so they cannot drift apart again, and the Authorization header is only forwarded upstream when the primary x-litellm-api-key header carried admission, since otherwise it is the caller's LiteLLM key --- .../mcp_server/rest_endpoints.py | 85 ++++++----- .../mcp_server/test_rest_endpoints.py | 134 ++++++++++++++++++ 2 files changed, 186 insertions(+), 33 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 3efb6429326..a1583154916 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -1,11 +1,13 @@ import asyncio import importlib from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass from datetime import datetime from typing import TYPE_CHECKING, Any, Final, Literal import httpx from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from starlette.datastructures import Headers from litellm._logging import verbose_logger from litellm.exceptions import ( @@ -1130,6 +1132,45 @@ if MCP_AVAILABLE: scopes: Final[list[str] | None] = scopes_raw if isinstance(scopes_raw, list) else None return client_id, client_secret, scopes + _STAGED_AUTH_VALUE_AUTH_TYPES: Final = frozenset( + (MCPAuth.api_key, MCPAuth.bearer_token, MCPAuth.basic, MCPAuth.authorization) + ) + + @dataclass(frozen=True, slots=True) + class _StagedServerTest: + request: NewMCPServerRequest + mcp_auth_header: str | None + oauth2_headers: dict[str, str] | None + + def _stage_server_test(new_mcp_server_request: NewMCPServerRequest, headers: Headers) -> _StagedServerTest: + """ + Resolve the credentials a not-yet-saved server config carries for a preview call. + + Both preview endpoints (``/test/connection`` and ``/test/tools/list``) must hand the + temporary client the same credentials, or a server that the saved connection reaches + fine fails one of them. + """ + from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( + MCPRequestHandler, + ) + + request: Final = _inherit_credentials_from_existing_server(new_mcp_server_request) + mcp_auth_header: Final = ( + request.credentials.get("auth_value") + if request.auth_type in _STAGED_AUTH_VALUE_AUTH_TYPES and isinstance(request.credentials, dict) + else None + ) + # Authorization doubles as the admission fallback (LITELLM_API_KEY_HEADER_NAME_SECONDARY): + # when the primary x-litellm-api-key header is absent, the Authorization value is the + # caller's LiteLLM key, not an upstream token, and must never be forwarded upstream. + oauth2_headers: Final = ( + MCPRequestHandler._get_oauth2_headers_from_headers(headers) + if request.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES + and headers.get(MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_PRIMARY) + else None + ) + return _StagedServerTest(request=request, mcp_auth_header=mcp_auth_header, oauth2_headers=oauth2_headers) + async def _execute_with_mcp_client( request: NewMCPServerRequest, operation: Callable[..., Awaitable[Mapping[str, object]]], @@ -1339,6 +1380,8 @@ if MCP_AVAILABLE: }, ) + staged: Final = _stage_server_test(new_mcp_server_request, request.headers) + async def _test_connection_operation(client): async def _noop(session): return "ok" @@ -1347,8 +1390,10 @@ if MCP_AVAILABLE: return {"status": "ok"} return await _execute_with_mcp_client( - new_mcp_server_request, + staged.request, _test_connection_operation, + mcp_auth_header=staged.mcp_auth_header, + oauth2_headers=staged.oauth2_headers, raw_headers=_safe_get_request_headers(request), ) @@ -1369,37 +1414,11 @@ if MCP_AVAILABLE: }, ) - new_mcp_server_request = _inherit_credentials_from_existing_server(new_mcp_server_request) + staged: Final = _stage_server_test(new_mcp_server_request, request.headers) # For OpenAPI spec servers, generate tools from the spec directly - if new_mcp_server_request.spec_path: - return await _preview_openapi_tools(new_mcp_server_request.spec_path) - - from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( - MCPRequestHandler, - ) - - headers: Final = request.headers - - mcp_auth_header: str | None = None - if new_mcp_server_request.auth_type in { - MCPAuth.api_key, - MCPAuth.bearer_token, - MCPAuth.basic, - MCPAuth.authorization, - }: - credentials: Final = getattr(new_mcp_server_request, "credentials", None) - if isinstance(credentials, dict): - mcp_auth_header = credentials.get("auth_value") - - # Authorization doubles as the admission fallback (LITELLM_API_KEY_HEADER_NAME_SECONDARY): - # when the primary x-litellm-api-key header is absent, the Authorization value is the - # caller's LiteLLM key, not an upstream token, and must never be forwarded upstream. - oauth2_headers: dict[str, str] | None = None - if new_mcp_server_request.auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and headers.get( - MCPRequestHandler.LITELLM_API_KEY_HEADER_NAME_PRIMARY - ): - oauth2_headers = MCPRequestHandler._get_oauth2_headers_from_headers(headers) + if staged.request.spec_path: + return await _preview_openapi_tools(staged.request.spec_path) async def _list_tools_operation(client): async def _list_tools_session_operation(session): @@ -1415,9 +1434,9 @@ if MCP_AVAILABLE: } return await _execute_with_mcp_client( - new_mcp_server_request, + staged.request, _list_tools_operation, - mcp_auth_header=mcp_auth_header, - oauth2_headers=oauth2_headers, + mcp_auth_header=staged.mcp_auth_header, + oauth2_headers=staged.oauth2_headers, raw_headers=_safe_get_request_headers(request), ) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index ef5631218f3..d32ffc90b55 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -464,6 +464,140 @@ class TestTestConnection: route = _get_route("/mcp-rest/test/connection", "POST") assert _route_has_dependency(route, user_api_key_auth) + @staticmethod + def _capture_execute(monkeypatch) -> dict: + captured: dict = {} + + async def fake_execute( + request, + operation, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers=None, + ): + captured["request"] = request + captured["mcp_auth_header"] = mcp_auth_header + captured["oauth2_headers"] = oauth2_headers + return {"status": "ok"} + + monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False) + return captured + + @staticmethod + def _oauth2_authorization_code_payload(**overrides) -> NewMCPServerRequest: + return NewMCPServerRequest( + server_name="github_mcp", + url="https://api.githubcopilot.com/mcp/", + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + authorization_url="https://github.com/login/oauth/authorize", + token_url="https://github.com/login/oauth/access_token", + **overrides, + ) + + @pytest.mark.asyncio + async def test_forwards_staged_oauth2_bearer(self, monkeypatch): + """The just-authorized upstream token rides the request's Authorization header, exactly + as /test/tools/list receives it; dropping it makes every authorization_code server fail + the connection test that its tools preview passes.""" + from litellm.proxy._types import LitellmUserRoles + + captured = self._capture_execute(monkeypatch) + request = _build_request( + {"x-litellm-api-key": "sk-admin-session", "authorization": "Bearer upstream-oauth-token"}, + path="/mcp-rest/test/connection", + ) + + result = await rest_endpoints.test_connection( + request, + self._oauth2_authorization_code_payload(), + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert result == {"status": "ok"} + assert captured["oauth2_headers"] == {"Authorization": "Bearer upstream-oauth-token"} + assert captured["mcp_auth_header"] is None + + @pytest.mark.asyncio + async def test_forwards_staged_auth_value(self, monkeypatch): + from litellm.proxy._types import LitellmUserRoles + + captured = self._capture_execute(monkeypatch) + request = _build_request({"x-litellm-api-key": "sk-admin-session"}, path="/mcp-rest/test/connection") + payload = NewMCPServerRequest( + server_name="example", + url="https://example.com/mcp", + auth_type=MCPAuth.bearer_token, + credentials={"auth_value": "upstream-static-token"}, + ) + + result = await rest_endpoints.test_connection( + request, + payload, + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert result == {"status": "ok"} + assert captured["mcp_auth_header"] == "upstream-static-token" + assert captured["oauth2_headers"] is None + + @pytest.mark.asyncio + async def test_inherits_stored_credentials_of_saved_server(self, monkeypatch): + """The edit form resends a saved server without its masked credential; the stored one + must be used, as /test/tools/list already does.""" + from litellm.proxy._types import LitellmUserRoles + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + captured = self._capture_execute(monkeypatch) + saved = MCPServer( + server_id="saved-server-id", + name="example", + url="https://example.com/mcp", + transport="http", + auth_type=MCPAuth.bearer_token, + authentication_token="stored-upstream-token", + ) + monkeypatch.setattr( + rest_endpoints.global_mcp_server_manager, + "get_mcp_server_by_id", + lambda server_id: saved if server_id == "saved-server-id" else None, + ) + request = _build_request({"x-litellm-api-key": "sk-admin-session"}, path="/mcp-rest/test/connection") + payload = NewMCPServerRequest( + server_id="saved-server-id", + server_name="example", + url="https://example.com/mcp", + auth_type=MCPAuth.bearer_token, + ) + + result = await rest_endpoints.test_connection( + request, + payload, + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert result == {"status": "ok"} + assert captured["mcp_auth_header"] == "stored-upstream-token" + assert captured["request"].credentials == {"auth_value": "stored-upstream-token"} + + @pytest.mark.asyncio + async def test_does_not_forward_authorization_that_satisfied_admission(self, monkeypatch): + """With no x-litellm-api-key, the Authorization value is the caller's LiteLLM key and + must never reach the upstream.""" + from litellm.proxy._types import LitellmUserRoles + + captured = self._capture_execute(monkeypatch) + request = _build_request({"authorization": "Bearer sk-litellm-admission-key"}, path="/mcp-rest/test/connection") + + result = await rest_endpoints.test_connection( + request, + self._oauth2_authorization_code_payload(), + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert result == {"status": "ok"} + assert captured["oauth2_headers"] is None + class TestTestToolsList: pytestmark = pytest.mark.asyncio From 4ef5db7c91ccfb4b690d811baf7cfad4129ab7ae Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:36:05 -0700 Subject: [PATCH 011/410] fix(responses): drop unsupported reasoning param for openai non-reasoning models --- .../llms/openai/responses/transformation.py | 29 ++++++++++++ .../test_openai_responses_transformation.py | 46 +++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index 2fa44cfc2e3..99ce158c4e2 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -75,6 +75,19 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): return OpenAIGPT5Config.effort_resolves_to_none(model, effort) + @staticmethod + def _is_o_series_name(model: str) -> bool: + base: Final = model.split("/")[-1] + return len(base) > 1 and base[0] == "o" and base[1].isdigit() + + def _supports_reasoning_param(self, model: str) -> bool: + if self._is_gpt_5_model(model=model) or self._is_o_series_name(model=model): + return True + base: Final = model.split("/")[-1] + if base not in litellm.open_ai_chat_completion_models: + return True + return litellm.supports_reasoning(model=base, custom_llm_provider=self.custom_llm_provider.value) + @staticmethod def _enforce_min_max_output_tokens(max_output_tokens: "int | None") -> "int | None": """Raise sub-minimum max_output_tokens up to the OpenAI Responses API minimum. @@ -124,6 +137,22 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): if "max_output_tokens" in params: params["max_output_tokens"] = self._enforce_min_max_output_tokens(params.get("max_output_tokens")) + if ( + self.custom_llm_provider == LlmProviders.OPENAI + and params.get("reasoning") is not None + and not self._supports_reasoning_param(model=model) + ): + if drop_params or litellm.drop_params: + params.pop("reasoning", None) + else: + raise litellm.UnsupportedParamsError( + message=( + f"{model} doesn't support the `reasoning` parameter. " + "To drop unsupported params set `litellm.drop_params = True`" + ), + status_code=400, + ) + if self._is_gpt_5_model(model=model): temperature: Final = params.get("temperature") if temperature is not None and temperature != 1: diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index e314b94444b..66d22cf8fb0 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -1626,3 +1626,49 @@ class TestResponsesSurfaceSharesTheEffortRule: drop_params=True, ) assert ("temperature" in mapped) is temperature_survives + + +class TestReasoningFollowsModelSupport: + """Responses API clients like Codex send `reasoning` on every request, and OpenAI 400s it + on non-reasoning models like gpt-4o. drop_params must strip it there, the same way the + chat completions surface already strips reasoning_effort for those models. + """ + + @pytest.mark.parametrize( + "model, reasoning_survives", + [ + ("gpt-4o", False), + ("gpt-4.1", False), + ("gpt-4o-mini", False), + ("gpt-5.6", True), + ("o3", True), + ("o3-deep-research", True), + ("codex-mini-latest", True), + ("computer-use-preview", True), + ], + ) + def test_drop_params_strips_reasoning_by_model(self, local_model_cost_map, model, reasoning_survives): + mapped = OpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params={"reasoning": {"effort": "medium", "summary": "auto"}}, + model=model, + drop_params=True, + ) + assert ("reasoning" in mapped) is reasoning_survives + + def test_without_drop_params_the_error_is_litellms_400(self, local_model_cost_map, monkeypatch): + monkeypatch.setattr(litellm, "drop_params", False) + with pytest.raises(litellm.UnsupportedParamsError) as excinfo: + OpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params={"reasoning": {"effort": "medium"}}, + model="gpt-4o", + drop_params=False, + ) + assert excinfo.value.status_code == 400 + + def test_azure_deployments_keep_reasoning(self, local_model_cost_map): + mapped = AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params={"reasoning": {"effort": "medium"}}, + model="my-o3-deployment", + drop_params=True, + ) + assert mapped["reasoning"] == {"effort": "medium"} From a099be02fda770802b41a6f5b4e072460b82bee9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:35:19 -0700 Subject: [PATCH 012/410] fix(guardrails): resolve generateContent routes and async-first passthrough call types API_ROUTE_TO_CALL_TYPES listed the sync llm_passthrough_route first, so every call_types[0] consumer resolved /llm_passthrough to a call type with no guardrail translation handler, and the {model}:generateContent patterns never matched a concrete route because the placeholder segment carries a literal suffix the matcher treated as an exact segment. Reorder the passthrough entries async-first, teach the matcher placeholder-with-suffix segments plus suffixed multi-segment tails (mirroring FastAPI's {model_name:path}), add the missing /v1beta generateContent entries, and register a Google GenAI guardrail translation handler so guardrails actually scan generateContent requests, responses, and streams. --- .../api_route_to_call_types.py | 43 +++- .../guardrail_translation/__init__.py | 20 ++ .../guardrail_translation/handler.py | 237 ++++++++++++++++++ litellm/types/utils.py | 12 +- .../test_api_route_to_call_types.py | 112 +++++++++ .../llms/gemini/google_genai/__init__.py | 0 .../guardrail_translation/__init__.py | 0 .../test_google_genai_guardrail_handler.py | 195 ++++++++++++++ 8 files changed, 609 insertions(+), 10 deletions(-) create mode 100644 litellm/llms/gemini/google_genai/guardrail_translation/__init__.py create mode 100644 litellm/llms/gemini/google_genai/guardrail_translation/handler.py create mode 100644 tests/test_litellm/litellm_core_utils/test_api_route_to_call_types.py create mode 100644 tests/test_litellm/llms/gemini/google_genai/__init__.py create mode 100644 tests/test_litellm/llms/gemini/google_genai/guardrail_translation/__init__.py create mode 100644 tests/test_litellm/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py diff --git a/litellm/litellm_core_utils/api_route_to_call_types.py b/litellm/litellm_core_utils/api_route_to_call_types.py index e3562095d7f..428d7563d4a 100644 --- a/litellm/litellm_core_utils/api_route_to_call_types.py +++ b/litellm/litellm_core_utils/api_route_to_call_types.py @@ -14,21 +14,48 @@ from typing import Final from litellm.types.utils import API_ROUTE_TO_CALL_TYPES, CallTypes +def _segment_matches(route_segment: str, pattern_segment: str) -> bool: + """ + Match one concrete path segment against one pattern segment. + A bare placeholder ({param}) matches any segment; a placeholder with a + literal suffix ({model}:generateContent) requires the segment to end with + that suffix and have a non-empty value before it. + """ + if not pattern_segment.startswith("{"): + return route_segment == pattern_segment + placeholder_end: Final = pattern_segment.find("}") + if placeholder_end == -1: + return route_segment == pattern_segment + literal_suffix: Final = pattern_segment[placeholder_end + 1 :] + if not literal_suffix: + return True + return route_segment.endswith(literal_suffix) and len(route_segment) > len(literal_suffix) + + +def _pattern_tail_spans_segments(pattern_tail: str) -> bool: + """ + Whether the pattern's last segment is a suffixed placeholder + ({model}:generateContent) that may absorb extra route segments, mirroring + FastAPI's {model_name:path} converter for slash-containing model names. + """ + return pattern_tail.startswith("{") and "}" in pattern_tail and not pattern_tail.endswith("}") + + def _route_matches_pattern(route: str, pattern: str) -> bool: """ Return True if the concrete route matches the pattern. - Pattern segments like {param} match any single path segment. + Pattern segments like {param} match any single path segment, and a + suffixed placeholder in the last segment may span multiple segments. """ route_parts: Final = route.strip("/").split("/") pattern_parts: Final = pattern.strip("/").split("/") - if len(route_parts) != len(pattern_parts): + if len(route_parts) < len(pattern_parts): return False - for r, p in zip(route_parts, pattern_parts): - if p.startswith("{") and p.endswith("}"): - continue - if r != p: - return False - return True + if len(route_parts) > len(pattern_parts) and not _pattern_tail_spans_segments(pattern_parts[-1]): + return False + head_count: Final = len(pattern_parts) - 1 + merged_parts: Final = (*route_parts[:head_count], "/".join(route_parts[head_count:])) + return all(_segment_matches(r, p) for r, p in zip(merged_parts, pattern_parts)) def get_call_types_for_route(route: str) -> Sequence[CallTypes] | None: diff --git a/litellm/llms/gemini/google_genai/guardrail_translation/__init__.py b/litellm/llms/gemini/google_genai/guardrail_translation/__init__.py new file mode 100644 index 00000000000..494a72d6999 --- /dev/null +++ b/litellm/llms/gemini/google_genai/guardrail_translation/__init__.py @@ -0,0 +1,20 @@ +"""Google GenAI generateContent guardrail translation handler.""" + +from typing import Final + +from litellm.llms.gemini.google_genai.guardrail_translation.handler import ( + GoogleGenAIGenerateContentHandler, +) +from litellm.types.utils import CallTypes + +guardrail_translation_mappings: Final = { # mutable-ok: discover_guardrail_translation_mappings only accepts isinstance(mappings, dict) + CallTypes.generate_content: GoogleGenAIGenerateContentHandler, + CallTypes.agenerate_content: GoogleGenAIGenerateContentHandler, + CallTypes.generate_content_stream: GoogleGenAIGenerateContentHandler, + CallTypes.agenerate_content_stream: GoogleGenAIGenerateContentHandler, +} + +__all__ = ( + "GoogleGenAIGenerateContentHandler", + "guardrail_translation_mappings", +) diff --git a/litellm/llms/gemini/google_genai/guardrail_translation/handler.py b/litellm/llms/gemini/google_genai/guardrail_translation/handler.py new file mode 100644 index 00000000000..dd76cd711d8 --- /dev/null +++ b/litellm/llms/gemini/google_genai/guardrail_translation/handler.py @@ -0,0 +1,237 @@ +""" +Google GenAI generateContent handler for Unified Guardrails. + +Extracts text from generateContent requests (contents[].parts[].text) and +responses (candidates[].content.parts[].text), applies the guardrail, and +writes the guardrailed text back in place. Requests and responses may be +dicts (wire format) or google-genai SDK objects; streaming chunks may +additionally be raw SSE frames, which are scanned for detection (a blocking +guardrail raises) without rewriting the frames. +""" + +import json +from collections.abc import Mapping, Sequence +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, Optional + +from litellm._logging import verbose_proxy_logger +from litellm.llms.base_llm.guardrail_translation.base_translation import ( + BaseTranslation, + StreamTransformSink, +) +from litellm.types.utils import GenericGuardrailAPIInputs + +if TYPE_CHECKING: + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.proxy._types import UserAPIKeyAuth + +_EMPTY_REQUEST_DATA: Final[Mapping[str, object]] = MappingProxyType({}) + + +def _field(container: object, name: str) -> object | None: + if isinstance(container, dict): + return container.get(name) + return getattr(container, name, None) + + +def _part_text(part: object) -> str | None: + text: Final = _field(part, "text") + if isinstance(text, str) and text: + return text + return None + + +def _write_part_text(part: object, text: str) -> None: + if isinstance(part, dict): + part["text"] = text # rebind-ok: guardrail write-back rewrites the caller's part in place by handler contract + return + setattr(part, "text", text) # noqa: B010 # SDK parts are typed as object here; direct assignment cannot type-check + + +def _content_text_parts(content: object) -> tuple[object, ...]: + parts: Final = _field(content, "parts") + if not isinstance(parts, (list, tuple)): + return () + return tuple(part for part in parts if _part_text(part) is not None) + + +def _request_text_parts(data: Mapping[str, object]) -> tuple[object, ...]: + contents: Final = data.get("contents") + content_list: Final = ( + (contents,) if isinstance(contents, dict) else tuple(contents) if isinstance(contents, list) else () + ) + return tuple(part for content in content_list for part in _content_text_parts(content)) + + +def _response_text_parts(response: object) -> tuple[object, ...]: + candidates: Final = _field(response, "candidates") + if not isinstance(candidates, (list, tuple)): + return () + return tuple(part for candidate in candidates for part in _content_text_parts(_field(candidate, "content"))) + + +def _part_texts(text_parts: Sequence[object]) -> tuple[str, ...]: + return tuple(text for part in text_parts for text in (_part_text(part),) if text is not None) + + +def _texts_payload( + texts: Sequence[str], +) -> list[str]: # mutable-ok: GenericGuardrailAPIInputs.texts is declared list[str] + return list(texts) # mutable-ok: GenericGuardrailAPIInputs.texts is declared list[str] + + +def _write_back_texts(text_parts: Sequence[object], guardrailed_texts: Sequence[str] | None) -> None: + if not guardrailed_texts or len(guardrailed_texts) != len(text_parts): + return + for part, text in zip(text_parts, guardrailed_texts): + _write_part_text(part, text) + + +def _parse_json_dict_or_none(payload: str) -> Mapping[str, object] | None: + try: + parsed: Final = json.loads(payload) + except json.JSONDecodeError: + return None + if isinstance(parsed, dict): + return parsed + return None + + +def _sse_payload_texts(sse_text: str) -> tuple[str, ...]: + return tuple( + text + for line in sse_text.splitlines() + if line.startswith("data:") + for payload in (line[len("data:") :].strip(),) + if payload and payload != "[DONE]" + for parsed in (_parse_json_dict_or_none(payload),) + if parsed is not None + for text in _part_texts(_response_text_parts(parsed)) + ) + + +def _chunk_sse_text(chunk: object) -> str | None: + if isinstance(chunk, bytes): + return chunk.decode("utf-8", errors="replace") + if isinstance(chunk, str): + return chunk + return None + + +def _accumulated_stream_text(responses_so_far: Sequence[object]) -> str: + object_texts: Final = tuple( + text + for chunk in responses_so_far + if _chunk_sse_text(chunk) is None + for text in _part_texts(_response_text_parts(chunk)) + ) + sse_text: Final = "".join(sse for chunk in responses_so_far for sse in (_chunk_sse_text(chunk),) if sse is not None) + return "".join(object_texts) + "".join(_sse_payload_texts(sse_text)) + + +class GoogleGenAIGenerateContentHandler(BaseTranslation): + """ + Guardrail translation for the google genai generateContent surface + (/models/{model}:generateContent, :streamGenerateContent, and the + litellm SDK generate_content call types). + """ + + async def process_input_messages( + self, + data: dict, # mutable-ok: base handler contract passes the proxy's request dict through to apply_guardrail + guardrail_to_apply: "CustomGuardrail", + litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> object: + text_parts: Final = _request_text_parts(data) + if not text_parts: + verbose_proxy_logger.debug("Google GenAI guardrail: no request text found, skipping") + return data + model: Final = data.get("model") + inputs: Final = ( + GenericGuardrailAPIInputs(texts=_texts_payload(_part_texts(text_parts)), model=model) + if isinstance(model, str) + else GenericGuardrailAPIInputs(texts=_texts_payload(_part_texts(text_parts))) + ) + guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail( + inputs=inputs, + request_data=data, + input_type="request", + logging_obj=litellm_logging_obj, + ) + _write_back_texts(text_parts, guardrailed_inputs.get("texts")) + return data + + async def process_output_response( + self, + response: object, + guardrail_to_apply: "CustomGuardrail", + litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, + user_api_key_dict: Optional["UserAPIKeyAuth"] = None, + request_data: Mapping[str, object] | None = None, + ) -> object: + text_parts: Final = _response_text_parts(response) + if not text_parts: + verbose_proxy_logger.debug("Google GenAI guardrail: no response text found, skipping") + return response + guardrail_request_data: Final = self._merged_request_data( + request_data=request_data, + user_api_key_dict=user_api_key_dict, + context_key="response", + context_value=response, + ) + model: Final = guardrail_request_data.get("model") + inputs: Final = ( + GenericGuardrailAPIInputs(texts=_texts_payload(_part_texts(text_parts)), model=model) + if isinstance(model, str) + else GenericGuardrailAPIInputs(texts=_texts_payload(_part_texts(text_parts))) + ) + guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail( + inputs=inputs, + request_data=guardrail_request_data, + input_type="response", + logging_obj=litellm_logging_obj, + ) + _write_back_texts(text_parts, guardrailed_inputs.get("texts")) + return response + + async def process_output_streaming_response( + self, + responses_so_far: Sequence[object], + guardrail_to_apply: "CustomGuardrail", + litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, + user_api_key_dict: Optional["UserAPIKeyAuth"] = None, + request_data: Mapping[str, object] | None = None, + stream_transform_sink: StreamTransformSink | None = None, + ) -> object: + accumulated_text: Final = _accumulated_stream_text(responses_so_far) + if not accumulated_text: + return responses_so_far + guardrail_request_data: Final = self._merged_request_data( + request_data=request_data, + user_api_key_dict=user_api_key_dict, + context_key="responses_so_far", + context_value=responses_so_far, + ) + _guardrailed_inputs: Final = await guardrail_to_apply.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=_texts_payload((accumulated_text,))), + request_data=guardrail_request_data, + input_type="response", + logging_obj=litellm_logging_obj, + ) + return responses_so_far + + def _merged_request_data( + self, + request_data: Mapping[str, object] | None, + user_api_key_dict: Optional["UserAPIKeyAuth"], + context_key: str, + context_value: object, + ) -> dict: # mutable-ok: CustomGuardrail.apply_guardrail requires a plain dict request payload + base: Final = request_data if request_data is not None else _EMPTY_REQUEST_DATA + user_metadata: Final = self.transform_user_api_key_dict_to_metadata(user_api_key_dict) + context_pairs: Final = ((context_key, context_value),) if context_key not in base else () + metadata_pairs: Final = ( + (("litellm_metadata", user_metadata),) if user_metadata and "litellm_metadata" not in base else () + ) + return dict((*base.items(), *context_pairs, *metadata_pairs)) # mutable-ok: apply_guardrail takes a plain dict diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 4bf8289d725..9e48031dd47 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -919,6 +919,14 @@ API_ROUTE_TO_CALL_TYPES: Final[Mapping[str, Sequence[CallTypes]]] = { CallTypes.agenerate_content_stream, CallTypes.generate_content_stream, ], + "/v1beta/models/{model}:generateContent": ( + CallTypes.agenerate_content, + CallTypes.generate_content, + ), + "/v1beta/models/{model}:streamGenerateContent": ( + CallTypes.agenerate_content_stream, + CallTypes.generate_content_stream, + ), # MCP (Model Context Protocol) "/mcp/call_tool": [CallTypes.call_mcp_tool], # A2A (Agent-to-Agent) @@ -926,12 +934,12 @@ API_ROUTE_TO_CALL_TYPES: Final[Mapping[str, Sequence[CallTypes]]] = { "/a2a/{agent_id}/message/send": [CallTypes.asend_message, CallTypes.send_message], # Passthrough endpoints "/llm_passthrough": [ - CallTypes.llm_passthrough_route, CallTypes.allm_passthrough_route, + CallTypes.llm_passthrough_route, ], "/v1/llm_passthrough": [ - CallTypes.llm_passthrough_route, CallTypes.allm_passthrough_route, + CallTypes.llm_passthrough_route, ], "/v1/messages": [CallTypes.anthropic_messages], # OCR diff --git a/tests/test_litellm/litellm_core_utils/test_api_route_to_call_types.py b/tests/test_litellm/litellm_core_utils/test_api_route_to_call_types.py new file mode 100644 index 00000000000..42ca91bfd8f --- /dev/null +++ b/tests/test_litellm/litellm_core_utils/test_api_route_to_call_types.py @@ -0,0 +1,112 @@ +""" +Tests for route -> CallTypes resolution (api_route_to_call_types). + +Regression coverage for the guardrail route table bugs: +- placeholder segments with a literal suffix ({model}:generateContent) never matched +- the /v1beta generateContent routes were missing from the table +- /llm_passthrough listed the sync call type first, resolving consumers that + take call_types[0] to a handler-less type +""" + +from litellm.litellm_core_utils.api_route_to_call_types import ( + get_call_types_for_route, +) +from litellm.types.utils import API_ROUTE_TO_CALL_TYPES, CallTypes + + +class TestGenerateContentRouteResolution: + def test_bare_generate_content_route_resolves(self): + call_types = get_call_types_for_route("/models/gemini-2.5-flash:generateContent") + assert call_types is not None + assert list(call_types) == [CallTypes.agenerate_content, CallTypes.generate_content] + + def test_v1beta_generate_content_route_resolves(self): + call_types = get_call_types_for_route("/v1beta/models/gemini-2.5-flash:generateContent") + assert call_types is not None + assert list(call_types) == [CallTypes.agenerate_content, CallTypes.generate_content] + + def test_bare_stream_generate_content_route_resolves(self): + call_types = get_call_types_for_route("/models/gemini-2.5-flash:streamGenerateContent") + assert call_types is not None + assert list(call_types) == [ + CallTypes.agenerate_content_stream, + CallTypes.generate_content_stream, + ] + + def test_v1beta_stream_generate_content_route_resolves(self): + call_types = get_call_types_for_route("/v1beta/models/gemini-2.5-flash:streamGenerateContent") + assert call_types is not None + assert list(call_types) == [ + CallTypes.agenerate_content_stream, + CallTypes.generate_content_stream, + ] + + def test_slash_containing_model_name_resolves(self): + call_types = get_call_types_for_route("/v1beta/models/gemini/gemini-2.5-flash:generateContent") + assert call_types is not None + assert list(call_types) == [CallTypes.agenerate_content, CallTypes.generate_content] + + def test_empty_model_name_does_not_match(self): + assert get_call_types_for_route("/models/:generateContent") is None + + def test_unrelated_model_action_does_not_match(self): + assert get_call_types_for_route("/models/gemini-2.5-flash:countTokens") is None + + +class TestPassthroughRouteOrdering: + def test_llm_passthrough_lists_async_call_type_first(self): + call_types = get_call_types_for_route("/llm_passthrough") + assert call_types is not None + assert list(call_types) == [ + CallTypes.allm_passthrough_route, + CallTypes.llm_passthrough_route, + ] + + def test_v1_llm_passthrough_lists_async_call_type_first(self): + call_types = get_call_types_for_route("/v1/llm_passthrough") + assert call_types is not None + assert list(call_types) == [ + CallTypes.allm_passthrough_route, + CallTypes.llm_passthrough_route, + ] + + +class TestFirstCallTypeHasTranslationHandler: + def test_first_call_type_is_translatable_whenever_any_is(self): + """ + Consumers (unified guardrail post-call and streaming resolution) take + call_types[0]. A route whose first call type lacks a guardrail + translation handler while a later one has it silently skips guardrail + scanning, so the table must list a handler-backed call type first. + """ + from litellm.llms import load_guardrail_translation_mappings + + mappings = load_guardrail_translation_mappings() + misordered = { + route: [call_type.value for call_type in call_types] + for route, call_types in API_ROUTE_TO_CALL_TYPES.items() + if call_types + and call_types[0] not in mappings + and any(call_type in mappings for call_type in call_types) + } + assert misordered == {} + + +class TestExistingRouteResolutionUnchanged: + def test_exact_route_still_resolves(self): + call_types = get_call_types_for_route("/chat/completions") + assert call_types is not None + assert CallTypes.acompletion in call_types + + def test_single_segment_placeholder_still_resolves(self): + call_types = get_call_types_for_route("/a2a/my-agent/message/send") + assert call_types is not None + assert list(call_types) == [CallTypes.asend_message, CallTypes.send_message] + + def test_longer_route_does_not_collapse_into_bare_placeholder_pattern(self): + call_types = get_call_types_for_route("/responses/resp_123/input_items") + assert call_types is not None + assert list(call_types) == [CallTypes.alist_input_items] + + def test_unknown_route_returns_none(self): + assert get_call_types_for_route("/not/a/real/route") is None diff --git a/tests/test_litellm/llms/gemini/google_genai/__init__.py b/tests/test_litellm/llms/gemini/google_genai/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/gemini/google_genai/guardrail_translation/__init__.py b/tests/test_litellm/llms/gemini/google_genai/guardrail_translation/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py b/tests/test_litellm/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py new file mode 100644 index 00000000000..42ab8a7431a --- /dev/null +++ b/tests/test_litellm/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py @@ -0,0 +1,195 @@ +""" +Tests for the Google GenAI generateContent guardrail translation handler. +""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import HTTPException + +from litellm.llms.gemini.google_genai.guardrail_translation.handler import ( + GoogleGenAIGenerateContentHandler, +) +from litellm.types.utils import CallTypes + + +def _mock_guardrail(returned_texts): + guardrail = MagicMock() + guardrail.apply_guardrail = AsyncMock(return_value={"texts": returned_texts}) + return guardrail + + +@pytest.mark.asyncio +async def test_input_contents_text_is_guardrailed_and_written_back(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = _mock_guardrail(["masked question"]) + data = { + "model": "gemini-2.5-flash", + "contents": [{"role": "user", "parts": [{"text": "raw question"}]}], + } + + result = await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + call_kwargs = guardrail.apply_guardrail.call_args.kwargs + assert call_kwargs["inputs"]["texts"] == ["raw question"] + assert call_kwargs["inputs"]["model"] == "gemini-2.5-flash" + assert call_kwargs["input_type"] == "request" + assert result["contents"][0]["parts"][0]["text"] == "masked question" + + +@pytest.mark.asyncio +async def test_input_without_text_skips_guardrail(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = _mock_guardrail([]) + data = {"model": "gemini-2.5-flash", "contents": [{"role": "user", "parts": [{"inlineData": {}}]}]} + + result = await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + guardrail.apply_guardrail.assert_not_called() + assert result is data + + +@pytest.mark.asyncio +async def test_output_dict_response_text_is_guardrailed_and_written_back(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = _mock_guardrail(["masked answer"]) + response = { + "candidates": [ + { + "content": {"role": "model", "parts": [{"text": "harmful answer"}]}, + "finishReason": "STOP", + } + ] + } + + result = await handler.process_output_response( + response=response, + guardrail_to_apply=guardrail, + request_data={"model": "gemini-2.5-flash"}, + ) + + call_kwargs = guardrail.apply_guardrail.call_args.kwargs + assert call_kwargs["inputs"]["texts"] == ["harmful answer"] + assert call_kwargs["input_type"] == "response" + assert call_kwargs["request_data"]["response"] is response + assert result["candidates"][0]["content"]["parts"][0]["text"] == "masked answer" + + +@pytest.mark.asyncio +async def test_output_object_response_text_is_guardrailed_and_written_back(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = _mock_guardrail(["masked answer"]) + part = SimpleNamespace(text="harmful answer") + response = SimpleNamespace( + candidates=[SimpleNamespace(content=SimpleNamespace(parts=[part]), finish_reason="STOP")] + ) + + await handler.process_output_response(response=response, guardrail_to_apply=guardrail) + + assert guardrail.apply_guardrail.call_args.kwargs["inputs"]["texts"] == ["harmful answer"] + assert part.text == "masked answer" + + +@pytest.mark.asyncio +async def test_output_without_text_skips_guardrail(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = _mock_guardrail([]) + + result = await handler.process_output_response(response={"candidates": []}, guardrail_to_apply=guardrail) + + guardrail.apply_guardrail.assert_not_called() + assert result == {"candidates": []} + + +@pytest.mark.asyncio +async def test_output_blocking_guardrail_exception_propagates(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = MagicMock() + guardrail.apply_guardrail = AsyncMock(side_effect=HTTPException(status_code=400, detail="blocked")) + response = {"candidates": [{"content": {"parts": [{"text": "harmful answer"}]}}]} + + with pytest.raises(HTTPException): + await handler.process_output_response(response=response, guardrail_to_apply=guardrail) + + +@pytest.mark.asyncio +async def test_streaming_dict_chunks_accumulate_text(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = _mock_guardrail(["clean"]) + chunks = [ + {"candidates": [{"content": {"parts": [{"text": "harmful "}]}}]}, + {"candidates": [{"content": {"parts": [{"text": "answer"}]}}]}, + ] + + result = await handler.process_output_streaming_response( + responses_so_far=chunks, + guardrail_to_apply=guardrail, + ) + + call_kwargs = guardrail.apply_guardrail.call_args.kwargs + assert call_kwargs["inputs"]["texts"] == ["harmful answer"] + assert call_kwargs["input_type"] == "response" + assert result is chunks + + +@pytest.mark.asyncio +async def test_streaming_raw_sse_chunks_accumulate_text_across_split_frames(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = _mock_guardrail(["clean"]) + frame_one = "data: " + json.dumps({"candidates": [{"content": {"parts": [{"text": "harmful "}]}}]}) + "\r\n\r\n" + frame_two = "data: " + json.dumps({"candidates": [{"content": {"parts": [{"text": "answer"}]}}]}) + "\r\n\r\n" + split_at = len(frame_one) // 2 + chunks = [frame_one[:split_at], frame_one[split_at:] + frame_two[:5], frame_two[5:].encode("utf-8")] + + await handler.process_output_streaming_response( + responses_so_far=chunks, + guardrail_to_apply=guardrail, + ) + + assert guardrail.apply_guardrail.call_args.kwargs["inputs"]["texts"] == ["harmful answer"] + + +@pytest.mark.asyncio +async def test_streaming_blocking_guardrail_exception_propagates(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = MagicMock() + guardrail.apply_guardrail = AsyncMock(side_effect=HTTPException(status_code=400, detail="blocked")) + chunks = [{"candidates": [{"content": {"parts": [{"text": "harmful"}]}}]}] + + with pytest.raises(HTTPException): + await handler.process_output_streaming_response(responses_so_far=chunks, guardrail_to_apply=guardrail) + + +@pytest.mark.asyncio +async def test_streaming_without_text_skips_guardrail(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = _mock_guardrail([]) + + result = await handler.process_output_streaming_response(responses_so_far=[], guardrail_to_apply=guardrail) + + guardrail.apply_guardrail.assert_not_called() + assert result == [] + + +def test_generate_content_call_types_are_registered(): + from litellm.llms.gemini.google_genai.guardrail_translation import ( + guardrail_translation_mappings, + ) + + for call_type in ( + CallTypes.generate_content, + CallTypes.agenerate_content, + CallTypes.generate_content_stream, + CallTypes.agenerate_content_stream, + ): + assert guardrail_translation_mappings[call_type] is GoogleGenAIGenerateContentHandler + + +def test_discovery_finds_generate_content_handler(): + from litellm.llms import load_guardrail_translation_mappings + + mappings = load_guardrail_translation_mappings() + assert mappings[CallTypes.agenerate_content] is GoogleGenAIGenerateContentHandler + assert mappings[CallTypes.agenerate_content_stream] is GoogleGenAIGenerateContentHandler From 05e4d2f946a2ee8a2beb51d5476b77c4ea4cc027 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 29 Aug 2026 22:11:47 -0700 Subject: [PATCH 013/410] fix(guardrails): scan generateContent systemInstruction text and drop fastapi import from handler tests --- .../guardrail_translation/handler.py | 24 +++++++-- .../test_google_genai_guardrail_handler.py | 49 +++++++++++++++++-- 2 files changed, 65 insertions(+), 8 deletions(-) diff --git a/litellm/llms/gemini/google_genai/guardrail_translation/handler.py b/litellm/llms/gemini/google_genai/guardrail_translation/handler.py index dd76cd711d8..e13e1e63cbb 100644 --- a/litellm/llms/gemini/google_genai/guardrail_translation/handler.py +++ b/litellm/llms/gemini/google_genai/guardrail_translation/handler.py @@ -1,8 +1,9 @@ """ Google GenAI generateContent handler for Unified Guardrails. -Extracts text from generateContent requests (contents[].parts[].text) and -responses (candidates[].content.parts[].text), applies the guardrail, and +Extracts text from generateContent requests (systemInstruction.parts[].text +and contents[].parts[].text) and responses (candidates[].content.parts[].text), +applies the guardrail, and writes the guardrailed text back in place. Requests and responses may be dicts (wire format) or google-genai SDK objects; streaming chunks may additionally be raw SSE frames, which are scanned for detection (a blocking @@ -56,12 +57,29 @@ def _content_text_parts(content: object) -> tuple[object, ...]: return tuple(part for part in parts if _part_text(part) is not None) +def _system_instruction(data: Mapping[str, object]) -> object | None: + return next( + ( + value + for container in (data, data.get("config")) + if container is not None + for key in ("systemInstruction", "system_instruction") + for value in (_field(container, key),) + if value is not None + ), + None, + ) + + def _request_text_parts(data: Mapping[str, object]) -> tuple[object, ...]: contents: Final = data.get("contents") content_list: Final = ( (contents,) if isinstance(contents, dict) else tuple(contents) if isinstance(contents, list) else () ) - return tuple(part for content in content_list for part in _content_text_parts(content)) + return ( + *_content_text_parts(_system_instruction(data)), + *(part for content in content_list for part in _content_text_parts(content)), + ) def _response_text_parts(response: object) -> tuple[object, ...]: diff --git a/tests/test_litellm/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py b/tests/test_litellm/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py index 42ab8a7431a..4119ce99423 100644 --- a/tests/test_litellm/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py +++ b/tests/test_litellm/llms/gemini/google_genai/guardrail_translation/test_google_genai_guardrail_handler.py @@ -7,7 +7,6 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock import pytest -from fastapi import HTTPException from litellm.llms.gemini.google_genai.guardrail_translation.handler import ( GoogleGenAIGenerateContentHandler, @@ -15,6 +14,10 @@ from litellm.llms.gemini.google_genai.guardrail_translation.handler import ( from litellm.types.utils import CallTypes +class GuardrailBlockedError(Exception): + pass + + def _mock_guardrail(returned_texts): guardrail = MagicMock() guardrail.apply_guardrail = AsyncMock(return_value={"texts": returned_texts}) @@ -39,6 +42,42 @@ async def test_input_contents_text_is_guardrailed_and_written_back(): assert result["contents"][0]["parts"][0]["text"] == "masked question" +@pytest.mark.asyncio +async def test_input_system_instruction_text_is_scanned_and_written_back(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = _mock_guardrail(["masked instruction", "masked question"]) + data = { + "model": "gemini-2.5-flash", + "systemInstruction": {"role": "system", "parts": [{"text": "prohibited instruction"}]}, + "contents": [{"role": "user", "parts": [{"text": "benign question"}]}], + } + + result = await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + assert guardrail.apply_guardrail.call_args.kwargs["inputs"]["texts"] == [ + "prohibited instruction", + "benign question", + ] + assert result["systemInstruction"]["parts"][0]["text"] == "masked instruction" + assert result["contents"][0]["parts"][0]["text"] == "masked question" + + +@pytest.mark.asyncio +async def test_input_config_nested_snake_case_system_instruction_is_scanned(): + handler = GoogleGenAIGenerateContentHandler() + guardrail = _mock_guardrail(["clean"]) + instruction_part = SimpleNamespace(text="prohibited instruction") + data = { + "contents": [], + "config": SimpleNamespace(system_instruction=SimpleNamespace(parts=[instruction_part])), + } + + await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + + assert guardrail.apply_guardrail.call_args.kwargs["inputs"]["texts"] == ["prohibited instruction"] + assert instruction_part.text == "clean" + + @pytest.mark.asyncio async def test_input_without_text_skips_guardrail(): handler = GoogleGenAIGenerateContentHandler() @@ -107,10 +146,10 @@ async def test_output_without_text_skips_guardrail(): async def test_output_blocking_guardrail_exception_propagates(): handler = GoogleGenAIGenerateContentHandler() guardrail = MagicMock() - guardrail.apply_guardrail = AsyncMock(side_effect=HTTPException(status_code=400, detail="blocked")) + guardrail.apply_guardrail = AsyncMock(side_effect=GuardrailBlockedError("blocked")) response = {"candidates": [{"content": {"parts": [{"text": "harmful answer"}]}}]} - with pytest.raises(HTTPException): + with pytest.raises(GuardrailBlockedError): await handler.process_output_response(response=response, guardrail_to_apply=guardrail) @@ -155,10 +194,10 @@ async def test_streaming_raw_sse_chunks_accumulate_text_across_split_frames(): async def test_streaming_blocking_guardrail_exception_propagates(): handler = GoogleGenAIGenerateContentHandler() guardrail = MagicMock() - guardrail.apply_guardrail = AsyncMock(side_effect=HTTPException(status_code=400, detail="blocked")) + guardrail.apply_guardrail = AsyncMock(side_effect=GuardrailBlockedError("blocked")) chunks = [{"candidates": [{"content": {"parts": [{"text": "harmful"}]}}]}] - with pytest.raises(HTTPException): + with pytest.raises(GuardrailBlockedError): await handler.process_output_streaming_response(responses_so_far=chunks, guardrail_to_apply=guardrail) From f0a2a2312704df73ba020290ef426c9c4360a130 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:32:55 -0700 Subject: [PATCH 014/410] fix(proxy): register SkillsInjectionHook at proxy startup instead of import time --- litellm/proxy/hooks/litellm_skills/__init__.py | 6 +----- litellm/proxy/hooks/litellm_skills/main.py | 11 +---------- .../proxy/hooks/litellm_skills/test_main.py | 16 ++++++++++++++++ 3 files changed, 18 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/hooks/litellm_skills/__init__.py b/litellm/proxy/hooks/litellm_skills/__init__.py index 751122ac51c..d24cdd37161 100644 --- a/litellm/proxy/hooks/litellm_skills/__init__.py +++ b/litellm/proxy/hooks/litellm_skills/__init__.py @@ -21,10 +21,7 @@ from litellm.llms.litellm_proxy.skills import ( code_execution_handler, get_litellm_code_execution_tool, ) -from litellm.proxy.hooks.litellm_skills.main import ( - SkillsInjectionHook, - skills_injection_hook, -) +from litellm.proxy.hooks.litellm_skills.main import SkillsInjectionHook __all__ = [ "LITELLM_CODE_EXECUTION_TOOL", @@ -35,5 +32,4 @@ __all__ = [ "SkillsSandboxExecutor", "code_execution_handler", "get_litellm_code_execution_tool", - "skills_injection_hook", ] diff --git a/litellm/proxy/hooks/litellm_skills/main.py b/litellm/proxy/hooks/litellm_skills/main.py index 569ec32c1a0..c5e8f03f792 100644 --- a/litellm/proxy/hooks/litellm_skills/main.py +++ b/litellm/proxy/hooks/litellm_skills/main.py @@ -29,6 +29,7 @@ import json from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Final, Protocol +import litellm from litellm._logging import verbose_proxy_logger from litellm.caching.caching import DualCache from litellm.integrations.custom_logger import CustomLogger @@ -475,7 +476,6 @@ class SkillsInjectionHook(CustomLogger): Returns the final response with generated files inline. """ - import litellm from litellm.llms.litellm_proxy.skills.code_execution import ( LiteLLMInternalTools, ) @@ -705,7 +705,6 @@ print('No executable skill module found') Returns the final response with generated files inline. """ - import litellm from litellm.llms.litellm_proxy.skills.code_execution import ( LiteLLMInternalTools, ) @@ -894,11 +893,3 @@ print('No executable skill module found') verbose_proxy_logger.debug("SkillsInjectionHook: Attached %s files to response", len(generated_files)) return response - - -# Global instance for registration -skills_injection_hook: Final = SkillsInjectionHook() - -import litellm - -litellm.logging_callback_manager.add_litellm_callback(skills_injection_hook) diff --git a/tests/test_litellm/proxy/hooks/litellm_skills/test_main.py b/tests/test_litellm/proxy/hooks/litellm_skills/test_main.py index f716a8533d8..c037f60ac25 100644 --- a/tests/test_litellm/proxy/hooks/litellm_skills/test_main.py +++ b/tests/test_litellm/proxy/hooks/litellm_skills/test_main.py @@ -1,3 +1,6 @@ +import subprocess +import sys +from typing import Final from unittest.mock import AsyncMock, patch import pytest @@ -65,3 +68,16 @@ async def test_execute_code_loop_dispatches_litellm_skill_tool(): mock_exec.assert_awaited_once() assert mock_exec.await_args.args[0] == SKILL_TOOL_NAME assert result is final_response + + +def test_importing_proxy_hooks_does_not_mutate_litellm_callbacks(): + script: Final = ( + "import litellm; " + "litellm.callbacks = []; " + "import litellm.proxy.hooks; " + "assert len(litellm.callbacks) == 0, f'mutated: {litellm.callbacks}'" + ) + result: Final = subprocess.run( + [sys.executable, "-c", script], capture_output=True, text=True + ) + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" From b8e11a75fa7e656d788855f42b182b9ff862a907 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sun, 30 Aug 2026 12:47:15 -0700 Subject: [PATCH 015/410] test: use local model cost map in import-isolation subprocess --- tests/test_litellm/proxy/hooks/litellm_skills/test_main.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/proxy/hooks/litellm_skills/test_main.py b/tests/test_litellm/proxy/hooks/litellm_skills/test_main.py index c037f60ac25..dc7ebf6e2ec 100644 --- a/tests/test_litellm/proxy/hooks/litellm_skills/test_main.py +++ b/tests/test_litellm/proxy/hooks/litellm_skills/test_main.py @@ -1,3 +1,4 @@ +import os import subprocess import sys from typing import Final @@ -78,6 +79,9 @@ def test_importing_proxy_hooks_does_not_mutate_litellm_callbacks(): "assert len(litellm.callbacks) == 0, f'mutated: {litellm.callbacks}'" ) result: Final = subprocess.run( - [sys.executable, "-c", script], capture_output=True, text=True + [sys.executable, "-c", script], + capture_output=True, + text=True, + env={**os.environ, "LITELLM_LOCAL_MODEL_COST_MAP": "True"}, ) assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" From f43a93eaad188543e1f9124b8699ef5d21ea6499 Mon Sep 17 00:00:00 2001 From: milan Date: Mon, 31 Aug 2026 22:22:42 +0000 Subject: [PATCH 016/410] fix(team_endpoints): only widen cleanup to the requested user when the roster is empty Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/team_endpoints.py | 4 +- .../test_team_endpoints.py | 102 ++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index fce109c0c8b..7112c6ec40f 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3310,7 +3310,9 @@ async def team_member_delete( ## DELETE TEAM ID from USER ROW, IF EXISTS ## # get user row removed_user_ids: Final = frozenset(m.user_id for m in removed_team_members if m.user_id is not None) - addressed_user_ids: Final = removed_user_ids.union((data.user_id,) if data.user_id is not None else ()) + addressed_user_ids: Final = ( + removed_user_ids if removed_team_members else frozenset((data.user_id,) if data.user_id is not None else ()) + ) key_val: Final[Mapping[str, object]] = ( {"user_id": {"in": sorted(addressed_user_ids)}} if addressed_user_ids else {"user_email": data.user_email} ) 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 e5b58f4b38e..973a37ccc06 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -4893,6 +4893,108 @@ async def test_team_member_delete_still_rejects_a_user_the_team_has_no_trace_of( mock_db_client.db.litellm_teammembership.delete_many.assert_not_awaited() +@pytest.mark.asyncio +async def test_team_member_delete_leaves_a_bystander_named_by_a_conflicting_user_id_alone( + mock_db_client, mock_admin_auth +): + """ + A request can carry a user_id and a user_email that point at two different people, and only the + email matches a roster entry. Cleaning up both ids would strip the team, the membership row and + the keys off the bystander the roster never listed, so the user_id only widens the cleanup when + the roster came back empty. + """ + from litellm.proxy._types import TeamMemberDeleteRequest + from litellm.proxy.management_endpoints.team_endpoints import team_member_delete + + test_team_id = "team-del-conflict-123" + roster_user_id = "user-del-conflict-roster" + bystander_user_id = "user-del-conflict-bystander" + roster_email = "roster@example.com" + + mock_team_row = MagicMock() + mock_team_row.model_dump.return_value = { + "team_id": test_team_id, + "members_with_roles": [ + {"user_id": roster_user_id, "user_email": roster_email, "role": "user"} + ], + "team_member_permissions": [], + "metadata": {}, + "models": [], + "spend": 0.0, + } + mock_db_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team_row + ) + mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row) + + roster_user_row = MagicMock() + roster_user_row.user_id = roster_user_id + roster_user_row.user_email = roster_email + roster_user_row.teams = [test_team_id] + + bystander_user_row = MagicMock() + bystander_user_row.user_id = bystander_user_id + bystander_user_row.user_email = "bystander@example.com" + bystander_user_row.teams = [test_team_id] + + rows_by_user_id = { + roster_user_id: roster_user_row, + bystander_user_id: bystander_user_row, + } + + async def find_user_rows(where): + user_id_filter = where.get("user_id") + if isinstance(user_id_filter, dict): + return [ + rows_by_user_id[uid] + for uid in user_id_filter.get("in", []) + if uid in rows_by_user_id + ] + return [ + row + for row in rows_by_user_id.values() + if row.user_email == where.get("user_email") + ] + + mock_db_client.db.litellm_usertable.find_many = AsyncMock( + side_effect=find_user_rows + ) + mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock()) + + mock_db_client.db.litellm_teammembership = MagicMock() + mock_db_client.db.litellm_teammembership.delete_many = AsyncMock( + return_value=MagicMock() + ) + + mock_db_client.db.litellm_verificationtoken = MagicMock() + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock( + return_value=MagicMock() + ) + + _wire_member_delete_tx(mock_db_client) + + await team_member_delete( + data=TeamMemberDeleteRequest( + team_id=test_team_id, + user_id=bystander_user_id, + user_email=roster_email, + ), + user_api_key_dict=mock_admin_auth, + ) + + mock_db_client.db.litellm_usertable.update.assert_awaited_once_with( + where={"user_id": roster_user_id}, + data={"teams": {"set": []}}, + ) + mock_db_client.db.litellm_teammembership.delete_many.assert_awaited_once_with( + where={"team_id": test_team_id, "user_id": roster_user_id} + ) + mock_db_client.db.litellm_verificationtoken.delete_many.assert_awaited_once_with( + where={"user_id": {"in": [roster_user_id]}, "team_id": test_team_id} + ) + + class _InjectedMemberDeleteFailure(Exception): pass From 84fad12f8546851c68c8471f14b221274a04038a Mon Sep 17 00:00:00 2001 From: milan Date: Mon, 31 Aug 2026 22:37:30 +0000 Subject: [PATCH 017/410] fix(team_endpoints): keep an email delete off the namesakes that never had the team Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/team_endpoints.py | 11 +-- .../test_team_endpoints.py | 76 +++++++++++++++++++ 2 files changed, 82 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 7112c6ec40f..bbd9ae1fccd 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3319,15 +3319,16 @@ async def team_member_delete( member_tx: Final[_MemberDeleteTx] = tx 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 = addressed_user_ids.union( - user.user_id for user in existing_user_rows if user.user_id - ) - # A user row can outlive its roster entry, and until the team is off user.teams the user # still sees it and still fails key creation against it, so removal has to clear it too stale_user_rows: Final = tuple(user for user in existing_user_rows if data.team_id in user.teams) + # Also clean up any existing team membership rows for this user and team. An email can + # match several user rows, so with no roster entry to name the member, only the rows + # actually carrying the team are the ones this request is allowed to touch + cleanup_user_rows: Final = existing_user_rows if removed_team_members else stale_user_rows + user_ids_to_delete: Final = addressed_user_ids.union(user.user_id for user in cleanup_user_rows if user.user_id) + if not removed_team_members and not stale_user_rows: raise HTTPException(status_code=400, detail={"error": "User not found in team"}) 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 973a37ccc06..e54326d1afd 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -4995,6 +4995,82 @@ async def test_team_member_delete_leaves_a_bystander_named_by_a_conflicting_user ) +@pytest.mark.asyncio +async def test_team_member_delete_by_email_only_touches_the_row_carrying_the_stale_team( + mock_db_client, mock_admin_auth +): + """ + user_email is not unique, so an email delete against an empty roster can match several user + rows. Only the row that actually carries the team is stale; the namesake keeps its team, its + membership row and its keys. + """ + from litellm.proxy._types import TeamMemberDeleteRequest + from litellm.proxy.management_endpoints.team_endpoints import team_member_delete + + test_team_id = "team-del-shared-email-123" + stale_user_id = "user-del-shared-email-stale" + namesake_user_id = "user-del-shared-email-namesake" + shared_email = "shared@example.com" + + mock_team_row = MagicMock() + mock_team_row.model_dump.return_value = { + "team_id": test_team_id, + "members_with_roles": [], + "team_member_permissions": [], + "metadata": {}, + "models": [], + "spend": 0.0, + } + mock_db_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team_row + ) + mock_db_client.db.litellm_teamtable.update = AsyncMock(return_value=mock_team_row) + + stale_user_row = MagicMock() + stale_user_row.user_id = stale_user_id + stale_user_row.user_email = shared_email + stale_user_row.teams = [test_team_id] + + namesake_user_row = MagicMock() + namesake_user_row.user_id = namesake_user_id + namesake_user_row.user_email = shared_email + namesake_user_row.teams = ["other-team"] + + mock_db_client.db.litellm_usertable.find_many = AsyncMock( + return_value=[stale_user_row, namesake_user_row] + ) + mock_db_client.db.litellm_usertable.update = AsyncMock(return_value=MagicMock()) + + mock_db_client.db.litellm_teammembership = MagicMock() + mock_db_client.db.litellm_teammembership.delete_many = AsyncMock( + return_value=MagicMock() + ) + + mock_db_client.db.litellm_verificationtoken = MagicMock() + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock( + return_value=MagicMock() + ) + + _wire_member_delete_tx(mock_db_client) + + await team_member_delete( + data=TeamMemberDeleteRequest(team_id=test_team_id, user_email=shared_email), + user_api_key_dict=mock_admin_auth, + ) + + mock_db_client.db.litellm_usertable.update.assert_awaited_once_with( + where={"user_id": stale_user_id}, + data={"teams": {"set": []}}, + ) + mock_db_client.db.litellm_teammembership.delete_many.assert_awaited_once_with( + where={"team_id": test_team_id, "user_id": stale_user_id} + ) + mock_db_client.db.litellm_verificationtoken.delete_many.assert_awaited_once_with( + where={"user_id": {"in": [stale_user_id]}, "team_id": test_team_id} + ) + + class _InjectedMemberDeleteFailure(Exception): pass From 2b8b0eb2024a12ba9b8b152c7d17de37277bacc3 Mon Sep 17 00:00:00 2001 From: David Abutbul Date: Tue, 25 Aug 2026 14:28:53 +0300 Subject: [PATCH 018/410] fix(guardrails): block Prompt Security file modifications --- .../prompt_security/__init__.py | 1 + .../prompt_security/prompt_security.py | 39 +++--- .../guardrail_hooks/prompt_security.py | 4 + .../test_prompt_security_guardrails.py | 118 ++++++++++++++++++ 4 files changed, 140 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/__init__.py index 0aaba4016cd..88cf92a4a8c 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/__init__.py @@ -21,6 +21,7 @@ def initialize_guardrail(litellm_params: "LitellmParams", guardrail: "Guardrail" event_hook=litellm_params.mode, default_on=litellm_params.default_on, file_sanitization_fail_open=getattr(litellm_params, "file_sanitization_fail_open", None), + block_on_file_modify=getattr(litellm_params, "block_on_file_modify", None), ) litellm.logging_callback_manager.add_litellm_callback(_prompt_security_callback) diff --git a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py index 84c4f118b00..0954fe1698a 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py +++ b/litellm/proxy/guardrails/guardrail_hooks/prompt_security/prompt_security.py @@ -93,6 +93,7 @@ class PromptSecurityGuardrail(CustomGuardrail): check_tool_results: bool | None = None, file_sanitization_timeout: float = _SANITIZE_FILE_FAIL_OPEN_TIMEOUT_SECONDS, file_sanitization_fail_open: bool | None = None, + block_on_file_modify: bool | None = None, **kwargs, ): kwargs.setdefault("supported_event_hooks", list(self.get_supported_event_hooks())) @@ -124,6 +125,7 @@ class PromptSecurityGuardrail(CustomGuardrail): self.poll_interval = 2 # Seconds between polling attempts self.file_sanitization_timeout = file_sanitization_timeout self.file_sanitization_fail_open = file_sanitization_fail_open is not False + self.block_on_file_modify = block_on_file_modify is not False super().__init__(**kwargs) @@ -372,13 +374,7 @@ class PromptSecurityGuardrail(CustomGuardrail): result = await self.sanitize_file_content( file_data, filename, user_api_key_alias=user_api_key_alias ) - - if result.get("action") == "block": - violations = result.get("violations", []) - raise HTTPException( - status_code=400, - detail=f"Image blocked by Prompt Security. Violations: {', '.join(violations)}", - ) + self._raise_if_file_blocked(result, "Image") except HTTPException: raise except Exception as e: @@ -408,7 +404,7 @@ class PromptSecurityGuardrail(CustomGuardrail): file_data: bytes, filename: str, user_api_key_alias: str | None = None, - ) -> dict: + ) -> _SanitizeResult: """ Sanitize file content using Prompt Security API. Returns: dict with keys 'action', 'content', 'metadata' @@ -528,6 +524,17 @@ class PromptSecurityGuardrail(CustomGuardrail): raise HTTPException(status_code=408, detail="File sanitization timeout") + def _raise_if_file_blocked(self, sanitization_result: _SanitizeResult, resource_name: str) -> None: + action: Final = sanitization_result.get("action") + if action != "block" and not (action == "modify" and self.block_on_file_modify): + return + + violations: Final = sanitization_result.get("violations", ()) + raise HTTPException( + status_code=400, + detail=f"{resource_name} blocked by Prompt Security. Violations: {', '.join(violations)}", + ) + async def _process_image_url_item(self, item: dict, user_api_key_alias: str | None) -> dict: """Process and sanitize image_url items.""" image_url_data: Final = item.get("image_url", {}) @@ -547,13 +554,7 @@ class PromptSecurityGuardrail(CustomGuardrail): file_data, filename, user_api_key_alias=user_api_key_alias ) action: Final = sanitization_result.get("action") - - if action == "block": - violations: Final = sanitization_result.get("violations", []) - raise HTTPException( - status_code=400, - detail=f"File blocked by Prompt Security. Violations: {', '.join(violations)}", - ) + self._raise_if_file_blocked(sanitization_result, "File") if action == "modify": sanitized_content: Final = sanitization_result.get("content", "") @@ -615,13 +616,7 @@ class PromptSecurityGuardrail(CustomGuardrail): file_data, filename, user_api_key_alias=user_api_key_alias ) action: Final = sanitization_result.get("action") - - if action == "block": - violations: Final = sanitization_result.get("violations", []) - raise HTTPException( - status_code=400, - detail=f"Document blocked by Prompt Security. Violations: {', '.join(violations)}", - ) + self._raise_if_file_blocked(sanitization_result, "Document") if action == "modify": sanitized_content: Final = sanitization_result.get("content", "") diff --git a/litellm/types/proxy/guardrails/guardrail_hooks/prompt_security.py b/litellm/types/proxy/guardrails/guardrail_hooks/prompt_security.py index 94f8161f44e..29f1b4bdcd6 100644 --- a/litellm/types/proxy/guardrails/guardrail_hooks/prompt_security.py +++ b/litellm/types/proxy/guardrails/guardrail_hooks/prompt_security.py @@ -16,6 +16,10 @@ class PromptSecurityGuardrailConfigModel(GuardrailConfigModel): default=True, description="Whether file sanitization timeouts allow the original file through instead of blocking the request.", ) + block_on_file_modify: bool = Field( + default=True, + description="Whether a file sanitization `modify` verdict blocks the request instead of replacing the file content.", + ) @staticmethod def ui_friendly_name() -> str: diff --git a/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py b/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py index ab4e15ff423..e650f796f29 100644 --- a/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/test_prompt_security_guardrails.py @@ -31,6 +31,7 @@ def test_prompt_security_guard_config(monkeypatch: pytest.MonkeyPatch): "mode": "during_call", "default_on": True, "file_sanitization_fail_open": False, + "block_on_file_modify": False, }, } ], @@ -43,9 +44,11 @@ def test_prompt_security_guard_config(monkeypatch: pytest.MonkeyPatch): assert registered[0].default_on is True assert registered[0].event_hook == "during_call" assert registered[0].file_sanitization_fail_open is False + assert registered[0].block_on_file_modify is False config_model = registered[0].get_config_model() assert config_model is not None assert config_model().file_sanitization_fail_open is True + assert config_model().block_on_file_modify is True def test_prompt_security_guard_config_no_api_key(monkeypatch: pytest.MonkeyPatch): @@ -379,6 +382,121 @@ async def test_file_sanitization(monkeypatch: pytest.MonkeyPatch): assert result is not None +@pytest.mark.asyncio +async def test_file_sanitization_modify_blocks_by_default(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PROMPT_SECURITY_API_KEY", "test-key") + monkeypatch.setenv("PROMPT_SECURITY_API_BASE", "https://test.prompt.security") + + guardrail = PromptSecurityGuardrail( + guardrail_name="test-guard", event_hook="pre_call", default_on=True + ) + csv_data = b"name,email\nAlice,alice@example.com\n" + item = { + "type": "file", + "file": { + "data": base64.b64encode(csv_data).decode(), + "mime_type": "text/csv", + }, + } + upload_response = Response( + json={"jobId": "modify-job"}, + status_code=200, + request=Request(method="POST", url="https://test.prompt.security/api/sanitizeFile"), + ) + poll_response = Response( + json={ + "status": "done", + "content": "name,email\nAlice,[REDACTED]\n", + "metadata": {"action": "modify", "violations": ["Sensitive Data"]}, + }, + status_code=200, + request=Request(method="GET", url="https://test.prompt.security/api/sanitizeFile"), + ) + + with patch.object(guardrail.async_handler, "post", AsyncMock(return_value=upload_response)): + with patch.object(guardrail.async_handler, "get", AsyncMock(return_value=poll_response)): + with pytest.raises(HTTPException) as exc_info: + await guardrail._process_document_item(item, None) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Document blocked by Prompt Security. Violations: Sensitive Data" + + +@pytest.mark.asyncio +async def test_standalone_image_sanitization_modify_blocks_by_default(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PROMPT_SECURITY_API_KEY", "test-key") + monkeypatch.setenv("PROMPT_SECURITY_API_BASE", "https://test.prompt.security") + + guardrail = PromptSecurityGuardrail( + guardrail_name="test-guard", event_hook="pre_call", default_on=True + ) + guardrail.poll_interval = 0 + image_url = "data:image/png;base64," + base64.b64encode(b"image-content").decode() + upload_response = Response( + json={"jobId": "modify-image-job"}, + status_code=200, + request=Request(method="POST", url="https://test.prompt.security/api/sanitizeFile"), + ) + poll_response = Response( + json={ + "status": "done", + "content": "Email: [REDACTED]", + "metadata": {"action": "modify", "violations": ["Sensitive Data"]}, + }, + status_code=200, + request=Request(method="GET", url="https://test.prompt.security/api/sanitizeFile"), + ) + + with patch.object(guardrail.async_handler, "post", AsyncMock(return_value=upload_response)): + with patch.object(guardrail.async_handler, "get", AsyncMock(return_value=poll_response)): + with pytest.raises(HTTPException) as exc_info: + await guardrail._process_standalone_images([image_url], None) + + assert exc_info.value.status_code == 400 + assert exc_info.value.detail == "Image blocked by Prompt Security. Violations: Sensitive Data" + + +@pytest.mark.asyncio +async def test_file_sanitization_modify_can_rewrite_when_blocking_disabled(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PROMPT_SECURITY_API_KEY", "test-key") + monkeypatch.setenv("PROMPT_SECURITY_API_BASE", "https://test.prompt.security") + + guardrail = PromptSecurityGuardrail( + guardrail_name="test-guard", + event_hook="pre_call", + default_on=True, + block_on_file_modify=False, + ) + csv_data = b"name,email\nAlice,alice@example.com\n" + item = { + "type": "file", + "file": { + "data": base64.b64encode(csv_data).decode(), + "mime_type": "text/csv", + }, + } + upload_response = Response( + json={"jobId": "modify-job"}, + status_code=200, + request=Request(method="POST", url="https://test.prompt.security/api/sanitizeFile"), + ) + poll_response = Response( + json={ + "status": "done", + "content": "name,email\nAlice,[REDACTED]\n", + "metadata": {"action": "modify", "violations": ["Sensitive Data"]}, + }, + status_code=200, + request=Request(method="GET", url="https://test.prompt.security/api/sanitizeFile"), + ) + + with patch.object(guardrail.async_handler, "post", AsyncMock(return_value=upload_response)): + with patch.object(guardrail.async_handler, "get", AsyncMock(return_value=poll_response)): + result = await guardrail._process_document_item(item, None) + + assert base64.b64decode(result["file"]["data"]) == b"name,email\nAlice,[REDACTED]\n" + + @pytest.mark.asyncio @pytest.mark.parametrize( "timeout", From f94bd6d903e1163803323b721c8677ffa8365057 Mon Sep 17 00:00:00 2001 From: mateo-berri Date: Wed, 2 Sep 2026 09:11:36 +0000 Subject: [PATCH 019/410] refactor(typing): replace Any with proven types in 65 backend files Typing-only pass over backend modules that carried the most reportAny and reportExplicitAny errors. Every new annotation is backed by a construction site, a call site, or an isinstance narrowing that already existed; untyped JSON boundaries were left alone rather than declared without validation. Tree-wide basedpyright errors drop 138,481 to 138,007. reportAny drops 8,854 to 8,645 and reportExplicitAny drops 3,119 to 2,814. --- .../bedrock_agentcore/transformation.py | 12 ++++-- litellm/caching/redis_semantic_cache.py | 14 +++---- .../compression/scoring/embedding_scorer.py | 3 +- litellm/experimental_mcp_client/client.py | 24 +++++++++--- litellm/files/main.py | 8 ++-- litellm/integrations/newrelic/newrelic.py | 24 ++++++------ litellm/integrations/opentelemetry.py | 10 ++--- litellm/integrations/prometheus.py | 6 +-- .../websearch_interception/tools.py | 11 +++--- .../websearch_interception/transformation.py | 8 ++-- litellm/interactions/agents/http_handler.py | 16 ++++---- litellm/interactions/agents/main.py | 18 ++++----- .../transformation.py | 14 +++---- litellm/interactions/main.py | 8 ++-- litellm/litellm_core_utils/core_helpers.py | 10 ++--- .../llm_response_utils/response_metadata.py | 2 +- .../prompt_templates/factory.py | 6 +-- .../adapters/streaming_iterator.py | 8 ++-- .../messages/transformation.py | 6 +-- .../azure_ai/vector_stores/transformation.py | 7 ++-- .../guardrail_translation/base_translation.py | 10 ++--- litellm/llms/bedrock/common_utils.py | 19 +++++---- ...n_nova_canvas_image_edit_transformation.py | 16 ++++---- .../bedrock/vector_stores/transformation.py | 4 +- .../image_edit/transformation.py | 4 +- litellm/llms/gemini/agents/transformation.py | 27 ++++++------- .../milvus/vector_stores/transformation.py | 7 ++-- .../minimax/text_to_speech/transformation.py | 9 +++-- .../responses/count_tokens/transformation.py | 16 ++++---- .../guardrail_translation/handler.py | 8 ++-- litellm/llms/openai/videos/transformation.py | 4 +- .../openrouter/image_edit/transformation.py | 4 +- .../guardrail_translation/handler.py | 4 +- .../perplexity/embedding/transformation.py | 6 +-- .../ragflow/vector_stores/transformation.py | 5 ++- litellm/llms/vertex_ai/fine_tuning/handler.py | 4 +- .../mcp_server/discoverable_endpoints.py | 12 +++--- .../mcp_server/elicitation_handler.py | 32 +++++++++------ .../proxy/_experimental/mcp_server/server.py | 2 +- litellm/proxy/a2a/version_convert.py | 13 ++++--- litellm/proxy/client/cli/commands/models.py | 4 +- .../proxy/common_utils/cache_coordinator.py | 26 ++++++------- .../proxy/common_utils/http_parsing_utils.py | 18 +++++---- .../container_endpoints/handler_factory.py | 6 +-- litellm/proxy/db/prisma_client.py | 4 +- litellm/proxy/guardrails/_content_utils.py | 16 ++++---- .../guardrail_hooks/qualifire/qualifire.py | 16 ++++---- .../guardrail_hooks/singulr/singulr.py | 4 +- .../unified_guardrail/unified_guardrail.py | 2 +- .../team_callback_endpoints.py | 8 ++-- .../proxy/openai_evals_endpoints/endpoints.py | 22 +++++------ litellm/proxy/policy_engine/init_policies.py | 5 ++- .../management_endpoints.py | 4 +- litellm/rag/ingestion/gemini_ingestion.py | 8 ++-- litellm/realtime_api/main.py | 9 +++-- litellm/repositories/config_repository.py | 17 +++++--- .../router_strategy/adaptive_router/hooks.py | 24 +++++++----- .../quality_router/quality_router.py | 11 +++--- .../router_utils/fallback_event_handlers.py | 14 +++---- .../io_token_rate_limit_check.py | 12 +++--- litellm/router_utils/search_api_router.py | 17 ++++++-- .../secret_managers/aws_secret_manager_v2.py | 4 +- litellm/skills/main.py | 26 ++++++------- litellm/types/vector_stores.py | 39 ++++++++++--------- litellm/vector_store_files/main.py | 14 +++---- 65 files changed, 411 insertions(+), 340 deletions(-) diff --git a/litellm/a2a_protocol/providers/bedrock_agentcore/transformation.py b/litellm/a2a_protocol/providers/bedrock_agentcore/transformation.py index 32252711997..4c5abf596cb 100644 --- a/litellm/a2a_protocol/providers/bedrock_agentcore/transformation.py +++ b/litellm/a2a_protocol/providers/bedrock_agentcore/transformation.py @@ -7,7 +7,7 @@ and signs requests via AmazonAgentCoreConfig (SigV4 or JWT). import json from collections.abc import AsyncIterator, Mapping -from typing import Any, Final +from typing import Any, Final, Protocol from litellm._logging import verbose_logger from litellm.llms.bedrock.chat.agentcore.transformation import AmazonAgentCoreConfig @@ -35,6 +35,12 @@ _RESERVED_PREFIX_HEADERS: Final[tuple[str, ...]] = ( ) +class _SSELineSource(Protocol): + """Minimal streaming-response surface used to read SSE lines.""" + + def aiter_lines(self) -> AsyncIterator[str]: ... + + def _filter_reserved_headers( agent_extra_headers: Mapping[str, str] | None, ) -> dict[str, str] | None: @@ -77,7 +83,7 @@ class BedrockAgentCoreA2ATransformation: @staticmethod def get_url_and_signed_request( request_id: str, - params: dict[str, Any], + params: Mapping[str, object], litellm_params: dict[str, Any], method: str = "message/send", stream: bool = False, @@ -170,7 +176,7 @@ class BedrockAgentCoreA2ATransformation: return url, signed_headers, signed_body @staticmethod - async def parse_sse_events(response: Any) -> AsyncIterator[dict[str, Any]]: + async def parse_sse_events(response: _SSELineSource) -> AsyncIterator[dict[str, Any]]: """ Parse SSE events from an httpx streaming response. diff --git a/litellm/caching/redis_semantic_cache.py b/litellm/caching/redis_semantic_cache.py index f5264e28124..9a70bfc1418 100644 --- a/litellm/caching/redis_semantic_cache.py +++ b/litellm/caching/redis_semantic_cache.py @@ -116,7 +116,7 @@ class RedisSemanticCache(BaseCache): password = password or os.environ["REDIS_PASSWORD"] except KeyError as e: # Raise a more informative exception if any of the required keys are missing - missing_var: Final = e.args[0] + missing_var: Final[object] = e.args[0] raise ValueError( f"Missing required Redis configuration: {missing_var}. Provide {missing_var} or redis_url." ) from e @@ -273,7 +273,7 @@ class RedisSemanticCache(BaseCache): return prompt or None @classmethod - def _collect_responses_input_text(cls, value: Any, prompt_parts: list[str]) -> None: + def _collect_responses_input_text(cls, value: object, prompt_parts: list[str]) -> None: value = cls._coerce_response_input_value(value) if value is None: return @@ -334,7 +334,7 @@ class RedisSemanticCache(BaseCache): resolve_embedding_max_input_tokens(self.embedding_max_input_tokens, self.embedding_model, router), ) - def _get_embedding(self, prompt: str, metadata: dict[str, Any] | None = None) -> list[float]: + def _get_embedding(self, prompt: str, metadata: dict[str, object] | None = None) -> list[float]: """ Routes through the proxy Router when the embedding model is a Router deployment so per-deployment auth (e.g. Bedrock aws_role_name) applies, @@ -425,7 +425,7 @@ class RedisSemanticCache(BaseCache): prompt_embedding: Final = self._get_embedding(prompt, metadata=kwargs.get("metadata")) - store_kwargs: Final[dict[str, Any]] = { + store_kwargs: Final[dict[str, object]] = { "vector": prompt_embedding, "filters": self._get_cache_filters(key), } @@ -504,7 +504,7 @@ class RedisSemanticCache(BaseCache): print_verbose(f"Error retrieving from Redis semantic cache: {e}") kwargs.setdefault("metadata", {})["semantic-similarity"] = 0.0 - async def _get_async_embedding(self, prompt: str, metadata: dict[str, Any] | None = None) -> list[float]: + async def _get_async_embedding(self, prompt: str, metadata: dict[str, object] | None = None) -> list[float]: """ Asynchronously generate an embedding for the given prompt. @@ -571,7 +571,7 @@ class RedisSemanticCache(BaseCache): # Generate embedding for the value (response) to cache prompt_embedding: Final = await self._get_async_embedding(prompt, metadata=kwargs.get("metadata")) - store_kwargs: Final[dict[str, Any]] = { + store_kwargs: Final[dict[str, object]] = { "vector": prompt_embedding, "filters": self._get_cache_filters(key), } @@ -665,7 +665,7 @@ class RedisSemanticCache(BaseCache): aindex: Final = await self.llmcache._get_async_index() return await aindex.info() - async def async_set_cache_pipeline(self, cache_list: list[tuple[str, Any]], **kwargs: object) -> None: + async def async_set_cache_pipeline(self, cache_list: list[tuple[str, object]], **kwargs: object) -> None: """ Asynchronously store multiple values in the semantic cache. diff --git a/litellm/compression/scoring/embedding_scorer.py b/litellm/compression/scoring/embedding_scorer.py index aab1371e097..7e645ba3f9c 100644 --- a/litellm/compression/scoring/embedding_scorer.py +++ b/litellm/compression/scoring/embedding_scorer.py @@ -5,6 +5,7 @@ Computes cosine similarity between the query embedding and each message embeddin """ import math +from collections.abc import Mapping from typing import Any, Final from litellm.caching.dual_cache import DualCache @@ -49,7 +50,7 @@ def embedding_score_messages( messages: list[dict], model: str, cache: DualCache | None = None, - embedding_model_params: dict[str, Any] | None = None, + embedding_model_params: Mapping[str, object] | None = None, ) -> list[float]: """ Score each message's semantic similarity to the query using embeddings. diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index ea81e323da4..34af6fcffba 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -5,18 +5,28 @@ LiteLLM Proxy uses this MCP Client to connnect to other MCP servers. import asyncio import base64 import os -from collections.abc import Awaitable, Callable, Generator +from collections.abc import Awaitable, Callable, Generator, Sequence +from contextlib import AbstractAsyncContextManager from datetime import timedelta from functools import partial from importlib import metadata -from typing import Any, Final, TypeVar +from typing import Any, Final, Protocol, TypeAlias, TypeVar import httpx from mcp import ClientSession, McpError, ReadResourceResult, Resource, StdioServerParameters from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client -streamable_http_client: Any | None = None +_TransportContext: TypeAlias = AbstractAsyncContextManager[Sequence[Any]] + + +class _StreamableHttpClientFactory(Protocol): + """The ``streamable_http_client`` entry point this module calls on the installed MCP SDK.""" + + def __call__(self, *, url: str, http_client: httpx.AsyncClient | None) -> _TransportContext: ... + + +streamable_http_client: _StreamableHttpClientFactory | None = None try: import mcp.client.streamable_http as streamable_http_module @@ -217,10 +227,12 @@ class MCPSigV4Auth(httpx.Auth): aws_region_name: str, ): """Call STS AssumeRole and return temporary credentials.""" + import time + import boto3 from botocore.credentials import Credentials - session_name: Final = aws_session_name or f"litellm-mcp-{int(__import__('time').time())}" + session_name: Final = aws_session_name or f"litellm-mcp-{int(time.time())}" sts_kwargs: Final[dict] = {"region_name": aws_region_name} if aws_access_key_id and aws_secret_access_key: sts_kwargs["aws_access_key_id"] = aws_access_key_id @@ -316,7 +328,7 @@ class MCPClient: def _create_transport_context( self, - ) -> tuple[Any, httpx.AsyncClient | None]: + ) -> tuple[_TransportContext, httpx.AsyncClient | None]: """ Create the appropriate transport context based on transport type. Returns: @@ -409,7 +421,7 @@ class MCPClient: async def _execute_session_operation( self, - transport_ctx: Any, + transport_ctx: _TransportContext, operation: Callable[[ClientSession], Awaitable[TSessionResult]], ) -> TSessionResult: """ diff --git a/litellm/files/main.py b/litellm/files/main.py index 294c62f3d80..e769a0a0508 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -431,7 +431,7 @@ async def afile_delete( extra_headers: dict[str, str] | None = None, extra_body: dict[str, str] | None = None, **kwargs, -) -> Coroutine[Any, Any, FileObject]: +) -> Coroutine[object, object, FileObject]: """ Async: Delete file @@ -1003,7 +1003,7 @@ def file_content_streaming( logging_obj: LiteLLMLoggingObj | None, _is_async: bool, client: Any | None, -) -> FileContentStreamingResult | Coroutine[Any, Any, FileContentStreamingResult]: +) -> FileContentStreamingResult | Coroutine[object, object, FileContentStreamingResult]: if logging_obj is not None: logging_obj.model = model or "" logging_obj.model_call_details["model"] = model or "" @@ -1028,8 +1028,8 @@ def file_content_streaming( headers=response.headers, ) - response: FileContentStreamingResult | Coroutine[Any, Any, FileContentStreamingResult] = FileContentStreamingResult( - stream_iterator=iter(()), headers={} + response: FileContentStreamingResult | Coroutine[object, object, FileContentStreamingResult] = ( + FileContentStreamingResult(stream_iterator=iter(()), headers={}) ) if custom_llm_provider in OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS: openai_creds: Final = get_openai_credentials( diff --git a/litellm/integrations/newrelic/newrelic.py b/litellm/integrations/newrelic/newrelic.py index f2f88ea55a8..9829ef4e18f 100644 --- a/litellm/integrations/newrelic/newrelic.py +++ b/litellm/integrations/newrelic/newrelic.py @@ -47,6 +47,8 @@ import os import threading import time import uuid +from collections.abc import Mapping, Sequence +from datetime import datetime from typing import Any, Final import litellm @@ -408,8 +410,8 @@ class NewRelicLogger(CustomLogger): def _get_duration( self, kwargs: dict, - start_time: Any, - end_time: Any, + start_time: datetime | float | None, + end_time: datetime | float | None, standard_logging_object: StandardLoggingPayload | None = None, ) -> float | None: """ @@ -438,7 +440,7 @@ class NewRelicLogger(CustomLogger): self, kwargs: dict, standard_logging_object: StandardLoggingPayload | None = None, - ) -> dict[str, Any]: + ) -> dict[str, object]: """ Extract request parameters like temperature and max_tokens, preferring StandardLoggingPayload.model_parameters. @@ -450,7 +452,7 @@ class NewRelicLogger(CustomLogger): else: source_params = kwargs.get("optional_params") or {} - params: Final = {} + params: Final[dict[str, object]] = {} temperature: Final = source_params.get("temperature") if temperature is not None: @@ -502,7 +504,7 @@ class NewRelicLogger(CustomLogger): response_model: str, vendor: str, standard_logging_object: StandardLoggingPayload | None = None, - ) -> list[dict[str, Any]]: + ) -> Sequence[Mapping[str, object]]: """ Extract all messages (request + response) with sequence numbers and timestamps. @@ -512,7 +514,7 @@ class NewRelicLogger(CustomLogger): Adds timestamps from StandardLoggingPayload (preferred) or kwargs if available (converted to epoch milliseconds). """ - messages: Final = [] + messages: Final[list[dict[str, object]]] = [] sequence = 0 # Extract timestamps, preferring StandardLoggingPayload @@ -544,7 +546,7 @@ class NewRelicLogger(CustomLogger): else: request_messages = kwargs.get("messages") or [] for msg in request_messages: - message_data = { + message_data: dict[str, object] = { "role": msg.get("role") or "user", "sequence": sequence, "response.model": response_model, @@ -599,11 +601,11 @@ class NewRelicLogger(CustomLogger): num_messages: int, usage: dict[str, int], duration: float | None = None, - request_params: dict[str, Any] | None = None, + request_params: Mapping[str, object] | None = None, ): """Record LlmChatCompletionSummary event to New Relic.""" try: - event_data: Final = { + event_data: Final[dict[str, object]] = { "id": request_id, "request_id": request_id, "request.model": request_model, @@ -647,7 +649,7 @@ class NewRelicLogger(CustomLogger): request_id: str, llm_response_id: str, trace_id: str | None, - messages: list[dict[str, Any]], + messages: Sequence[Mapping[str, object]], ): """Record LlmChatCompletionMessage events to New Relic. @@ -666,7 +668,7 @@ class NewRelicLogger(CustomLogger): for message in messages: sequence = message["sequence"] - event_data = { + event_data: dict[str, object] = { "id": f"{llm_response_id}-{sequence}", "request_id": request_id, "completion_id": request_id, diff --git a/litellm/integrations/opentelemetry.py b/litellm/integrations/opentelemetry.py index e8f3b305139..d4e7fcb577e 100644 --- a/litellm/integrations/opentelemetry.py +++ b/litellm/integrations/opentelemetry.py @@ -1,7 +1,7 @@ import os import threading from collections import OrderedDict -from collections.abc import Callable, Mapping +from collections.abc import Callable, Iterable, Mapping from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, field from datetime import datetime @@ -166,7 +166,7 @@ class OTELMetricAttributeFilter: exclude_list: list[str] | None = None -def _build_metric_attribute_filter(value: Any) -> OTELMetricAttributeFilter: +def _build_metric_attribute_filter(value: object) -> OTELMetricAttributeFilter: if isinstance(value, OTELMetricAttributeFilter): return value if not isinstance(value, dict): @@ -205,7 +205,7 @@ def _resolve_metric_attribute_filter( ) -def _normalize_team_metadata_keys(value: Any) -> list[str]: +def _normalize_team_metadata_keys(value: str | Iterable[object] | None) -> list[str]: """Coerce a team-metadata allowlist from a list or comma-separated string. config.yaml passes a YAML list; an env var passes a comma-separated string. @@ -1569,7 +1569,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): self.safe_set_attribute(span=span, key=RESPONSE_SERVICE_TIER_ATTRIBUTE, value=served_tier) @staticmethod - def _team_metadata_json(value: Any, allowed_keys: list[str]) -> str | None: + def _team_metadata_json(value: object, allowed_keys: list[str]) -> str | None: """JSON-serialize only the allowlisted sub-keys of a team's metadata. Returns ``None`` when nothing is allowlisted or no allowlisted key is @@ -3524,7 +3524,7 @@ class OpenTelemetry(OTELGenAISemconvMixin, CustomLogger): kwargs={"standard_logging_object": {"error_information": error_information}}, ) - def set_preprocessing_duration_attribute(self, span: Span | None, container: Any) -> None: + def set_preprocessing_duration_attribute(self, span: Span | None, container: object) -> None: """ Set ``litellm.preprocessing.duration_ms`` (proxy-receive -> first provider handoff) on the proxy SERVER span. ``litellm_received_at`` diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 975a9bd8639..3e75c9cbf93 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -2607,7 +2607,7 @@ class PrometheusLogger(CustomLogger): for all successful requests (both streaming and non-streaming). """ - def _safe_get(self, obj: Any, key: str, default: object = None) -> Any: + def _safe_get(self, obj: object, key: str, default: object = None) -> Any: """Get value from dict or Pydantic model.""" if obj is None: return default @@ -4215,8 +4215,8 @@ class PrometheusLogger(CustomLogger): def _safe_duration_seconds( self, - start_time: Any, - end_time: Any, + start_time: object, + end_time: object, ) -> float | None: """ Compute the duration in seconds between two objects. diff --git a/litellm/integrations/websearch_interception/tools.py b/litellm/integrations/websearch_interception/tools.py index b083a796a00..97c6c90d2ba 100644 --- a/litellm/integrations/websearch_interception/tools.py +++ b/litellm/integrations/websearch_interception/tools.py @@ -6,12 +6,13 @@ Native provider tools (like Anthropic's web_search_20250305) are converted to this format for consistent interception and execution. """ +from collections.abc import Mapping from typing import Any, Final from litellm.constants import LITELLM_WEB_SEARCH_TOOL_NAME -def get_litellm_web_search_tool() -> dict[str, Any]: +def get_litellm_web_search_tool() -> dict[str, object]: """ Get the standard LiteLLM web search tool definition. @@ -49,7 +50,7 @@ def get_litellm_web_search_tool() -> dict[str, Any]: } -def get_litellm_web_search_tool_openai() -> dict[str, Any]: +def get_litellm_web_search_tool_openai() -> dict[str, object]: """ Get the standard LiteLLM web search tool definition in OpenAI format. @@ -82,7 +83,7 @@ def get_litellm_web_search_tool_openai() -> dict[str, Any]: } -def get_litellm_web_search_tool_responses() -> dict[str, Any]: +def get_litellm_web_search_tool_responses() -> dict[str, object]: """ Get the standard LiteLLM web search tool definition in Responses API format. @@ -114,7 +115,7 @@ def get_litellm_web_search_tool_responses() -> dict[str, Any]: } -def is_web_search_tool_responses(tool: dict[str, Any]) -> bool: +def is_web_search_tool_responses(tool: Mapping[str, object]) -> bool: """ Check if a tool is a web search tool for the Responses API. @@ -195,7 +196,7 @@ def is_web_search_tool_chat_completion(tool: dict[str, Any]) -> bool: return False -def is_anthropic_native_web_search_tool(tool: dict[str, Any]) -> bool: +def is_anthropic_native_web_search_tool(tool: Mapping[str, object]) -> bool: """ Check if a tool is an Anthropic-native ``web_search_*`` tool. diff --git a/litellm/integrations/websearch_interception/transformation.py b/litellm/integrations/websearch_interception/transformation.py index 199ab020559..fe4b6583c55 100644 --- a/litellm/integrations/websearch_interception/transformation.py +++ b/litellm/integrations/websearch_interception/transformation.py @@ -24,7 +24,7 @@ class WebSearchTransformation: @staticmethod def transform_request( - response: Any, + response: object, stream: bool, response_format: str = "anthropic", ) -> tuple[bool, list[dict]]: @@ -66,7 +66,7 @@ class WebSearchTransformation: @staticmethod def _detect_from_responses_response( - response: Any, + response: object, ) -> tuple[bool, list[dict]]: """Parse a Responses API response for ``litellm_web_search`` function calls. @@ -399,7 +399,7 @@ class WebSearchTransformation: def build_web_search_tool_result_block( tool_use_id: str, search_response: SearchResponse | None, - ) -> dict[str, Any]: + ) -> dict[str, object]: """ Build an Anthropic-native ``web_search_tool_result`` content block. @@ -433,7 +433,7 @@ class WebSearchTransformation: emitted with an empty result list (signals "search ran, no results" rather than "search did not run"). """ - items: Final[list[dict[str, Any]]] = [] + items: Final[list[dict[str, object]]] = [] if search_response is not None: results: Final = getattr(search_response, "results", None) or [] for r in results: diff --git a/litellm/interactions/agents/http_handler.py b/litellm/interactions/agents/http_handler.py index 14000ffaffd..ec9df0fb488 100644 --- a/litellm/interactions/agents/http_handler.py +++ b/litellm/interactions/agents/http_handler.py @@ -6,7 +6,7 @@ Extends InteractionsHTTPHandler so that the shared HTTP infrastructure duplicated. BaseAgentsAPIConfig stays as pure transform code. """ -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from typing import Any, Final import httpx @@ -39,11 +39,11 @@ class AgentsHTTPHandler(InteractionsHTTPHandler): litellm_params: GenericLiteLLMParams, logging_obj: LiteLLMLoggingObj, extra_headers: dict[str, Any] | None = None, - extra_body: dict[str, Any] | None = None, + extra_body: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, client: HTTPHandler | None = None, _is_async: bool = False, - ) -> AgentCreateResponse | Coroutine[Any, Any, AgentCreateResponse]: + ) -> AgentCreateResponse | Coroutine[object, object, AgentCreateResponse]: if _is_async: return self.async_create_agent( agents_api_config=agents_api_config, @@ -94,7 +94,7 @@ class AgentsHTTPHandler(InteractionsHTTPHandler): litellm_params: GenericLiteLLMParams, logging_obj: LiteLLMLoggingObj, extra_headers: dict[str, Any] | None = None, - extra_body: dict[str, Any] | None = None, + extra_body: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, client: AsyncHTTPHandler | None = None, ) -> AgentCreateResponse: @@ -145,7 +145,7 @@ class AgentsHTTPHandler(InteractionsHTTPHandler): timeout: float | httpx.Timeout | None = None, client: HTTPHandler | None = None, _is_async: bool = False, - ) -> AgentListResponse | Coroutine[Any, Any, AgentListResponse]: + ) -> AgentListResponse | Coroutine[object, object, AgentListResponse]: if _is_async: return self.async_list_agents( agents_api_config=agents_api_config, @@ -220,7 +220,7 @@ class AgentsHTTPHandler(InteractionsHTTPHandler): timeout: float | httpx.Timeout | None = None, client: HTTPHandler | None = None, _is_async: bool = False, - ) -> AgentCreateResponse | Coroutine[Any, Any, AgentCreateResponse]: + ) -> AgentCreateResponse | Coroutine[object, object, AgentCreateResponse]: if _is_async: return self.async_get_agent( agents_api_config=agents_api_config, @@ -299,7 +299,7 @@ class AgentsHTTPHandler(InteractionsHTTPHandler): timeout: float | httpx.Timeout | None = None, client: HTTPHandler | None = None, _is_async: bool = False, - ) -> AgentDeleteResult | Coroutine[Any, Any, AgentDeleteResult]: + ) -> AgentDeleteResult | Coroutine[object, object, AgentDeleteResult]: if _is_async: return self.async_delete_agent( agents_api_config=agents_api_config, @@ -378,7 +378,7 @@ class AgentsHTTPHandler(InteractionsHTTPHandler): timeout: float | httpx.Timeout | None = None, client: HTTPHandler | None = None, _is_async: bool = False, - ) -> AgentVersionsResponse | Coroutine[Any, Any, AgentVersionsResponse]: + ) -> AgentVersionsResponse | Coroutine[object, object, AgentVersionsResponse]: if _is_async: return self.async_list_agent_versions( agents_api_config=agents_api_config, diff --git a/litellm/interactions/agents/main.py b/litellm/interactions/agents/main.py index b63bea42f4f..1ca28adf0a4 100644 --- a/litellm/interactions/agents/main.py +++ b/litellm/interactions/agents/main.py @@ -30,7 +30,7 @@ Usage: import asyncio import contextvars -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from functools import partial from typing import Any, Final @@ -75,7 +75,7 @@ def _make_logging_obj( model: str, custom_llm_provider: str, call_type: str, - optional_params: dict[str, Any], + optional_params: dict[str, object], ) -> LiteLLMLoggingObj: litellm_logging_obj: Final[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj") litellm_call_id: Final[str | None] = kwargs.get("litellm_call_id", None) @@ -102,7 +102,7 @@ async def acreate( base_environment: InteractionEnvironment | None = None, custom_llm_provider: str | None = None, extra_headers: dict[str, Any] | None = None, - extra_body: dict[str, Any] | None = None, + extra_body: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, **kwargs, ) -> AgentCreateResponse: @@ -146,10 +146,10 @@ def create( base_environment: InteractionEnvironment | None = None, custom_llm_provider: str | None = None, extra_headers: dict[str, Any] | None = None, - extra_body: dict[str, Any] | None = None, + extra_body: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, **kwargs, -) -> AgentCreateResponse | Coroutine[Any, Any, AgentCreateResponse]: +) -> AgentCreateResponse | Coroutine[object, object, AgentCreateResponse]: """ Sync: Create a managed agent on the provider side. @@ -244,7 +244,7 @@ def list( extra_headers: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, **kwargs, -) -> AgentListResponse | Coroutine[Any, Any, AgentListResponse]: +) -> AgentListResponse | Coroutine[object, object, AgentListResponse]: """Sync: List all agents on the provider side.""" local_vars: Final = locals() custom_llm_provider = custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini" @@ -320,7 +320,7 @@ def get( extra_headers: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, **kwargs, -) -> AgentCreateResponse | Coroutine[Any, Any, AgentCreateResponse]: +) -> AgentCreateResponse | Coroutine[object, object, AgentCreateResponse]: """Sync: Get a specific agent by name.""" local_vars: Final = locals() custom_llm_provider = custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini" @@ -397,7 +397,7 @@ def delete( extra_headers: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, **kwargs, -) -> AgentDeleteResult | Coroutine[Any, Any, AgentDeleteResult]: +) -> AgentDeleteResult | Coroutine[object, object, AgentDeleteResult]: """Sync: Delete a specific agent by name.""" local_vars: Final = locals() custom_llm_provider = custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini" @@ -474,7 +474,7 @@ def list_versions( extra_headers: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, **kwargs, -) -> AgentVersionsResponse | Coroutine[Any, Any, AgentVersionsResponse]: +) -> AgentVersionsResponse | Coroutine[object, object, AgentVersionsResponse]: """Sync: List versions of a specific agent.""" local_vars: Final = locals() custom_llm_provider = custom_llm_provider or kwargs.get("custom_llm_provider") or "gemini" diff --git a/litellm/interactions/litellm_responses_transformation/transformation.py b/litellm/interactions/litellm_responses_transformation/transformation.py index 9657b444969..39ccc26c38c 100644 --- a/litellm/interactions/litellm_responses_transformation/transformation.py +++ b/litellm/interactions/litellm_responses_transformation/transformation.py @@ -34,8 +34,8 @@ class LiteLLMResponsesInteractionsConfig: model: str, input: InteractionInput | None, optional_params: InteractionsAPIOptionalRequestParams, - **kwargs, - ) -> dict[str, Any]: + **kwargs: object, + ) -> dict[str, object]: """ Transform an Interactions API request to a Responses API request. @@ -45,7 +45,7 @@ class LiteLLMResponsesInteractionsConfig: - tools -> tools (similar format) - generation_config -> temperature, top_p, etc. """ - responses_request: Final[dict[str, Any]] = { + responses_request: Final[dict[str, object]] = { "model": model, } @@ -201,15 +201,15 @@ class LiteLLMResponsesInteractionsConfig: - Extract usage """ # Extract text from outputs and build both `outputs` (legacy) and `steps` (new schema). - outputs: Final[list[dict[str, Any]]] = [] - steps: Final[list[dict[str, Any]]] = [] + outputs: Final[list[dict[str, object]]] = [] + steps: Final[list[dict[str, object]]] = [] if hasattr(responses_response, "output") and responses_response.output: for output_item in responses_response.output: # Use getattr with None default to safely access content content = getattr(output_item, "content", None) if content is not None: content_items = content if isinstance(content, list) else [content] - model_output_contents: list[dict[str, Any]] = [] + model_output_contents: list[dict[str, object]] = [] for content_item in content_items: # Check if content_item has text attribute text = getattr(content_item, "text", None) @@ -264,7 +264,7 @@ class LiteLLMResponsesInteractionsConfig: # Add usage if available # Map Responses API usage (input_tokens, output_tokens) to Interactions API spec format # (total_input_tokens, total_output_tokens) - usage: Final = getattr(responses_response, "usage", None) + usage: Final[object] = getattr(responses_response, "usage", None) if usage: interactions_response_dict["usage"] = { "total_input_tokens": getattr(usage, "input_tokens", 0), diff --git a/litellm/interactions/main.py b/litellm/interactions/main.py index a2c3d510fae..8a33e9b39c5 100644 --- a/litellm/interactions/main.py +++ b/litellm/interactions/main.py @@ -229,7 +229,7 @@ def create( ) -> ( InteractionsAPIResponse | Iterator[InteractionsAPIStreamingResponse] - | Coroutine[Any, Any, InteractionsAPIResponse | AsyncIterator[InteractionsAPIStreamingResponse]] + | Coroutine[object, object, InteractionsAPIResponse | AsyncIterator[InteractionsAPIStreamingResponse]] ): """ Sync: Create a new interaction using Google's Interactions API. @@ -406,7 +406,7 @@ def get( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> InteractionsAPIResponse | Coroutine[Any, Any, InteractionsAPIResponse]: +) -> InteractionsAPIResponse | Coroutine[object, object, InteractionsAPIResponse]: """Sync: Get an interaction by its ID.""" local_vars: Final = locals() custom_llm_provider = custom_llm_provider or "gemini" @@ -510,7 +510,7 @@ def delete( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> DeleteInteractionResult | Coroutine[Any, Any, DeleteInteractionResult]: +) -> DeleteInteractionResult | Coroutine[object, object, DeleteInteractionResult]: """Sync: Delete an interaction by its ID.""" local_vars: Final = locals() custom_llm_provider = custom_llm_provider or "gemini" @@ -612,7 +612,7 @@ def cancel( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> CancelInteractionResult | Coroutine[Any, Any, CancelInteractionResult]: +) -> CancelInteractionResult | Coroutine[object, object, CancelInteractionResult]: """Sync: Cancel an interaction by its ID.""" local_vars: Final = locals() custom_llm_provider = custom_llm_provider or "gemini" diff --git a/litellm/litellm_core_utils/core_helpers.py b/litellm/litellm_core_utils/core_helpers.py index 1738e30d865..2cdcfe4879c 100644 --- a/litellm/litellm_core_utils/core_helpers.py +++ b/litellm/litellm_core_utils/core_helpers.py @@ -419,7 +419,7 @@ def safe_deep_copy(data): if litellm.safe_memory_mode is True: return data - litellm_parent_otel_span: Any | None = None + litellm_parent_otel_span: object | None = None # Step 1: Remove the litellm_parent_otel_span litellm_parent_otel_span = None if isinstance(data, dict): @@ -510,7 +510,7 @@ def independent_snapshot( } -def filter_exceptions_from_params(data: Any, max_depth: int = 20) -> Any: +def filter_exceptions_from_params(data: object, max_depth: int = 20) -> Any: """ Recursively filter out Exception objects and callable objects from dicts/lists. @@ -542,7 +542,7 @@ def filter_exceptions_from_params(data: Any, max_depth: int = 20) -> Any: return None if isinstance(data, dict): - result: Final[dict[str, Any]] = {} + result: Final[dict[str, object]] = {} for k, v in data.items(): # Skip exception and callable values if isinstance(v, Exception) or (callable(v) and not isinstance(v, type)): @@ -556,7 +556,7 @@ def filter_exceptions_from_params(data: Any, max_depth: int = 20) -> Any: continue return result elif isinstance(data, list): - result_list: Final[list[Any]] = [] + result_list: Final[list[object]] = [] for item in data: # Skip exception and callable items if isinstance(item, Exception) or (callable(item) and not isinstance(item, type)): @@ -624,7 +624,7 @@ def redact_nested_match_and_regex_keys( # Iterative traversal; `seen` guards against cyclic refs preserved by deepcopy. try: seen: Final[set] = set() - stack: Final[list[Any]] = [redacted] + stack: Final[list[object]] = [redacted] while stack: node = stack.pop() node_id = id(node) diff --git a/litellm/litellm_core_utils/llm_response_utils/response_metadata.py b/litellm/litellm_core_utils/llm_response_utils/response_metadata.py index b53a2d36753..c83c266a17e 100644 --- a/litellm/litellm_core_utils/llm_response_utils/response_metadata.py +++ b/litellm/litellm_core_utils/llm_response_utils/response_metadata.py @@ -168,7 +168,7 @@ class ResponseMetadata: def update_response_metadata( - result: Any, + result: object, logging_obj: LiteLLMLoggingObject, model: str | None, kwargs: dict, diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index ba59e3fa997..56c1d605700 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1708,8 +1708,8 @@ def _find_server_tool_result( def convert_to_anthropic_tool_invoke( tool_calls: list[ChatCompletionAssistantToolCall], - web_search_results: list[Any] | None = None, - tool_results: list[Any] | None = None, + web_search_results: Sequence[object] | None = None, + tool_results: Sequence[object] | None = None, ) -> list[AnthropicMessagesToolUseParam | dict[str, Any]]: """ OpenAI tool invokes: @@ -5349,7 +5349,7 @@ class NormalizedToolCall(TypedDict): arguments: dict[str, object] -def _parse_tool_call_arguments(raw: Any, tool_name: str | None, context: str) -> dict[str, object]: +def _parse_tool_call_arguments(raw: object, tool_name: str | None, context: str) -> dict[str, object]: # Anthropic's tool_use blocks already carry a parsed dict in "input"; # chat completions and the Responses API carry a JSON string that may be # truncated by the model, so route those through the repair-aware parser. diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index cc5879df56d..ca993d40708 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -4,7 +4,7 @@ import copy import json import traceback from collections import deque -from collections.abc import AsyncIterator, Iterator, Sequence +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence from typing import ( TYPE_CHECKING, Any, @@ -418,7 +418,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): augmented["usage"] = augmented_usage return augmented - def _next_compaction_event(self) -> dict[str, Any] | None: + def _next_compaction_event(self) -> dict[str, object] | None: """Return the next compaction content-block SSE event, or ``None``. Anthropic delivers compaction as a single delta (no token-by-token @@ -457,7 +457,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): "delta": {"type": "compaction_delta", "content": summary_content}, } - stop_event: Final = { + stop_event: Final[dict[str, object]] = { "type": "content_block_stop", "index": compaction_index, } @@ -989,7 +989,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): self.current_content_block_index += 1 @staticmethod - def _delta_has_content(processed_chunk: dict[str, Any]) -> bool: + def _delta_has_content(processed_chunk: Mapping[str, object]) -> bool: """Return True if a translated chunk carries a non-empty ``content_block_delta`` payload. diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 3d62b8b4784..b62e55f30f3 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -87,7 +87,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): Processes both `system` and `messages` content blocks. """ - def _sanitize(cache_control: Any) -> None: + def _sanitize(cache_control: object) -> None: if isinstance(cache_control, dict): cache_control.pop("scope", None) @@ -152,7 +152,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): return system_param @staticmethod - def _as_system_content_blocks(value: Any) -> list: + def _as_system_content_blocks(value: object) -> list: if value is None: return [] if isinstance(value, list): @@ -162,7 +162,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): return [value] @staticmethod - def _is_system_role_message(message: Any) -> bool: + def _is_system_role_message(message: object) -> bool: return isinstance(message, dict) and message.get("role") == "system" _CONVERTED_SYSTEM_NOTE: Final = ( diff --git a/litellm/llms/azure_ai/vector_stores/transformation.py b/litellm/llms/azure_ai/vector_stores/transformation.py index 5e61d0a1dd9..6db8c6a6c9f 100644 --- a/litellm/llms/azure_ai/vector_stores/transformation.py +++ b/litellm/llms/azure_ai/vector_stores/transformation.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final import httpx @@ -114,8 +115,8 @@ class AzureAIVectorStoreConfig(BaseVectorStoreConfig, BaseAzureLLM): api_base: str, litellm_logging_obj: LiteLLMLoggingObj, litellm_params: dict, - extra_body: dict[str, Any] | None = None, - ) -> tuple[str, dict[str, Any]]: + extra_body: Mapping[str, object] | None = None, + ) -> tuple[str, dict[str, object]]: """ Transform search request for Azure AI Search API @@ -162,7 +163,7 @@ class AzureAIVectorStoreConfig(BaseVectorStoreConfig, BaseAzureLLM): url: Final = f"{api_base}/indexes/{index_name}/docs/search?api-version=2024-07-01" # Build the request body for Azure AI Search with vector search - request_body: Final = { + request_body: Final[dict[str, object]] = { "search": "*", # Get all documents (filtered by vector similarity) "vectorQueries": [ { diff --git a/litellm/llms/base_llm/guardrail_translation/base_translation.py b/litellm/llms/base_llm/guardrail_translation/base_translation.py index 220fcedb0f8..0334b7f267c 100644 --- a/litellm/llms/base_llm/guardrail_translation/base_translation.py +++ b/litellm/llms/base_llm/guardrail_translation/base_translation.py @@ -39,7 +39,7 @@ class BaseTranslation(ABC): @staticmethod def transform_user_api_key_dict_to_metadata( user_api_key_dict: Any | None, - ) -> dict[str, Any]: + ) -> dict[str, object]: """ Transform user_api_key_dict to a metadata dict with prefixed keys. @@ -62,7 +62,7 @@ class BaseTranslation(ABC): return {} # Transform keys to be prefixed with 'user_api_key_' - transformed: Final = {} + transformed: Final[dict[str, object]] = {} for key, value in user_dict.items(): # Skip None values and internal fields if value is None or key.startswith("_"): @@ -155,7 +155,7 @@ class BaseTranslation(ABC): self, exc: "ModifyResponseException", stream_started: bool = False, - responses_so_far: Sequence[Any] | None = None, + responses_so_far: Sequence[object] | None = None, ) -> Sequence[bytes] | None: """ Build the streaming chunks that deliver a guardrail block message and @@ -178,8 +178,8 @@ class BaseTranslation(ABC): def build_stream_error_items( self, exc: "HTTPException", - responses_so_far: Sequence[Any] | None = None, - ) -> Sequence[Any] | None: + responses_so_far: Sequence[object] | None = None, + ) -> Sequence[object] | None: """ Build the stream items that surface a guardrail HTTPException (a block with the default exception-on-block config, or a failed scan) after the diff --git a/litellm/llms/bedrock/common_utils.py b/litellm/llms/bedrock/common_utils.py index 66ee5f10679..6e27cc7024f 100644 --- a/litellm/llms/bedrock/common_utils.py +++ b/litellm/llms/bedrock/common_utils.py @@ -52,8 +52,8 @@ _BEDROCK_AWS_AUTH_PARAMETER_KEYS: Final[tuple[str, ...]] = ( def merge_bedrock_aws_request_params( - litellm_params: Mapping[str, Any], - optional_params: Mapping[str, Any], + litellm_params: Mapping[str, object], + optional_params: Mapping[str, object], ) -> dict[str, Any]: """Merge deployment and request parameters without allowing auth escalation. @@ -303,7 +303,7 @@ def normalize_json_schema_custom_types_to_object(schema: dict) -> None: Uses an explicit stack (not recursion) to satisfy recursive-function guards in CI. """ - stack: Final[list[Any]] = [schema] + stack: Final[list[object]] = [schema] seen: Final[set[int]] = set() while stack: node = stack.pop() @@ -901,7 +901,7 @@ def _get_bedrock_converse_strict_tools_flag(base_model: str) -> bool | None: return None -def normalize_bedrock_opus_output_config_effort(model: str, output_config: Any) -> None: +def normalize_bedrock_opus_output_config_effort(model: str, output_config: object) -> None: """ Normalize Anthropic ``output_config.effort`` values for Bedrock Opus ids. @@ -1424,6 +1424,11 @@ class BedrockEventStreamDecoderBase: return chunk.decode() +def _decoded_json_value(raw: str) -> object: + """Decode a JSON document into an opaque value for isinstance narrowing.""" + return json.loads(raw) + + def get_anthropic_beta_from_headers(headers: dict) -> list[str]: """ Extract anthropic-beta header values and convert them to a list. @@ -1451,7 +1456,7 @@ def get_anthropic_beta_from_headers(headers: dict) -> list[str]: anthropic_beta_header = anthropic_beta_header.strip() if anthropic_beta_header.startswith("[") and anthropic_beta_header.endswith("]"): try: - parsed: Final = json.loads(anthropic_beta_header) + parsed: Final = _decoded_json_value(anthropic_beta_header) if isinstance(parsed, list): return [str(beta).strip() for beta in parsed] except json.JSONDecodeError: @@ -1464,8 +1469,8 @@ def get_anthropic_beta_from_headers(headers: dict) -> list[str]: def resolve_s3_encryption_key_id( - litellm_params: Mapping[str, Any], - optional_params: Mapping[str, Any] | None = None, + litellm_params: Mapping[str, object], + optional_params: Mapping[str, object] | None = None, ) -> str | None: """ Resolve the SSE-KMS key configured for Bedrock batch/file S3 objects. diff --git a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py index ba76c7e628c..18d47301ee5 100644 --- a/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py +++ b/litellm/llms/bedrock/image_edit/amazon_nova_canvas_image_edit_transformation.py @@ -47,7 +47,7 @@ def _nova_canvas_task_body( task_type: str | None, mask_prompt: str | None, out_painting_mode: str | None, -) -> dict[str, Any]: +) -> dict[str, object]: """Build InvokeModel body task section (without imageGenerationConfig).""" if task_type == "BACKGROUND_REMOVAL": return { @@ -60,7 +60,7 @@ def _nova_canvas_task_body( "OUTPAINTING requires either a mask image or a mask prompt. " "Pass mask= or maskPrompt= in the request." ) - out_params: Final[dict[str, Any]] = { + out_params: Final[dict[str, object]] = { "image": image_b64, "text": text, } @@ -79,7 +79,7 @@ def _nova_canvas_task_body( # Honour explicit IMAGE_VARIATION even when a mask is present (mask is ignored # for this task type; callers use INPAINTING when they want mask semantics). if task_type == "IMAGE_VARIATION": - var_params_explicit: Final[dict[str, Any]] = { + var_params_explicit: Final[dict[str, object]] = { "images": [image_b64], "text": text, } @@ -100,7 +100,7 @@ def _nova_canvas_task_body( "or omit taskType for automatic routing (mask → INPAINTING, else IMAGE_VARIATION)." ) if mask_b64 is not None or mask_prompt is not None or task_type == "INPAINTING": - in_params: Final[dict[str, Any]] = {"image": image_b64, "text": text} + in_params: Final[dict[str, object]] = {"image": image_b64, "text": text} if mask_prompt is not None: in_params["maskPrompt"] = mask_prompt elif mask_b64 is not None: @@ -114,7 +114,7 @@ def _nova_canvas_task_body( "See https://docs.aws.amazon.com/nova/latest/userguide/image-gen-req-resp-structure.html" ) return {"taskType": "INPAINTING", "inPaintingParams": in_params} - var_params: Final[dict[str, Any]] = { + var_params: Final[dict[str, object]] = { "images": [image_b64], "text": text, } @@ -250,9 +250,9 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): image_edit_optional_params: ImageEditOptionalRequestParams, model: str, drop_params: bool, - ) -> dict[str, Any]: + ) -> dict[str, object]: supported: Final = set(self.get_supported_openai_params(model)) - mapped: Final[dict[str, Any]] = dict(image_edit_optional_params) + mapped: Final[dict[str, object]] = dict(image_edit_optional_params) _size: Final = mapped.pop("size", None) if _size is not None and isinstance(_size, str) and "x" in _size: w, h = _size.split("x", 1) @@ -327,7 +327,7 @@ class BedrockAmazonNovaCanvasImageEditConfig(BaseImageEditConfig): cfg_scale: Final = op.pop("cfgScale", None) seed: Final = op.pop("seed", None) - image_generation_config: Final[dict[str, Any]] = {} + image_generation_config: Final[dict[str, object]] = {} nested_igc: Final = op.pop("imageGenerationConfig", None) if isinstance(nested_igc, dict): image_generation_config.update(nested_igc) diff --git a/litellm/llms/bedrock/vector_stores/transformation.py b/litellm/llms/bedrock/vector_stores/transformation.py index 2d72db0cdba..6940077391f 100644 --- a/litellm/llms/bedrock/vector_stores/transformation.py +++ b/litellm/llms/bedrock/vector_stores/transformation.py @@ -203,7 +203,7 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): encoded_vector_store_id: Final = encode_url_path_segment(vector_store_id, field_name="vector_store_id") url: Final = f"{api_base}/{encoded_vector_store_id}/retrieve" - request_body: Final[dict[str, Any]] = { + request_body: Final[dict[str, object]] = { "retrievalQuery": BedrockKBRetrievalQuery(text=query), } @@ -288,7 +288,7 @@ class BedrockVectorStoreConfig(BaseVectorStoreConfig, BaseAWSLLM): data_source_id: Final = metadata.get("x-amz-bedrock-kb-data-source-id", "unknown") if metadata else "unknown" return f"bedrock-kb-document-{data_source_id}" - def _get_attributes_from_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]: + def _get_attributes_from_metadata(self, metadata: dict[str, object]) -> dict[str, object]: """ Extract all attributes from Bedrock KB metadata. Returns a copy of the metadata dictionary. diff --git a/litellm/llms/black_forest_labs/image_edit/transformation.py b/litellm/llms/black_forest_labs/image_edit/transformation.py index 013053e5bd5..62b631a7671 100644 --- a/litellm/llms/black_forest_labs/image_edit/transformation.py +++ b/litellm/llms/black_forest_labs/image_edit/transformation.py @@ -84,7 +84,7 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): BFL-specific params are passed through directly. """ - optional_params: Final[dict[str, Any]] = {} + optional_params: Final[dict[str, object]] = {} # Pass through BFL-specific params bfl_params: Final = [ @@ -246,7 +246,7 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): b64_image: Final = base64.b64encode(image_bytes).decode("utf-8") # Build request body - request_body: Final[dict[str, Any]] = { + request_body: Final[dict[str, object]] = { "prompt": prompt, "input_image": b64_image, } diff --git a/litellm/llms/gemini/agents/transformation.py b/litellm/llms/gemini/agents/transformation.py index 2de78242c43..cfdb55f2048 100644 --- a/litellm/llms/gemini/agents/transformation.py +++ b/litellm/llms/gemini/agents/transformation.py @@ -9,6 +9,7 @@ Proxies the Gemini v1beta Agents API: GET /v1beta/agents/{name}/versions list versions """ +from collections.abc import Mapping from typing import Any, Final import httpx @@ -87,7 +88,7 @@ class GeminiAgentsConfig(BaseAgentsAPIConfig): def get_complete_url( self, api_base: str | None, - litellm_params: dict[str, Any], + litellm_params: Mapping[str, object], ) -> str: return f"{self._base_url(api_base)}/agents" @@ -132,9 +133,9 @@ class GeminiAgentsConfig(BaseAgentsAPIConfig): def transform_create_request( self, name: str, - litellm_params: dict[str, Any], - ) -> dict[str, Any]: - body: Final[dict[str, Any]] = {"name": name} + litellm_params: Mapping[str, object], + ) -> dict[str, object]: + body: Final[dict[str, object]] = {"name": name} for key in _GEMINI_AGENT_BODY_KEYS: value = litellm_params.get(key) if value is not None: @@ -174,10 +175,10 @@ class GeminiAgentsConfig(BaseAgentsAPIConfig): def transform_list_request( self, api_base: str | None, - litellm_params: dict[str, Any], - ) -> tuple[str, dict[str, Any]]: + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, object]]: url: Final = f"{self._base_url(api_base)}/agents" - params: Final[dict[str, Any]] = {} + params: Final[dict[str, object]] = {} if litellm_params.get("page_size"): params["pageSize"] = litellm_params["page_size"] if litellm_params.get("page_token"): @@ -207,8 +208,8 @@ class GeminiAgentsConfig(BaseAgentsAPIConfig): self, name: str, api_base: str | None, - litellm_params: dict[str, Any], - ) -> tuple[str, dict[str, Any]]: + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, object]]: url: Final = f"{self._base_url(api_base)}/agents/{name}" return url, {} @@ -236,7 +237,7 @@ class GeminiAgentsConfig(BaseAgentsAPIConfig): self, name: str, api_base: str | None, - litellm_params: dict[str, Any], + litellm_params: Mapping[str, object], ) -> str: return f"{self._base_url(api_base)}/agents/{name}" @@ -262,10 +263,10 @@ class GeminiAgentsConfig(BaseAgentsAPIConfig): self, name: str, api_base: str | None, - litellm_params: dict[str, Any], - ) -> tuple[str, dict[str, Any]]: + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, object]]: url: Final = f"{self._base_url(api_base)}/agents/{name}/versions" - params: Final[dict[str, Any]] = {} + params: Final[dict[str, object]] = {} if litellm_params.get("page_size"): params["pageSize"] = litellm_params["page_size"] if litellm_params.get("page_token"): diff --git a/litellm/llms/milvus/vector_stores/transformation.py b/litellm/llms/milvus/vector_stores/transformation.py index 34f0cd854c4..0e96b3577fd 100644 --- a/litellm/llms/milvus/vector_stores/transformation.py +++ b/litellm/llms/milvus/vector_stores/transformation.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final import httpx @@ -122,8 +123,8 @@ class MilvusVectorStoreConfig(BaseVectorStoreConfig): api_base: str, litellm_logging_obj: LiteLLMLoggingObj, litellm_params: dict, - extra_body: dict[str, Any] | None = None, - ) -> tuple[str, dict[str, Any]]: + extra_body: Mapping[str, object] | None = None, + ) -> tuple[str, dict[str, object]]: """ Transform search request for Azure AI Search API @@ -165,7 +166,7 @@ class MilvusVectorStoreConfig(BaseVectorStoreConfig): url: Final = f"{api_base}/v2/vectordb/entities/search" # Build the request body for Azure AI Search with vector search - request_body: Final[dict[str, Any]] = { + request_body: Final[dict[str, object]] = { "collectionName": index_name, "data": [query_vector], "annsField": "book_intro_vector", diff --git a/litellm/llms/minimax/text_to_speech/transformation.py b/litellm/llms/minimax/text_to_speech/transformation.py index 2263a98551e..f8926df1f3f 100644 --- a/litellm/llms/minimax/text_to_speech/transformation.py +++ b/litellm/llms/minimax/text_to_speech/transformation.py @@ -5,6 +5,7 @@ Maps OpenAI TTS spec to MiniMax TTS API (WebSocket-based HTTP API) Reference: https://platform.minimax.io/docs """ +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final import httpx @@ -86,8 +87,8 @@ class MinimaxTextToSpeechConfig(BaseTextToSpeechConfig): def _resolve_voice_id( self, - voice: str | dict[str, Any] | None, - params: dict[str, Any], + voice: str | Mapping[str, object] | None, + params: dict[str, object], ) -> str: """ Determine the MiniMax voice_id based on provided voice input or parameters. @@ -127,7 +128,7 @@ class MinimaxTextToSpeechConfig(BaseTextToSpeechConfig): """ Map OpenAI parameters to MiniMax TTS parameters """ - mapped_params: Final[dict[str, Any]] = {} + mapped_params: Final[dict[str, object]] = {} # Work on a copy so we don't mutate the caller's dictionary params: Final = dict(optional_params) if optional_params else {} @@ -242,7 +243,7 @@ class MinimaxTextToSpeechConfig(BaseTextToSpeechConfig): # Output format: 'url' or 'hex' (default is 'hex') output_format: Final = params.pop("output_format", "hex") - request_body: Final[dict[str, Any]] = { + request_body: Final[dict[str, object]] = { "model": model, "text": input, "stream": False, # HTTP endpoint doesn't support streaming diff --git a/litellm/llms/openai/responses/count_tokens/transformation.py b/litellm/llms/openai/responses/count_tokens/transformation.py index 88f04c59e01..9f596505f91 100644 --- a/litellm/llms/openai/responses/count_tokens/transformation.py +++ b/litellm/llms/openai/responses/count_tokens/transformation.py @@ -117,16 +117,16 @@ class OpenAICountTokensConfig: def transform_request_to_count_tokens( self, model: str, - input: str | list[Any], + input: str | Sequence[object], tools: list[dict[str, Any]] | None = None, instructions: str | None = None, - ) -> dict[str, Any]: + ) -> dict[str, object]: """ Transform request to OpenAI Responses API token counting format. The Responses API uses `input` (not `messages`) and `instructions` (not `system`). """ - request: Final[dict[str, Any]] = { + request: Final[dict[str, object]] = { "model": model, "input": input, } @@ -145,7 +145,7 @@ class OpenAICountTokensConfig: "Authorization": f"Bearer {api_key}", } - def validate_request(self, model: str, input: str | list[Any]) -> None: + def validate_request(self, model: str, input: str | Sequence[object]) -> None: if not model: raise ValueError("model parameter is required") @@ -155,18 +155,18 @@ class OpenAICountTokensConfig: @staticmethod def _transform_tools_for_responses_api( tools: list[dict[str, Any]], - ) -> list[dict[str, Any]]: + ) -> list[dict[str, object]]: """ Transform OpenAI chat tools format to Responses API tools format. Chat format: {"type": "function", "function": {"name": "...", "parameters": {...}}} Responses format: {"type": "function", "name": "...", "parameters": {...}} """ - transformed: Final = [] + transformed: Final[list[dict[str, object]]] = [] for tool in tools: if tool.get("type") == "function" and "function" in tool: func = tool["function"] - item: dict[str, Any] = { + item: dict[str, object] = { "type": "function", "name": func.get("name", ""), "description": func.get("description", ""), @@ -191,7 +191,7 @@ class OpenAICountTokensConfig: (input_items, instructions) tuple where instructions is extracted from system/developer messages. """ - input_items: Final[list[dict[str, Any]]] = [] + input_items: Final[list[dict[str, object]]] = [] instructions_parts: Final[list[str]] = [] for msg in messages: diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 1530c154e93..1db28193d10 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -110,7 +110,7 @@ class ResponsesStreamChunk(TypedDict, total=False): content_index: ReadOnly[int] -def _next_stream_sequence_number(responses_so_far: Sequence[Any] | None) -> int: +def _next_stream_sequence_number(responses_so_far: Sequence[object] | None) -> int: sequence_numbers: Final = ( item.get("sequence_number") if isinstance(item, dict) else getattr(item, "sequence_number", None) for item in reversed(responses_so_far or ()) @@ -337,7 +337,7 @@ class OpenAIResponsesHandler(BaseTranslation): def _extract_input_text_and_images( self, - message: Any, + message: Mapping[str, object], msg_idx: int, texts_to_check: list[str], images_to_check: list[str], @@ -661,8 +661,8 @@ class OpenAIResponsesHandler(BaseTranslation): def build_stream_error_items( self, exc: "HTTPException", - responses_so_far: Sequence[Any] | None = None, - ) -> Sequence[Any] | None: + responses_so_far: Sequence[object] | None = None, + ) -> Sequence[object] | None: from litellm.proxy.common_request_processing import ( serialize_http_exception_detail, ) diff --git a/litellm/llms/openai/videos/transformation.py b/litellm/llms/openai/videos/transformation.py index f1b6dcb330a..94dc30f41e5 100644 --- a/litellm/llms/openai/videos/transformation.py +++ b/litellm/llms/openai/videos/transformation.py @@ -571,8 +571,8 @@ class OpenAIVideoConfig(BaseVideoConfig): def _add_image_to_files( self, - files_list: list[tuple[str, Any]], - image: Any, + files_list: list[tuple[str, FileTypes]], + image: FileContent, field_name: str, ) -> None: """Add an image to the files list with appropriate content type""" diff --git a/litellm/llms/openrouter/image_edit/transformation.py b/litellm/llms/openrouter/image_edit/transformation.py index e3a2bf34854..b01c25aad0c 100644 --- a/litellm/llms/openrouter/image_edit/transformation.py +++ b/litellm/llms/openrouter/image_edit/transformation.py @@ -152,7 +152,7 @@ class OpenRouterImageEditConfig(BaseImageEditConfig): litellm_params: GenericLiteLLMParams, headers: dict, ) -> tuple[dict, RequestFiles]: - content_parts: Final[list[dict[str, Any]]] = [] + content_parts: Final[list[dict[str, object]]] = [] # Add source image(s) as base64 data URLs if image is not None: @@ -174,7 +174,7 @@ class OpenRouterImageEditConfig(BaseImageEditConfig): if prompt: content_parts.append({"type": "text", "text": prompt}) - request_body: Final[dict[str, Any]] = { + request_body: Final[dict[str, object]] = { "model": model, "messages": [ { diff --git a/litellm/llms/pass_through/guardrail_translation/handler.py b/litellm/llms/pass_through/guardrail_translation/handler.py index 6573ca827f0..f07acf2f728 100644 --- a/litellm/llms/pass_through/guardrail_translation/handler.py +++ b/litellm/llms/pass_through/guardrail_translation/handler.py @@ -127,7 +127,7 @@ class PassThroughEndpointHandler(BaseTranslation): async def process_output_response( self, - response: Any, + response: object, guardrail_to_apply: "CustomGuardrail", litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, user_api_key_dict: Any | None = None, @@ -236,7 +236,7 @@ class LlmPassthroughRouteHandler(BaseTranslation): async def process_output_response( self, - response: Any, + response: object, guardrail_to_apply: "CustomGuardrail", litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, user_api_key_dict: Any | None = None, diff --git a/litellm/llms/perplexity/embedding/transformation.py b/litellm/llms/perplexity/embedding/transformation.py index cb29a598d30..a911fa62719 100644 --- a/litellm/llms/perplexity/embedding/transformation.py +++ b/litellm/llms/perplexity/embedding/transformation.py @@ -13,7 +13,7 @@ This module decodes them into float arrays for OpenAI-compatible responses. import base64 import struct -from typing import Any, Final +from typing import Final import httpx @@ -117,7 +117,7 @@ class PerplexityEmbeddingConfig(BaseEmbeddingConfig): } @staticmethod - def _decode_base64_embedding(embedding_value: Any) -> list[float]: + def _decode_base64_embedding(embedding_value: object) -> object: """ Decode a Perplexity embedding into a list of floats. @@ -154,7 +154,7 @@ class PerplexityEmbeddingConfig(BaseEmbeddingConfig): model_response.object = raw_response_json.get("object", "list") raw_data: Final = raw_response_json.get("data", []) - decoded_data: Final[list[dict[str, Any]]] = [] + decoded_data: Final[list[dict[str, object]]] = [] for item in raw_data: decoded_item = dict(item) decoded_item["embedding"] = self._decode_base64_embedding(item.get("embedding")) diff --git a/litellm/llms/ragflow/vector_stores/transformation.py b/litellm/llms/ragflow/vector_stores/transformation.py index 282cb7a92a7..38a06a37f7e 100644 --- a/litellm/llms/ragflow/vector_stores/transformation.py +++ b/litellm/llms/ragflow/vector_stores/transformation.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final import httpx @@ -91,7 +92,7 @@ class RAGFlowVectorStoreConfig(BaseVectorStoreConfig): api_base: str, litellm_logging_obj: LiteLLMLoggingObj, litellm_params: dict, - extra_body: dict[str, Any] | None = None, + extra_body: Mapping[str, object] | None = None, ) -> tuple[str, dict]: """RAGFlow vector stores are management-only, search is not supported.""" raise NotImplementedError("RAGFlow vector stores support dataset management only, not search/retrieval") @@ -121,7 +122,7 @@ class RAGFlowVectorStoreConfig(BaseVectorStoreConfig): raise ValueError("name is required for RAGFlow dataset creation") # Build request body - request_body: Final[dict[str, Any]] = { + request_body: Final[dict[str, object]] = { "name": name, } diff --git a/litellm/llms/vertex_ai/fine_tuning/handler.py b/litellm/llms/vertex_ai/fine_tuning/handler.py index df9b1f8c66a..7ecc5e8ff3d 100644 --- a/litellm/llms/vertex_ai/fine_tuning/handler.py +++ b/litellm/llms/vertex_ai/fine_tuning/handler.py @@ -2,7 +2,7 @@ import json import traceback from collections.abc import Coroutine from datetime import datetime -from typing import Any, Final, Literal +from typing import Final, Literal import httpx @@ -207,7 +207,7 @@ class VertexFineTuningAPI(VertexLLM): timeout: float | httpx.Timeout, kwargs: dict | None = None, original_hyperparameters: dict | None = {}, - ) -> LiteLLMFineTuningJob | Coroutine[Any, Any, LiteLLMFineTuningJob]: + ) -> LiteLLMFineTuningJob | Coroutine[object, object, LiteLLMFineTuningJob]: verbose_logger.debug("creating fine tuning job, args= %s", create_fine_tuning_job_data) _auth_header, vertex_project = self._ensure_access_token( credentials=vertex_credentials, diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 94bca9460dd..bf5f95d3f38 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -430,7 +430,7 @@ def _clear_oauth_state_cookie(response: Response, request: Request, state: str) ) -def _get_validated_client_redirect_uri(request: Request, state_data: dict[str, Any]) -> str: +def _get_validated_client_redirect_uri(request: Request, state_data: Mapping[str, object]) -> str: """Return a trusted (same-origin, loopback, or ops-allowlisted) client redirect URI from OAuth state. """ @@ -469,7 +469,7 @@ def _resolve_oauth2_server_for_root_endpoints( return None -def _normalize_for_token_comparison(value: Any) -> str: +def _normalize_for_token_comparison(value: object) -> str: """Stringify ``value`` for token-rule comparison. Booleans are lower-cased so Python's ``True`` / ``False`` line up with @@ -481,8 +481,8 @@ def _normalize_for_token_comparison(value: Any) -> str: def _validate_token_response( - token_response: dict[str, Any], - validation_rules: dict[str, Any], + token_response: Mapping[str, object], + validation_rules: Mapping[str, object], server_id: str, ) -> None: """Raise HTTPException 403 if any validation rule doesn't match the token response. @@ -496,10 +496,10 @@ def _validate_token_response( responses of ``{"verified": true}``. """ for key, expected in validation_rules.items(): - actual: Any = token_response.get(key) + actual: object | None = token_response.get(key) # Try dot-notation traversal when top-level lookup returns None if actual is None and "." in key: - obj: Any = token_response + obj: object = token_response for part in key.split("."): if isinstance(obj, dict): obj = obj.get(part) diff --git a/litellm/proxy/_experimental/mcp_server/elicitation_handler.py b/litellm/proxy/_experimental/mcp_server/elicitation_handler.py index ce7e963f55f..bbd1c9aaf1e 100644 --- a/litellm/proxy/_experimental/mcp_server/elicitation_handler.py +++ b/litellm/proxy/_experimental/mcp_server/elicitation_handler.py @@ -9,7 +9,7 @@ MCP Spec Reference: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation """ -from typing import TYPE_CHECKING, Any, Final, Union +from typing import TYPE_CHECKING, Final, Protocol, Union from litellm._logging import verbose_logger @@ -37,11 +37,21 @@ except ImportError: MCP_ELICITATION_AVAILABLE = False +class _DownstreamElicitSession(Protocol): + """The downstream MCP client session methods this module relays elicitation requests through.""" + + async def elicit_url(self, message: str, url: str, elicitation_id: str) -> "ElicitResult": ... + + async def elicit_form(self, message: str, requestedSchema: dict[str, object]) -> "ElicitResult": ... + + async def elicit(self, message: str, requestedSchema: dict[str, object]) -> "ElicitResult": ... + + async def handle_elicitation_request( - context: Any, + context: object, params: "ElicitRequestParams", - downstream_session: Any | None = None, - downstream_capabilities: Any | None = None, + downstream_session: _DownstreamElicitSession | None = None, + downstream_capabilities: object = None, ) -> Union["ElicitResult", "ErrorData"]: """ Handle an MCP elicitation/create request from an upstream MCP server. @@ -94,8 +104,8 @@ async def handle_elicitation_request( async def _relay_elicitation_to_downstream( params: "ElicitRequestParams", - downstream_session: Any, - downstream_capabilities: Any | None = None, + downstream_session: _DownstreamElicitSession, + downstream_capabilities: object = None, ) -> Union["ElicitResult", "ErrorData"]: """ Relay an elicitation request to the downstream MCP client. @@ -111,17 +121,17 @@ async def _relay_elicitation_to_downstream( mode: Final = getattr(params, "mode", "form") # Check if the downstream client supports the requested mode if downstream_capabilities is not None: - elicit_caps: Final = getattr(downstream_capabilities, "elicitation", None) + elicit_caps: Final[object] = getattr(downstream_capabilities, "elicitation", None) if elicit_caps is None: verbose_logger.info("MCP elicitation: downstream client does not support elicitation") return ElicitResult(action="decline") if mode == "url": - url_cap: Final = getattr(elicit_caps, "url", None) + url_cap: Final[object] = getattr(elicit_caps, "url", None) if url_cap is None: verbose_logger.info("MCP elicitation: downstream client does not support URL mode") return ElicitResult(action="decline") if mode == "form": - form_cap: Final = getattr(elicit_caps, "form", None) + form_cap: Final[object] = getattr(elicit_caps, "form", None) if form_cap is None: verbose_logger.info("MCP elicitation: downstream client does not support form mode") return ElicitResult(action="decline") @@ -135,14 +145,14 @@ async def _relay_elicitation_to_downstream( result = await downstream_session.elicit_url( message=params.message, url=params.url, - elicitation_id=getattr(params, "elicitationId", None), + elicitation_id=params.elicitationId, ) elif isinstance(params, ElicitRequestFormParams): # Form mode: relay structured form to client verbose_logger.info("MCP elicitation: relaying form mode to downstream") result = await downstream_session.elicit_form( message=params.message, - requestedSchema=getattr(params, "requestedSchema", None), + requestedSchema=params.requestedSchema, ) else: # Fallback for generic ElicitRequestParams — pass an empty schema diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 989b08b929a..57b60ff68a2 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -3477,7 +3477,7 @@ if MCP_AVAILABLE: is best-effort in that mode. """ - def _bytes_for_hash(value: Any) -> bytes | None: + def _bytes_for_hash(value: object) -> bytes | None: """Only hash str/bytes secrets; skip mocks and other unexpected types.""" if value is None: return None diff --git a/litellm/proxy/a2a/version_convert.py b/litellm/proxy/a2a/version_convert.py index 35587ee274c..bde2ff45a88 100644 --- a/litellm/proxy/a2a/version_convert.py +++ b/litellm/proxy/a2a/version_convert.py @@ -25,7 +25,6 @@ The two wire shapes: """ from collections.abc import Callable -from types import ModuleType from typing import Final, Literal from pydantic import BaseModel @@ -181,7 +180,7 @@ def _send_result_to(result: JsonDict, target: A2AVersion, request_id: RequestId) ) if target == "1.0": - compat_result: Final = _validate_message_or_task(result, types_v03) + compat_result: Final = _validate_message_or_task(result) response: Final = types_v03.SendMessageResponse( root=types_v03.SendMessageSuccessResponse( id=str(request_id) if request_id is not None else "", @@ -285,7 +284,7 @@ def _stream_result_to(result: JsonDict, target: A2AVersion, request_id: RequestI ) if target == "1.0": - event: Final = _validate_stream_event(result, types_v03) + event: Final = _validate_stream_event(result) wrapper: Final = types_v03.SendStreamingMessageSuccessResponse( id=str(request_id) if request_id is not None else "", result=event, # pyright: ignore[reportArgumentType] @@ -318,13 +317,17 @@ def _convert_agent_card(card: JsonDict, target: A2AVersion) -> JsonDict: return MessageToDict(core, preserving_proto_field_name=False) -def _validate_message_or_task(result: JsonDict, types_v03: ModuleType) -> BaseModel: +def _validate_message_or_task(result: JsonDict) -> BaseModel: + from a2a.compat.v0_3.conversions import types_v03 + if result.get("kind") == "task": return types_v03.Task.model_validate(result) return types_v03.Message.model_validate(result) -def _validate_stream_event(result: JsonDict, types_v03: ModuleType) -> BaseModel: +def _validate_stream_event(result: JsonDict) -> BaseModel: + from a2a.compat.v0_3.conversions import types_v03 + kind: Final = result.get("kind") if kind == "task": return types_v03.Task.model_validate(result) diff --git a/litellm/proxy/client/cli/commands/models.py b/litellm/proxy/client/cli/commands/models.py index cc165504113..4c83a7b799a 100644 --- a/litellm/proxy/client/cli/commands/models.py +++ b/litellm/proxy/client/cli/commands/models.py @@ -17,8 +17,8 @@ from ... import Client @dataclass class ModelYamlInfo: model_name: str - model_params: dict[str, Any] - model_info: dict[str, Any] + model_params: dict[str, object] + model_info: dict[str, object] model_id: str access_groups: list[str] provider: str diff --git a/litellm/proxy/common_utils/cache_coordinator.py b/litellm/proxy/common_utils/cache_coordinator.py index e36307ae2df..f6ce96d8777 100644 --- a/litellm/proxy/common_utils/cache_coordinator.py +++ b/litellm/proxy/common_utils/cache_coordinator.py @@ -13,14 +13,14 @@ pattern: global spend, feature flags, config, or other shared read-through data. import asyncio import time from collections.abc import Awaitable, Callable -from typing import Any, Final, Protocol, TypeVar +from typing import Final, Protocol, TypeVar from litellm._logging import verbose_proxy_logger T = TypeVar("T") -class AsyncCacheProtocol(Protocol): +class AsyncCacheProtocol(Protocol[T]): """Protocol for cache backends used by EventDrivenCacheCoordinator. Matches ``DualCache`` / ``UserApiKeyCache`` call shapes (explicit optional params @@ -30,18 +30,18 @@ class AsyncCacheProtocol(Protocol): async def async_get_cache( self, key: str, - parent_otel_span: Any = None, + parent_otel_span: object = None, local_only: bool = False, - **kwargs: Any, - ) -> Any: ... + **kwargs: object, + ) -> T | None: ... async def async_set_cache( self, key: str, - value: Any, + value: T, local_only: bool = False, - **kwargs: Any, - ) -> Any: ... + **kwargs: object, + ) -> object: ... class EventDrivenCacheCoordinator: @@ -64,11 +64,11 @@ class EventDrivenCacheCoordinator: self._query_in_progress = False self._log_prefix = log_prefix - async def _get_cached(self, cache_key: str, cache: AsyncCacheProtocol) -> Any | None: + async def _get_cached(self, cache_key: str, cache: AsyncCacheProtocol[T]) -> T | None: """Return value from cache if present, else None.""" return await cache.async_get_cache(key=cache_key) - def _log_cache_hit(self, value: T) -> None: + def _log_cache_hit(self, value: object) -> None: if self._log_prefix: verbose_proxy_logger.debug("%s Cache hit, value: %s", self._log_prefix, value) @@ -98,7 +98,7 @@ class EventDrivenCacheCoordinator: self, event: asyncio.Event, cache_key: str, - cache: AsyncCacheProtocol, + cache: AsyncCacheProtocol[T], ) -> T | None: """Wait for loader to finish, then read from cache.""" await event.wait() @@ -118,7 +118,7 @@ class EventDrivenCacheCoordinator: async def _load_and_cache( self, cache_key: str, - cache: AsyncCacheProtocol, + cache: AsyncCacheProtocol[T], load_fn: Callable[[], Awaitable[T]], ) -> T | None: """Double-check cache, run load_fn, set cache, return value. Caller must call _signal_done in finally.""" @@ -163,7 +163,7 @@ class EventDrivenCacheCoordinator: async def get_or_load( self, cache_key: str, - cache: AsyncCacheProtocol, + cache: AsyncCacheProtocol[T], load_fn: Callable[[], Awaitable[T]], ) -> T | None: """ diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 96621b08ba1..2b730c450fb 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -1,6 +1,6 @@ import json import re -from collections.abc import Collection +from collections.abc import Collection, Mapping from typing import Any, Final import orjson @@ -186,7 +186,7 @@ def _safe_get_request_headers(request: Request | None) -> dict: if request is None: return {} state: Final = getattr(request, "state", None) - cached: Final = getattr(state, "_cached_headers", None) + cached: Final[object] = getattr(state, "_cached_headers", None) if isinstance(cached, dict): return cached if cached is not None: @@ -344,7 +344,9 @@ async def get_request_body(request: Request) -> dict[str, Any]: return {} -def extract_nested_form_metadata(form_data: dict[str, Any], prefix: str = "litellm_metadata[") -> dict[str, Any]: +def extract_nested_form_metadata( + form_data: Mapping[str, object], prefix: str = "litellm_metadata[" +) -> dict[str, object]: """ Extract nested metadata from form data with bracket notation. @@ -382,7 +384,7 @@ def extract_nested_form_metadata(form_data: dict[str, Any], prefix: str = "litel if not form_data: return {} - metadata: Final[dict[str, Any]] = {} + metadata: Final[dict[str, object]] = {} for key, value in form_data.items(): # Skip keys that don't start with the prefix @@ -430,7 +432,7 @@ def extract_nested_form_metadata(form_data: dict[str, Any], prefix: str = "litel return metadata -def get_tags_from_request_body(request_body: dict) -> list[str]: +def get_tags_from_request_body(request_body: Mapping[str, object]) -> list[str]: """ Extract tags from request body metadata. @@ -447,12 +449,12 @@ def get_tags_from_request_body(request_body: dict) -> list[str]: if isinstance(metadata, str): from litellm.litellm_core_utils.safe_json_loads import safe_json_loads - parsed: Final = safe_json_loads(metadata) + parsed: Final[object] = safe_json_loads(metadata) metadata = parsed if isinstance(parsed, dict) else {} elif not isinstance(metadata, dict): metadata = {} - tags_in_metadata: Final[Any] = metadata.get("tags", []) - tags_in_request_body: Final[Any] = request_body.get("tags", []) + tags_in_metadata: Final[object] = metadata.get("tags", []) + tags_in_request_body: Final[object] = request_body.get("tags", []) combined_tags: Final[list[str]] = [] ###################################### diff --git a/litellm/proxy/container_endpoints/handler_factory.py b/litellm/proxy/container_endpoints/handler_factory.py index aaee1d3e264..892ff9771cf 100644 --- a/litellm/proxy/container_endpoints/handler_factory.py +++ b/litellm/proxy/container_endpoints/handler_factory.py @@ -7,7 +7,7 @@ FastAPI route handlers for ALL container file endpoints. import json from pathlib import Path -from typing import Any, Final +from typing import Final from fastapi import APIRouter, Depends, Request, Response from fastapi.responses import ORJSONResponse @@ -194,7 +194,7 @@ async def _process_binary_request( user_api_key_dict=user_api_key_dict, custom_llm_provider=custom_llm_provider, ) - data: Final[dict[str, Any]] = { + data: Final[dict[str, object]] = { "file_id": file_id, **( await get_container_forwarding_params( @@ -374,7 +374,7 @@ async def _process_request( ) query_params: Final = dict(request.query_params) - data: Final[dict[str, Any]] = { + data: Final[dict[str, object]] = { "query_params": query_params, **path_params, } diff --git a/litellm/proxy/db/prisma_client.py b/litellm/proxy/db/prisma_client.py index 4bd007769b8..2190ae55fd2 100644 --- a/litellm/proxy/db/prisma_client.py +++ b/litellm/proxy/db/prisma_client.py @@ -503,7 +503,7 @@ class PrismaWrapper: async def recreate_prisma_client( self, new_db_url: str, - http_client: Any | None = None, + http_client: object | None = None, *, expected_generation: int | None = None, ) -> bool: @@ -541,7 +541,7 @@ class PrismaWrapper: async def _recreate_prisma_client_locked( self, new_db_url: str, - http_client: Any | None = None, + http_client: object | None = None, *, expected_generation: int | None = None, ) -> bool: diff --git a/litellm/proxy/guardrails/_content_utils.py b/litellm/proxy/guardrails/_content_utils.py index ae92adcb1ee..c6e3f8ce34c 100644 --- a/litellm/proxy/guardrails/_content_utils.py +++ b/litellm/proxy/guardrails/_content_utils.py @@ -54,7 +54,7 @@ def _part_text(part: Mapping[str, object]) -> str | None: return None -def _iter_text_parts_in_content(content: Any) -> Iterator[str]: +def _iter_text_parts_in_content(content: object) -> Iterator[str]: """Yield text fragments from a ``message.content`` value (string or multimodal list). Non-text parts (images, audio, …) are skipped.""" if isinstance(content, str): @@ -75,13 +75,13 @@ def _iter_text_parts_in_content(content: Any) -> Iterator[str]: yield text -def _coerce_input_to_messages(input_value: Any) -> list[dict[str, Any]]: +def _coerce_input_to_messages(input_value: object) -> list[dict[str, object]]: """Coerce a Responses-API ``data["input"]`` value into chat-style messages.""" if isinstance(input_value, str): return [{"role": "user", "content": input_value}] if not isinstance(input_value, list): return [] - messages: Final[list[dict[str, Any]]] = [] + messages: Final[list[dict[str, object]]] = [] for item in input_value: if isinstance(item, str): messages.append({"role": "user", "content": item}) @@ -110,7 +110,7 @@ def _coerce_input_to_messages(input_value: Any) -> list[dict[str, Any]]: return messages -def _iter_inspection_messages(data: dict[str, Any]) -> Iterator[dict[str, Any]]: +def _iter_inspection_messages(data: Mapping[str, object]) -> Iterator[object]: """Yield every message-like dict, walking ``messages`` AND ``input``.""" messages: Final = data.get("messages") if isinstance(messages, list): @@ -118,7 +118,7 @@ def _iter_inspection_messages(data: dict[str, Any]) -> Iterator[dict[str, Any]]: yield from _coerce_input_to_messages(data.get("input")) -def iter_message_text(data: dict[str, Any]) -> Iterator[str]: +def iter_message_text(data: Mapping[str, object]) -> Iterator[str]: """Yield every text fragment from ``messages`` AND ``input``. Walks every role (user, assistant, system, …) — guardrails inspect @@ -139,7 +139,7 @@ def walk_user_text(data: dict[str, Any], visit: Callable[[str], str]) -> int: """ visited = 0 - def _rewrite_content(content: Any) -> Any: + def _rewrite_content(content: object) -> object: nonlocal visited if isinstance(content, str): if content: @@ -147,7 +147,7 @@ def walk_user_text(data: dict[str, Any], visit: Callable[[str], str]) -> int: return visit(content) return content if isinstance(content, list): - new_parts: Final[list[Any]] = [] + new_parts: Final[list[object]] = [] for part in content: if isinstance(part, str) and part: visited += 1 @@ -218,7 +218,7 @@ def apply_redacted_messages_back(data: dict[str, Any], redacted_messages: list[d data["input"] = "\n".join(text_parts) -def has_non_string_content(data: dict[str, Any]) -> bool: +def has_non_string_content(data: Mapping[str, object]) -> bool: """Return True if any inspected content is not a plain string. Used by hooks whose mask/redact path operates on string offsets and diff --git a/litellm/proxy/guardrails/guardrail_hooks/qualifire/qualifire.py b/litellm/proxy/guardrails/guardrail_hooks/qualifire/qualifire.py index f834426d619..d82944c44ed 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/qualifire/qualifire.py +++ b/litellm/proxy/guardrails/guardrail_hooks/qualifire/qualifire.py @@ -139,7 +139,7 @@ class QualifireGuardrail(CustomGuardrail): ] ) - def _convert_messages_to_api_format(self, messages: list[AllMessageValues]) -> list[dict[str, Any]]: + def _convert_messages_to_api_format(self, messages: list[AllMessageValues]) -> list[dict[str, object]]: """ Convert LiteLLM messages to Qualifire API format. Supports tool calls for tool_selection_quality_check. @@ -167,7 +167,7 @@ class QualifireGuardrail(CustomGuardrail): text_parts.append(part) content = "\n".join(text_parts) - api_message: dict[str, Any] = { + api_message: dict[str, object] = { "role": role, "content": content if isinstance(content, str) else str(content), } @@ -205,7 +205,7 @@ class QualifireGuardrail(CustomGuardrail): return api_messages - def _convert_tools_to_api_format(self, tools: list[Any] | None) -> list[dict[str, Any]] | None: + def _convert_tools_to_api_format(self, tools: list[object] | None) -> list[dict[str, object]] | None: """ Convert OpenAI-format tools to Qualifire API format. @@ -264,13 +264,13 @@ class QualifireGuardrail(CustomGuardrail): def _build_evaluate_payload( self, - api_messages: list[dict[str, Any]], + api_messages: list[dict[str, object]], output: str | None, assertions: list[str] | None, - available_tools: list[dict[str, Any]] | None, - ) -> dict[str, Any]: + available_tools: list[dict[str, object]] | None, + ) -> dict[str, object]: """Build payload dictionary for the /api/evaluation/evaluate endpoint.""" - payload: Final[dict[str, Any]] = {"messages": api_messages} + payload: Final[dict[str, object]] = {"messages": api_messages} if output is not None: payload["output"] = output @@ -305,7 +305,7 @@ class QualifireGuardrail(CustomGuardrail): messages: list[AllMessageValues], output: str | None, dynamic_params: dict[str, Any], - available_tools: list[Any] | None = None, + available_tools: list[object] | None = None, ) -> None: """ Core Qualifire check logic - shared between hooks. diff --git a/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py b/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py index 3865ba4ed0e..07340e95835 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py +++ b/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py @@ -97,7 +97,7 @@ class SingulrGuardrail(CustomGuardrail): request_data: dict[str, Any], inputs: GenericGuardrailAPIInputs, input_type: str, - ) -> dict[str, Any]: + ) -> dict[str, object]: if not request_data: texts: Final = inputs.get("texts", []) @@ -138,7 +138,7 @@ class SingulrGuardrail(CustomGuardrail): if value ) - async def _call_api(self, payload: dict[str, Any]) -> SingulrGuardrailResponse | None: + async def _call_api(self, payload: dict[str, object]) -> SingulrGuardrailResponse | None: endpoint: Final = f"{self.singulr_api_base}{_GUARD_ENDPOINT}" verbose_proxy_logger.debug("Singulr: %s", endpoint) diff --git a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py index 46b00829b74..267087817d0 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py +++ b/litellm/proxy/guardrails/guardrail_hooks/unified_guardrail/unified_guardrail.py @@ -878,7 +878,7 @@ class UnifiedLLMGuardrails(CustomLogger): async def async_post_call_streaming_iterator_hook( self, user_api_key_dict: UserAPIKeyAuth, - response: Any, + response: AsyncIterable[object], request_data: dict, guardrail_to_apply: CustomGuardrail | None = None, buffer_until_moderated_default: bool = False, diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index c2f5dbb4032..fe658a13c24 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -9,7 +9,7 @@ import copy import json import traceback from datetime import datetime, timezone -from typing import Annotated, Any, Final +from typing import Annotated, Final from fastapi import APIRouter, Depends, Header, HTTPException, Request, status @@ -62,7 +62,7 @@ def _validate_team_callback(data: "AddTeamCallback") -> None: raise _callback_config_error(error) -def _redact_callback_secrets(metadata: Any) -> Any: +def _redact_callback_secrets(metadata: object) -> object: """Strip secret values out of a team-metadata snapshot before audit logging. Both ``team_metadata["logging"]`` (list of ``AddTeamCallback`` dicts) and @@ -176,8 +176,8 @@ def _log_audit_task_exception(task: "asyncio.Task[None]") -> None: async def _emit_team_callback_audit_log( *, team_id: str, - before_metadata: Any, - after_metadata: Any, + before_metadata: object, + after_metadata: object, user_api_key_dict: UserAPIKeyAuth, litellm_changed_by: str | None, ) -> None: diff --git a/litellm/proxy/openai_evals_endpoints/endpoints.py b/litellm/proxy/openai_evals_endpoints/endpoints.py index 25d73e0dc1b..abfbed5f822 100644 --- a/litellm/proxy/openai_evals_endpoints/endpoints.py +++ b/litellm/proxy/openai_evals_endpoints/endpoints.py @@ -35,7 +35,7 @@ async def create_eval( request: Request, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ Create a new evaluation. @@ -131,7 +131,7 @@ async def list_evals( order_by: str | None = None, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ List evaluations with pagination. @@ -228,7 +228,7 @@ async def get_eval( request: Request, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ Get a specific evaluation by ID. @@ -316,7 +316,7 @@ async def update_eval( request: Request, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ Update an evaluation. @@ -406,7 +406,7 @@ async def delete_eval( request: Request, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ Delete an evaluation. @@ -494,7 +494,7 @@ async def cancel_eval( request: Request, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ Cancel a running evaluation. @@ -587,7 +587,7 @@ async def create_run( request: Request, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ Create a new run for an evaluation. @@ -690,7 +690,7 @@ async def list_runs( order: str | None = None, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ List all runs for an evaluation with pagination. @@ -780,7 +780,7 @@ async def get_run( request: Request, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ Get a specific run by ID. @@ -867,7 +867,7 @@ async def cancel_run( request: Request, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ Cancel a running run. @@ -956,7 +956,7 @@ async def delete_run( request: Request, custom_llm_provider: str | None = "openai", user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -): +) -> object: """ Delete a run. diff --git a/litellm/proxy/policy_engine/init_policies.py b/litellm/proxy/policy_engine/init_policies.py index 67fe25160ec..061a852b701 100644 --- a/litellm/proxy/policy_engine/init_policies.py +++ b/litellm/proxy/policy_engine/init_policies.py @@ -6,6 +6,7 @@ Configuration structure: - policy_attachments: Define WHERE policies apply (teams, keys, models) """ +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Final, Optional from litellm._logging import verbose_proxy_logger @@ -25,8 +26,8 @@ _reset_color_code: Final = "\033[0m" def _print_policies_on_startup( - policies_config: dict[str, Any], - policy_attachments_config: list[dict[str, Any]] | None = None, + policies_config: Mapping[str, Mapping[str, object]], + policy_attachments_config: Sequence[Mapping[str, object]] | None = None, ) -> None: """ Print loaded policies to console on startup (similar to model list). diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index 183a03cc13c..d5cf1249fbf 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -87,7 +87,7 @@ def _get_embedding_config_cache() -> InMemoryCache: return _embedding_config_cache -def _redact_sensitive_litellm_params(litellm_params: Any, _depth: int = 0) -> Any: +def _redact_sensitive_litellm_params(litellm_params: object, _depth: int = 0) -> Any: """ Replace credential-bearing values in ``litellm_params`` with ``REDACTED_BY_LITELM`` while preserving non-secret keys (``api_base``, @@ -119,7 +119,7 @@ def _redact_sensitive_litellm_params(litellm_params: Any, _depth: int = 0) -> An return json.dumps(_redact_sensitive_litellm_params(parsed, _depth + 1)) if not isinstance(litellm_params, dict): return litellm_params - out: Final[dict[str, Any]] = {} + out: Final[dict[str, object]] = {} for k, v in litellm_params.items(): if _LITELLM_PARAMS_MASKER.is_sensitive_key(k): out[k] = REDACTED_BY_LITELM_STRING diff --git a/litellm/rag/ingestion/gemini_ingestion.py b/litellm/rag/ingestion/gemini_ingestion.py index fa563d5a678..73a0159fc9f 100644 --- a/litellm/rag/ingestion/gemini_ingestion.py +++ b/litellm/rag/ingestion/gemini_ingestion.py @@ -7,7 +7,7 @@ so this implementation skips the embedding step and directly uploads files. from __future__ import annotations -from typing import TYPE_CHECKING, Any, Final, cast +from typing import TYPE_CHECKING, Final, cast from litellm._logging import verbose_logger from litellm.llms.custom_httpx.http_handler import ( @@ -83,7 +83,7 @@ class GeminiRAGIngestion(BaseRAGIngestion): """ vector_store_id = self.vector_store_config.get("vector_store_id") - vector_store_config: Final = cast(dict[str, Any], self.vector_store_config) + vector_store_config: Final = self.vector_store_config # Get API credentials api_key: Final = cast(str | None, vector_store_config.get("api_key")) or GeminiModelInfo.get_api_key() @@ -228,7 +228,7 @@ class GeminiRAGIngestion(BaseRAGIngestion): url: Final = f"{api_base}/upload/v1beta/{vector_store_id}:uploadToFileSearchStore" # Build request body with chunking config and metadata if provided - request_body: Final[dict[str, Any]] = {"displayName": filename} + request_body: Final[dict[str, object]] = {"displayName": filename} # Add chunking configuration if provided chunking_strategy: Final = self.chunking_strategy @@ -244,7 +244,7 @@ class GeminiRAGIngestion(BaseRAGIngestion): # Add custom metadata if provided in vector_store_config custom_metadata: Final = cast( - list[dict[str, Any]] | None, + list[dict[str, object]] | None, self.vector_store_config.get("custom_metadata"), ) if custom_metadata: diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index d4b9f4e8cce..aa229270800 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -4,7 +4,7 @@ import asyncio import os from collections.abc import Mapping from types import MappingProxyType -from typing import Any, Final, Literal, cast +from typing import TYPE_CHECKING, Any, Final, Literal, cast import litellm from litellm.constants import ( @@ -41,6 +41,9 @@ from ..llms.vertex_ai.vertex_llm_base import VertexBase from ..llms.xai.realtime.handler import XAIRealtime from ..utils import client as wrapper_client +if TYPE_CHECKING: + from litellm.llms.base_llm.realtime.http_transformation import BaseRealtimeHTTPConfig + azure_realtime: Final = AzureOpenAIRealtime() openai_realtime: Final = OpenAIRealtime() bedrock_realtime: Final = BedrockRealtime() @@ -50,7 +53,7 @@ base_llm_http_handler = BaseLLMHTTPHandler() _EMPTY_MODEL_PARAMS: Final[Mapping[str, Any]] = MappingProxyType({}) -def _with_resolved_session_model(session: dict[str, Any], model_name: str) -> dict[str, Any]: +def _with_resolved_session_model(session: dict[str, object], model_name: str) -> dict[str, object]: if "model" not in session: return session return {**session, "model": model_name} @@ -70,7 +73,7 @@ def _get_realtime_http_provider_config( dynamic_api_base: str | None, dynamic_api_key: str | None, litellm_params: GenericLiteLLMParams, -) -> tuple[Any, str, str]: +) -> tuple["BaseRealtimeHTTPConfig | None", str, str]: """ Return (provider_config, resolved_api_base, resolved_api_key) for the realtime HTTP endpoints (client_secrets / realtime_calls). diff --git a/litellm/repositories/config_repository.py b/litellm/repositories/config_repository.py index 76b9a3a5809..2e8e760db07 100644 --- a/litellm/repositories/config_repository.py +++ b/litellm/repositories/config_repository.py @@ -17,6 +17,11 @@ from litellm._logging import verbose_proxy_logger from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper +def _decoded_json(raw: str) -> object: + """Decode a JSON-encoded config row value into an opaque object.""" + return json.loads(raw) + + class _ConfigRow(Protocol): @property def param_name(self) -> str: ... @@ -48,7 +53,7 @@ class _PrismaHandle(Protocol): class ConfigParam: """Simple wrapper for config parameter from DB.""" - def __init__(self, param_name: str, param_value: Any): + def __init__(self, param_name: str, param_value: object): self.param_name = param_name self.param_value = param_value @@ -85,12 +90,12 @@ class ConfigRepository: record: Final = await self._config_table.find_unique(where={"param_name": param_name}) if record is None: return None - param_value = record.param_value + param_value: object = record.param_value if isinstance(param_value, str): - param_value = json.loads(param_value) + param_value = _decoded_json(param_value) return ConfigParam(param_name=param_name, param_value=param_value) - async def set_param(self, param_name: str, param_value: Any) -> ConfigParam: + async def set_param(self, param_name: str, param_value: object) -> ConfigParam: """Set a config parameter in the database.""" value_json: Final = json.dumps(param_value) if not isinstance(param_value, str) else param_value await self._config_table.upsert( @@ -115,9 +120,9 @@ class ConfigRepository: records: Final = await self._config_table.find_many() result: Final[dict[str, object]] = {} for record in records: - param_value = record.param_value + param_value: object = record.param_value if isinstance(param_value, str): - param_value = json.loads(param_value) + param_value = _decoded_json(param_value) result[record.param_name] = param_value return result diff --git a/litellm/router_strategy/adaptive_router/hooks.py b/litellm/router_strategy/adaptive_router/hooks.py index b59ce6e3621..709910753f2 100644 --- a/litellm/router_strategy/adaptive_router/hooks.py +++ b/litellm/router_strategy/adaptive_router/hooks.py @@ -13,7 +13,8 @@ from __future__ import annotations import hashlib import json -from typing import Any, Final +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Any, Final from litellm._logging import verbose_router_logger from litellm.integrations.custom_logger import CustomLogger @@ -26,6 +27,9 @@ from litellm.router_strategy.adaptive_router.config import ( ) from litellm.router_strategy.adaptive_router.signals import Turn +if TYPE_CHECKING: + from litellm.proxy._types import UserAPIKeyAuth + # Identity fields hashed into a derived session key so the same conversation # from the same caller produces a stable key, while different keys/teams/users # stay segregated even if they happen to send identical first messages. @@ -100,8 +104,8 @@ def _last_user_content(messages: list[dict[str, Any]] | None) -> str | None: def _recent_tool_results( - messages: list[dict[str, Any]] | None, -) -> list[dict[str, Any]]: + messages: Sequence[Mapping[str, object]] | None, +) -> list[dict[str, object]]: """Extract the current turn's tool result payloads from the request messages. Tool results are `role == "tool"` messages that sit at the tail of the @@ -115,7 +119,7 @@ def _recent_tool_results( """ if not messages: return [] - results: Final[list[dict[str, Any]]] = [] + results: Final[list[dict[str, object]]] = [] for msg in reversed(messages): if not isinstance(msg, dict): break @@ -154,7 +158,7 @@ def _assistant_content_and_tool_calls(response_obj: Any) -> tuple: raw_tool_calls = getattr(msg, "tool_calls", None) if raw_tool_calls is None and isinstance(msg, dict): raw_tool_calls = msg.get("tool_calls") - tool_calls: Final[list[dict[str, Any]]] = [] + tool_calls: Final[list[dict[str, object]]] = [] for tc in raw_tool_calls or []: if isinstance(tc, dict): tool_calls.append(tc) @@ -174,11 +178,11 @@ class AdaptiveRouterPostCallHook(CustomLogger): async def async_post_call_response_headers_hook( self, - data: dict[str, Any], - user_api_key_dict: Any, - response: Any, + data: Mapping[str, object], + user_api_key_dict: UserAPIKeyAuth, + response: object, request_headers: dict[str, str] | None = None, - litellm_call_info: dict[str, Any] | None = None, + litellm_call_info: dict[str, object] | None = None, ) -> dict[str, str] | None: """ Surface the chosen logical model as the `x-litellm-adaptive-router-model` @@ -209,7 +213,7 @@ class AdaptiveRouterPostCallHook(CustomLogger): async def _record( self, kwargs: dict[str, Any], - response_obj: Any, + response_obj: object, response_status: int, ) -> None: try: diff --git a/litellm/router_strategy/quality_router/quality_router.py b/litellm/router_strategy/quality_router/quality_router.py index 91e4cad4d27..e99d473d455 100644 --- a/litellm/router_strategy/quality_router/quality_router.py +++ b/litellm/router_strategy/quality_router/quality_router.py @@ -16,6 +16,7 @@ then cheapest `model_info.input_cost_per_token`). """ import math +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Optional from litellm._logging import verbose_router_logger @@ -98,7 +99,7 @@ class QualityRouter(CustomLogger): self._tier_to_models_cache = self._build_tier_index() return self._tier_to_models_cache - def _get_routing_preferences(self, deployment: Any) -> dict[str, Any] | None: + def _get_routing_preferences(self, deployment: object) -> dict[str, Any] | None: """ Extract litellm_routing_preferences from a deployment, handling both dict-shaped and Pydantic-object-shaped deployments. @@ -119,7 +120,7 @@ class QualityRouter(CustomLogger): return model_info.get("litellm_routing_preferences") return getattr(model_info, "litellm_routing_preferences", None) - def _get_deployment_input_cost(self, deployment: Any) -> float | None: + def _get_deployment_input_cost(self, deployment: object) -> float | None: """ Extract `input_cost_per_token` from a deployment's model_info. @@ -144,7 +145,7 @@ class QualityRouter(CustomLogger): except (TypeError, ValueError): return None - def _get_deployment_model_name(self, deployment: Any) -> str | None: + def _get_deployment_model_name(self, deployment: object) -> str | None: """Extract `model_name` from a dict- or object-shaped deployment.""" if isinstance(deployment, dict): return deployment.get("model_name") @@ -304,8 +305,8 @@ class QualityRouter(CustomLogger): def _stash_decision( self, - request_kwargs: dict[str, Any] | None, - decision: dict[str, Any], + request_kwargs: dict[str, object] | None, + decision: Mapping[str, object], ) -> None: """ Stash the routing decision in request_kwargs.metadata so the Router can diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index 3d37ca216a7..2bcac84ec19 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -1,6 +1,6 @@ import hashlib import json -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass from enum import Enum from typing import TYPE_CHECKING, Any, Final @@ -39,7 +39,7 @@ _REQUEST_SCOPED_STATUS_CODES: Final = frozenset((404,)) def _trigger_cooldown_for_failed_deployment( litellm_router: LitellmRouter, - kwargs: Mapping[str, Any], + kwargs: Mapping[str, object], exception: Exception, ) -> None: """ @@ -218,7 +218,7 @@ PRE_ROUTING_SELECTED_MODEL_KEY: Final = "pre_routing_selected_model" _ROUTER_METADATA_BUCKETS: Final = ("metadata", "litellm_metadata") -def record_pre_routing_selection(request_kwargs: Mapping[str, Any] | None, selected_model: str) -> None: +def record_pre_routing_selection(request_kwargs: Mapping[str, object] | None, selected_model: str) -> None: """ Remember which model a pre-routing hook picked, so fallback lookup can key off it. @@ -257,14 +257,14 @@ def clear_pre_routing_selection(request_kwargs: Mapping[str, object] | None) -> del bucket[PRE_ROUTING_SELECTED_MODEL_KEY] -def get_pre_routing_selection(kwargs: Mapping[str, Any]) -> str | None: +def get_pre_routing_selection(kwargs: Mapping[str, object]) -> str | None: """The model a pre-routing hook selected for this request, if one did.""" buckets: Final = (kwargs.get(name) for name in _ROUTER_METADATA_BUCKETS) selections: Final = (bucket.get(PRE_ROUTING_SELECTED_MODEL_KEY) for bucket in buckets if isinstance(bucket, dict)) return next((selected for selected in selections if isinstance(selected, str) and selected), None) -def fallback_lookup_groups(kwargs: Mapping[str, Any], model_group: str | None) -> tuple[str, ...]: +def fallback_lookup_groups(kwargs: Mapping[str, object], model_group: str | None) -> tuple[str, ...]: """ Ordered keys for resolving a fallback chain: the tier a pre-routing hook selected wins, and the requested group still resolves when no tier-keyed chain exists, so configs keyed @@ -413,7 +413,7 @@ def creates_provider_scoped_resource(kwargs: Mapping[str, object]) -> bool: async def run_async_fallback( - *args: tuple[Any], + *args: object, litellm_router: LitellmRouter, fallback_model_group: list[str], original_model_group: str, @@ -630,5 +630,5 @@ def _check_non_standard_fallback_format(fallbacks: list[Any] | None) -> bool: return False -def run_non_standard_fallback_format(fallbacks: list[str] | list[dict[str, Any]], model_group: str): +def run_non_standard_fallback_format(fallbacks: Sequence[str] | Sequence[Mapping[str, object]], model_group: str): pass diff --git a/litellm/router_utils/pre_call_checks/io_token_rate_limit_check.py b/litellm/router_utils/pre_call_checks/io_token_rate_limit_check.py index 48b1f24ae8a..01d42627001 100644 --- a/litellm/router_utils/pre_call_checks/io_token_rate_limit_check.py +++ b/litellm/router_utils/pre_call_checks/io_token_rate_limit_check.py @@ -526,8 +526,8 @@ async def async_io_token_pre_call_check( def io_token_reconcile_success( dual_cache: DualCache, - kwargs: Any, - response_obj: Any, + kwargs: Mapping[str, object] | None, + response_obj: object, ) -> None: request_kwargs: Final[Mapping[str, object] | None] = kwargs response: Final[object] = response_obj @@ -577,8 +577,8 @@ def io_token_reconcile_success( async def async_io_token_reconcile_success( dual_cache: DualCache, - kwargs: Any, - response_obj: Any, + kwargs: Mapping[str, object] | None, + response_obj: object, *, parent_otel_span: Span | None = None, ) -> None: @@ -638,7 +638,7 @@ async def async_io_token_reconcile_success( def io_token_refund_failure( dual_cache: DualCache, - kwargs: Any, + kwargs: Mapping[str, object] | None, ) -> None: request_kwargs: Final[Mapping[str, object] | None] = kwargs itpm_reserved, otpm_reserved, itpm_key, otpm_key = _read_reservation_from_kwargs(request_kwargs) @@ -689,7 +689,7 @@ def refund_stale_reservation_before_retry(dual_cache: DualCache, kwargs: Mapping async def async_io_token_refund_failure( dual_cache: DualCache, - kwargs: Any, + kwargs: Mapping[str, object] | None, *, parent_otel_span: Span | None = None, ) -> None: diff --git a/litellm/router_utils/search_api_router.py b/litellm/router_utils/search_api_router.py index ab5ef5853c9..309894957ea 100644 --- a/litellm/router_utils/search_api_router.py +++ b/litellm/router_utils/search_api_router.py @@ -10,10 +10,19 @@ import traceback from collections.abc import Callable from functools import partial from types import MappingProxyType -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final, Protocol from litellm._logging import verbose_router_logger +if TYPE_CHECKING: + from litellm.types.router import SearchToolTypedDict + + +class _SearchToolsRouter(Protocol): + """The one router attribute the search-tool helpers read and replace.""" + + search_tools: "list[SearchToolTypedDict]" + class SearchAPIRouter: """ @@ -45,7 +54,7 @@ class SearchAPIRouter: return resolved_api_key, resolved_api_base @staticmethod - async def update_router_search_tools(router_instance: Any, search_tools: list): + async def update_router_search_tools(router_instance: _SearchToolsRouter, search_tools: list): """ Update the router with search tools from the database. @@ -83,7 +92,7 @@ class SearchAPIRouter: @staticmethod def get_matching_search_tools( - router_instance: Any, + router_instance: _SearchToolsRouter, search_tool_name: str, ) -> list: """ @@ -175,7 +184,7 @@ class SearchAPIRouter: @staticmethod async def async_search_with_fallbacks_helper( - router_instance: Any, + router_instance: _SearchToolsRouter, model: str, original_generic_function: Callable, **kwargs, diff --git a/litellm/secret_managers/aws_secret_manager_v2.py b/litellm/secret_managers/aws_secret_manager_v2.py index 2c7f1f8389d..e86c8e7c919 100644 --- a/litellm/secret_managers/aws_secret_manager_v2.py +++ b/litellm/secret_managers/aws_secret_manager_v2.py @@ -266,7 +266,7 @@ class AWSSecretsManagerV2(BaseAWSLLM, BaseSecretManager): """ from litellm._uuid import uuid - data: Final[dict[str, Any]] = { + data: Final[dict[str, object]] = { "Name": secret_name, "SecretString": secret_value, "ClientRequestToken": str(uuid.uuid4()), @@ -415,7 +415,7 @@ class AWSSecretsManagerV2(BaseAWSLLM, BaseSecretManager): """ from litellm._uuid import uuid - data: Final[dict[str, Any]] = { + data: Final[dict[str, object]] = { "SecretId": secret_name, "SecretString": secret_value, "ClientRequestToken": str(uuid.uuid4()), diff --git a/litellm/skills/main.py b/litellm/skills/main.py index ae1ce150368..9d2ed524ce5 100644 --- a/litellm/skills/main.py +++ b/litellm/skills/main.py @@ -5,7 +5,7 @@ Provides create, list, get, and delete operations for skills import asyncio import contextvars -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from functools import partial from typing import Any, Final @@ -35,7 +35,7 @@ DEFAULT_ANTHROPIC_API_BASE: Final = "https://api.anthropic.com/v1" _litellm_skills_handler = None -def _get_user_api_key_auth_from_kwargs(kwargs: dict[str, Any]) -> Any | None: +def _get_user_api_key_auth_from_kwargs(kwargs: Mapping[str, object]) -> Any | None: for metadata_key in ("metadata", "litellm_metadata"): metadata = kwargs.get(metadata_key) if isinstance(metadata, dict) and metadata.get("user_api_key_auth") is not None: @@ -44,7 +44,7 @@ def _get_user_api_key_auth_from_kwargs(kwargs: dict[str, Any]) -> Any | None: def _get_skill_request_metadata( - kwargs: dict[str, Any], + kwargs: Mapping[str, object], extra_body: dict[str, Any] | None, ) -> dict[str, Any] | None: if extra_body and isinstance(extra_body.get("metadata"), dict): @@ -73,7 +73,7 @@ async def acreate_skill( files: list[Any] | None = None, display_title: str | None = None, extra_headers: dict[str, Any] | None = None, - extra_query: dict[str, Any] | None = None, + extra_query: Mapping[str, object] | None = None, extra_body: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -136,12 +136,12 @@ def create_skill( files: list[Any] | None = None, display_title: str | None = None, extra_headers: dict[str, Any] | None = None, - extra_query: dict[str, Any] | None = None, + extra_query: Mapping[str, object] | None = None, extra_body: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> Skill | Coroutine[Any, Any, Skill]: +) -> Skill | Coroutine[object, object, Skill]: """ Create a new skill @@ -330,7 +330,7 @@ def list_skills( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> ListSkillsResponse | Coroutine[Any, Any, ListSkillsResponse]: +) -> ListSkillsResponse | Coroutine[object, object, ListSkillsResponse]: """ List all skills @@ -444,7 +444,7 @@ def list_skills( async def aget_skill( skill_id: str, extra_headers: dict[str, Any] | None = None, - extra_query: dict[str, Any] | None = None, + extra_query: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, @@ -501,11 +501,11 @@ async def aget_skill( def get_skill( skill_id: str, extra_headers: dict[str, Any] | None = None, - extra_query: dict[str, Any] | None = None, + extra_query: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> Skill | Coroutine[Any, Any, Skill]: +) -> Skill | Coroutine[object, object, Skill]: """ Get a skill by ID @@ -608,7 +608,7 @@ def get_skill( async def adelete_skill( skill_id: str, extra_headers: dict[str, Any] | None = None, - extra_query: dict[str, Any] | None = None, + extra_query: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, @@ -665,11 +665,11 @@ async def adelete_skill( def delete_skill( skill_id: str, extra_headers: dict[str, Any] | None = None, - extra_query: dict[str, Any] | None = None, + extra_query: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> DeleteSkillResponse | Coroutine[Any, Any, DeleteSkillResponse]: +) -> DeleteSkillResponse | Coroutine[object, object, DeleteSkillResponse]: """ Delete a skill by ID diff --git a/litellm/types/vector_stores.py b/litellm/types/vector_stores.py index 474c652ff3a..8bb0235ea2a 100644 --- a/litellm/types/vector_stores.py +++ b/litellm/types/vector_stores.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import datetime from enum import Enum @@ -128,31 +129,31 @@ class VertexSearchDataStoreExtraBody(TypedDict, total=False): pageToken: str offset: int oneBoxPageSize: int - pageCategories: list[str] - imageQuery: dict[str, Any] + pageCategories: Sequence[str] + imageQuery: Mapping[str, object] filter: str canonicalFilter: str orderBy: str - userInfo: dict[str, Any] + userInfo: Mapping[str, object] languageCode: str - facetSpecs: list[dict[str, Any]] - boostSpec: dict[str, Any] - params: dict[str, Any] - queryExpansionSpec: dict[str, Any] - spellCorrectionSpec: dict[str, Any] + facetSpecs: Sequence[Mapping[str, object]] + boostSpec: Mapping[str, object] + params: Mapping[str, object] + queryExpansionSpec: Mapping[str, object] + spellCorrectionSpec: Mapping[str, object] userPseudoId: str - contentSearchSpec: dict[str, Any] + contentSearchSpec: Mapping[str, object] rankingExpression: str rankingExpressionBackend: str safeSearch: bool - userLabels: dict[str, str] - naturalLanguageQueryUnderstandingSpec: dict[str, Any] - searchAsYouTypeSpec: dict[str, Any] - displaySpec: dict[str, Any] - crowdingSpecs: list[dict[str, Any]] + userLabels: Mapping[str, str] + naturalLanguageQueryUnderstandingSpec: Mapping[str, object] + searchAsYouTypeSpec: Mapping[str, object] + displaySpec: Mapping[str, object] + crowdingSpecs: Sequence[Mapping[str, object]] relevanceThreshold: str - relevanceScoreSpec: dict[str, Any] - customRankingParams: dict[str, Any] + relevanceScoreSpec: Mapping[str, object] + customRankingParams: Mapping[str, object] class VertexSearchEngineExtraBody(VertexSearchDataStoreExtraBody, total=False): @@ -166,7 +167,7 @@ class VertexSearchEngineExtraBody(VertexSearchDataStoreExtraBody, total=False): (per-store scoping/filtering) and ``numResultsPerDataStore``. """ - dataStoreSpecs: list[dict[str, Any]] + dataStoreSpecs: Sequence[Mapping[str, object]] numResultsPerDataStore: int @@ -256,7 +257,7 @@ class IndexCreateLiteLLMParams(BaseModel): class IndexCreateRequest(BaseModel): index_name: str litellm_params: IndexCreateLiteLLMParams - index_info: dict[str, Any] | None = None + index_info: dict[str, object] | None = None class BaseVectorStoreAuthCredentials(TypedDict, total=False): @@ -270,7 +271,7 @@ class LiteLLM_ManagedVectorStoreIndex(BaseModel): id: str index_name: str litellm_params: IndexCreateLiteLLMParams - index_info: dict[str, Any] | None = None + index_info: dict[str, object] | None = None created_at: datetime | None = None created_by: str | None = None updated_at: datetime | None = None diff --git a/litellm/vector_store_files/main.py b/litellm/vector_store_files/main.py index 7af8dc7d435..5bc3c8f1525 100644 --- a/litellm/vector_store_files/main.py +++ b/litellm/vector_store_files/main.py @@ -39,7 +39,7 @@ def _ensure_provider(custom_llm_provider: str | None) -> str: def _prepare_registry_credentials( *, vector_store_id: str, - kwargs: dict[str, Any], + kwargs: dict[str, object], ) -> None: if litellm.vector_store_registry is None: return @@ -116,7 +116,7 @@ def create( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> VectorStoreFileObject | Coroutine[Any, Any, VectorStoreFileObject]: +) -> VectorStoreFileObject | Coroutine[object, object, VectorStoreFileObject]: local_vars: Final = locals() try: litellm_logging_obj: Final[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj") @@ -245,7 +245,7 @@ def list( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> VectorStoreFileListResponse | Coroutine[Any, Any, VectorStoreFileListResponse]: +) -> VectorStoreFileListResponse | Coroutine[object, object, VectorStoreFileListResponse]: local_vars: Final = locals() try: litellm_logging_obj: Final[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj") @@ -355,7 +355,7 @@ def retrieve( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> VectorStoreFileObject | Coroutine[Any, Any, VectorStoreFileObject]: +) -> VectorStoreFileObject | Coroutine[object, object, VectorStoreFileObject]: local_vars: Final = locals() try: litellm_logging_obj: Final[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj") @@ -463,7 +463,7 @@ def retrieve_content( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> VectorStoreFileContentResponse | Coroutine[Any, Any, VectorStoreFileContentResponse]: +) -> VectorStoreFileContentResponse | Coroutine[object, object, VectorStoreFileContentResponse]: local_vars: Final = locals() try: litellm_logging_obj: Final[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj") @@ -577,7 +577,7 @@ def update( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> VectorStoreFileObject | Coroutine[Any, Any, VectorStoreFileObject]: +) -> VectorStoreFileObject | Coroutine[object, object, VectorStoreFileObject]: local_vars: Final = locals() try: litellm_logging_obj: Final[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj") @@ -692,7 +692,7 @@ def delete( timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, -) -> VectorStoreFileDeleteResponse | Coroutine[Any, Any, VectorStoreFileDeleteResponse]: +) -> VectorStoreFileDeleteResponse | Coroutine[object, object, VectorStoreFileDeleteResponse]: local_vars: Final = locals() try: litellm_logging_obj: Final[LiteLLMLoggingObj] = kwargs.get("litellm_logging_obj") From 25c5f0d993dc87069d18ad8b0a9b1fe8c57ada31 Mon Sep 17 00:00:00 2001 From: mateo Date: Wed, 2 Sep 2026 10:05:50 +0000 Subject: [PATCH 020/410] test: deflake JWT tamper assertions and fuzzy picker widget driver Tamper tests rewrote the last two base64url characters of the signature, which on roughly 1 in 250 RS256 tokens (1 in 1000 HS256) only touched padding bits, so the decoded signature was unchanged and still verified. Corrupt the decoded signature bytes instead. The fuzzy picker driver sent keys after fixed sleeps, so a slow worker could receive the filter text before the widget had highlighted the match. Wait on the widget's highlighted choice instead. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_session_credentials.py | 10 +++- .../test_session_token.py | 16 +++--- .../proxy/client/cli/autoroute/test_wizard.py | 51 +++++++++++++------ 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_session_credentials.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_session_credentials.py index 00ff06ea082..992b1e8632b 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_session_credentials.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_session_credentials.py @@ -5,6 +5,7 @@ from datetime import datetime, timedelta, timezone import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.utils import base64url_decode, base64url_encode from pydantic import SecretStr from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( @@ -52,6 +53,12 @@ def _refresh_token() -> str: return minted.token.get_secret_value() +def _corrupt_signature(token: str) -> str: + unsigned, signature = token.rsplit(".", 1) + raw = base64url_decode(signature) + return f"{unsigned}.{base64url_encode(bytes((raw[0] ^ 0x01,)) + raw[1:]).decode()}" + + def test_kdf_is_deterministic_and_key_length_is_256_bit(): again = session_keys_from_master_key(MASTER_KEY) assert again.signing_key.get_secret_value() == KEYS.signing_key.get_secret_value() @@ -109,8 +116,7 @@ def test_resolve_fails_expired_token_closed_and_flags_expiry(): def test_resolve_fails_tampered_token_closed_without_expiry_flag(): token = _access_token() - tampered = token[:-2] + ("aa" if not token.endswith("aa") else "bb") - result = resolve_session_bearer(f"Bearer {tampered}", KEYS, NOW) + result = resolve_session_bearer(f"Bearer {_corrupt_signature(token)}", KEYS, NOW) assert isinstance(result, SessionBearerInvalid) assert result.expired is False diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_session_token.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_session_token.py index 2a59e6c1baa..321d6d0a1d2 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_session_token.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_session_token.py @@ -6,6 +6,7 @@ import jwt import pytest from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa +from jwt.utils import base64url_decode, base64url_encode from pydantic import SecretStr, ValidationError from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import ( @@ -66,6 +67,12 @@ def _mint_refresh() -> str: return minted.token.get_secret_value() +def _corrupt_signature(token: str) -> str: + unsigned, signature = token.rsplit(".", 1) + raw = base64url_decode(signature) + return f"{unsigned}.{base64url_encode(bytes((raw[0] ^ 0x01,)) + raw[1:]).decode()}" + + def _sign_claims(payload: dict, prefix: str = SESSION_TOKEN_PREFIX, keys: SessionKeys = KEYS) -> str: return prefix + jwt.encode(payload, keys.signing_key.get_secret_value(), algorithm="HS256") @@ -138,8 +145,7 @@ def test_still_valid_one_second_before_expiry(): def test_tampered_signature_is_bad_signature(): token = _mint_access() - tampered = token[:-2] + ("aa" if not token.endswith("aa") else "bb") - assert isinstance(open_session_token(tampered, KEYS, NOW), SessionBadSignature) + assert isinstance(open_session_token(_corrupt_signature(token), KEYS, NOW), SessionBadSignature) def test_key_rotation_invalidates_outstanding_tokens(): @@ -329,8 +335,7 @@ def test_rs256_tampered_signature_is_bad_signature(): minted = mint_session_token(PRINCIPAL, RSA_KEYS, NOW) assert isinstance(minted, MintedSessionToken) token = minted.token.get_secret_value() - tampered = token[:-2] + ("aa" if not token.endswith("aa") else "bb") - assert isinstance(open_session_token(tampered, RSA_KEYS, NOW), SessionBadSignature) + assert isinstance(open_session_token(_corrupt_signature(token), RSA_KEYS, NOW), SessionBadSignature) def test_rs256_expired_token_is_expired(): @@ -413,8 +418,7 @@ def test_rotation_window_still_enforces_expiry_and_tamper_on_the_previous_key(): ) after = NOW + timedelta(seconds=SESSION_TTL_SECONDS + 1) assert isinstance(open_session_token(token, rotated, after), SessionExpired) - tampered = token[:-2] + ("aa" if not token.endswith("aa") else "bb") - assert isinstance(open_session_token(tampered, rotated, NOW), SessionBadSignature) + assert isinstance(open_session_token(_corrupt_signature(token), rotated, NOW), SessionBadSignature) def test_weak_or_garbage_private_key_pem_rejected_at_construction(): diff --git a/tests/test_litellm/proxy/client/cli/autoroute/test_wizard.py b/tests/test_litellm/proxy/client/cli/autoroute/test_wizard.py index a17fed36f52..fc6de53cb9e 100644 --- a/tests/test_litellm/proxy/client/cli/autoroute/test_wizard.py +++ b/tests/test_litellm/proxy/client/cli/autoroute/test_wizard.py @@ -1,5 +1,5 @@ import asyncio -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple from unittest.mock import patch import click @@ -7,7 +7,8 @@ import pytest import yaml from click.testing import CliRunner from InquirerPy.base.control import Choice -from prompt_toolkit.application import create_app_session +from InquirerPy.prompts.fuzzy import InquirerPyFuzzyControl +from prompt_toolkit.application import AppSession, create_app_session from prompt_toolkit.input import create_pipe_input from prompt_toolkit.output import DummyOutput @@ -283,27 +284,45 @@ class TestRunConfigureWizardNotInteractive: assert not config_path.exists() +def _highlighted_choice(session: AppSession) -> Optional[str]: + if session.app is None: + return None + controls = [c for c in session.app.layout.find_all_controls() if isinstance(c, InquirerPyFuzzyControl)] + if not controls or controls[0].choice_count == 0: + return None + return controls[0].selection["name"] + + +async def _wait_until_highlighted(session: AppSession, name: str) -> None: + async def _poll() -> None: + while _highlighted_choice(session) != name: + await asyncio.sleep(0.01) + + await asyncio.wait_for(_poll(), timeout=5) + + def _drive_fuzzy_pick( models: Tuple[DiscoveredModel, ...], prompt_label: str, multiselect: bool, - key_events: List[Tuple[str, float]], + key_events: List[Tuple[str, Optional[str]]], ) -> List[str]: """Drives the real InquirerPy fuzzy prompt through prompt_toolkit's own test input/output, exercising the actual widget (filtering, tab-to-toggle, enter-to-confirm) rather than mocking it away. asyncio.to_thread propagates the create_app_session context into the worker thread - running _fuzzy_pick's synchronous .execute() call.""" + running _fuzzy_pick's synchronous .execute() call. Each key event names the choice the widget + must highlight before the next key is sent (None sends the next key immediately).""" async def _run() -> List[str]: with create_pipe_input() as pipe_input: - with create_app_session(input=pipe_input, output=DummyOutput()): + with create_app_session(input=pipe_input, output=DummyOutput()) as session: task = asyncio.ensure_future( asyncio.to_thread(wizard_module._fuzzy_pick, models, prompt_label, multiselect) ) - await asyncio.sleep(0.05) - for text, delay in key_events: + for text, highlighted in key_events: pipe_input.send_text(text) - await asyncio.sleep(delay) + if highlighted is not None: + await _wait_until_highlighted(session, highlighted) return await task return asyncio.run(_run()) @@ -315,13 +334,13 @@ class TestFuzzyPickWidget: def test_single_select_filters_and_returns_highlighted_match(self): result = _drive_fuzzy_pick( - self._models(), "test", multiselect=False, key_events=[("model-13", 0.3), ("\r", 0.1)] + self._models(), "test", multiselect=False, key_events=[("model-13", "model-13"), ("\r", None)] ) assert result == ["model-13"] def test_multiselect_requires_tab_to_toggle_before_enter(self): result = _drive_fuzzy_pick( - self._models(), "test", multiselect=True, key_events=[("model-7", 0.3), ("\t", 0.1), ("\r", 0.1)] + self._models(), "test", multiselect=True, key_events=[("model-7", "model-7"), ("\t", None), ("\r", None)] ) assert result == ["model-7"] @@ -331,12 +350,12 @@ class TestFuzzyPickWidget: "test", multiselect=True, key_events=[ - ("model-3", 0.3), - ("\t", 0.1), - *[("\x7f", 0.02) for _ in range("model-3".__len__())], - ("model-15", 0.3), - ("\t", 0.1), - ("\r", 0.1), + ("model-3", "model-3"), + ("\t", None), + ("\x7f" * len("model-3"), None), + ("model-15", "model-15"), + ("\t", None), + ("\r", None), ], ) assert set(result) == {"model-3", "model-15"} From 362fb4cffe71d2df1b8e3d335a0d6550bdcf4724 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:35:01 +0000 Subject: [PATCH 021/410] refactor(typing): replace Any with proven types in 42 more backend files --- .../providers/bedrock_agentcore/handler.py | 12 ++--- litellm/a2a_protocol/streaming_iterator.py | 4 +- litellm/a2a_protocol/utils.py | 7 +-- litellm/caching/caching_handler.py | 4 +- litellm/experimental_mcp_client/client.py | 12 ++++- litellm/files/main.py | 3 +- litellm/integrations/arize/_utils.py | 23 +++++++-- .../focus/destinations/s3_destination.py | 50 +++++++++++-------- litellm/integrations/prometheus.py | 13 +++-- litellm/interactions/http_handler.py | 30 +++++------ .../messages/fake_stream_iterator.py | 32 ++++++------ litellm/llms/bedrock/chat/invoke_handler.py | 12 +++-- litellm/llms/cohere/embed/transformation.py | 13 +++-- .../llms/dashscope/rerank/transformation.py | 4 +- .../llms/dataforseo/search/transformation.py | 4 +- .../text_to_speech/transformation.py | 22 ++++---- .../fireworks_ai/rerank/transformation.py | 4 +- litellm/llms/gemini/count_tokens/handler.py | 4 +- litellm/llms/gigachat/file_handler.py | 12 ++++- litellm/llms/huggingface/embedding/handler.py | 16 ++++-- .../minimax/text_to_speech/transformation.py | 2 +- .../openai/vector_stores/transformation.py | 2 +- .../guardrail_translation/handler.py | 11 ++-- .../text_to_speech/transformation.py | 17 ++++--- litellm/proxy/client/cli/commands/auth.py | 14 +++--- litellm/proxy/client/cli/commands/models.py | 22 ++++++-- litellm/proxy/client/cli/commands/users.py | 13 +++-- litellm/proxy/client/http_client.py | 5 +- litellm/proxy/client/models.py | 9 ++-- .../container_endpoints/handler_factory.py | 6 +-- .../cato_networks/cato_networks.py | 8 +-- .../guardrail_hooks/dynamoai/dynamoai.py | 8 +-- .../guardrail_hooks/singulr/singulr.py | 7 ++- .../tool_policy/tool_policy_guardrail.py | 9 +++- litellm/proxy/guardrails/usage_endpoints.py | 13 +++-- .../usage_endpoints/ai_usage_chat.py | 36 +++++++------ litellm/realtime_api/main.py | 8 +-- litellm/responses/utils.py | 7 ++- .../adaptive_router/signals.py | 13 ++--- litellm/router_utils/cooldown_handlers.py | 8 +-- litellm/skills/main.py | 16 +++--- litellm/vector_store_files/main.py | 34 ++++++------- 42 files changed, 336 insertions(+), 213 deletions(-) diff --git a/litellm/a2a_protocol/providers/bedrock_agentcore/handler.py b/litellm/a2a_protocol/providers/bedrock_agentcore/handler.py index db57072ca38..a4e6fa50901 100644 --- a/litellm/a2a_protocol/providers/bedrock_agentcore/handler.py +++ b/litellm/a2a_protocol/providers/bedrock_agentcore/handler.py @@ -6,8 +6,8 @@ completion bridge that would otherwise strip the envelope. """ import json -from collections.abc import AsyncIterator -from typing import Any, Final, cast +from collections.abc import AsyncIterator, Mapping +from typing import Any, Final from litellm._logging import verbose_logger from litellm.a2a_protocol.providers.bedrock_agentcore.transformation import ( @@ -28,7 +28,7 @@ class BedrockAgentCoreA2AHandler: @staticmethod async def handle_non_streaming( request_id: str, - params: dict[str, Any], + params: Mapping[str, object], litellm_params: dict[str, Any], agent_extra_headers: dict[str, str] | None = None, ) -> dict[str, Any]: @@ -56,7 +56,7 @@ class BedrockAgentCoreA2AHandler: verbose_logger.info("BedrockAgentCore A2A: Sending non-streaming request to %s", url) client: Final = get_async_httpx_client( - llm_provider=cast(Any, httpxSpecialProvider.A2AProvider), + llm_provider=httpxSpecialProvider.A2AProvider, ) response: Final = await client.post( url, @@ -74,7 +74,7 @@ class BedrockAgentCoreA2AHandler: @staticmethod async def handle_streaming( request_id: str, - params: dict[str, Any], + params: Mapping[str, object], litellm_params: dict[str, Any], agent_extra_headers: dict[str, str] | None = None, ) -> AsyncIterator[dict[str, Any]]: @@ -103,7 +103,7 @@ class BedrockAgentCoreA2AHandler: verbose_logger.info("BedrockAgentCore A2A: Sending streaming request to %s", url) client: Final = get_async_httpx_client( - llm_provider=cast(Any, httpxSpecialProvider.A2AProvider), + llm_provider=httpxSpecialProvider.A2AProvider, ) response: Final = await client.post( url, diff --git a/litellm/a2a_protocol/streaming_iterator.py b/litellm/a2a_protocol/streaming_iterator.py index 413691f233d..67db8e905e3 100644 --- a/litellm/a2a_protocol/streaming_iterator.py +++ b/litellm/a2a_protocol/streaming_iterator.py @@ -148,9 +148,9 @@ class A2AStreamingIterator: except Exception as e: verbose_logger.debug("Error in A2A streaming completion handler: %s", e) - def _build_logging_result(self, usage: litellm.Usage) -> dict[str, Any]: + def _build_logging_result(self, usage: litellm.Usage) -> dict[str, object]: """Build a result dict for logging.""" - result: Final[dict[str, Any]] = { + result: Final[dict[str, object]] = { "id": getattr(self.request, "id", "unknown"), "jsonrpc": "2.0", "usage": (usage.model_dump() if hasattr(usage, "model_dump") else dict(usage)), diff --git a/litellm/a2a_protocol/utils.py b/litellm/a2a_protocol/utils.py index f2e61f66105..5ffca68130b 100644 --- a/litellm/a2a_protocol/utils.py +++ b/litellm/a2a_protocol/utils.py @@ -2,6 +2,7 @@ Utility functions for A2A protocol. """ +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final import litellm @@ -46,7 +47,7 @@ class A2ARequestUtils: return " ".join(text_parts) @staticmethod - def extract_text_from_response(response_dict: dict[str, Any]) -> str: + def extract_text_from_response(response_dict: Mapping[str, object]) -> str: """ Extract text content from A2A response result. @@ -109,7 +110,7 @@ class A2ARequestUtils: @staticmethod def calculate_usage_from_request_response( request: "SendMessageRequest | SendStreamingMessageRequest", - response_dict: dict[str, Any], + response_dict: Mapping[str, object], ) -> tuple[int, int, int]: """ Calculate token usage from A2A request and response. @@ -145,5 +146,5 @@ def extract_text_from_a2a_message(message: Any) -> str: return A2ARequestUtils.extract_text_from_message(message) -def extract_text_from_a2a_response(response_dict: dict[str, Any]) -> str: +def extract_text_from_a2a_response(response_dict: Mapping[str, object]) -> str: return A2ARequestUtils.extract_text_from_response(response_dict) diff --git a/litellm/caching/caching_handler.py b/litellm/caching/caching_handler.py index 8fe60876b4e..0de88eacaa5 100644 --- a/litellm/caching/caching_handler.py +++ b/litellm/caching/caching_handler.py @@ -672,7 +672,7 @@ class LLMCachingHandler: def _async_log_cache_hit_on_callbacks( self, logging_obj: LiteLLMLoggingObj, - cached_result: Any, + cached_result: object, start_time: datetime.datetime, end_time: datetime.datetime, cache_hit: bool, @@ -1184,7 +1184,7 @@ class LLMCachingHandler: logging_obj: LiteLLMLoggingObj, model: str, kwargs: dict[str, Any], - cached_result: Any, + cached_result: object, is_async: bool, is_embedding: bool = False, custom_llm_provider: str | None = None, diff --git a/litellm/experimental_mcp_client/client.py b/litellm/experimental_mcp_client/client.py index 34af6fcffba..f40941d62cc 100644 --- a/litellm/experimental_mcp_client/client.py +++ b/litellm/experimental_mcp_client/client.py @@ -5,7 +5,7 @@ LiteLLM Proxy uses this MCP Client to connnect to other MCP servers. import asyncio import base64 import os -from collections.abc import Awaitable, Callable, Generator, Sequence +from collections.abc import Awaitable, Callable, Generator from contextlib import AbstractAsyncContextManager from datetime import timedelta from functools import partial @@ -13,11 +13,19 @@ from importlib import metadata from typing import Any, Final, Protocol, TypeAlias, TypeVar import httpx +from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream from mcp import ClientSession, McpError, ReadResourceResult, Resource, StdioServerParameters from mcp.client.sse import sse_client from mcp.client.stdio import stdio_client +from mcp.shared.message import SessionMessage +from typing_extensions import Unpack -_TransportContext: TypeAlias = AbstractAsyncContextManager[Sequence[Any]] +_TransportStreams: TypeAlias = tuple[ + MemoryObjectReceiveStream[SessionMessage | Exception], + MemoryObjectSendStream[SessionMessage], + Unpack[tuple[object, ...]], +] +_TransportContext: TypeAlias = AbstractAsyncContextManager[_TransportStreams] class _StreamableHttpClientFactory(Protocol): diff --git a/litellm/files/main.py b/litellm/files/main.py index e769a0a0508..19da77b7364 100644 --- a/litellm/files/main.py +++ b/litellm/files/main.py @@ -14,6 +14,7 @@ from functools import partial from typing import Any, Final, Literal, cast import httpx +from openai import AsyncOpenAI, OpenAI # Type aliases for provider parameters FileCreateProvider = Literal[ @@ -1002,7 +1003,7 @@ def file_content_streaming( timeout: float | httpx.Timeout, logging_obj: LiteLLMLoggingObj | None, _is_async: bool, - client: Any | None, + client: OpenAI | AsyncOpenAI | None, ) -> FileContentStreamingResult | Coroutine[object, object, FileContentStreamingResult]: if logging_obj is not None: logging_obj.model = model or "" diff --git a/litellm/integrations/arize/_utils.py b/litellm/integrations/arize/_utils.py index e7e1ab538d5..5a5324eae5e 100644 --- a/litellm/integrations/arize/_utils.py +++ b/litellm/integrations/arize/_utils.py @@ -2,7 +2,7 @@ import json from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final -from typing_extensions import override +from typing_extensions import ReadOnly, TypedDict, override from litellm._logging import verbose_logger from litellm.integrations.opentelemetry_utils.base_otel_llm_obs_attributes import ( @@ -492,12 +492,12 @@ def _sanitize_optional_params(optional_params: dict | None) -> dict: return optional_params -def _set_metadata_attributes(span: "Span", metadata: Any | None, span_attrs) -> None: +def _set_metadata_attributes(span: "Span", metadata: object | None, span_attrs) -> None: if metadata is not None: safe_set_attribute(span, span_attrs.METADATA, safe_dumps(metadata)) -def _extract_metadata_tools(metadata: Any | None) -> list | None: +def _extract_metadata_tools(metadata: object | None) -> list | None: if not isinstance(metadata, dict): return None llm_obj: Final = metadata.get("llm") @@ -670,7 +670,22 @@ def _get_tool_calls(message) -> list | None: return tool_calls if isinstance(tool_calls, list) and tool_calls else None -def _normalize_tool_call(raw_tc) -> dict[str, Any] | None: +class _NormalizedToolCallFunction(TypedDict): + """The ``function`` sub-object of a normalized tool call.""" + + name: ReadOnly[object] + arguments: ReadOnly[object] + + +class _NormalizedToolCall(TypedDict): + """A tool call reduced to the stable shape the OpenInference emitters read.""" + + id: ReadOnly[object] + type: ReadOnly[object] + function: ReadOnly[_NormalizedToolCallFunction] + + +def _normalize_tool_call(raw_tc) -> _NormalizedToolCall | None: """Normalize a single tool_call (dict or Pydantic) into a stable shape: {"id": str|None, "type": str, "function": {"name": str|None, "arguments": str|None}} diff --git a/litellm/integrations/focus/destinations/s3_destination.py b/litellm/integrations/focus/destinations/s3_destination.py index d6530b889d9..661cf1933ff 100644 --- a/litellm/integrations/focus/destinations/s3_destination.py +++ b/litellm/integrations/focus/destinations/s3_destination.py @@ -3,14 +3,26 @@ from __future__ import annotations import asyncio +from collections.abc import Mapping from datetime import timezone -from typing import Any, Final +from typing import Final, TypedDict import boto3 +from typing_extensions import ReadOnly from .base import FocusDestination, FocusTimeWindow +class _S3ClientKwargs(TypedDict, total=False): + """Optional boto3 client arguments the destination config may supply.""" + + region_name: ReadOnly[str] + endpoint_url: ReadOnly[str] + aws_access_key_id: ReadOnly[str] + aws_secret_access_key: ReadOnly[str] + aws_session_token: ReadOnly[str] + + class FocusS3Destination(FocusDestination): """Handles uploading serialized exports to S3 buckets.""" @@ -18,7 +30,7 @@ class FocusS3Destination(FocusDestination): self, *, prefix: str, - config: dict[str, Any] | None = None, + config: Mapping[str, str] | None = None, ) -> None: config = config or {} bucket_name: Final = config.get("bucket_name") @@ -47,25 +59,23 @@ class FocusS3Destination(FocusDestination): key_prefix: Final = "/".join(filter(None, parts)) return f"{key_prefix}/{filename}" if key_prefix else filename + def _client_kwargs(self) -> _S3ClientKwargs: + """Collect the boto3 client arguments the destination config provides.""" + region: Final = self.config.get("region_name") + endpoint: Final = self.config.get("endpoint_url") + key_id: Final = self.config.get("aws_access_key_id") + secret: Final = self.config.get("aws_secret_access_key") + token: Final = self.config.get("aws_session_token") + return { + **(_S3ClientKwargs(region_name=region) if region else _S3ClientKwargs()), + **(_S3ClientKwargs(endpoint_url=endpoint) if endpoint else _S3ClientKwargs()), + **(_S3ClientKwargs(aws_access_key_id=key_id) if key_id else _S3ClientKwargs()), + **(_S3ClientKwargs(aws_secret_access_key=secret) if secret else _S3ClientKwargs()), + **(_S3ClientKwargs(aws_session_token=token) if token else _S3ClientKwargs()), + } + def _upload(self, content: bytes, object_key: str) -> None: - client_kwargs: Final[dict[str, Any]] = {} - region_name: Final = self.config.get("region_name") - if region_name: - client_kwargs["region_name"] = region_name - endpoint_url: Final = self.config.get("endpoint_url") - if endpoint_url: - client_kwargs["endpoint_url"] = endpoint_url - - session_kwargs: Final[dict[str, Any]] = {} - for key in ( - "aws_access_key_id", - "aws_secret_access_key", - "aws_session_token", - ): - if self.config.get(key): - session_kwargs[key] = self.config[key] - - s3_client: Final = boto3.client("s3", **client_kwargs, **session_kwargs) + s3_client: Final = boto3.client("s3", **self._client_kwargs()) s3_client.put_object( Bucket=self.bucket_name, Key=object_key, diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 3e75c9cbf93..6766d246894 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -10,7 +10,7 @@ import sys from collections.abc import Awaitable, Callable, Mapping, Sequence from dataclasses import replace from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, cast +from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeAlias, TypeVar, cast from pydantic import BaseModel @@ -142,6 +142,9 @@ class _ExcludedLabelMetric: return self._metric.labels(*kept_values) if kept_values else self._metric +_MetricLike: TypeAlias = "NoOpMetric | _ExcludedLabelMetric | MetricWrapperBase" + + def _get_budget_metrics_per_request_timeout() -> float: raw: Final = os.getenv("PROMETHEUS_BUDGET_METRICS_PER_REQUEST_TIMEOUT") if raw is None: @@ -1652,7 +1655,7 @@ class PrometheusLogger(CustomLogger): cache_creation_detail_tokens: Final = PrometheusLogger._resolve_cache_write_tokens(prompt_details) - detail_metrics: Final[list[tuple[Any, DEFINED_PROMETHEUS_METRICS, object]]] = [ + detail_metrics: Final[list[tuple[_MetricLike, DEFINED_PROMETHEUS_METRICS, object]]] = [ ( self.litellm_input_cached_tokens_metric, "litellm_input_cached_tokens_metric", @@ -1705,7 +1708,7 @@ class PrometheusLogger(CustomLogger): if not isinstance(usage_object, dict): return - media_metrics: Final[list[tuple[Any, DEFINED_PROMETHEUS_METRICS, object]]] = [ + media_metrics: Final[list[tuple[_MetricLike, DEFINED_PROMETHEUS_METRICS, object]]] = [ ( self.litellm_video_duration_seconds_metric, "litellm_video_duration_seconds_metric", @@ -1727,7 +1730,7 @@ class PrometheusLogger(CustomLogger): def _inc_sparse_usage_counters( self, - counters_with_values: Sequence[tuple[Any, DEFINED_PROMETHEUS_METRICS, object]], + counters_with_values: Sequence[tuple[_MetricLike, DEFINED_PROMETHEUS_METRICS, object]], enum_values: UserAPIKeyLabelValues, label_context: PrometheusLabelFactoryContext | None = None, ) -> None: @@ -2623,7 +2626,7 @@ class PrometheusLogger(CustomLogger): """ standard_logging_payload: Final = request_kwargs.get("standard_logging_object", {}) or {} _litellm_params: Final = request_kwargs.get("litellm_params", {}) or {} - _metadata_raw: Final = self._safe_get(standard_logging_payload, "metadata") or {} + _metadata_raw: Final[object] = self._safe_get(standard_logging_payload, "metadata") or {} if isinstance(_metadata_raw, dict): _metadata = _metadata_raw else: diff --git a/litellm/interactions/http_handler.py b/litellm/interactions/http_handler.py index 044c171653c..17ec4a3398d 100644 --- a/litellm/interactions/http_handler.py +++ b/litellm/interactions/http_handler.py @@ -4,7 +4,7 @@ HTTP Handler for Interactions API requests. This module handles the HTTP communication for the Google Interactions API. """ -from collections.abc import AsyncIterator, Coroutine, Iterator +from collections.abc import AsyncIterator, Coroutine, Iterator, Mapping from typing import Any, Final import httpx @@ -96,8 +96,8 @@ class InteractionsHTTPHandler(_BaseHTTPHandler): model: str | None = None, agent: str | None = None, input: InteractionInput | None = None, - extra_headers: dict[str, Any] | None = None, - extra_body: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + extra_body: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, client: HTTPHandler | None = None, _is_async: bool = False, @@ -105,7 +105,7 @@ class InteractionsHTTPHandler(_BaseHTTPHandler): ) -> ( InteractionsAPIResponse | Iterator[InteractionsAPIStreamingResponse] - | Coroutine[Any, Any, InteractionsAPIResponse | AsyncIterator[InteractionsAPIStreamingResponse]] + | Coroutine[object, object, InteractionsAPIResponse | AsyncIterator[InteractionsAPIStreamingResponse]] ): """ Create a new interaction (synchronous or async based on _is_async flag). @@ -211,8 +211,8 @@ class InteractionsHTTPHandler(_BaseHTTPHandler): model: str | None = None, agent: str | None = None, input: InteractionInput | None = None, - extra_headers: dict[str, Any] | None = None, - extra_body: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, + extra_body: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, client: AsyncHTTPHandler | None = None, stream: bool | None = None, @@ -345,11 +345,11 @@ class InteractionsHTTPHandler(_BaseHTTPHandler): custom_llm_provider: str, litellm_params: GenericLiteLLMParams, logging_obj: LiteLLMLoggingObj, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, client: HTTPHandler | None = None, _is_async: bool = False, - ) -> InteractionsAPIResponse | Coroutine[Any, Any, InteractionsAPIResponse]: + ) -> InteractionsAPIResponse | Coroutine[object, object, InteractionsAPIResponse]: """Get an interaction by ID.""" if _is_async: return self.async_get_interaction( @@ -407,7 +407,7 @@ class InteractionsHTTPHandler(_BaseHTTPHandler): custom_llm_provider: str, litellm_params: GenericLiteLLMParams, logging_obj: LiteLLMLoggingObj, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, client: AsyncHTTPHandler | None = None, ) -> InteractionsAPIResponse: @@ -464,11 +464,11 @@ class InteractionsHTTPHandler(_BaseHTTPHandler): custom_llm_provider: str, litellm_params: GenericLiteLLMParams, logging_obj: LiteLLMLoggingObj, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, client: HTTPHandler | None = None, _is_async: bool = False, - ) -> DeleteInteractionResult | Coroutine[Any, Any, DeleteInteractionResult]: + ) -> DeleteInteractionResult | Coroutine[object, object, DeleteInteractionResult]: """Delete an interaction by ID.""" if _is_async: return self.async_delete_interaction( @@ -527,7 +527,7 @@ class InteractionsHTTPHandler(_BaseHTTPHandler): custom_llm_provider: str, litellm_params: GenericLiteLLMParams, logging_obj: LiteLLMLoggingObj, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, client: AsyncHTTPHandler | None = None, ) -> DeleteInteractionResult: @@ -585,11 +585,11 @@ class InteractionsHTTPHandler(_BaseHTTPHandler): custom_llm_provider: str, litellm_params: GenericLiteLLMParams, logging_obj: LiteLLMLoggingObj, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, client: HTTPHandler | None = None, _is_async: bool = False, - ) -> CancelInteractionResult | Coroutine[Any, Any, CancelInteractionResult]: + ) -> CancelInteractionResult | Coroutine[object, object, CancelInteractionResult]: """Cancel an interaction by ID.""" if _is_async: return self.async_cancel_interaction( @@ -648,7 +648,7 @@ class InteractionsHTTPHandler(_BaseHTTPHandler): custom_llm_provider: str, litellm_params: GenericLiteLLMParams, logging_obj: LiteLLMLoggingObj, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, client: AsyncHTTPHandler | None = None, ) -> CancelInteractionResult: diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py b/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py index 14f1b7697cf..d0fed3225af 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/fake_stream_iterator.py @@ -9,6 +9,7 @@ the LLM doesn't make a tool call, and we need to return a stream to the user. """ import json +from collections.abc import Mapping from typing import Any, Final, cast from litellm.types.llms.anthropic_messages.anthropic_response import ( @@ -38,7 +39,7 @@ class FakeAnthropicMessagesStreamIterator: self.chunks = self._create_streaming_chunks() self.current_index = 0 - def _create_content_block_chunks(self, block_dict: dict[str, Any], index: int) -> list[bytes]: + def _create_content_block_chunks(self, block_dict: Mapping[str, object], index: int) -> list[bytes]: """Build SSE chunks for a single content block.""" chunks: Final = [] block_type: Final = block_dict.get("type") @@ -133,14 +134,14 @@ class FakeAnthropicMessagesStreamIterator: response_dict: Final = cast(dict[str, Any], self.response) # 1. message_start event - usage: Final = response_dict.get("usage", {}) + usage: Final = self.response.get("usage") message_start: Final = { "type": "message_start", "message": { - "id": response_dict.get("id"), + "id": self.response.get("id"), "type": "message", - "role": response_dict.get("role", "assistant"), - "model": response_dict.get("model"), + "role": self.response.get("role", "assistant"), + "model": self.response.get("model"), "content": [], "stop_reason": None, "stop_sequence": None, @@ -161,21 +162,24 @@ class FakeAnthropicMessagesStreamIterator: # 5. message_delta event (with final usage and stop_reason) # Include cache usage fields so clients that only read message_delta # (like Claude Code's SDK) see the full input token breakdown. - delta_usage: Final[dict[str, Any]] = { + delta_usage: Final[dict[str, int]] = { "output_tokens": usage.get("output_tokens", 0) if usage else 0, } if usage: - if usage.get("input_tokens") is not None: - delta_usage["input_tokens"] = usage["input_tokens"] - if usage.get("cache_creation_input_tokens") is not None: - delta_usage["cache_creation_input_tokens"] = usage["cache_creation_input_tokens"] - if usage.get("cache_read_input_tokens") is not None: - delta_usage["cache_read_input_tokens"] = usage["cache_read_input_tokens"] + input_tokens: Final = usage.get("input_tokens") + if input_tokens is not None: + delta_usage["input_tokens"] = input_tokens + cache_creation_input_tokens: Final = usage.get("cache_creation_input_tokens") + if cache_creation_input_tokens is not None: + delta_usage["cache_creation_input_tokens"] = cache_creation_input_tokens + cache_read_input_tokens: Final = usage.get("cache_read_input_tokens") + if cache_read_input_tokens is not None: + delta_usage["cache_read_input_tokens"] = cache_read_input_tokens message_delta: Final = { "type": "message_delta", "delta": { - "stop_reason": response_dict.get("stop_reason"), - "stop_sequence": response_dict.get("stop_sequence"), + "stop_reason": self.response.get("stop_reason"), + "stop_sequence": self.response.get("stop_sequence"), }, "usage": delta_usage, } diff --git a/litellm/llms/bedrock/chat/invoke_handler.py b/litellm/llms/bedrock/chat/invoke_handler.py index fc34e403beb..c39c88240c5 100644 --- a/litellm/llms/bedrock/chat/invoke_handler.py +++ b/litellm/llms/bedrock/chat/invoke_handler.py @@ -163,7 +163,7 @@ async def make_call( json_mode: bool | None = False, bedrock_invoke_provider: litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL | None = None, stream_chunk_size: int | None = None, -) -> tuple[Any, httpx.Headers]: +) -> "tuple[MockResponseIterator | AsyncIterator[GChunk | ModelResponseStream | dict], httpx.Headers]": try: if client is None: client = get_async_httpx_client( @@ -199,7 +199,9 @@ async def make_call( messages=messages, encoding=litellm.encoding, ) - completion_stream: Any = MockResponseIterator(model_response=model_response, json_mode=json_mode) + completion_stream: MockResponseIterator | AsyncIterator[GChunk | ModelResponseStream | dict] = ( + MockResponseIterator(model_response=model_response, json_mode=json_mode) + ) elif bedrock_invoke_provider == "anthropic": decoder: AWSEventStreamDecoder = AmazonAnthropicClaudeStreamDecoder( model=model, @@ -248,7 +250,7 @@ def make_sync_call( json_mode: bool | None = False, bedrock_invoke_provider: litellm.BEDROCK_INVOKE_PROVIDERS_LITERAL | None = None, stream_chunk_size: int | None = None, -) -> tuple[Any, httpx.Headers]: +) -> "tuple[MockResponseIterator | Iterator[GChunk | ModelResponseStream | dict], httpx.Headers]": try: if client is None: client = _get_httpx_client( @@ -283,7 +285,9 @@ def make_sync_call( messages=messages, encoding=litellm.encoding, ) - completion_stream: Any = MockResponseIterator(model_response=model_response, json_mode=json_mode) + completion_stream: MockResponseIterator | Iterator[GChunk | ModelResponseStream | dict] = ( + MockResponseIterator(model_response=model_response, json_mode=json_mode) + ) elif bedrock_invoke_provider == "anthropic": decoder: AWSEventStreamDecoder = AmazonAnthropicClaudeStreamDecoder( model=model, diff --git a/litellm/llms/cohere/embed/transformation.py b/litellm/llms/cohere/embed/transformation.py index eb3f65bec94..bac899c4142 100644 --- a/litellm/llms/cohere/embed/transformation.py +++ b/litellm/llms/cohere/embed/transformation.py @@ -10,7 +10,8 @@ Convers Docs - https://docs.cohere.com/v2/reference/embed """ -from typing import Any, Final, cast +from collections.abc import Sized +from typing import Final, Protocol, cast import httpx @@ -30,6 +31,12 @@ from litellm.utils import is_base64_encoded from ..common_utils import CohereError +class _SupportsEncode(Protocol): + """Tokenizer handle: the embedding usage path only encodes text to measure its token length.""" + + def encode(self, text: str, /) -> Sized: ... + + class CohereEmbeddingConfig(BaseEmbeddingConfig): """ Reference: https://docs.cohere.com/v2/reference/embed @@ -133,7 +140,7 @@ class CohereEmbeddingConfig(BaseEmbeddingConfig): ), ) - def _calculate_usage(self, input: list[str], encoding: Any, meta: dict) -> Usage: + def _calculate_usage(self, input: list[str], encoding: _SupportsEncode, meta: dict) -> Usage: input_tokens = 0 text_tokens: Final[int | None] = meta.get("billed_units", {}).get("input_tokens") @@ -169,7 +176,7 @@ class CohereEmbeddingConfig(BaseEmbeddingConfig): data: dict | CohereEmbeddingRequest, model_response: EmbeddingResponse, model: str, - encoding: Any, + encoding: _SupportsEncode, input: list, ) -> EmbeddingResponse: response_json: Final = response.json() diff --git a/litellm/llms/dashscope/rerank/transformation.py b/litellm/llms/dashscope/rerank/transformation.py index 3dd3996b2ee..490757a0948 100644 --- a/litellm/llms/dashscope/rerank/transformation.py +++ b/litellm/llms/dashscope/rerank/transformation.py @@ -148,7 +148,7 @@ class DashScopeRerankConfig(BaseRerankConfig): if "documents" not in optional_rerank_params: raise ValueError("documents is required for DashScope rerank") - request: Final[dict[str, Any]] = { + request: Final[dict[str, object]] = { "model": model, "query": optional_rerank_params["query"], "documents": optional_rerank_params["documents"], @@ -209,7 +209,7 @@ class DashScopeRerankConfig(BaseRerankConfig): # which already matches LiteLLM's RerankResponseDocument shape. transformed_results: Final[list[dict]] = [] for r in results: - item: dict[str, Any] = { + item: dict[str, object] = { "index": r["index"], "relevance_score": r["relevance_score"], } diff --git a/litellm/llms/dataforseo/search/transformation.py b/litellm/llms/dataforseo/search/transformation.py index eedffd844ef..fcd4ae70645 100644 --- a/litellm/llms/dataforseo/search/transformation.py +++ b/litellm/llms/dataforseo/search/transformation.py @@ -4,7 +4,7 @@ Calls DataForSEO SERP API to search the web. DataForSEO API Reference: https://docs.dataforseo.com/v3/serp/google/organic/live/advanced/?bash """ -from typing import Any, Final, Literal +from typing import Final, Literal import httpx @@ -126,7 +126,7 @@ class DataForSEOSearchConfig(BaseSearchConfig): List[Dict]: Request body for DataForSEO API (array of task objects as required by API) """ # DataForSEO expects an array of task objects - task: Final[dict[str, Any]] = {} + task: Final[dict[str, object]] = {} # Convert query to string if it's a list if isinstance(query, list): diff --git a/litellm/llms/elevenlabs/text_to_speech/transformation.py b/litellm/llms/elevenlabs/text_to_speech/transformation.py index 3439f4872c3..3cf9a983efe 100644 --- a/litellm/llms/elevenlabs/text_to_speech/transformation.py +++ b/litellm/llms/elevenlabs/text_to_speech/transformation.py @@ -80,8 +80,8 @@ class ElevenLabsTextToSpeechConfig(BaseTextToSpeechConfig): def _resolve_voice_id( self, - voice: str | dict[str, Any] | None, - params: dict[str, Any], + voice: str | dict[str, object] | None, + params: dict[str, object], ) -> str: """ Determine the ElevenLabs voice_id based on provided voice input or parameters. @@ -115,17 +115,17 @@ class ElevenLabsTextToSpeechConfig(BaseTextToSpeechConfig): optional_params: dict, voice: str | dict | None = None, drop_params: bool = False, - kwargs: dict[str, Any] | None = None, + kwargs: dict[str, object] | None = None, ) -> tuple[str | None, dict]: """ Map OpenAI parameters to ElevenLabs TTS parameters """ - mapped_params: Final[dict[str, Any]] = {} - query_params: Final[dict[str, Any]] = {} + mapped_params: Final[dict[str, object]] = {} + query_params: Final[dict[str, object]] = {} # Work on a copy so we don't mutate the caller's dictionary params: Final = dict(optional_params) if optional_params else {} - passthrough_kwargs: Final[dict[str, Any]] = kwargs if kwargs is not None else {} + passthrough_kwargs: Final[dict[str, object]] = kwargs if kwargs is not None else {} # Extract voice identifier mapped_voice: Final = self._resolve_voice_id(voice, params) @@ -205,7 +205,7 @@ class ElevenLabsTextToSpeechConfig(BaseTextToSpeechConfig): params: Final = dict(optional_params) if optional_params else {} extra_body: Final = params.pop("extra_body", None) - request_body: Final[dict[str, Any]] = { + request_body: Final[dict[str, object]] = { "text": input, "model_id": model, } @@ -229,10 +229,10 @@ class ElevenLabsTextToSpeechConfig(BaseTextToSpeechConfig): def _add_elevenlabs_specific_params( self, mapped_voice: str, - query_params: dict[str, Any], - mapped_params: dict[str, Any], - kwargs: dict[str, Any] | None, - remaining_params: dict[str, Any], + query_params: dict[str, object], + mapped_params: dict[str, object], + kwargs: dict[str, object] | None, + remaining_params: dict[str, object], ) -> None: if kwargs is None: kwargs = {} diff --git a/litellm/llms/fireworks_ai/rerank/transformation.py b/litellm/llms/fireworks_ai/rerank/transformation.py index 8ef2c9acccb..e142622aa1b 100644 --- a/litellm/llms/fireworks_ai/rerank/transformation.py +++ b/litellm/llms/fireworks_ai/rerank/transformation.py @@ -67,11 +67,11 @@ class FireworksAIRerankConfig(FireworksAIMixin, BaseRerankConfig): max_chunks_per_doc: int | None = None, max_tokens_per_doc: int | None = None, instruction: str | None = None, - ) -> dict[str, Any]: + ) -> dict[str, object]: """ Map Cohere rerank params to Fireworks AI rerank params """ - params: Final[dict[str, Any]] = { + params: Final[dict[str, object]] = { "query": query, "documents": documents, } diff --git a/litellm/llms/gemini/count_tokens/handler.py b/litellm/llms/gemini/count_tokens/handler.py index 1920cd698f5..cb2be2c860e 100644 --- a/litellm/llms/gemini/count_tokens/handler.py +++ b/litellm/llms/gemini/count_tokens/handler.py @@ -58,9 +58,9 @@ class GoogleAIStudioTokenCounter: self, api_base: str | None = None, api_key: str | None = None, - headers: dict[str, Any] | None = None, + headers: dict[str, object] | None = None, model: str = "", - litellm_params: dict[str, Any] | None = None, + litellm_params: dict[str, object] | None = None, ) -> tuple[dict[str, Any], str]: """ Returns a Tuple of headers and url for the Google Gen AI Studio countTokens endpoint. diff --git a/litellm/llms/gigachat/file_handler.py b/litellm/llms/gigachat/file_handler.py index 163e944f124..359553e144f 100644 --- a/litellm/llms/gigachat/file_handler.py +++ b/litellm/llms/gigachat/file_handler.py @@ -50,13 +50,21 @@ def _parse_data_url(data_url: str) -> tuple[bytes, str, str] | None: return content_bytes, content_type, ext +def _content_type_or_default(headers: Mapping[str, str]) -> str: + """Return the response's ``content-type`` header, falling back to ``image/jpeg`` when absent.""" + try: + return headers["content-type"] + except KeyError: + return "image/jpeg" + + def _download_image_sync(url: str) -> tuple[bytes, str, str]: """Download image from URL synchronously.""" client: Final = _get_httpx_client(params={"ssl_verify": False}) response: Final = client.get(url) response.raise_for_status() - content_type: Final = response.headers.get("content-type", "image/jpeg") + content_type: Final = _content_type_or_default(response.headers) ext: Final = content_type.split("/")[-1].split(";")[0] or "jpg" return response.content, content_type, ext @@ -71,7 +79,7 @@ async def _download_image_async(url: str) -> tuple[bytes, str, str]: response: Final = await client.get(url) response.raise_for_status() - content_type: Final = response.headers.get("content-type", "image/jpeg") + content_type: Final = _content_type_or_default(response.headers) ext: Final = content_type.split("/")[-1].split(";")[0] or "jpg" return response.content, content_type, ext diff --git a/litellm/llms/huggingface/embedding/handler.py b/litellm/llms/huggingface/embedding/handler.py index 12c070b3461..57d1357ee46 100644 --- a/litellm/llms/huggingface/embedding/handler.py +++ b/litellm/llms/huggingface/embedding/handler.py @@ -1,7 +1,7 @@ import json import os -from collections.abc import Callable -from typing import Any, Final, Literal, get_args +from collections.abc import Sequence +from typing import Final, Literal, Protocol, get_args import httpx @@ -29,6 +29,12 @@ hf_tasks_embeddings: Final = ( ) +class _SupportsTokenEncode(Protocol): + """Token encoder handle. Only ``encode`` is ever called on it here.""" + + def encode(self, text: str, *, disallowed_special: tuple[str, ...]) -> Sequence[int]: ... + + def get_hf_task_embedding_for_model(model: str, task_type: str | None, api_base: str) -> str | None: if task_type is not None: if task_type in get_args(hf_tasks_embeddings): @@ -173,7 +179,7 @@ class HuggingFaceEmbedding(BaseLLM): model_response: EmbeddingResponse, model: str, input: list, - encoding: Any, + encoding: _SupportsTokenEncode, ) -> EmbeddingResponse: output_data: Final = [] if "similarities" in embeddings: @@ -234,7 +240,7 @@ class HuggingFaceEmbedding(BaseLLM): api_base: str, api_key: str | None, headers: dict, - encoding: Callable, + encoding: _SupportsTokenEncode, client: AsyncHTTPHandler | None = None, ): ## TRANSFORMATION ## @@ -294,7 +300,7 @@ class HuggingFaceEmbedding(BaseLLM): optional_params: dict, litellm_params: dict, logging_obj: LiteLLMLoggingObj, - encoding: Callable, + encoding: _SupportsTokenEncode, api_key: str | None = None, api_base: str | None = None, timeout: float | httpx.Timeout = httpx.Timeout(None), diff --git a/litellm/llms/minimax/text_to_speech/transformation.py b/litellm/llms/minimax/text_to_speech/transformation.py index f8926df1f3f..e38a8a2c3a3 100644 --- a/litellm/llms/minimax/text_to_speech/transformation.py +++ b/litellm/llms/minimax/text_to_speech/transformation.py @@ -123,7 +123,7 @@ class MinimaxTextToSpeechConfig(BaseTextToSpeechConfig): optional_params: dict, voice: str | dict | None = None, drop_params: bool = False, - kwargs: dict[str, Any] | None = None, + kwargs: Mapping[str, object] | None = None, ) -> tuple[str | None, dict]: """ Map OpenAI parameters to MiniMax TTS parameters diff --git a/litellm/llms/openai/vector_stores/transformation.py b/litellm/llms/openai/vector_stores/transformation.py index f6c093f2e2a..125e5168c69 100644 --- a/litellm/llms/openai/vector_stores/transformation.py +++ b/litellm/llms/openai/vector_stores/transformation.py @@ -98,7 +98,7 @@ class OpenAIVectorStoreConfig(BaseVectorStoreConfig): api_base: str, litellm_logging_obj: LiteLLMLoggingObj, litellm_params: dict, - extra_body: dict[str, Any] | None = None, + extra_body: dict[str, object] | None = None, ) -> tuple[str, dict]: encoded_vector_store_id: Final = encode_url_path_segment(vector_store_id, field_name="vector_store_id") url: Final = f"{api_base}/{encoded_vector_store_id}/search" diff --git a/litellm/llms/pass_through/guardrail_translation/handler.py b/litellm/llms/pass_through/guardrail_translation/handler.py index f07acf2f728..1f295a6e656 100644 --- a/litellm/llms/pass_through/guardrail_translation/handler.py +++ b/litellm/llms/pass_through/guardrail_translation/handler.py @@ -6,6 +6,7 @@ It uses the field targeting configuration from litellm_logging_obj to extract specific fields for guardrail processing. """ +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Optional from litellm._logging import verbose_proxy_logger @@ -89,7 +90,7 @@ class PassThroughEndpointHandler(BaseTranslation): data: dict, guardrail_to_apply: "CustomGuardrail", litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, - ) -> Any: + ) -> Mapping[str, object]: """ Process input by applying guardrails to targeted fields or full payload. """ @@ -130,9 +131,9 @@ class PassThroughEndpointHandler(BaseTranslation): response: object, guardrail_to_apply: "CustomGuardrail", litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, - user_api_key_dict: Any | None = None, + user_api_key_dict: Optional["UserAPIKeyAuth"] = None, request_data: dict | None = None, - ) -> Any: + ) -> object: """ Process output response by applying guardrails to targeted fields. @@ -239,9 +240,9 @@ class LlmPassthroughRouteHandler(BaseTranslation): response: object, guardrail_to_apply: "CustomGuardrail", litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None, - user_api_key_dict: Any | None = None, + user_api_key_dict: Optional["UserAPIKeyAuth"] = None, request_data: dict | None = None, - ) -> Any: + ) -> object: provider: Final = (request_data or {}).get("custom_llm_provider") handler_cls: Final = _get_provider_handlers().get(provider or "") if handler_cls is None: diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 332f892ae6b..d7b4ad22a01 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -29,6 +29,7 @@ from litellm.types.llms.vertex_ai_text_to_speech import ( if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.types.llms.openai import HttpxBinaryResponseContent else: LiteLLMLoggingObj = Any @@ -131,19 +132,19 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): model: str, input: str, voice: str | dict | None, - optional_params: dict, - litellm_params_dict: dict, + optional_params: dict[str, object], + litellm_params_dict: dict[str, object], logging_obj: "LiteLLMLoggingObj", timeout: float | httpx.Timeout, - extra_headers: dict[str, Any] | None, - base_llm_http_handler: Any, + extra_headers: dict[str, object] | None, + base_llm_http_handler: "BaseLLMHTTPHandler", aspeech: bool, api_base: str | None, api_key: str | None, - **kwargs: Any, + **kwargs: object, ) -> Union[ "HttpxBinaryResponseContent", - Coroutine[Any, Any, "HttpxBinaryResponseContent"], + Coroutine[object, object, "HttpxBinaryResponseContent"], ]: """ Dispatch method to handle Vertex AI TTS requests @@ -227,7 +228,7 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): Returns: Tuple of (mapped_voice_str, mapped_params) """ - mapped_params: Final[dict[str, Any]] = {} + mapped_params: Final[dict[str, object]] = {} ########################################################## # Map voice using helper @@ -428,7 +429,7 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): speakingRate=speaking_rate, ) - request_body: Final[dict[str, Any]] = { + request_body: Final[dict[str, object]] = { "input": dict(vertex_input), "voice": dict(vertex_voice), "audioConfig": dict(vertex_audio_config), diff --git a/litellm/proxy/client/cli/commands/auth.py b/litellm/proxy/client/cli/commands/auth.py index 2fad9f933c1..4da31c82b57 100644 --- a/litellm/proxy/client/cli/commands/auth.py +++ b/litellm/proxy/client/cli/commands/auth.py @@ -1,8 +1,8 @@ import sys import time import webbrowser -from collections.abc import Callable, Mapping -from typing import Any, Final +from collections.abc import Callable, Mapping, Sequence +from typing import Any, Final, TypeVar from urllib.parse import urlencode import click @@ -112,6 +112,8 @@ class CliAuthResult(TypedDict): team_id: str | None +_TeamMapping: Final = TypeVar("_TeamMapping", bound=Mapping[str, object]) + KEYRING_INSTALL_HINT: Final = "pip install 'litellm[cli]'" KEYRING_ENABLE_HINT: Final = "keyring --enable (or unset PYTHON_KEYRING_BACKEND)" @@ -353,7 +355,7 @@ def get_key_input(): return None -def display_interactive_team_selection(teams: list[dict[str, Any]], selected_index: int = 0) -> None: +def display_interactive_team_selection(teams: Sequence[Mapping[str, Any]], selected_index: int = 0) -> None: """Display teams with one highlighted for selection""" console: Final = Console() @@ -391,7 +393,7 @@ def display_interactive_team_selection(teams: list[dict[str, Any]], selected_ind console.print(f" Budget: [dim]{budget_str}[/dim]\n") -def prompt_team_selection(teams: list[dict[str, Any]]) -> dict[str, Any] | None: +def prompt_team_selection(teams: Sequence[_TeamMapping]) -> _TeamMapping | None: """Interactive team selection with arrow keys""" if not teams: return None @@ -441,8 +443,8 @@ def prompt_team_selection(teams: list[dict[str, Any]]) -> dict[str, Any] | None: def prompt_team_selection_fallback( - teams: list[dict[str, Any]], -) -> dict[str, Any] | None: + teams: Sequence[_TeamMapping], +) -> _TeamMapping | None: """Fallback team selection for non-interactive environments""" if not teams: return None diff --git a/litellm/proxy/client/cli/commands/models.py b/litellm/proxy/client/cli/commands/models.py index 4c83a7b799a..f2b38c6eab4 100644 --- a/litellm/proxy/client/cli/commands/models.py +++ b/litellm/proxy/client/cli/commands/models.py @@ -1,17 +1,32 @@ # stdlib imports import re from collections import defaultdict +from collections.abc import Callable from dataclasses import dataclass from datetime import datetime -from typing import Any, Final, Literal +from typing import TYPE_CHECKING, Any, Final, Literal # third party imports import click import rich import yaml +from typing_extensions import NotRequired, ReadOnly, TypedDict # local imports from ... import Client +from ._cli_context import cli_context_values + +if TYPE_CHECKING: + from rich.console import JustifyMethod + + +class _ModelInfoColumnConfig(TypedDict): + """Rendering config for one column of the ``models info`` table.""" + + header: ReadOnly[str] + style: ReadOnly[str] + justify: NotRequired[ReadOnly["JustifyMethod"]] + get_value: ReadOnly[Callable[..., str]] @dataclass @@ -84,7 +99,8 @@ def format_cost_per_1k_tokens(cost: float | None) -> str: def create_client(ctx: click.Context) -> Client: """Helper function to create a client from context.""" - return Client(base_url=ctx.obj["base_url"], api_key=ctx.obj["api_key"]) + context: Final = cli_context_values(ctx) + return Client(base_url=context["base_url"], api_key=context["api_key"]) @click.group() @@ -216,7 +232,7 @@ def get_models_info(ctx: click.Context, output_format: Literal["table", "json"], table: Final = rich.table.Table(title="Models Information") # Define all possible columns with their configurations - column_configs: Final[dict[str, dict[str, Any]]] = { + column_configs: Final[dict[str, _ModelInfoColumnConfig]] = { "public_model": { "header": "Public Model", "style": "cyan", diff --git a/litellm/proxy/client/cli/commands/users.py b/litellm/proxy/client/cli/commands/users.py index 2cfba5ec357..a5ebefd1c4a 100644 --- a/litellm/proxy/client/cli/commands/users.py +++ b/litellm/proxy/client/cli/commands/users.py @@ -4,6 +4,7 @@ import click import rich from ... import UsersManagementClient +from ._cli_context import cli_context_values @click.group() @@ -15,7 +16,8 @@ def users(): @click.pass_context def list_users(ctx: click.Context): """List all users""" - client: Final = UsersManagementClient(base_url=ctx.obj["base_url"], api_key=ctx.obj["api_key"]) + context: Final = cli_context_values(ctx) + client: Final = UsersManagementClient(base_url=context["base_url"], api_key=context["api_key"]) users = client.list_users() if isinstance(users, dict) and "users" in users: users = users["users"] @@ -46,7 +48,8 @@ def list_users(ctx: click.Context): @click.pass_context def get_user(ctx: click.Context, user_id: str): """Get information about a specific user""" - client: Final = UsersManagementClient(base_url=ctx.obj["base_url"], api_key=ctx.obj["api_key"]) + context: Final = cli_context_values(ctx) + client: Final = UsersManagementClient(base_url=context["base_url"], api_key=context["api_key"]) result: Final = client.get_user(user_id=user_id) rich.print_json(data=result) @@ -60,7 +63,8 @@ def get_user(ctx: click.Context, user_id: str): @click.pass_context def create_user(ctx: click.Context, email, role, alias, team, max_budget): """Create a new user""" - client: Final = UsersManagementClient(base_url=ctx.obj["base_url"], api_key=ctx.obj["api_key"]) + context: Final = cli_context_values(ctx) + client: Final = UsersManagementClient(base_url=context["base_url"], api_key=context["api_key"]) user_data: Final = { "user_email": email, "user_role": role, @@ -80,6 +84,7 @@ def create_user(ctx: click.Context, email, role, alias, team, max_budget): @click.pass_context def delete_user(ctx: click.Context, user_ids): """Delete one or more users by user_id""" - client: Final = UsersManagementClient(base_url=ctx.obj["base_url"], api_key=ctx.obj["api_key"]) + context: Final = cli_context_values(ctx) + client: Final = UsersManagementClient(base_url=context["base_url"], api_key=context["api_key"]) result: Final = client.delete_user(list(user_ids)) rich.print_json(data=result) diff --git a/litellm/proxy/client/http_client.py b/litellm/proxy/client/http_client.py index 18344f267b9..aa0b986b1ad 100644 --- a/litellm/proxy/client/http_client.py +++ b/litellm/proxy/client/http_client.py @@ -1,5 +1,6 @@ """HTTP client for making requests to the LiteLLM proxy server.""" +from collections.abc import Mapping from typing import Any, Final import requests @@ -25,8 +26,8 @@ class HTTPClient: method: str, uri: str, *, - data: dict[str, Any] | list | bytes | None = None, - json: dict[str, Any] | list | None = None, + data: Mapping[str, object] | list | bytes | None = None, + json: Mapping[str, object] | list | None = None, headers: dict[str, str] | None = None, **kwargs: Any, ) -> Any: diff --git a/litellm/proxy/client/models.py b/litellm/proxy/client/models.py index 4b16087e15b..10626f95e49 100644 --- a/litellm/proxy/client/models.py +++ b/litellm/proxy/client/models.py @@ -1,4 +1,5 @@ import builtins +from collections.abc import Mapping from typing import Any, Final import requests @@ -68,8 +69,8 @@ class ModelsManagementClient: def new( self, model_name: str, - model_params: dict[str, Any], - model_info: dict[str, Any] | None = None, + model_params: Mapping[str, object], + model_info: Mapping[str, object] | None = None, return_request: bool = False, ) -> dict[str, Any] | requests.Request: """ @@ -245,8 +246,8 @@ class ModelsManagementClient: def update( self, model_id: str, - model_params: dict[str, Any], - model_info: dict[str, Any] | None = None, + model_params: Mapping[str, object], + model_info: Mapping[str, object] | None = None, return_request: bool = False, ) -> dict[str, Any] | requests.Request: """ diff --git a/litellm/proxy/container_endpoints/handler_factory.py b/litellm/proxy/container_endpoints/handler_factory.py index 892ff9771cf..95642bc74bc 100644 --- a/litellm/proxy/container_endpoints/handler_factory.py +++ b/litellm/proxy/container_endpoints/handler_factory.py @@ -207,7 +207,7 @@ async def _process_binary_request( processor: Final = ProxyBaseLLMRequestProcessing(data=data) try: - content: Final = await processor.base_process_llm_request( + content: Final[object] = await processor.base_process_llm_request( request=request, fastapi_response=fastapi_response, user_api_key_dict=user_api_key_dict, @@ -268,7 +268,7 @@ async def _process_multipart_upload_request( user_api_key_dict: UserAPIKeyAuth, route_type: str, container_id: str, -): +) -> object: """Process multipart file upload requests.""" from litellm.proxy.common_utils.http_parsing_utils import ( convert_upload_files_to_file_data, @@ -357,7 +357,7 @@ async def _process_request( user_api_key_dict: UserAPIKeyAuth, route_type: str, path_params: dict[str, str], -): +) -> object: """Common request processing logic.""" from litellm.proxy.proxy_server import ( general_settings, diff --git a/litellm/proxy/guardrails/guardrail_hooks/cato_networks/cato_networks.py b/litellm/proxy/guardrails/guardrail_hooks/cato_networks/cato_networks.py index 958f84e18de..9c635128510 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/cato_networks/cato_networks.py +++ b/litellm/proxy/guardrails/guardrail_hooks/cato_networks/cato_networks.py @@ -299,7 +299,7 @@ class CatoNetworksGuardrail(CustomGuardrail): return data if action_type == "monitor_action": verbose_proxy_logger.info("Cato: monitor action") - elif action_type == "block_action": + elif action_type == "block_action" and required_action is not None: self._handle_block_action(res.get("analysis_result", {}), required_action) elif action_type == "anonymize_action": return self._anonymize_request(res, data) @@ -310,7 +310,7 @@ class CatoNetworksGuardrail(CustomGuardrail): def _handle_block_action( self, analysis_result: _CatoAnalysisResult, - required_action: Any, + required_action: _CatoRequiredAction, ) -> None: detection_message: Final = required_action.get("detection_message", None) verbose_proxy_logger.info( @@ -410,7 +410,7 @@ class CatoNetworksGuardrail(CustomGuardrail): res: Final[_CatoAnalyzeResponse] = response.json() required_action: Final = res.get("required_action") action_type: Final = required_action and required_action.get("action_type", None) - if action_type and action_type == "block_action": + if action_type == "block_action" and required_action is not None: self._handle_block_action_on_output(res.get("analysis_result", {}), required_action) redacted_chat: Final = res.get("redacted_chat", None) @@ -425,7 +425,7 @@ class CatoNetworksGuardrail(CustomGuardrail): def _handle_block_action_on_output( self, analysis_result: _CatoAnalysisResult, - required_action: Any, + required_action: _CatoRequiredAction, ) -> None: detection_message: Final = required_action.get("detection_message", None) verbose_proxy_logger.info( diff --git a/litellm/proxy/guardrails/guardrail_hooks/dynamoai/dynamoai.py b/litellm/proxy/guardrails/guardrail_hooks/dynamoai/dynamoai.py index 694db182fe7..bc419b359c1 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/dynamoai/dynamoai.py +++ b/litellm/proxy/guardrails/guardrail_hooks/dynamoai/dynamoai.py @@ -6,7 +6,7 @@ # +-------------------------------------------------------------+ import os -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, AsyncIterable from datetime import datetime from typing import Any, Final @@ -188,7 +188,7 @@ class DynamoAIGuardrails(CustomGuardrail): applied_policies: Final = response.get("appliedPolicies", []) violations_detected: Final[list[str]] = [] - violation_details: Final[dict[str, Any]] = {} + violation_details: Final[dict[str, object]] = {} # For now, only handle BLOCK action if final_action == "BLOCK": @@ -404,7 +404,7 @@ class DynamoAIGuardrails(CustomGuardrail): # to avoid sending empty content to DynamoAI (e.g., during tool calls) if isinstance(response, litellm.ModelResponse): has_text_content = False - dynamoai_messages: Final[list[dict[str, Any]]] = [] + dynamoai_messages: Final[list[dict[str, str]]] = [] for choice in response.choices: if isinstance(choice, litellm.Choices): @@ -446,7 +446,7 @@ class DynamoAIGuardrails(CustomGuardrail): async def async_post_call_streaming_iterator_hook( self, user_api_key_dict: UserAPIKeyAuth, - response: Any, + response: AsyncIterable[ModelResponseStream], request_data: dict, ) -> AsyncGenerator[ModelResponseStream, None]: """ diff --git a/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py b/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py index 07340e95835..5109f09d9c2 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py +++ b/litellm/proxy/guardrails/guardrail_hooks/singulr/singulr.py @@ -4,6 +4,7 @@ from urllib.parse import urlparse import httpx import pydantic +from typing_extensions import TypedDict, Unpack from litellm._logging import verbose_proxy_logger from litellm.exceptions import GuardrailRaisedException @@ -34,6 +35,10 @@ _GUARD_ENDPOINT: Final = "/api/v1/ai-gateway/litellm" _DEFAULT_TIMEOUT: Final = 30.0 +class _CustomGuardrailOptions(TypedDict, total=False, extra_items=object): + """Base-class constructor options this guardrail forwards untouched to CustomGuardrail.""" + + class SingulrGuardrail(CustomGuardrail): def __init__( self, @@ -43,7 +48,7 @@ class SingulrGuardrail(CustomGuardrail): singulr_guardrail_id: str | None = None, block_on_error: bool | None = None, timeout: float | None = None, - **kwargs: Any, + **kwargs: Unpack[_CustomGuardrailOptions], ) -> None: self.singulr_api_key = singulr_api_key or os.environ.get("SINGULR_API_KEY") self.singulr_api_base = (singulr_api_base or os.environ.get("SINGULR_API_BASE") or _DEFAULT_API_BASE).rstrip( diff --git a/litellm/proxy/guardrails/guardrail_hooks/tool_policy/tool_policy_guardrail.py b/litellm/proxy/guardrails/guardrail_hooks/tool_policy/tool_policy_guardrail.py index cd983801c34..f54e4bc30f7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/tool_policy/tool_policy_guardrail.py +++ b/litellm/proxy/guardrails/guardrail_hooks/tool_policy/tool_policy_guardrail.py @@ -20,9 +20,10 @@ Configuration in proxy config YAML: mode: post_call """ -from typing import TYPE_CHECKING, Any, Final, Literal, Optional +from typing import TYPE_CHECKING, Final, Literal, Optional from fastapi import HTTPException +from typing_extensions import TypedDict, Unpack from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_guardrail import ( @@ -39,6 +40,10 @@ if TYPE_CHECKING: GUARDRAIL_NAME: Final = "tool_policy" +class _CustomGuardrailOptions(TypedDict, total=False, extra_items=object): + """Base-class constructor options this guardrail forwards untouched to CustomGuardrail.""" + + def _get_request_object_permission_ids( request_data: dict, ) -> tuple[str | None, str | None]: @@ -106,7 +111,7 @@ class ToolPolicyGuardrail(CustomGuardrail): ToolPolicyRegistry (synced from DB). """ - def __init__(self, **kwargs: Any) -> None: + def __init__(self, **kwargs: Unpack[_CustomGuardrailOptions]) -> None: if "supported_event_hooks" not in kwargs: kwargs["supported_event_hooks"] = [ GuardrailEventHooks.pre_call, diff --git a/litellm/proxy/guardrails/usage_endpoints.py b/litellm/proxy/guardrails/usage_endpoints.py index 7a0edbddca8..9490eda9d47 100644 --- a/litellm/proxy/guardrails/usage_endpoints.py +++ b/litellm/proxy/guardrails/usage_endpoints.py @@ -355,6 +355,11 @@ def _to_dict(value: object) -> dict[str, Any]: return {} +def _field_str(mapping: Mapping[str, object], key: str, default: str) -> str: + """Stringify `mapping[key]`, falling back to `default` when the key is absent.""" + return str(mapping.get(key, default)) + + def _get_guardrail_attrs(g: "_DbOrConfigGuardrail") -> tuple[Any, str]: """Get (guardrail_id, display_name) from guardrail - handles Prisma model or dict.""" gid: Final = _get_guardrail_field(g, "guardrail_id") @@ -383,9 +388,9 @@ def _guardrail_overview_rows( req, blocked = a["requests"], a["blocked"] fail_rate = (100.0 * blocked / req) if req else 0.0 litellm_params = _to_dict(_get_guardrail_field(g, "litellm_params")) - provider = str(litellm_params.get("guardrail", "Unknown")) + provider = _field_str(litellm_params, "guardrail", "Unknown") guardrail_info = _to_dict(_get_guardrail_field(g, "guardrail_info")) - gtype = str(guardrail_info.get("type", "Guardrail")) + gtype = _field_str(guardrail_info, "type", "Guardrail") prev_fail = 0.0 for k in lookup_keys: if k in prev_agg: @@ -624,8 +629,8 @@ async def guardrails_usage_detail( return UsageDetailResponse( guardrail_id=guardrail_id, guardrail_name=_guardrail_name or guardrail_id, - type=str(guardrail_info.get("type", "Guardrail")), - provider=str(litellm_params.get("guardrail", "Unknown")), + type=_field_str(guardrail_info, "type", "Guardrail"), + provider=_field_str(litellm_params, "guardrail", "Unknown"), requestsEvaluated=requests, failRate=round(fail_rate, 1), avgScore=None, diff --git a/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py b/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py index 9d5ddda017a..4fcc798f93c 100644 --- a/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py +++ b/litellm/proxy/management_endpoints/usage_endpoints/ai_usage_chat.py @@ -6,7 +6,7 @@ usage/spend data by querying the aggregated daily activity endpoints. import json from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence from datetime import date -from typing import Any, Final, Literal, Protocol, cast, overload +from typing import Any, Final, Literal, NamedTuple, Protocol, cast, overload from typing_extensions import ReadOnly, TypedDict @@ -82,6 +82,15 @@ class _DayDump(TypedDict, total=False): breakdown: ReadOnly[Mapping[str, Mapping[str, _EntityEntry]]] +class _EntityTotal(NamedTuple): + """Running per-entity totals accumulated while summarising a usage dump.""" + + alias: str + spend: float + requests: float + tokens: float + + class _UsageDump(Protocol): @overload def get(self, key: Literal["metadata"], default: Mapping[str, float], /) -> Mapping[str, float]: ... @@ -241,7 +250,7 @@ def _parse_csv_ids(raw: str | None) -> list[str] | None: async def _query_activity( table_name: str, entity_id_field: str, - entity_id: Any | None, + entity_id: str | list[str] | None, start_date: str, end_date: str, *, @@ -382,23 +391,22 @@ def _summarise_entity_data(data: _UsageDump, entity_label: str) -> str: if not results: return f"No {entity_label} usage data found for the given date range." - totals: Final[dict[str, dict[str, Any]]] = {} + totals: Final[dict[str, _EntityTotal]] = {} for day in results: for eid, entry in day.get("breakdown", {}).get("entities", {}).items(): - if eid not in totals: - alias = entry.get("metadata", {}).get("alias", eid) - totals[eid] = {"alias": alias, "spend": 0.0, "requests": 0, "tokens": 0} + previous = totals.get(eid) m = entry.get("metrics", {}) - totals[eid]["spend"] += m.get("spend", 0) - totals[eid]["requests"] += m.get("api_requests", 0) - totals[eid]["tokens"] += m.get("total_tokens", 0) + totals[eid] = _EntityTotal( + alias=previous.alias if previous is not None else entry.get("metadata", {}).get("alias", eid), + spend=(previous.spend if previous is not None else 0.0) + m.get("spend", 0), + requests=(previous.requests if previous is not None else 0) + m.get("api_requests", 0), + tokens=(previous.tokens if previous is not None else 0) + m.get("total_tokens", 0), + ) lines: Final = [f"{entity_label} Usage ({len(totals)} {entity_label.lower()}s):", ""] - for eid, d in sorted(totals.items(), key=lambda x: -x[1]["spend"]): - label = d["alias"] if d["alias"] != eid else eid - lines.append( - f"- {label} (ID: {eid}): ${d['spend']:.4f} | {int(d['requests'])} reqs | {int(d['tokens'])} tokens" - ) + for eid, d in sorted(totals.items(), key=lambda x: -x[1].spend): + label = d.alias if d.alias != eid else eid + lines.append(f"- {label} (ID: {eid}): ${d.spend:.4f} | {int(d.requests)} reqs | {int(d.tokens)} tokens") return "\n".join(lines) diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index aa229270800..f6c872d3b92 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -42,6 +42,8 @@ from ..llms.xai.realtime.handler import XAIRealtime from ..utils import client as wrapper_client if TYPE_CHECKING: + from fastapi import WebSocket + from litellm.llms.base_llm.realtime.http_transformation import BaseRealtimeHTTPConfig azure_realtime: Final = AzureOpenAIRealtime() @@ -332,12 +334,12 @@ async def _resolve_vertex_access_token_bounded( @wrapper_client async def _arealtime( model: str, - websocket: Any, # fastapi websocket + websocket: "WebSocket", # fastapi websocket api_base: str | None = None, api_key: str | None = None, api_version: str | None = None, azure_ad_token: str | None = None, - client: Any | None = None, + client: object | None = None, timeout: float | None = None, query_params: RealtimeQueryParams | None = None, **kwargs, @@ -574,7 +576,7 @@ _TRANSCRIPTION_QUERY_PARAMS: Final[RealtimeQueryParams] = {"intent": "transcript def _azure_realtime_health_protocol( - model: str, realtime_protocol: str | None, model_params: Mapping[str, Any] + model: str, realtime_protocol: str | None, model_params: Mapping[str, object] ) -> tuple[str, RealtimeQueryParams | None]: query_params: Final = _TRANSCRIPTION_QUERY_PARAMS if _is_transcription_only_realtime_model(model, "azure") else None configured_raw: Final = ( diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 39675faf735..3ca7b0503bf 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -1,7 +1,7 @@ import base64 import re from collections.abc import Iterable, Mapping, Sequence -from typing import Any, Final, Optional, Union, cast, get_type_hints, overload +from typing import Any, Final, Optional, TypeVar, 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 @@ -59,6 +59,9 @@ def _as_input_text_part(part: object) -> object: return part +_RequestInputT: Final = TypeVar("_RequestInputT") + + class ResponsesAPIRequestUtils: """Helper utils for constructing ResponseAPI requests""" @@ -502,7 +505,7 @@ class ResponsesAPIRequestUtils: return response @staticmethod - def _restore_encrypted_content_item_ids_in_input(request_input: object) -> Any: + def _restore_encrypted_content_item_ids_in_input(request_input: _RequestInputT) -> _RequestInputT: """Decode litellm-encoded item IDs in request input back to original IDs. Called before forwarding the request to the upstream provider so the diff --git a/litellm/router_strategy/adaptive_router/signals.py b/litellm/router_strategy/adaptive_router/signals.py index 310a7717b38..7b69714aad9 100644 --- a/litellm/router_strategy/adaptive_router/signals.py +++ b/litellm/router_strategy/adaptive_router/signals.py @@ -13,6 +13,7 @@ bounded list of recent tool call signatures. from __future__ import annotations import re +from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from typing import Any, Final @@ -92,7 +93,7 @@ class Turn: user_content: str | None = None assistant_content: str | None = None tool_calls: list[dict[str, Any]] = field(default_factory=list) - tool_results: list[dict[str, Any]] = field(default_factory=list) + tool_results: Sequence[Mapping[str, object]] = field(default_factory=list[Mapping[str, object]]) response_status: int | None = None @@ -104,7 +105,7 @@ _TOKEN_RE: Final = re.compile(r"[A-Za-z0-9]+") def _tokens(text: str | None) -> set[str]: if not text: return set() - return {t.lower() for t in _TOKEN_RE.findall(text)} + return {match.group(0).lower() for match in _TOKEN_RE.finditer(text)} def _jaccard(a: set[str], b: set[str]) -> float: @@ -160,7 +161,7 @@ def _detect_satisfaction(curr_user: str | None) -> bool: return any(p.search(curr_user) for p in _SATISFACTION_PATTERNS) -def _detect_failure(tool_results: list[dict[str, Any]]) -> bool: +def _detect_failure(tool_results: Sequence[Mapping[str, object]]) -> bool: """Any tool result explicitly flagged as an error. We do NOT treat empty content as failure — many tools legitimately return @@ -209,7 +210,7 @@ _EXHAUSTION_KEYWORDS: Final = ( ) -def _detect_exhaustion(status: int | None, tool_results: list[dict[str, Any]]) -> bool: +def _detect_exhaustion(status: int | None, tool_results: Sequence[Mapping[str, object]]) -> bool: if status is not None and status in _EXHAUSTION_STATUSES: return True for r in tool_results: @@ -222,7 +223,7 @@ def _detect_exhaustion(status: int | None, tool_results: list[dict[str, Any]]) - def detect_user_feedback( previous_user_content: str | None, current_user_content: str | None, - tool_results: list[dict[str, Any]], + tool_results: Sequence[Mapping[str, object]], allow_satisfaction: bool, ) -> SignalDelta: return SignalDelta( @@ -238,7 +239,7 @@ def detect_response_signals( current_assistant_content: str | None, tool_call_history: list[str], tool_calls: list[dict[str, Any]], - tool_results: list[dict[str, Any]], + tool_results: Sequence[Mapping[str, object]], response_status: int | None, ) -> SignalDelta: return SignalDelta( diff --git a/litellm/router_utils/cooldown_handlers.py b/litellm/router_utils/cooldown_handlers.py index 86d9bb5c3ed..4534fa114b3 100644 --- a/litellm/router_utils/cooldown_handlers.py +++ b/litellm/router_utils/cooldown_handlers.py @@ -259,7 +259,7 @@ def _should_run_cooldown_logic( litellm_router_instance: LitellmRouter, deployment: str | None, exception_status: str | int, - original_exception: Any, + original_exception: Exception, time_to_cooldown: float | None = None, ) -> bool: """ @@ -318,7 +318,7 @@ def _should_cooldown_deployment( litellm_router_instance: LitellmRouter, deployment: str, exception_status: str | int, - original_exception: Any, + original_exception: Exception, requested_model_group: str | None = None, ) -> bool: """ @@ -412,7 +412,7 @@ def _should_cooldown_deployment( def _set_cooldown_deployments( litellm_router_instance: LitellmRouter, - original_exception: Any, + original_exception: Exception, exception_status: str | int, deployment: str | None = None, time_to_cooldown: float | None = None, @@ -547,7 +547,7 @@ def _get_cooldown_deployments(litellm_router_instance: LitellmRouter, parent_ote def should_cooldown_based_on_allowed_fails_policy( litellm_router_instance: LitellmRouter, deployment: str, - original_exception: Any, + original_exception: Exception, allowed_fails_override: int | None = None, cooldown_time_override: float | None = None, cache_key_suffix: str | None = None, diff --git a/litellm/skills/main.py b/litellm/skills/main.py index 9d2ed524ce5..002419dbad4 100644 --- a/litellm/skills/main.py +++ b/litellm/skills/main.py @@ -72,7 +72,7 @@ def _get_litellm_skills_handler(): async def acreate_skill( files: list[Any] | None = None, display_title: str | None = None, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_query: Mapping[str, object] | None = None, extra_body: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, @@ -135,7 +135,7 @@ async def acreate_skill( def create_skill( files: list[Any] | None = None, display_title: str | None = None, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_query: Mapping[str, object] | None = None, extra_body: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, @@ -262,7 +262,7 @@ async def alist_skills( limit: int | None = None, page: str | None = None, source: str | None = None, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_query: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -325,7 +325,7 @@ def list_skills( limit: int | None = None, page: str | None = None, source: str | None = None, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_query: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -443,7 +443,7 @@ def list_skills( @client async def aget_skill( skill_id: str, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_query: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -500,7 +500,7 @@ async def aget_skill( @client def get_skill( skill_id: str, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_query: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -607,7 +607,7 @@ def get_skill( @client async def adelete_skill( skill_id: str, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_query: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -664,7 +664,7 @@ async def adelete_skill( @client def delete_skill( skill_id: str, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_query: Mapping[str, object] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, diff --git a/litellm/vector_store_files/main.py b/litellm/vector_store_files/main.py index 5bc3c8f1525..3b6f1de3c7a 100644 --- a/litellm/vector_store_files/main.py +++ b/litellm/vector_store_files/main.py @@ -2,7 +2,7 @@ import asyncio import contextvars -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from functools import partial from typing import Any, Final @@ -57,9 +57,9 @@ async def acreate( vector_store_id: str, file_id: str, attributes: VectorStoreFileAttributes | None = None, - chunking_strategy: dict[str, Any] | None = None, - extra_headers: dict[str, Any] | None = None, - extra_query: dict[str, Any] | None = None, + chunking_strategy: Mapping[str, object] | None = None, + extra_headers: dict[str, str] | None = None, + extra_query: Mapping[str, object] | None = None, extra_body: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -109,9 +109,9 @@ def create( vector_store_id: str, file_id: str, attributes: VectorStoreFileAttributes | None = None, - chunking_strategy: dict[str, Any] | None = None, - extra_headers: dict[str, Any] | None = None, - extra_query: dict[str, Any] | None = None, + chunking_strategy: Mapping[str, object] | None = None, + extra_headers: dict[str, str] | None = None, + extra_query: Mapping[str, object] | None = None, extra_body: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -187,7 +187,7 @@ async def alist( filter: str | None = None, limit: int | None = None, order: str | None = None, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_query: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -240,7 +240,7 @@ def list( filter: str | None = None, limit: int | None = None, order: str | None = None, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_query: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -308,7 +308,7 @@ async def aretrieve( *, vector_store_id: str, file_id: str, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, @@ -351,7 +351,7 @@ def retrieve( *, vector_store_id: str, file_id: str, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, @@ -417,7 +417,7 @@ async def aretrieve_content( *, vector_store_id: str, file_id: str, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, @@ -459,7 +459,7 @@ def retrieve_content( *, vector_store_id: str, file_id: str, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, @@ -526,7 +526,7 @@ async def aupdate( vector_store_id: str, file_id: str, attributes: VectorStoreFileAttributes, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_body: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -572,7 +572,7 @@ def update( vector_store_id: str, file_id: str, attributes: VectorStoreFileAttributes, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, extra_body: dict[str, Any] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, @@ -646,7 +646,7 @@ async def adelete( *, vector_store_id: str, file_id: str, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, @@ -688,7 +688,7 @@ def delete( *, vector_store_id: str, file_id: str, - extra_headers: dict[str, Any] | None = None, + extra_headers: dict[str, str] | None = None, timeout: float | httpx.Timeout | None = None, custom_llm_provider: str | None = None, **kwargs, From a616b8aaed87fcde79b0be3c4ab7776b35b5e42e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 09:02:36 -0700 Subject: [PATCH 022/410] feat(vector_stores): add MongoDB Atlas vector store provider Atlas Vector Search has no HTTP query API, since the Data API and HTTPS Endpoints are end-of-life, so this provider extends BaseDirectVectorStoreConfig and runs the $vectorSearch aggregation through pymongo rather than shaping an httpx request. That is the same seam Valkey uses for RESP. vector_store_id names the Atlas Search index, matching Valkey, with the database and collection supplied through litellm_params. pymongo lives in a new optional `mongodb` extra and is imported lazily, so the base install still pulls no MongoDB driver. The floor is 4.17 because that is where dnspython became a core dependency instead of the `srv` extra, and Atlas issues mongodb+srv:// URIs that will not resolve without it. Clients are cached per connection rather than opened per search. Measured against Atlas, a fresh client costs ~890ms versus ~80ms warm, so copying the Valkey open-and-close-per-call pattern would have added ~810ms to every query. --- litellm/llms/mongodb/__init__.py | 0 litellm/llms/mongodb/common_utils.py | 163 +++++++++ .../llms/mongodb/vector_stores/__init__.py | 0 .../mongodb/vector_stores/transformation.py | 337 ++++++++++++++++++ litellm/types/utils.py | 1 + litellm/utils.py | 6 + pyproject.toml | 6 + uv.lock | 79 +++- 8 files changed, 590 insertions(+), 2 deletions(-) create mode 100644 litellm/llms/mongodb/__init__.py create mode 100644 litellm/llms/mongodb/common_utils.py create mode 100644 litellm/llms/mongodb/vector_stores/__init__.py create mode 100644 litellm/llms/mongodb/vector_stores/transformation.py diff --git a/litellm/llms/mongodb/__init__.py b/litellm/llms/mongodb/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py new file mode 100644 index 00000000000..7f26eadb08f --- /dev/null +++ b/litellm/llms/mongodb/common_utils.py @@ -0,0 +1,163 @@ +"""Shared helpers for MongoDB Atlas integrations. + +pymongo ships in the optional ``mongodb`` extra, so every import of it is +deferred to call time and raises an actionable error when it is absent. + +Clients are cached per connection because building one costs an SRV lookup, a +TLS handshake and topology discovery: measured at ~890ms against Atlas versus +~80ms on a warm client, so a client per search would dominate query latency. +""" + +import asyncio +from dataclasses import dataclass +from typing import TYPE_CHECKING, Final + +if TYPE_CHECKING: + from pymongo import AsyncMongoClient, MongoClient + +PYMONGO_INSTALL_HINT: Final = ( + "The MongoDB vector store requires the 'pymongo' package. " + "Run 'pip install litellm[mongodb]' (or 'pip install pymongo') to install it." +) + +DEFAULT_CONNECT_TIMEOUT_MS: Final = 10_000 +DEFAULT_SOCKET_TIMEOUT_MS: Final = 30_000 +DEFAULT_SERVER_SELECTION_TIMEOUT_MS: Final = 10_000 + +_MAX_CACHED_CLIENTS: Final = 32 + +_APP_NAME: Final = "litellm" + + +@dataclass(frozen=True, slots=True) +class MongoClientKey: + connection_string: str + connect_timeout_ms: int + socket_timeout_ms: int + server_selection_timeout_ms: int + + +_sync_clients: dict[MongoClientKey, "MongoClient"] = {} # mutable-ok: process-level connection cache, see module docstring +_async_clients: dict[tuple[MongoClientKey, int], "AsyncMongoClient"] = {} # mutable-ok: same cache, keyed per event loop + + +def import_sync_mongo_client() -> "type[MongoClient]": + try: + from pymongo import MongoClient as SyncMongoClient + except ImportError as e: + raise ValueError(PYMONGO_INSTALL_HINT) from e + return SyncMongoClient + + +def import_async_mongo_client() -> "type[AsyncMongoClient]": + try: + from pymongo import AsyncMongoClient as AsyncMongoClientClass + except ImportError as e: + raise ValueError(PYMONGO_INSTALL_HINT) from e + return AsyncMongoClientClass + + +def _client_kwargs(key: MongoClientKey) -> dict[str, object]: + return { # mutable-ok: pymongo's client constructor takes keyword arguments + "connectTimeoutMS": key.connect_timeout_ms, + "socketTimeoutMS": key.socket_timeout_ms, + "serverSelectionTimeoutMS": key.server_selection_timeout_ms, + "appname": _APP_NAME, + } + + +def get_sync_client(key: MongoClientKey) -> "MongoClient": + cached: Final = _sync_clients.get(key) + if cached is not None: + return cached + client: Final = import_sync_mongo_client()(key.connection_string, **_client_kwargs(key)) + if len(_sync_clients) < _MAX_CACHED_CLIENTS: + _sync_clients[key] = client + return client + + +def get_async_client(key: MongoClientKey) -> "AsyncMongoClient": + """Async clients bind to the loop that created them, so the cache is keyed per loop.""" + loop_key: Final = (key, id(asyncio.get_running_loop())) + cached: Final = _async_clients.get(loop_key) + if cached is not None: + return cached + client: Final = import_async_mongo_client()(key.connection_string, **_client_kwargs(key)) + if len(_async_clients) < _MAX_CACHED_CLIENTS: + _async_clients[loop_key] = client + return client + + +def reset_client_cache() -> None: + _sync_clients.clear() + _async_clients.clear() + + +_AUTHENTICATION_FAILED_CODE: Final = 18 +_UNAUTHORIZED_CODE: Final = 13 + + +def _index_hint(index_name: str, database: str, collection: str) -> str: + return ( + f"No queryable Atlas Vector Search index named '{index_name}' was found on " + f"'{database}.{collection}'. Confirm the index exists on that exact collection, that its " + "status is READY rather than still building, and that the vector store id matches the index name." + ) + + +def translate_mongo_error(error: Exception, index_name: str, database: str, collection: str) -> Exception: + """Turn a driver failure into a message that names the misconfiguration, never a silent empty result. + + Returns the exception to raise so callers keep the original as ``__cause__``. + """ + try: + from pymongo.errors import ( + ConfigurationError, + ExecutionTimeout, + InvalidOperation, + NetworkTimeout, + OperationFailure, + ServerSelectionTimeoutError, + ) + except ImportError: + return error + + if isinstance(error, ServerSelectionTimeoutError): + return ValueError( + "Could not reach the MongoDB deployment before the timeout. On Atlas this is usually the " + "project's IP access list not containing this host, or a paused cluster; it can also be an " + f"unresolvable hostname. Driver detail: {error}" + ) + if isinstance(error, OperationFailure): + code: Final = error.code + if code in (_AUTHENTICATION_FAILED_CODE, _UNAUTHORIZED_CODE): + return ValueError( + "MongoDB rejected the credentials in mongodb_connection_string, or the database user " + f"lacks read access to '{database}.{collection}'. Driver detail: {error.details}" + ) + detail: Final = str(error).lower() + if "index" in detail and ("not found" in detail or "does not exist" in detail or "unknown" in detail): + return ValueError(f"{_index_hint(index_name, database, collection)} Driver detail: {error}") + if "dimension" in detail or "numdimensions" in detail or "queryvector" in detail: + return ValueError( + "The query embedding does not match the vector dimensions the Atlas index was built for. " + "litellm_embedding_model must be the same model that produced the stored vectors. " + f"Driver detail: {error}" + ) + return ValueError( + f"MongoDB rejected the vector search against '{database}.{collection}' using index " + f"'{index_name}'. Driver detail: {error}" + ) + if isinstance(error, (NetworkTimeout, ExecutionTimeout)): + return ValueError( + f"The MongoDB vector search against '{database}.{collection}' timed out before returning. " + f"Driver detail: {error}" + ) + if isinstance(error, ConfigurationError): + return ValueError( + "mongodb_connection_string is not a usable MongoDB connection string. " + f"Driver detail: {error}" + ) + if isinstance(error, InvalidOperation): + return ValueError(f"The MongoDB client was already closed or is unusable. Driver detail: {error}") + return error diff --git a/litellm/llms/mongodb/vector_stores/__init__.py b/litellm/llms/mongodb/vector_stores/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py new file mode 100644 index 00000000000..efeff16ae5a --- /dev/null +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -0,0 +1,337 @@ +"""MongoDB Atlas vector store provider. + +Atlas Vector Search has no HTTP query API (the Data API and HTTPS Endpoints are +end-of-life), so this config extends BaseDirectVectorStoreConfig and runs the +``$vectorSearch`` aggregation itself through pymongo instead of shaping an httpx +request. + +``vector_store_id`` is the Atlas Search index name, matching the Valkey provider +where the id names the index; the database and collection it covers come from +litellm_params. +""" + +from collections.abc import Awaitable, Callable, Mapping, Sequence +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, NoReturn + +import httpx +from pydantic import BaseModel, ConfigDict + +import litellm +from litellm.llms.base_llm.vector_store.transformation import BaseDirectVectorStoreConfig +from litellm.llms.mongodb.common_utils import ( + DEFAULT_CONNECT_TIMEOUT_MS, + DEFAULT_SERVER_SELECTION_TIMEOUT_MS, + DEFAULT_SOCKET_TIMEOUT_MS, + MongoClientKey, + get_async_client, + get_sync_client, + translate_mongo_error, +) +from litellm.types.utils import EmbeddingResponse +from litellm.types.vector_stores import ( + VectorStoreCreateOptionalRequestParams, + VectorStoreResultContent, + VectorStoreSearchOptionalRequestParams, + VectorStoreSearchResponse, + VectorStoreSearchResult, +) + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + +DEFAULT_EMBEDDING_FIELD_NAME: Final = "embedding" +DEFAULT_TEXT_FIELD_NAME: Final = "text" +SCORE_FIELD_NAME: Final = "score" + +DEFAULT_MAX_NUM_RESULTS: Final = 10 +MIN_MAX_NUM_RESULTS: Final = 1 +MAX_MAX_NUM_RESULTS: Final = 50 + +NUM_CANDIDATES_MULTIPLIER: Final = 10 +MIN_NUM_CANDIDATES: Final = 100 +MAX_NUM_CANDIDATES: Final = 10_000 + +MAX_QUERY_CHARACTERS: Final = 32_000 + +_EMPTY_EMBEDDING_CONFIG: Final = MappingProxyType({}) + +_SEARCH_ONLY_MESSAGE: Final = ( + "MongoDB vector store is search-only. Create the collection and its Atlas Vector Search " + "index in MongoDB directly, then register it here by index name." +) + + +class _MongoDBSearchParams(BaseModel): + """Typed view over the vector store's litellm_params; unrelated keys are ignored.""" + + model_config = ConfigDict(frozen=True, extra="ignore") + + litellm_embedding_model: str | None = None + litellm_embedding_config: Mapping[str, object] | None = None + mongodb_connection_string: str | None = None + mongodb_database: str | None = None + mongodb_collection: str | None = None + mongodb_text_field: str | None = None + mongodb_embedding_field: str | None = None + mongodb_num_candidates: int | None = None + + @property + def text_field(self) -> str: + return self.mongodb_text_field or DEFAULT_TEXT_FIELD_NAME + + @property + def embedding_field(self) -> str: + return self.mongodb_embedding_field or DEFAULT_EMBEDDING_FIELD_NAME + + def require_embedding_model(self) -> str: + if not self.litellm_embedding_model: + raise ValueError( + "litellm_embedding_model is required in litellm_params for the MongoDB vector store. " + "It must be the same model that produced the vectors stored in " + f"'{self.mongodb_collection or ''}.{self.embedding_field}', or search results " + "will be meaningless. Example: litellm_embedding_model: openai/text-embedding-3-small" + ) + return self.litellm_embedding_model + + def require_connection_string(self) -> str: + if not self.mongodb_connection_string: + raise ValueError( + "mongodb_connection_string is required in litellm_params for the MongoDB vector store. " + "Example: mongodb+srv://:@.mongodb.net" + ) + scheme: Final = self.mongodb_connection_string.split("://", 1)[0].lower() + if scheme not in ("mongodb", "mongodb+srv"): + raise ValueError( + "mongodb_connection_string must start with 'mongodb://' or 'mongodb+srv://', " + f"got '{self.mongodb_connection_string.split('://', 1)[0]}://'" + ) + return self.mongodb_connection_string + + def require_database(self) -> str: + if not self.mongodb_database: + raise ValueError( + "mongodb_database is required in litellm_params for the MongoDB vector store. " + "Example: mongodb_database: sample_mflix" + ) + return self.mongodb_database + + def require_collection(self) -> str: + if not self.mongodb_collection: + raise ValueError( + "mongodb_collection is required in litellm_params for the MongoDB vector store. " + "Example: mongodb_collection: embedded_movies" + ) + return self.mongodb_collection + + +class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): + def __init__( + self, + embedding_fn: Callable[..., EmbeddingResponse] | None = None, + aembedding_fn: Callable[..., Awaitable[EmbeddingResponse]] | None = None, + sync_client_factory: Callable[[MongoClientKey], object] | None = None, + async_client_factory: Callable[[MongoClientKey], object] | None = None, + ) -> None: + super().__init__() + self.embedding_fn = embedding_fn if embedding_fn is not None else litellm.embedding + self.aembedding_fn = aembedding_fn if aembedding_fn is not None else litellm.aembedding + self.sync_client_factory = sync_client_factory if sync_client_factory is not None else get_sync_client + self.async_client_factory = async_client_factory if async_client_factory is not None else get_async_client + + @staticmethod + def _query_text(query: str | Sequence[str]) -> str: + text: Final = query if isinstance(query, str) else " ".join(query) + if not text.strip(): + raise ValueError("query must not be empty") + if len(text) > MAX_QUERY_CHARACTERS: + raise ValueError(f"query must be at most {MAX_QUERY_CHARACTERS} characters, got {len(text)}") + return text + + @staticmethod + def _limit(vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams) -> int: + requested: Final = vector_store_search_optional_params.get("max_num_results") + if requested is None: + return DEFAULT_MAX_NUM_RESULTS + if not MIN_MAX_NUM_RESULTS <= requested <= MAX_MAX_NUM_RESULTS: + raise ValueError( + f"max_num_results must be between {MIN_MAX_NUM_RESULTS} and {MAX_MAX_NUM_RESULTS}, got {requested}" + ) + return requested + + @staticmethod + def _num_candidates(limit: int, configured: int | None) -> int: + if configured is not None: + if not limit <= configured <= MAX_NUM_CANDIDATES: + raise ValueError( + f"mongodb_num_candidates must be between max_num_results ({limit}) and " + f"{MAX_NUM_CANDIDATES}, got {configured}" + ) + return configured + return min(max(limit * NUM_CANDIDATES_MULTIPLIER, MIN_NUM_CANDIDATES), MAX_NUM_CANDIDATES) + + @staticmethod + def _client_key(params: _MongoDBSearchParams, timeout: float | httpx.Timeout | None) -> MongoClientKey: + if isinstance(timeout, httpx.Timeout): + connect_ms: Final = int((timeout.connect or DEFAULT_CONNECT_TIMEOUT_MS / 1000) * 1000) + socket_ms: Final = int((timeout.read or DEFAULT_SOCKET_TIMEOUT_MS / 1000) * 1000) + elif timeout is not None: + connect_ms = min(int(float(timeout) * 1000), DEFAULT_CONNECT_TIMEOUT_MS) + socket_ms = int(float(timeout) * 1000) + else: + connect_ms = DEFAULT_CONNECT_TIMEOUT_MS + socket_ms = DEFAULT_SOCKET_TIMEOUT_MS + return MongoClientKey( + connection_string=params.require_connection_string(), + connect_timeout_ms=connect_ms, + socket_timeout_ms=socket_ms, + server_selection_timeout_ms=min(connect_ms, DEFAULT_SERVER_SELECTION_TIMEOUT_MS), + ) + + @classmethod + def _pipeline( + cls, + vector_store_id: str, + query_vector: Sequence[float], + params: _MongoDBSearchParams, + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + ) -> list[dict[str, object]]: + if vector_store_search_optional_params.get("filters") is not None: + raise ValueError( + "MongoDB vector store does not support the filters parameter yet. " + "Restrict the collection or the Atlas Vector Search index definition instead." + ) + limit: Final = cls._limit(vector_store_search_optional_params) + return [ # mutable-ok: pymongo's aggregate contract is a list of stage dicts + { + "$vectorSearch": { + "index": vector_store_id, + "path": params.embedding_field, + "queryVector": list(query_vector), + "numCandidates": cls._num_candidates(limit, params.mongodb_num_candidates), + "limit": limit, + } + }, + {"$project": {params.text_field: 1, SCORE_FIELD_NAME: {"$meta": "vectorSearchScore"}}}, + ] + + @staticmethod + def _field_value(document: Mapping[str, object], dotted_path: str) -> str: + current: object = document + for segment in dotted_path.split("."): + if not isinstance(current, Mapping): + return "" + current = current.get(segment) + return "" if current is None else str(current) + + @classmethod + def _to_result(cls, document: Mapping[str, object], text_field: str) -> VectorStoreSearchResult: + document_id: Final = document.get("_id") + identifier: Final = None if document_id is None else str(document_id) + content: Final = [ # mutable-ok: VectorStoreSearchResult declares a list of content parts + VectorStoreResultContent(text=cls._field_value(document, text_field), type="text") + ] + raw_score: Final = document.get(SCORE_FIELD_NAME) + return VectorStoreSearchResult( + score=float(raw_score) if isinstance(raw_score, (int, float)) else None, + content=content, + file_id=identifier, + filename=identifier, + ) + + @classmethod + def _to_response( + cls, documents: Sequence[Mapping[str, object]], query_text: str, text_field: str + ) -> VectorStoreSearchResponse: + return VectorStoreSearchResponse( + object="vector_store.search_results.page", + search_query=query_text, + data=[cls._to_result(document, text_field) for document in documents], + ) + + @staticmethod + def _embedding_vector(embedding_response: EmbeddingResponse) -> Sequence[float]: + data: Final = embedding_response.data + if not data: + raise ValueError( + "The embedding model returned no embedding for the search query, so there is nothing " + "to search MongoDB with. Check the embedding deployment named by litellm_embedding_model." + ) + return data[0]["embedding"] + + def execute_search_vector_store_request( + self, + vector_store_id: str, + query: str | Sequence[str], + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + litellm_logging_obj: "LiteLLMLoggingObj", + litellm_params: Mapping[str, object], + timeout: float | httpx.Timeout | None = None, + ) -> VectorStoreSearchResponse: + params: Final = _MongoDBSearchParams.model_validate(litellm_params) + query_text: Final = self._query_text(query) + key: Final = self._client_key(params, timeout) + database: Final = params.require_database() + collection: Final = params.require_collection() + + embedding_response: Final = self.embedding_fn( + model=params.require_embedding_model(), + input=[query_text], # mutable-ok: litellm.embedding's input contract is a list + **(params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG), + ) + pipeline: Final = self._pipeline( + vector_store_id, self._embedding_vector(embedding_response), params, vector_store_search_optional_params + ) + + client: Final = self.sync_client_factory(key) + try: + documents: Final = list(client[database][collection].aggregate(pipeline)) # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted + except Exception as e: + raise translate_mongo_error( + e, index_name=vector_store_id, database=database, collection=collection + ) from e + return self._to_response(documents, query_text, params.text_field) + + async def aexecute_search_vector_store_request( + self, + vector_store_id: str, + query: str | Sequence[str], + vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, + litellm_logging_obj: "LiteLLMLoggingObj", + litellm_params: Mapping[str, object], + timeout: float | httpx.Timeout | None = None, + ) -> VectorStoreSearchResponse: + params: Final = _MongoDBSearchParams.model_validate(litellm_params) + query_text: Final = self._query_text(query) + key: Final = self._client_key(params, timeout) + database: Final = params.require_database() + collection: Final = params.require_collection() + + embedding_response: Final = await self.aembedding_fn( + model=params.require_embedding_model(), + input=[query_text], # mutable-ok: litellm.embedding's input contract is a list + **(params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG), + ) + pipeline: Final = self._pipeline( + vector_store_id, self._embedding_vector(embedding_response), params, vector_store_search_optional_params + ) + + client: Final = self.async_client_factory(key) + try: + cursor: Final = await client[database][collection].aggregate(pipeline) # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted + documents: Final = [document async for document in cursor] + except Exception as e: + raise translate_mongo_error( + e, index_name=vector_store_id, database=database, collection=collection + ) from e + return self._to_response(documents, query_text, params.text_field) + + def transform_create_vector_store_request( + self, + vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams, + api_base: str, + ) -> NoReturn: + raise NotImplementedError(_SEARCH_ONLY_MESSAGE) + + def transform_create_vector_store_response(self, response: httpx.Response) -> NoReturn: + raise NotImplementedError(_SEARCH_ONLY_MESSAGE) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 5783a39b30c..fd98bbf4896 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3867,6 +3867,7 @@ class LlmProviders(str, Enum): PG_VECTOR = "pg_vector" S3_VECTORS = "s3_vectors" VALKEY = "valkey" + MONGODB = "mongodb" HELICONE = "helicone" HYPERBOLIC = "hyperbolic" RECRAFT = "recraft" diff --git a/litellm/utils.py b/litellm/utils.py index 252b6756937..2dbfe096a59 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9022,6 +9022,12 @@ class ProviderConfigManager: ) return ValkeyVectorStoreConfig() + elif litellm.LlmProviders.MONGODB == provider: + from litellm.llms.mongodb.vector_stores.transformation import ( + MongoDBVectorStoreConfig, + ) + + return MongoDBVectorStoreConfig() return None @staticmethod diff --git a/pyproject.toml b/pyproject.toml index 2866e27e84c..65d8886bc26 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,6 +112,12 @@ utils = [ ] caching = ["diskcache>=5.6.3,<6.0"] mcp = ["mcp>=1.28.1,<2.0"] +# Driver for the MongoDB Atlas vector store. Atlas Vector Search has no HTTP query +# API, so that provider talks to the cluster over the wire protocol. Imported lazily +# and kept out of the base install, which never needs a MongoDB driver. The floor is +# 4.17 because that is where dnspython became a core dependency rather than the `srv` +# extra, and Atlas hands out mongodb+srv:// URIs that do not resolve without it. +mongodb = ["pymongo>=4.17,<5.0"] # SAML SSO for the admin UI. python3-saml pulls in xmlsec/lxml, whose wheels # bundle the native libxmlsec1/libxml2 libraries, so no system packages are # required. Kept out of the base `proxy` extra so it stays optional. diff --git a/uv.lock b/uv.lock index 27be919eea1..e4b23804b4a 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-29T17:58:57.633306Z" +exclude-newer = "2026-08-30T07:50:56.793842Z" exclude-newer-span = "P3D" [manifest] @@ -4323,6 +4323,9 @@ mcp = [ mlflow = [ { name = "mlflow" }, ] +mongodb = [ + { name = "pymongo" }, +] proxy = [ { name = "apscheduler" }, { name = "azure-identity" }, @@ -4551,6 +4554,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.14.1,<3.0" }, { name = "pyjwt", marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" }, + { name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.17,<5.0" }, { name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.6.2,<2.0" }, { name = "pypdf", marker = "extra == 'proxy-runtime'", specifier = ">=6.12.0,<7.0" }, { name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8.16,<1.0" }, @@ -4577,7 +4581,7 @@ requires-dist = [ { name = "uvloop", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.21.0,<1.0" }, { name = "websockets", marker = "extra == 'proxy'", specifier = ">=15.0.1,<16.0" }, ] -provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "mcp", "saml", "semantic-router", "mlflow", "grpc", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"] +provides-extras = ["proxy", "cli", "extra-proxy", "utils", "caching", "mcp", "mongodb", "saml", "semantic-router", "mlflow", "grpc", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"] [package.metadata.requires-dev] ci = [ @@ -7518,6 +7522,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d5/6f/9ac2548e290764781f9e7e2aaf0685b086379dabfb29ca38536985471eaf/pylint-4.0.5-py3-none-any.whl", hash = "sha256:00f51c9b14a3b3ae08cff6b2cdd43f28165c78b165b628692e428fb1f8dc2cf2", size = 536694, upload-time = "2026-02-20T09:07:31.028Z" }, ] +[[package]] +name = "pymongo" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/64/50be6fbac9c79fe2e4c17401a467da2d8764d82833d83cec325afe5cab32/pymongo-4.17.0.tar.gz", hash = "sha256:70ffa08ba641468cc068cf46c06b34f01a8ce3489f6411309fcb5ceabe6b2fc0", size = 2523370, upload-time = "2026-04-20T16:39:53.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c9/77/28ebbf69772a4341d530831c7a006cdb06877ac23075cb53b0a227df4fe1/pymongo-4.17.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:47b021363cd923ace5edc7a1d63c0ff8a6d9d43859b8a1ba23645f5afae63221", size = 819234, upload-time = "2026-04-20T16:37:20.888Z" }, + { url = "https://files.pythonhosted.org/packages/88/cf/5a70cee503ff9a2fea20607607f14d189f4d975960ac0945ec306ee7b695/pymongo-4.17.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:422fa50d7d7f5c22ea0953554396c9ef95684a2d775f860bd75a7b510538dfca", size = 819969, upload-time = "2026-04-20T16:37:24.187Z" }, + { url = "https://files.pythonhosted.org/packages/23/d5/07b7e27e662c58d872efd104a0e8055eb6569aa1b6d4da436f3fdee7f897/pymongo-4.17.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:addd0498ebbdc6354227f6ed457ed9fce442d48a3bb30d5b5bad33e104996561", size = 1244510, upload-time = "2026-04-20T16:37:26.069Z" }, + { url = "https://files.pythonhosted.org/packages/fb/be/7cac5b1e89bd5a8e395067648241390321593a7c29243e36f91343c02a90/pymongo-4.17.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5c8e180cb2cabe37300e1e36c60aa4f2ff956cc579f0142135a5d2cba252243", size = 1263245, upload-time = "2026-04-20T16:37:28.003Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/40e8e99824c1fda18261411e65ce3b0cd3d9a6ed3c056cdd0a569adc870b/pymongo-4.17.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd835cdb37a1adec359dd072c24f8bb14809e2644fde86fab4ee2fc9719b9483", size = 1304113, upload-time = "2026-04-20T16:37:30.048Z" }, + { url = "https://files.pythonhosted.org/packages/3a/94/fb7e25441dd66f2069a9b172380849b0eaa5881c18b3db217bf64a6d393c/pymongo-4.17.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4979e7e8887862bbb44d203f00cc8263a3f27237876fa691b6beba23e40e6d8", size = 1297046, upload-time = "2026-04-20T16:37:32.054Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c9/7352e0c20fe772541556e4d283c05e07ec48f8b0d2737ad930ac4a1b6655/pymongo-4.17.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77aa4bc164b4de60d5db193b322f0f5b6ead716e831031bfdef8e8bd92205556", size = 1265708, upload-time = "2026-04-20T16:37:33.934Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e4/3df15494c2015ed297958517f0e4f6493e21b00990748068a973e66d45e0/pymongo-4.17.0-cp310-cp310-win32.whl", hash = "sha256:48bbc576677b50af043df870d84ded67cc3a9b4aa7553201beef4da5dc050a0a", size = 805533, upload-time = "2026-04-20T16:37:35.744Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/b4e71bb8cb82ad7d21bb4e8c476f2d573ba68b20368aac36ef06e4a196b4/pymongo-4.17.0-cp310-cp310-win_amd64.whl", hash = "sha256:e46767f28dea610e02edf6c5d956ce615c3c7790ea396660b9b1efd5c5ead2e0", size = 815677, upload-time = "2026-04-20T16:37:37.808Z" }, + { url = "https://files.pythonhosted.org/packages/22/e2/0a4bba644f1cda3970ea1012149eeae3594ebfeed3f81fdaf32b61d90c95/pymongo-4.17.0-cp310-cp310-win_arm64.whl", hash = "sha256:757f2a4c0c2c46cab87df0333681ce69e86c9d5b45bc5203ceba5410b3489e59", size = 807293, upload-time = "2026-04-20T16:37:39.707Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e2/336d86f221cf1b56b2ed9330d4a3b98f9f38f0b37829ae9a9184617d5419/pymongo-4.17.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4141e6c6a339789b2974efa00ecd9409101672d77a0e3ee2cc3839eedf8ec4df", size = 874668, upload-time = "2026-04-20T16:37:41.39Z" }, + { url = "https://files.pythonhosted.org/packages/34/8e/75d3c6c935d187ab59c61e9c15d9aab3f274b563eaf1706e8cae5f508dec/pymongo-4.17.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e68c76b84e0c132d9dbf9307f12ff8185702328187a87b9aca8c941303873433", size = 875294, upload-time = "2026-04-20T16:37:43.432Z" }, + { url = "https://files.pythonhosted.org/packages/5f/ec/62e855744489dbcd54fd778aae4d80fa4c4819e8fb228ca0cf6f21a03997/pymongo-4.17.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba2195d4f386f839a52a23ea1cfd60ffaaba78a3d7841db51b7e433001139918", size = 1496233, upload-time = "2026-04-20T16:37:45.518Z" }, + { url = "https://files.pythonhosted.org/packages/82/e8/93e4e5e5ce8fdf8929dabeefe24aafa5ce046028eed0dfa8eeb936e72c49/pymongo-4.17.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446ff4bfcb6ec2a2e50998c860986a1e992136f998b7f53e7a717fb8aa5a0b9", size = 1522927, upload-time = "2026-04-20T16:37:47.492Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/425dc1d21e0f17bdea0072fc463f662f7fa06d2852af52975c9eced3c07c/pymongo-4.17.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2a0d5ac205728c86e0a02192f1aa5f865b0d7d51f8df6101c01a69a7fc620d72", size = 1583468, upload-time = "2026-04-20T16:37:49.221Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9d/f08b07eeffda1a43c1759f0fa625e88ae12360996eb56d42aad832fa7dff/pymongo-4.17.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:485c8a8eaa4c739f00a331fc73757898ee7c092c214a79e63866ff76aaf282ff", size = 1572787, upload-time = "2026-04-20T16:37:51.061Z" }, + { url = "https://files.pythonhosted.org/packages/e9/c2/6855a07aafa7b894929af23675b6fb9634800ce43122b76a62f6eeb8da2a/pymongo-4.17.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b2dfcc795f5b9fedbe179a11fdf6051581479d196582a3fe819a92a00e9b9969", size = 1526184, upload-time = "2026-04-20T16:37:53.358Z" }, + { url = "https://files.pythonhosted.org/packages/4e/05/c952bac7db71c1942ea3559fcd308b49754cc5004b455935fb4000d1f37b/pymongo-4.17.0-cp311-cp311-win32.whl", hash = "sha256:c2292144505fb12156b981bd440f3dc994a883da06ac726c0c8692ccdbc1c510", size = 852621, upload-time = "2026-04-20T16:37:55.28Z" }, + { url = "https://files.pythonhosted.org/packages/11/c0/c04da9f4c0c6252404598f4e394b862a58a9e866822a70ae261c8a018fdf/pymongo-4.17.0-cp311-cp311-win_amd64.whl", hash = "sha256:2e190827834fce70ecdf9d46796c6dbc0ce08ea87dc2ff5bc6f3f5579b605cb9", size = 867852, upload-time = "2026-04-20T16:37:57.233Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b2/c7b4870fbeef471e947d3e014676f5910d02e0197074d692ebcf24ec049a/pymongo-4.17.0-cp311-cp311-win_arm64.whl", hash = "sha256:a8f9c40a09bb7d4b9fc8b1da65ecf6efa79bda5cb2756f39d9b6940fac1d19ae", size = 855019, upload-time = "2026-04-20T16:37:58.983Z" }, + { url = "https://files.pythonhosted.org/packages/98/90/60bcb508840135d5ee46b51b1a950f548338aa8145a8366dbe6639ae51ac/pymongo-4.17.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53ffa94b2340dbf6b055e09a0090618c60482c158ecfc9565642fc996bf0944", size = 930529, upload-time = "2026-04-20T16:38:00.936Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e9/313840f1e52c6dfac47f704428cbfbce59956ebe7633bffc92b03f74f0ad/pymongo-4.17.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6fe0de9d0f6791abce3471230b32b4817bf89d27b1182b6a550e1ec0fa72aa9a", size = 930665, upload-time = "2026-04-20T16:38:02.915Z" }, + { url = "https://files.pythonhosted.org/packages/78/35/9d3565ea45b1606f635c1e2cd2563c28d66caafdc50f7ad7d979fcd1b363/pymongo-4.17.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e537e95514dae1aaa718f481ec03151a0f0394bcd05f1322896d8fc1330cb729", size = 1762369, upload-time = "2026-04-20T16:38:05.375Z" }, + { url = "https://files.pythonhosted.org/packages/95/ee/149b0d4b1a11c38bff6f14c23d5814c9b0843fd6dc38ad40596bdb1a62d2/pymongo-4.17.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37a8385c29881b43eab31f584100fa0eaddedd5607adf010147ba1810118be90", size = 1798044, upload-time = "2026-04-20T16:38:07.195Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d4/4cee4a7b8d8f6f0550ef6cd2fea42455c5ed619a220cb6ba4fb40d6a5bc8/pymongo-4.17.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f3ee3d241ed77a4fc99ce3cff3b289c3ebce37f61fdd7349d3592c23b82c8784", size = 1878567, upload-time = "2026-04-20T16:38:09.121Z" }, + { url = "https://files.pythonhosted.org/packages/45/ef/7fe366c84952619ee2f69973566c214775e083dd4df465751912153e4b72/pymongo-4.17.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9eb5d63a3c518cb0804ed678f5e2b875af032d89a7cf57a57360322cf6a4d222", size = 1864881, upload-time = "2026-04-20T16:38:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/2f/35/b577d82c6d1be7aee7ac7e249bc86f7847998345042e5f8360de238e177b/pymongo-4.17.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e97e03fa13327c87e3fdc5656acd01e71817f0c1dc3221cd8f30de136bf4ec3", size = 1800349, upload-time = "2026-04-20T16:38:13.589Z" }, + { url = "https://files.pythonhosted.org/packages/b8/69/dafcf04f66e130ddd91aeb92e7a692480eda46dcd04ec1dbe82c06619e10/pymongo-4.17.0-cp312-cp312-win32.whl", hash = "sha256:6877214bff5f06f6884a9fc8d9016a4a7a5f51f537f5c51ac3a576f93e7dfb32", size = 900518, upload-time = "2026-04-20T16:38:15.541Z" }, + { url = "https://files.pythonhosted.org/packages/11/35/5c9262a459f988b4eb2605f70815240b77a0d4131136c4326d18f1822b89/pymongo-4.17.0-cp312-cp312-win_amd64.whl", hash = "sha256:9828485f72f63c7d802e0ec41f71906f633c2692621ab3af55ca990186b091b1", size = 920335, upload-time = "2026-04-20T16:38:17.665Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/e9c7265ee176faccf4e52c4797837e794d93569a1046f6b19a4acc36e5ad/pymongo-4.17.0-cp312-cp312-win_arm64.whl", hash = "sha256:1195370a77baf003b59b10e91ecc4706297197f0dd9d29c840cc556dc08f7cee", size = 903289, upload-time = "2026-04-20T16:38:19.33Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/c1206879708b94e82fcd8b9653440ec271f79a3674d122192df383047f5a/pymongo-4.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:809ec74de3b9148ae43fa8df9faf53470f511c8d384f13b99d6f671f2a379f15", size = 985829, upload-time = "2026-04-20T16:38:21.031Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cf/bb044ed85160e5c40f568c7c4f4e8ea16f40764ff5d302e5befbe8f6f814/pymongo-4.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a431b737816bf4cddd4fa0fcef04e424ad36b7692734a64150f872fb8f3208be", size = 985899, upload-time = "2026-04-20T16:38:23.409Z" }, + { url = "https://files.pythonhosted.org/packages/74/0a/f6dfd5ea3901e5d6888da8de8ba728971a1d447debab681cfc56f90d1208/pymongo-4.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4fab10f8403169ce92f3cea921609d9ee81107306caae06c08f592d4b8ad2b5", size = 2028569, upload-time = "2026-04-20T16:38:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c5/081f59a1c02ae8c0dc73ae58e563838c44eec81aeafa7d0b93a637841c9b/pymongo-4.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20323b0b1c1d33770ad1fc68d429c757734ce9ad3594421c3d6618f10572b1b9", size = 2072916, upload-time = "2026-04-20T16:38:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/31/42/6e41d434297ffe8b30d9c3717916591a4a7be9075a0dcc2fafdfaaaa62ed/pymongo-4.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5a5de048e6da5c18e27cc2437e8c15b3b0cdc8385c15b41178b0caa3322a09c2", size = 2173234, upload-time = "2026-04-20T16:38:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cf/1e4a7db352ef9485831c7268dfe8402f0117b32a9ad54b16e810699e3617/pymongo-4.17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dff3de1294fbbc1db0ba6b511f77b8e540601d092538a31312e99c8a91a78b1e", size = 2156784, upload-time = "2026-04-20T16:38:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/12/10/6195be29962a61ebb5f4bd9e4c7519890b172f7968a0a0d880398c6ddb02/pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faf03e4c2aafd6de626dbd30ba246d369ae33f47f10629d1bbe40f72115027a6", size = 2074446, upload-time = "2026-04-20T16:38:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/33410b8819837ed370c738587306bdf060b59cef11823be212f4a07703c5/pymongo-4.17.0-cp313-cp313-win32.whl", hash = "sha256:c9786665926a09630c5d420c79762cfadbff35a9438bcbc4c81a9fb5ab9228b7", size = 948435, upload-time = "2026-04-20T16:38:35.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/77/c0ed522f798a286b99acaa7914ed8d9c80ab091f97f57c59ffed72906e5e/pymongo-4.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:5960519b4d7168f1ecdd3ea10c81b2aedeb9423651aca953cfbc8e76705d3b38", size = 972847, upload-time = "2026-04-20T16:38:37.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/f0/c39480a2db385fde23861d0c8acda41cdaf1d43e46579db72c5c013a2e81/pymongo-4.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:0ff6bd2f735ab5356541e3e57d5b7dbfbc3f2ee1ccb10b6b0f82d58af69d1d8e", size = 951575, upload-time = "2026-04-20T16:38:40.544Z" }, + { url = "https://files.pythonhosted.org/packages/da/49/2b0250762a89737ed6f9cea238331baca061b89a8ddd10dd17fee52c3970/pymongo-4.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff5aa3f1c7e3f08eb0e7a016c91ba468b1850ccfd63d9b1f12f56350f4974cef", size = 1040945, upload-time = "2026-04-20T16:38:42.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/1c/7a9b5447a08be20e84b6e5b17330917e8d6d9507daa3cd099a9309f11ad7/pymongo-4.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e816db649ba5d7de0568cf3a9f287a9dc9aad21cf0ca667ab156a7ef47fca0b0", size = 1041187, upload-time = "2026-04-20T16:38:45.358Z" }, + { url = "https://files.pythonhosted.org/packages/78/a1/71704f61632dfc90407a5834fe5f6132854937c4a3648f6c05c351d85a45/pymongo-4.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c4fded3a9f1d6a687e36ebd384ac6d00b9b00de1969aa74048e7051ec2a713", size = 2294806, upload-time = "2026-04-20T16:38:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b9/aff42be75108b96c2469b1d9329b912c15108f3e7ef32fdc86da8423c330/pymongo-4.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db66aa8dd253a0fc1fad3b0d23d5b3993f7ebde02fbbd7727128debf2853675", size = 2348231, upload-time = "2026-04-20T16:38:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/f2/30/44c115b8ba1479942c15fd9480eb29a7da0ba68acd56983423ba0deb4a94/pymongo-4.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3987e96e7c7be4083d42e8ac2cc6c0d5b78db9973c90fce42ae800b616ca6b20", size = 2467614, upload-time = "2026-04-20T16:38:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/d2/84/21ee95c8bf0ca7acae7ec7eb365d740bf8fc0156c194baf2c3bdfcb85ec0/pymongo-4.17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cee36b3c0d0354f880fa7a7fdcdaf2bb5e542c2281e25c1bfadf8cfe21eba7d2", size = 2445970, upload-time = "2026-04-20T16:38:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/06/89/081d7f1809d5ca09d1e47e49f2111b245f5694de3a7af32cd3a353a6f43f/pymongo-4.17.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:320b34457b20bbcc79997801f95d25ce00472915ca5241167242b42c4359e027", size = 2348605, upload-time = "2026-04-20T16:38:57.557Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c3/0d949f9d3f2a341c1f635c398c16615e96f89f51ff424ed81e914cf1a4de/pymongo-4.17.0-cp314-cp314-win32.whl", hash = "sha256:df4a644af9ae132d4bfdb2e9516ea51a615fd881caddfbfbd071cf1354844479", size = 1004119, upload-time = "2026-04-20T16:39:00.309Z" }, + { url = "https://files.pythonhosted.org/packages/f7/55/5c3a3db1048054c695c75c5964cc8bedc2247fdb5a75ef6fab4ec8bb013e/pymongo-4.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:c797f8a80957134f6dd9690367a0f8f5906d672119af2c6aa55f0c527b656bed", size = 1032314, upload-time = "2026-04-20T16:39:02.665Z" }, + { url = "https://files.pythonhosted.org/packages/e0/19/e235f39906134cb0ffd5574c5a59c355ef5380f0499644ab94994afbb109/pymongo-4.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:68fca71e05ee5da23a8d73cee8379dfb3d26e609a377cae731d742771ed96946", size = 1007627, upload-time = "2026-04-20T16:39:04.678Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e0/c4c1a86791415b14c684fa0908f9da96de91594a3fd1fa1b8dc689fbb800/pymongo-4.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b4384700cffc3f1dd98e088bc0072dedf6d7d68a230bb4b972665cf69c071c1e", size = 1099151, upload-time = "2026-04-20T16:39:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/81/4b/69c67f3e23fd9b23b9bedc7ebd23754881cc9d5c5d5b2a9811e96b07f475/pymongo-4.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:93641192644fa1ee0f34030e774fd31022a27ad11ba22cb1716142231524f8bd", size = 1099346, upload-time = "2026-04-20T16:39:08.996Z" }, + { url = "https://files.pythonhosted.org/packages/a2/19/a5208f62f9508a26d73acc69bd3821b8c8adae253679a3c26d2f9652f0d5/pymongo-4.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75bc3aa5b94fdb7138d357ec6ca61cd97e0c79f4f7f0bd3efe9639b15cc50942", size = 2619034, upload-time = "2026-04-20T16:39:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/77/27/426cba1ec5973082a56d4150798529bfdf4151c31391ed1fbbecb23ef2ac/pymongo-4.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e8f8e23c6df7c6d6929f5e734980b227706e73ee847517c9ba5af90f7fc466", size = 2689939, upload-time = "2026-04-20T16:39:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2e/f70993d1255e33f6ee59a4ec4371cc65bff7a7e3fda7d55c3386f25287e8/pymongo-4.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d3f3d732aecac1f8d481bde4029755615639bd3076f258a2147210aec8515a", size = 2824994, upload-time = "2026-04-20T16:39:16.057Z" }, + { url = "https://files.pythonhosted.org/packages/b3/eb/87b0e988ba889e1fcc3430c2cfc166b251872c813e92b43174298bee17ff/pymongo-4.17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5f62862d0f87be481fa1fe8cb811994486773c94a2b61e509285e3f2890763", size = 2801745, upload-time = "2026-04-20T16:39:18.476Z" }, + { url = "https://files.pythonhosted.org/packages/67/4c/3f83412d086f682d4d468761d66ddc49cf161e786ea74073045eb4491c60/pymongo-4.17.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64837adbbd72073301af51bb0fc80e3d7707fe5527cea1033ba0320f0b2f881b", size = 2684636, upload-time = "2026-04-20T16:39:20.878Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/b75f6f4ab6c8beb50b0270a4f1e2530b5774f5e116563440e1677ca1820f/pymongo-4.17.0-cp314-cp314t-win32.whl", hash = "sha256:b93b22eedc62598cf5ee9d8c8007a8e9121c50fd88137012d8985500e9dc3151", size = 1056356, upload-time = "2026-04-20T16:39:22.996Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5e/648c8a238eef18a25ed8a169ea6542d4a860bbec3e95b3d9badac2935c71/pymongo-4.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3689ea34f6b647c7d1e7bdc60fcfb214b2789ed1359a7fb96569c69f50e5f18f", size = 1090964, upload-time = "2026-04-20T16:39:24.989Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cb/d9780b66939c4fc1f024bcc7be23a2abcfe06a9745ca8fa76dc73395482e/pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb", size = 1058526, upload-time = "2026-04-20T16:39:27.924Z" }, +] + [[package]] name = "pynacl" version = "1.6.2" From 800cd17d17369db618f0cdb5fa817d262a3d9ef0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 09:04:54 -0700 Subject: [PATCH 023/410] test(vector_stores): cover the MongoDB Atlas vector store config 65 cases across pipeline construction, response mapping, parameter validation, client caching, and driver-error translation. The sad-path cases assert on the message the caller actually sees, since a vector search that fails quietly returns an empty result set rather than an error. --- .../test_mongodb_transformation.py | 644 ++++++++++++++++++ 1 file changed, 644 insertions(+) create mode 100644 tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py new file mode 100644 index 00000000000..bae0ad51b05 --- /dev/null +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -0,0 +1,644 @@ +import sys +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import httpx +import pytest + +from litellm.llms.mongodb.common_utils import ( + MongoClientKey, + get_async_client, + get_sync_client, + reset_client_cache, + translate_mongo_error, +) +from litellm.llms.mongodb.vector_stores.transformation import ( + MongoDBVectorStoreConfig, + _MongoDBSearchParams, +) +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + +CONNECTION_STRING = "mongodb+srv://user:pw@cluster.example.mongodb.net" +INDEX = "movies_vector_index" + +BASE_PARAMS = { + "litellm_embedding_model": "openai/text-embedding-ada-002", + "mongodb_connection_string": CONNECTION_STRING, + "mongodb_database": "sample_mflix", + "mongodb_collection": "embedded_movies", +} + + +class FakeCollection: + def __init__(self, documents, error=None): + self.documents = documents + self.error = error + self.pipeline = None + + def aggregate(self, pipeline): + self.pipeline = pipeline + if self.error is not None: + raise self.error + return iter(self.documents) + + +class FakeAsyncCollection(FakeCollection): + async def aggregate(self, pipeline): + self.pipeline = pipeline + if self.error is not None: + raise self.error + + async def cursor(): + for document in self.documents: + yield document + + return cursor() + + +class FakeDatabase: + def __init__(self, collection): + self.collection = collection + self.requested_collection = None + + def __getitem__(self, name): + self.requested_collection = name + return self.collection + + +class FakeClient: + def __init__(self, collection): + self.database = FakeDatabase(collection) + self.requested_database = None + + def __getitem__(self, name): + self.requested_database = name + return self.database + + +class FakeEmbeddingFn: + def __init__(self, embedding): + self.embedding = embedding + self.captured_kwargs = None + + def __call__(self, **kwargs): + self.captured_kwargs = kwargs + return SimpleNamespace(data=[{"embedding": self.embedding}] if self.embedding is not None else []) + + +class FakeAsyncEmbeddingFn(FakeEmbeddingFn): + async def __call__(self, **kwargs): + self.captured_kwargs = kwargs + return SimpleNamespace(data=[{"embedding": self.embedding}] if self.embedding is not None else []) + + +def _config(documents=(), embedding=(0.1, 0.2, 0.3), error=None): + collection = FakeCollection(list(documents), error) + client = FakeClient(collection) + config = MongoDBVectorStoreConfig( + embedding_fn=FakeEmbeddingFn(list(embedding) if embedding is not None else None), + sync_client_factory=lambda key: client, + ) + return config, client, collection + + +def _async_config(documents=(), embedding=(0.1, 0.2, 0.3), error=None): + collection = FakeAsyncCollection(list(documents), error) + client = FakeClient(collection) + config = MongoDBVectorStoreConfig( + aembedding_fn=FakeAsyncEmbeddingFn(list(embedding) if embedding is not None else None), + async_client_factory=lambda key: client, + ) + return config, client, collection + + +def _search(config, query="a lone astronaut", optional_params=None, litellm_params=None, timeout=None): + return config.execute_search_vector_store_request( + vector_store_id=INDEX, + query=query, + vector_store_search_optional_params=optional_params or {}, + litellm_logging_obj=MagicMock(), + litellm_params={**BASE_PARAMS, **(litellm_params or {})}, + timeout=timeout, + ) + + +async def _asearch(config, query="a lone astronaut", optional_params=None, litellm_params=None): + return await config.aexecute_search_vector_store_request( + vector_store_id=INDEX, + query=query, + vector_store_search_optional_params=optional_params or {}, + litellm_logging_obj=MagicMock(), + litellm_params={**BASE_PARAMS, **(litellm_params or {})}, + ) + + +def _stage(collection, name): + return next(stage[name] for stage in collection.pipeline if name in stage) + + +def test_search_builds_vector_search_stage_against_the_named_index(): + config, client, collection = _config() + + _search(config, optional_params={"max_num_results": 5}) + + assert client.requested_database == "sample_mflix" + assert client.database.requested_collection == "embedded_movies" + assert _stage(collection, "$vectorSearch") == { + "index": INDEX, + "path": "embedding", + "queryVector": [0.1, 0.2, 0.3], + "numCandidates": 100, + "limit": 5, + } + + +def test_search_projects_the_text_field_and_the_similarity_score(): + config, _, collection = _config() + + _search(config) + + assert _stage(collection, "$project") == {"text": 1, "score": {"$meta": "vectorSearchScore"}} + + +def test_search_defaults_to_ten_results(): + config, _, collection = _config() + + _search(config) + + assert _stage(collection, "$vectorSearch")["limit"] == 10 + + +def test_search_honors_custom_field_names(): + config, _, collection = _config() + + _search( + config, + litellm_params={"mongodb_embedding_field": "plot_embedding", "mongodb_text_field": "plot"}, + ) + + assert _stage(collection, "$vectorSearch")["path"] == "plot_embedding" + assert _stage(collection, "$project") == {"plot": 1, "score": {"$meta": "vectorSearchScore"}} + + +def test_num_candidates_scales_with_the_requested_limit(): + config, _, collection = _config() + + _search(config, optional_params={"max_num_results": 40}) + + assert _stage(collection, "$vectorSearch")["numCandidates"] == 400 + + +def test_num_candidates_can_be_overridden(): + config, _, collection = _config() + + _search(config, optional_params={"max_num_results": 5}, litellm_params={"mongodb_num_candidates": 250}) + + assert _stage(collection, "$vectorSearch")["numCandidates"] == 250 + + +@pytest.mark.parametrize("configured", [4, 10_001]) +def test_num_candidates_below_the_limit_or_above_the_ceiling_is_rejected(configured): + config, _, _ = _config() + + with pytest.raises(ValueError, match="mongodb_num_candidates"): + _search(config, optional_params={"max_num_results": 5}, litellm_params={"mongodb_num_candidates": configured}) + + +def test_list_query_is_joined_into_one_embedding_input(): + config, _, _ = _config() + embedding_fn = config.embedding_fn + + _search(config, query=["deep", "space", "rescue"]) + + assert embedding_fn.captured_kwargs["input"] == ["deep space rescue"] + + +def test_embedding_config_is_expanded_into_the_embedding_call(): + config, _, _ = _config() + embedding_fn = config.embedding_fn + + _search(config, litellm_params={"litellm_embedding_config": {"api_base": "https://example.test", "timeout": 7}}) + + assert embedding_fn.captured_kwargs["api_base"] == "https://example.test" + assert embedding_fn.captured_kwargs["timeout"] == 7 + assert embedding_fn.captured_kwargs["model"] == "openai/text-embedding-ada-002" + + +def test_response_maps_documents_to_openai_shaped_results(): + documents = [ + {"_id": "abc123", "text": "an astronaut adrift", "score": 0.94}, + {"_id": "def456", "text": "a robot dog", "score": 0.81}, + ] + config, _, _ = _config(documents=documents) + + response = _search(config) + + assert response["object"] == "vector_store.search_results.page" + assert response["search_query"] == "a lone astronaut" + assert [result["score"] for result in response["data"]] == [0.94, 0.81] + assert [result["content"][0]["text"] for result in response["data"]] == ["an astronaut adrift", "a robot dog"] + assert [result["file_id"] for result in response["data"]] == ["abc123", "def456"] + assert [result["filename"] for result in response["data"]] == ["abc123", "def456"] + assert response["data"][0]["content"][0]["type"] == "text" + + +def test_response_reads_a_dotted_text_field_path(): + config, _, _ = _config(documents=[{"_id": 1, "metadata": {"body": "nested text"}, "score": 0.5}]) + + response = _search(config, litellm_params={"mongodb_text_field": "metadata.body"}) + + assert response["data"][0]["content"][0]["text"] == "nested text" + + +def test_response_tolerates_a_document_missing_the_text_field(): + config, _, _ = _config(documents=[{"_id": 1, "score": 0.5}]) + + response = _search(config) + + assert response["data"][0]["content"][0]["text"] == "" + + +def test_response_tolerates_a_document_missing_a_score(): + config, _, _ = _config(documents=[{"_id": 1, "text": "no score"}]) + + response = _search(config) + + assert response["data"][0]["score"] is None + + +def test_response_stringifies_a_non_string_document_id(): + config, _, _ = _config(documents=[{"_id": 12345, "text": "numeric id", "score": 0.5}]) + + response = _search(config) + + assert response["data"][0]["file_id"] == "12345" + + +def test_search_requires_an_embedding_model(): + config, _, _ = _config() + + with pytest.raises(ValueError, match="litellm_embedding_model is required"): + config.execute_search_vector_store_request( + vector_store_id=INDEX, + query="q", + vector_store_search_optional_params={}, + litellm_logging_obj=MagicMock(), + litellm_params={k: v for k, v in BASE_PARAMS.items() if k != "litellm_embedding_model"}, + ) + + +def test_missing_embedding_model_message_names_the_field_being_searched(): + config, _, _ = _config() + + with pytest.raises(ValueError, match=r"embedded_movies\.embedding"): + config.execute_search_vector_store_request( + vector_store_id=INDEX, + query="q", + vector_store_search_optional_params={}, + litellm_logging_obj=MagicMock(), + litellm_params={k: v for k, v in BASE_PARAMS.items() if k != "litellm_embedding_model"}, + ) + + +def test_search_requires_a_connection_string(): + config, _, _ = _config() + + with pytest.raises(ValueError, match="mongodb_connection_string is required"): + _search(config, litellm_params={"mongodb_connection_string": None}) + + +@pytest.mark.parametrize("connection_string", ["postgres://host/db", "https://cluster.mongodb.net", "redis://host"]) +def test_search_rejects_a_non_mongodb_connection_scheme(connection_string): + config, _, _ = _config() + + with pytest.raises(ValueError, match="must start with 'mongodb://' or 'mongodb\\+srv://'"): + _search(config, litellm_params={"mongodb_connection_string": connection_string}) + + +def test_search_accepts_the_plain_mongodb_scheme(): + config, _, collection = _config() + + _search(config, litellm_params={"mongodb_connection_string": "mongodb://localhost:27017"}) + + assert collection.pipeline is not None + + +def test_search_requires_a_database(): + config, _, _ = _config() + + with pytest.raises(ValueError, match="mongodb_database is required"): + _search(config, litellm_params={"mongodb_database": None}) + + +def test_search_requires_a_collection(): + config, _, _ = _config() + + with pytest.raises(ValueError, match="mongodb_collection is required"): + _search(config, litellm_params={"mongodb_collection": None}) + + +def test_search_rejects_filters_rather_than_silently_ignoring_them(): + config, _, _ = _config() + + with pytest.raises(ValueError, match="does not support the filters parameter"): + _search(config, optional_params={"filters": {"genre": "sci-fi"}}) + + +@pytest.mark.asyncio +async def test_async_search_rejects_filters_rather_than_silently_ignoring_them(): + config, _, _ = _async_config() + + with pytest.raises(ValueError, match="does not support the filters parameter"): + await _asearch(config, optional_params={"filters": {"genre": "sci-fi"}}) + + +@pytest.mark.parametrize("query", ["", " ", "\n\t", []]) +def test_search_rejects_an_empty_query(query): + config, _, _ = _config() + + with pytest.raises(ValueError, match="query must not be empty"): + _search(config, query=query) + + +def test_search_rejects_an_oversized_query(): + config, _, _ = _config() + + with pytest.raises(ValueError, match="at most 32000 characters"): + _search(config, query="x" * 32_001) + + +def test_search_accepts_a_query_at_the_size_ceiling(): + config, _, collection = _config() + + _search(config, query="x" * 32_000) + + assert collection.pipeline is not None + + +@pytest.mark.parametrize("max_num_results", [0, -1, 51, 1000]) +def test_search_rejects_out_of_range_max_num_results(max_num_results): + config, _, _ = _config() + + with pytest.raises(ValueError, match="max_num_results must be between 1 and 50"): + _search(config, optional_params={"max_num_results": max_num_results}) + + +@pytest.mark.parametrize("max_num_results", [1, 50]) +def test_search_allows_max_num_results_at_the_bounds(max_num_results): + config, _, collection = _config() + + _search(config, optional_params={"max_num_results": max_num_results}) + + assert _stage(collection, "$vectorSearch")["limit"] == max_num_results + + +def test_search_treats_an_explicit_null_max_num_results_as_the_default(): + config, _, collection = _config() + + _search(config, optional_params={"max_num_results": None}) + + assert _stage(collection, "$vectorSearch")["limit"] == 10 + + +def test_search_fails_when_the_embedding_model_returns_nothing(): + config, _, _ = _config(embedding=None) + + with pytest.raises(ValueError, match="returned no embedding"): + _search(config) + + +def test_validation_runs_before_any_connection_is_opened(): + opened = [] + config = MongoDBVectorStoreConfig( + embedding_fn=FakeEmbeddingFn([0.1]), + sync_client_factory=lambda key: opened.append(key) or FakeClient(FakeCollection([])), + ) + + with pytest.raises(ValueError, match="query must not be empty"): + _search(config, query="") + + assert opened == [] + + +def test_create_vector_store_is_not_supported_and_says_why(): + config = MongoDBVectorStoreConfig() + + with pytest.raises(NotImplementedError, match="search-only"): + config.transform_create_vector_store_request({}, "https://example.test") + + with pytest.raises(NotImplementedError, match="search-only"): + config.transform_create_vector_store_response(httpx.Response(200)) + + +def test_provider_config_manager_returns_the_mongodb_config(): + config = ProviderConfigManager.get_provider_vector_stores_config(LlmProviders.MONGODB) + + assert isinstance(config, MongoDBVectorStoreConfig) + + +@pytest.mark.asyncio +async def test_async_search_builds_the_same_pipeline_and_maps_the_response(): + documents = [{"_id": "abc123", "text": "an astronaut adrift", "score": 0.94}] + config, client, collection = _async_config(documents=documents) + + response = await _asearch(config, optional_params={"max_num_results": 3}) + + assert client.requested_database == "sample_mflix" + assert client.database.requested_collection == "embedded_movies" + assert _stage(collection, "$vectorSearch")["limit"] == 3 + assert _stage(collection, "$vectorSearch")["queryVector"] == [0.1, 0.2, 0.3] + assert response["data"][0]["content"][0]["text"] == "an astronaut adrift" + assert response["data"][0]["score"] == 0.94 + + +@pytest.mark.asyncio +async def test_async_search_requires_an_embedding_model(): + config, _, _ = _async_config() + + with pytest.raises(ValueError, match="litellm_embedding_model is required"): + await config.aexecute_search_vector_store_request( + vector_store_id=INDEX, + query="q", + vector_store_search_optional_params={}, + litellm_logging_obj=MagicMock(), + litellm_params={k: v for k, v in BASE_PARAMS.items() if k != "litellm_embedding_model"}, + ) + + +class TestClientCache: + def setup_method(self): + reset_client_cache() + + def teardown_method(self): + reset_client_cache() + + def _key(self, connection_string=CONNECTION_STRING, socket_timeout_ms=30_000): + return MongoClientKey( + connection_string=connection_string, + connect_timeout_ms=10_000, + socket_timeout_ms=socket_timeout_ms, + server_selection_timeout_ms=10_000, + ) + + def test_the_same_connection_reuses_one_client(self): + with patch("litellm.llms.mongodb.common_utils.import_sync_mongo_client") as importer: + importer.return_value = lambda *args, **kwargs: MagicMock() + + first = get_sync_client(self._key()) + second = get_sync_client(self._key()) + + assert first is second + assert importer.return_value + + def test_a_different_connection_gets_its_own_client(self): + with patch("litellm.llms.mongodb.common_utils.import_sync_mongo_client") as importer: + importer.return_value = lambda *args, **kwargs: MagicMock() + + first = get_sync_client(self._key()) + second = get_sync_client(self._key(connection_string="mongodb://other.example.test")) + + assert first is not second + + def test_a_different_timeout_gets_its_own_client(self): + with patch("litellm.llms.mongodb.common_utils.import_sync_mongo_client") as importer: + importer.return_value = lambda *args, **kwargs: MagicMock() + + first = get_sync_client(self._key()) + second = get_sync_client(self._key(socket_timeout_ms=5_000)) + + assert first is not second + + @pytest.mark.asyncio + async def test_async_clients_are_cached_per_event_loop(self): + with patch("litellm.llms.mongodb.common_utils.import_async_mongo_client") as importer: + importer.return_value = lambda *args, **kwargs: MagicMock() + + first = get_async_client(self._key()) + second = get_async_client(self._key()) + + assert first is second + + +class TestClientKeyDerivation: + def test_no_timeout_uses_the_bounded_defaults(self): + key = MongoDBVectorStoreConfig._client_key(_MongoDBSearchParams.model_validate(BASE_PARAMS), None) + + assert key.connect_timeout_ms == 10_000 + assert key.socket_timeout_ms == 30_000 + assert key.server_selection_timeout_ms == 10_000 + + def test_a_numeric_timeout_bounds_the_connect_phase(self): + key = MongoDBVectorStoreConfig._client_key(_MongoDBSearchParams.model_validate(BASE_PARAMS), 3.0) + + assert key.socket_timeout_ms == 3_000 + assert key.connect_timeout_ms == 3_000 + + def test_an_httpx_timeout_maps_connect_and_read_separately(self): + key = MongoDBVectorStoreConfig._client_key( + _MongoDBSearchParams.model_validate(BASE_PARAMS), httpx.Timeout(connect=2.0, read=45.0, write=5.0, pool=5.0) + ) + + assert key.connect_timeout_ms == 2_000 + assert key.socket_timeout_ms == 45_000 + + +class TestErrorTranslation: + def _translate(self, error): + return translate_mongo_error(error, index_name=INDEX, database="sample_mflix", collection="embedded_movies") + + def test_server_selection_timeout_points_at_the_atlas_access_list(self): + from pymongo.errors import ServerSelectionTimeoutError + + translated = self._translate(ServerSelectionTimeoutError("no servers")) + + assert "IP access list" in str(translated) + assert "paused cluster" in str(translated) + + def test_authentication_failure_points_at_the_connection_string_credentials(self): + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("auth failed", code=18)) + + assert "rejected the credentials" in str(translated) + + def test_unauthorized_points_at_the_database_user_permissions(self): + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("not authorized", code=13)) + + assert "sample_mflix.embedded_movies" in str(translated) + + def test_a_missing_index_names_the_index_and_the_collection(self): + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("Index not found for name movies_vector_index", code=27)) + + assert INDEX in str(translated) + assert "READY" in str(translated) + + def test_a_dimension_mismatch_points_at_the_embedding_model(self): + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("queryVector has 1536 dimensions, index expects 2048")) + + assert "litellm_embedding_model must be the same model" in str(translated) + + def test_an_unrecognised_operation_failure_still_names_the_target(self): + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("something else entirely")) + + assert "sample_mflix.embedded_movies" in str(translated) + assert INDEX in str(translated) + + def test_a_configuration_error_points_at_the_connection_string(self): + from pymongo.errors import ConfigurationError + + translated = self._translate(ConfigurationError("bad uri")) + + assert "not a usable MongoDB connection string" in str(translated) + + def test_a_non_driver_error_is_returned_unchanged(self): + original = RuntimeError("unrelated") + + assert self._translate(original) is original + + def test_search_surfaces_a_translated_driver_error(self): + from pymongo.errors import ServerSelectionTimeoutError + + config, _, _ = _config(error=ServerSelectionTimeoutError("no servers")) + + with pytest.raises(ValueError, match="IP access list"): + _search(config) + + @pytest.mark.asyncio + async def test_async_search_surfaces_a_translated_driver_error(self): + from pymongo.errors import OperationFailure + + config, _, _ = _async_config(error=OperationFailure("auth failed", code=18)) + + with pytest.raises(ValueError, match="rejected the credentials"): + await _asearch(config) + + +class TestMissingDriver: + def test_the_sync_import_names_the_extra_to_install(self): + from litellm.llms.mongodb.common_utils import import_sync_mongo_client + + with patch.dict(sys.modules, {"pymongo": None}): + with pytest.raises(ValueError, match=r"pip install litellm\[mongodb\]"): + import_sync_mongo_client() + + def test_the_async_import_names_the_extra_to_install(self): + from litellm.llms.mongodb.common_utils import import_async_mongo_client + + with patch.dict(sys.modules, {"pymongo": None}): + with pytest.raises(ValueError, match=r"pip install litellm\[mongodb\]"): + import_async_mongo_client() + + def test_error_translation_degrades_gracefully_without_the_driver(self): + original = RuntimeError("boom") + + with patch.dict(sys.modules, {"pymongo.errors": None}): + assert translate_mongo_error(original, INDEX, "db", "col") is original From 85bda43d632a6521f59aacafbc2bebd198cfdf20 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 09:10:51 -0700 Subject: [PATCH 024/410] fix(vector_stores): turn MongoDB's silent misconfiguration failures into errors Driving the sad path against a live Atlas cluster showed four cases returning an empty result set instead of failing: a missing index, a missing database, a missing collection, and the async path for all three. $vectorSearch reports none of these as errors, so a misconfigured store looked exactly like a query that matched nothing, which is the worst shape for this to fail in. An empty result set is now checked against the index catalogue, which does report all three correctly, and a store that cannot work says so. The check costs one extra round trip and only on the empty path, so a search that returned hits is unaffected. Atlas also reports a wrong vector path and a dimension mismatch under the same error code. Both previously surfaced as "index not found", which sent the reader looking in the wrong place; they are now told apart and each names the setting that is actually wrong. --- litellm/llms/mongodb/common_utils.py | 29 +++- .../mongodb/vector_stores/transformation.py | 38 ++++- .../test_mongodb_transformation.py | 153 +++++++++++++++++- 3 files changed, 210 insertions(+), 10 deletions(-) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 7f26eadb08f..2571821381a 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -105,6 +105,24 @@ def _index_hint(index_name: str, database: str, collection: str) -> str: ) +def missing_index_error(index_name: str, database: str, collection: str) -> ValueError: + """$vectorSearch against a missing index, database or collection returns zero documents + instead of failing, so an empty result set is checked against the index catalogue and + turned into this rather than being reported as 'no matches'.""" + return ValueError( + f"{_index_hint(index_name, database, collection)} A vector search against a database, " + "collection or index that does not exist returns no results rather than an error, so this " + "was reported as an empty result set by MongoDB." + ) + + +def index_not_ready_error(index_name: str, database: str, collection: str, status: str) -> ValueError: + return ValueError( + f"The Atlas Vector Search index '{index_name}' on '{database}.{collection}' is not queryable " + f"yet; its status is {status}. Searches against it return no results until the build finishes." + ) + + def translate_mongo_error(error: Exception, index_name: str, database: str, collection: str) -> Exception: """Turn a driver failure into a message that names the misconfiguration, never a silent empty result. @@ -136,14 +154,19 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll f"lacks read access to '{database}.{collection}'. Driver detail: {error.details}" ) detail: Final = str(error).lower() - if "index" in detail and ("not found" in detail or "does not exist" in detail or "unknown" in detail): - return ValueError(f"{_index_hint(index_name, database, collection)} Driver detail: {error}") - if "dimension" in detail or "numdimensions" in detail or "queryvector" in detail: + if "dimension" in detail: return ValueError( "The query embedding does not match the vector dimensions the Atlas index was built for. " "litellm_embedding_model must be the same model that produced the stored vectors. " f"Driver detail: {error}" ) + if "is not indexed as vector" in detail: + return ValueError( + "mongodb_embedding_field names a field the Atlas Vector Search index does not cover. " + f"It must match the 'path' the index '{index_name}' was created on. Driver detail: {error}" + ) + if "index" in detail and ("not found" in detail or "does not exist" in detail or "unknown" in detail): + return ValueError(f"{_index_hint(index_name, database, collection)} Driver detail: {error}") return ValueError( f"MongoDB rejected the vector search against '{database}.{collection}' using index " f"'{index_name}'. Driver detail: {error}" diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index efeff16ae5a..20dcf62dcc3 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -26,6 +26,8 @@ from litellm.llms.mongodb.common_utils import ( MongoClientKey, get_async_client, get_sync_client, + index_not_ready_error, + missing_index_error, translate_mongo_error, ) from litellm.types.utils import EmbeddingResponse @@ -249,6 +251,19 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): data=[cls._to_result(document, text_field) for document in documents], ) + @staticmethod + def _raise_for_unusable_index( + catalogue: Sequence[Mapping[str, object]], index_name: str, database: str, collection: str + ) -> None: + """An empty result set is ambiguous: Atlas returns zero documents both for a query that + genuinely matched nothing and for a missing database, collection or index. Only the second + is a misconfiguration, so the index catalogue decides which one happened.""" + if not catalogue: + raise missing_index_error(index_name, database, collection) + entry: Final = catalogue[0] + if not entry.get("queryable"): + raise index_not_ready_error(index_name, database, collection, str(entry.get("status") or "unknown")) + @staticmethod def _embedding_vector(embedding_response: EmbeddingResponse) -> Sequence[float]: data: Final = embedding_response.data @@ -284,12 +299,21 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): ) client: Final = self.sync_client_factory(key) + target: Final = client[database][collection] # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted try: - documents: Final = list(client[database][collection].aggregate(pipeline)) # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted + documents: Final = list(target.aggregate(pipeline)) except Exception as e: raise translate_mongo_error( e, index_name=vector_store_id, database=database, collection=collection ) from e + if not documents: + try: + catalogue: Final = list(target.list_search_indexes(vector_store_id)) + except Exception as e: + raise translate_mongo_error( + e, index_name=vector_store_id, database=database, collection=collection + ) from e + self._raise_for_unusable_index(catalogue, vector_store_id, database, collection) return self._to_response(documents, query_text, params.text_field) async def aexecute_search_vector_store_request( @@ -317,13 +341,23 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): ) client: Final = self.async_client_factory(key) + target: Final = client[database][collection] # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted try: - cursor: Final = await client[database][collection].aggregate(pipeline) # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted + cursor: Final = await target.aggregate(pipeline) documents: Final = [document async for document in cursor] except Exception as e: raise translate_mongo_error( e, index_name=vector_store_id, database=database, collection=collection ) from e + if not documents: + try: + index_cursor: Final = await target.list_search_indexes(vector_store_id) + catalogue: Final = [entry async for entry in index_cursor] + except Exception as e: + raise translate_mongo_error( + e, index_name=vector_store_id, database=database, collection=collection + ) from e + self._raise_for_unusable_index(catalogue, vector_store_id, database, collection) return self._to_response(documents, query_text, params.text_field) def transform_create_vector_store_request( diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index bae0ad51b05..c9378015f5a 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -30,11 +30,16 @@ BASE_PARAMS = { } +READY_INDEX = [{"name": INDEX, "status": "READY", "queryable": True}] + + class FakeCollection: - def __init__(self, documents, error=None): + def __init__(self, documents, error=None, search_indexes=None): self.documents = documents self.error = error + self.search_indexes = READY_INDEX if search_indexes is None else search_indexes self.pipeline = None + self.listed_indexes = [] def aggregate(self, pipeline): self.pipeline = pipeline @@ -42,6 +47,10 @@ class FakeCollection: raise self.error return iter(self.documents) + def list_search_indexes(self, name): + self.listed_indexes.append(name) + return iter(self.search_indexes) + class FakeAsyncCollection(FakeCollection): async def aggregate(self, pipeline): @@ -55,6 +64,15 @@ class FakeAsyncCollection(FakeCollection): return cursor() + async def list_search_indexes(self, name): + self.listed_indexes.append(name) + + async def cursor(): + for entry in self.search_indexes: + yield entry + + return cursor() + class FakeDatabase: def __init__(self, collection): @@ -92,8 +110,8 @@ class FakeAsyncEmbeddingFn(FakeEmbeddingFn): return SimpleNamespace(data=[{"embedding": self.embedding}] if self.embedding is not None else []) -def _config(documents=(), embedding=(0.1, 0.2, 0.3), error=None): - collection = FakeCollection(list(documents), error) +def _config(documents=(), embedding=(0.1, 0.2, 0.3), error=None, search_indexes=None): + collection = FakeCollection(list(documents), error, search_indexes) client = FakeClient(collection) config = MongoDBVectorStoreConfig( embedding_fn=FakeEmbeddingFn(list(embedding) if embedding is not None else None), @@ -102,8 +120,8 @@ def _config(documents=(), embedding=(0.1, 0.2, 0.3), error=None): return config, client, collection -def _async_config(documents=(), embedding=(0.1, 0.2, 0.3), error=None): - collection = FakeAsyncCollection(list(documents), error) +def _async_config(documents=(), embedding=(0.1, 0.2, 0.3), error=None, search_indexes=None): + collection = FakeAsyncCollection(list(documents), error, search_indexes) client = FakeClient(collection) config = MongoDBVectorStoreConfig( aembedding_fn=FakeAsyncEmbeddingFn(list(embedding) if embedding is not None else None), @@ -642,3 +660,128 @@ class TestMissingDriver: with patch.dict(sys.modules, {"pymongo.errors": None}): assert translate_mongo_error(original, INDEX, "db", "col") is original + + +class TestEmptyResultsAreDisambiguated: + """$vectorSearch returns zero documents for a missing database, collection or index just as it + does for a query that matched nothing, so an empty result set is checked against the index + catalogue before it is reported as 'no matches'.""" + + def test_a_missing_index_becomes_an_error_rather_than_an_empty_page(self): + config, _, collection = _config(documents=[], search_indexes=[]) + + with pytest.raises(ValueError, match="No queryable Atlas Vector Search index"): + _search(config) + + assert collection.listed_indexes == [INDEX] + + def test_the_missing_index_error_explains_why_mongodb_reported_no_results(self): + config, _, _ = _config(documents=[], search_indexes=[]) + + with pytest.raises(ValueError, match="returns no results rather than an error"): + _search(config) + + def test_an_index_still_building_becomes_an_error_naming_its_status(self): + config, _, _ = _config( + documents=[], search_indexes=[{"name": INDEX, "status": "PENDING", "queryable": False}] + ) + + with pytest.raises(ValueError, match="not queryable yet; its status is PENDING"): + _search(config) + + def test_a_genuine_no_match_against_a_ready_index_returns_an_empty_page(self): + config, _, collection = _config(documents=[]) + + response = _search(config) + + assert response["data"] == [] + assert response["object"] == "vector_store.search_results.page" + assert collection.listed_indexes == [INDEX] + + def test_the_catalogue_is_not_consulted_when_the_search_returned_hits(self): + config, _, collection = _config(documents=[{"_id": 1, "text": "hit", "score": 0.9}]) + + _search(config) + + assert collection.listed_indexes == [] + + @pytest.mark.asyncio + async def test_async_missing_index_becomes_an_error_rather_than_an_empty_page(self): + config, _, collection = _async_config(documents=[], search_indexes=[]) + + with pytest.raises(ValueError, match="No queryable Atlas Vector Search index"): + await _asearch(config) + + assert collection.listed_indexes == [INDEX] + + @pytest.mark.asyncio + async def test_async_index_still_building_becomes_an_error_naming_its_status(self): + config, _, _ = _async_config( + documents=[], search_indexes=[{"name": INDEX, "status": "PENDING", "queryable": False}] + ) + + with pytest.raises(ValueError, match="not queryable yet; its status is PENDING"): + await _asearch(config) + + @pytest.mark.asyncio + async def test_async_genuine_no_match_returns_an_empty_page(self): + config, _, _ = _async_config(documents=[]) + + response = await _asearch(config) + + assert response["data"] == [] + + @pytest.mark.asyncio + async def test_async_catalogue_is_not_consulted_when_the_search_returned_hits(self): + config, _, collection = _async_config(documents=[{"_id": 1, "text": "hit", "score": 0.9}]) + + await _asearch(config) + + assert collection.listed_indexes == [] + + def test_a_failure_while_checking_the_catalogue_is_translated_too(self): + from pymongo.errors import OperationFailure + + class ExplodingCollection(FakeCollection): + def list_search_indexes(self, name): + raise OperationFailure("not authorized", code=13) + + collection = ExplodingCollection([], None, []) + config = MongoDBVectorStoreConfig( + embedding_fn=FakeEmbeddingFn([0.1]), + sync_client_factory=lambda key: FakeClient(collection), + ) + + with pytest.raises(ValueError, match="lacks read access"): + _search(config) + + +class TestAtlasPlanExecutorErrors: + """Atlas reports a wrong vector path and a dimension mismatch through the same error code, so + each one has to be told apart by its message or both come back as a generic index failure.""" + + def _translate(self, message): + from pymongo.errors import OperationFailure + + return translate_mongo_error( + OperationFailure(message, code=8), + index_name=INDEX, + database="sample_mflix", + collection="embedded_movies", + ) + + def test_a_wrong_vector_path_points_at_the_embedding_field_setting(self): + translated = self._translate( + "PlanExecutor error during aggregation :: caused by :: nope is not indexed as vector" + ) + + assert "mongodb_embedding_field names a field" in str(translated) + + def test_a_dimension_mismatch_is_not_reported_as_a_wrong_path(self): + translated = self._translate( + "PlanExecutor error during aggregation :: caused by :: vector field is indexed with " + "1536 dimensions but queried with 3072" + ) + + assert "does not match the vector dimensions" in str(translated) + assert "mongodb_embedding_field" not in str(translated) From 22d34960e5471e7640a4b99e72387401dcf85218 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 09:56:17 -0700 Subject: [PATCH 025/410] fix(vector_stores): redact wire-protocol connection strings in management responses A MongoDB vector store's whole credential is its connection string, and mongodb+srv://:@ embeds the database password. None of the masker's default patterns (api_key, secret, token, credential) match a key named mongodb_connection_string, so /vector_store/list and /vector_store/info returned it verbatim to every caller that can read a vector store. SensitiveDataMasker gains extra_sensitive_patterns, which unions onto the defaults instead of replacing them, and the vector-store redactor adds "connection" so the URI is masked while mongodb_database, mongodb_collection and the field names stay readable. --- .../sensitive_data_masker.py | 42 +++++++++++-------- .../management_endpoints.py | 5 ++- .../test_sensitive_data_masker.py | 19 +++++++++ .../test_vector_store_endpoints.py | 36 ++++++++++++++++ 4 files changed, 84 insertions(+), 18 deletions(-) diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 3d60c1bda12..3b0806ab069 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -6,33 +6,41 @@ from pydantic import BaseModel from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER +_DEFAULT_SENSITIVE_PATTERNS: Final = frozenset( + { + "password", + "secret", + "key", + "token", + "auth", + "authorization", + "credential", + # Plural form: Vertex uses ``vertex_credentials``; segment-exact + # matching otherwise misses it because "credential" != "credentials". + "credentials", + "access", + "private", + "certificate", + "fingerprint", + "tenancy", + } +) + + class SensitiveDataMasker: def __init__( self, sensitive_patterns: set[str] | None = None, + extra_sensitive_patterns: set[str] | None = None, non_sensitive_overrides: set[str] | None = None, visible_prefix: int = 4, visible_suffix: int = 4, mask_char: str = "*", mask_short_values: bool = True, ): - self.sensitive_patterns = sensitive_patterns or { - "password", - "secret", - "key", - "token", - "auth", - "authorization", - "credential", - # Plural form: Vertex uses ``vertex_credentials``; segment-exact - # matching otherwise misses it because "credential" != "credentials". - "credentials", - "access", - "private", - "certificate", - "fingerprint", - "tenancy", - } + self.sensitive_patterns = (sensitive_patterns or _DEFAULT_SENSITIVE_PATTERNS) | ( + extra_sensitive_patterns or frozenset() + ) # If any key segment matches one of these, the key is not considered sensitive # even if it also matches a sensitive pattern. For example, "input_cost_per_token" # contains "token" but "cost" overrides that — it's a pricing field, not a secret. diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index 183a03cc13c..a62c0f711cb 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -59,7 +59,10 @@ def _row_to_vector_store(row: "_VectorStoreRow") -> LiteLLM_ManagedVectorStore: return LiteLLM_ManagedVectorStore(**row.model_dump()) -_LITELLM_PARAMS_MASKER: Final = SensitiveDataMasker() +# "connection" covers wire-protocol providers whose whole credential is a URI +# (mongodb_connection_string embeds the username and password), which the +# default api_key/secret/token patterns do not match. +_LITELLM_PARAMS_MASKER: Final = SensitiveDataMasker(extra_sensitive_patterns={"connection"}) _REDACT_LITELLM_PARAMS_MAX_DEPTH: Final = 10 diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index f6b8a93c472..27a83223864 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -312,3 +312,22 @@ def test_mask_credentials_in_payload_masks_only_sensitive_string_leaves(): assert masked != plaintext assert masked.startswith(plaintext[:4]) assert masked.endswith(plaintext[-4:]) + + +def test_extra_sensitive_patterns_add_to_the_defaults(): + from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker + + masker = SensitiveDataMasker(extra_sensitive_patterns={"connection"}) + + assert masker.is_sensitive_key("mongodb_connection_string") is True + assert masker.is_sensitive_key("api_key") is True + assert masker.is_sensitive_key("aws_secret_access_key") is True + assert masker.is_sensitive_key("mongodb_database") is False + + +def test_extra_sensitive_patterns_do_not_leak_into_other_maskers(): + from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker + + SensitiveDataMasker(extra_sensitive_patterns={"connection"}) + + assert SensitiveDataMasker().is_sensitive_key("mongodb_connection_string") is False 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 eae6f90863a..16ec6e9796c 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 @@ -1,3 +1,4 @@ +import json from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch @@ -2700,6 +2701,41 @@ class TestRedactSensitiveLitellmParams: for k, v in params.items(): assert out[k] == v, f"{k} should be preserved verbatim" + def test_redacts_wire_protocol_connection_strings(self): + """ + A MongoDB vector store's whole credential is its connection string: + ``mongodb+srv://:@`` embeds the database + password, and none of the default api_key/secret/token patterns match + the key name, so an unextended masker returns it verbatim to every + caller of /vector_store/list and /vector_store/info. + """ + from litellm.constants import REDACTED_BY_LITELM_STRING + from litellm.proxy.vector_store_endpoints.management_endpoints import ( + _redact_sensitive_litellm_params, + ) + + password = "hunter2-not-for-callers" + params = { + "mongodb_connection_string": f"mongodb+srv://dbuser:{password}@cluster0.mongodb.net", + "mongodb_database": "sample_mflix", + "mongodb_collection": "embedded_movies", + "mongodb_embedding_field": "plot_embedding", + "mongodb_text_field": "plot", + "litellm_embedding_model": "openai/text-embedding-ada-002", + } + out = _redact_sensitive_litellm_params(params) + + assert out["mongodb_connection_string"] == REDACTED_BY_LITELM_STRING + assert password not in json.dumps(out) + for k in ( + "mongodb_database", + "mongodb_collection", + "mongodb_embedding_field", + "mongodb_text_field", + "litellm_embedding_model", + ): + assert out[k] == params[k], f"{k} is not a credential and must survive redaction" + def test_handles_none_and_empty(self): from litellm.proxy.vector_store_endpoints.management_endpoints import ( _redact_sensitive_litellm_params, From 8374b34b8168a9fd643b0d7fc5404ea7ff3c2edf Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 10:01:31 -0700 Subject: [PATCH 026/410] fix(vector_stores): return 400 for MongoDB misconfiguration instead of 500 litellm.exception_type passes a litellm exception through untouched and wraps anything else into APIConnectionError, so every bare ValueError this provider raised reached the caller as HTTP 500 with a Python traceback in the response body. "max_num_results must be between 1 and 50" is the caller's to fix, not a connection failure. Configuration and validation failures now raise BadRequestError (400) and the two timeout cases raise Timeout (408). ExecutionTimeout subclasses OperationFailure, so it is matched before it; previously an Atlas query that ran out of time was reported as "MongoDB rejected the vector search". --- litellm/llms/mongodb/common_utils.py | 54 +++++--- .../mongodb/vector_stores/transformation.py | 23 ++-- .../test_mongodb_transformation.py | 125 ++++++++++++++---- 3 files changed, 147 insertions(+), 55 deletions(-) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 2571821381a..299a9772817 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -12,6 +12,8 @@ import asyncio from dataclasses import dataclass from typing import TYPE_CHECKING, Final +from litellm.exceptions import BadRequestError, Timeout + if TYPE_CHECKING: from pymongo import AsyncMongoClient, MongoClient @@ -20,6 +22,19 @@ PYMONGO_INSTALL_HINT: Final = ( "Run 'pip install litellm[mongodb]' (or 'pip install pymongo') to install it." ) +MONGODB_PROVIDER: Final = "mongodb" + + +def config_error(message: str) -> BadRequestError: + """Misconfiguration is the caller's to fix, so it maps to 400 rather than the 500 + a bare ValueError would become once litellm.exception_type wraps it.""" + return BadRequestError(message=message, model=None, llm_provider=MONGODB_PROVIDER) + + +def timeout_error(message: str) -> Timeout: + return Timeout(message=message, model=None, llm_provider=MONGODB_PROVIDER) + + DEFAULT_CONNECT_TIMEOUT_MS: Final = 10_000 DEFAULT_SOCKET_TIMEOUT_MS: Final = 30_000 DEFAULT_SERVER_SELECTION_TIMEOUT_MS: Final = 10_000 @@ -45,7 +60,7 @@ def import_sync_mongo_client() -> "type[MongoClient]": try: from pymongo import MongoClient as SyncMongoClient except ImportError as e: - raise ValueError(PYMONGO_INSTALL_HINT) from e + raise config_error(PYMONGO_INSTALL_HINT) from e return SyncMongoClient @@ -53,7 +68,7 @@ def import_async_mongo_client() -> "type[AsyncMongoClient]": try: from pymongo import AsyncMongoClient as AsyncMongoClientClass except ImportError as e: - raise ValueError(PYMONGO_INSTALL_HINT) from e + raise config_error(PYMONGO_INSTALL_HINT) from e return AsyncMongoClientClass @@ -105,19 +120,19 @@ def _index_hint(index_name: str, database: str, collection: str) -> str: ) -def missing_index_error(index_name: str, database: str, collection: str) -> ValueError: +def missing_index_error(index_name: str, database: str, collection: str) -> BadRequestError: """$vectorSearch against a missing index, database or collection returns zero documents instead of failing, so an empty result set is checked against the index catalogue and turned into this rather than being reported as 'no matches'.""" - return ValueError( + return config_error( f"{_index_hint(index_name, database, collection)} A vector search against a database, " "collection or index that does not exist returns no results rather than an error, so this " "was reported as an empty result set by MongoDB." ) -def index_not_ready_error(index_name: str, database: str, collection: str, status: str) -> ValueError: - return ValueError( +def index_not_ready_error(index_name: str, database: str, collection: str, status: str) -> BadRequestError: + return config_error( f"The Atlas Vector Search index '{index_name}' on '{database}.{collection}' is not queryable " f"yet; its status is {status}. Searches against it return no results until the build finishes." ) @@ -141,46 +156,47 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll return error if isinstance(error, ServerSelectionTimeoutError): - return ValueError( + return timeout_error( "Could not reach the MongoDB deployment before the timeout. On Atlas this is usually the " "project's IP access list not containing this host, or a paused cluster; it can also be an " f"unresolvable hostname. Driver detail: {error}" ) + # ExecutionTimeout subclasses OperationFailure, so it has to be matched before it + if isinstance(error, (NetworkTimeout, ExecutionTimeout)): + return timeout_error( + f"The MongoDB vector search against '{database}.{collection}' timed out before returning. " + f"Driver detail: {error}" + ) if isinstance(error, OperationFailure): code: Final = error.code if code in (_AUTHENTICATION_FAILED_CODE, _UNAUTHORIZED_CODE): - return ValueError( + return config_error( "MongoDB rejected the credentials in mongodb_connection_string, or the database user " f"lacks read access to '{database}.{collection}'. Driver detail: {error.details}" ) detail: Final = str(error).lower() if "dimension" in detail: - return ValueError( + return config_error( "The query embedding does not match the vector dimensions the Atlas index was built for. " "litellm_embedding_model must be the same model that produced the stored vectors. " f"Driver detail: {error}" ) if "is not indexed as vector" in detail: - return ValueError( + return config_error( "mongodb_embedding_field names a field the Atlas Vector Search index does not cover. " f"It must match the 'path' the index '{index_name}' was created on. Driver detail: {error}" ) if "index" in detail and ("not found" in detail or "does not exist" in detail or "unknown" in detail): - return ValueError(f"{_index_hint(index_name, database, collection)} Driver detail: {error}") - return ValueError( + return config_error(f"{_index_hint(index_name, database, collection)} Driver detail: {error}") + return config_error( f"MongoDB rejected the vector search against '{database}.{collection}' using index " f"'{index_name}'. Driver detail: {error}" ) - if isinstance(error, (NetworkTimeout, ExecutionTimeout)): - return ValueError( - f"The MongoDB vector search against '{database}.{collection}' timed out before returning. " - f"Driver detail: {error}" - ) if isinstance(error, ConfigurationError): - return ValueError( + return config_error( "mongodb_connection_string is not a usable MongoDB connection string. " f"Driver detail: {error}" ) if isinstance(error, InvalidOperation): - return ValueError(f"The MongoDB client was already closed or is unusable. Driver detail: {error}") + return config_error(f"The MongoDB client was already closed or is unusable. Driver detail: {error}") return error diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 20dcf62dcc3..2570e368990 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -24,6 +24,7 @@ from litellm.llms.mongodb.common_utils import ( DEFAULT_SERVER_SELECTION_TIMEOUT_MS, DEFAULT_SOCKET_TIMEOUT_MS, MongoClientKey, + config_error, get_async_client, get_sync_client, index_not_ready_error, @@ -88,7 +89,7 @@ class _MongoDBSearchParams(BaseModel): def require_embedding_model(self) -> str: if not self.litellm_embedding_model: - raise ValueError( + raise config_error( "litellm_embedding_model is required in litellm_params for the MongoDB vector store. " "It must be the same model that produced the vectors stored in " f"'{self.mongodb_collection or ''}.{self.embedding_field}', or search results " @@ -98,13 +99,13 @@ class _MongoDBSearchParams(BaseModel): def require_connection_string(self) -> str: if not self.mongodb_connection_string: - raise ValueError( + raise config_error( "mongodb_connection_string is required in litellm_params for the MongoDB vector store. " "Example: mongodb+srv://:@.mongodb.net" ) scheme: Final = self.mongodb_connection_string.split("://", 1)[0].lower() if scheme not in ("mongodb", "mongodb+srv"): - raise ValueError( + raise config_error( "mongodb_connection_string must start with 'mongodb://' or 'mongodb+srv://', " f"got '{self.mongodb_connection_string.split('://', 1)[0]}://'" ) @@ -112,7 +113,7 @@ class _MongoDBSearchParams(BaseModel): def require_database(self) -> str: if not self.mongodb_database: - raise ValueError( + raise config_error( "mongodb_database is required in litellm_params for the MongoDB vector store. " "Example: mongodb_database: sample_mflix" ) @@ -120,7 +121,7 @@ class _MongoDBSearchParams(BaseModel): def require_collection(self) -> str: if not self.mongodb_collection: - raise ValueError( + raise config_error( "mongodb_collection is required in litellm_params for the MongoDB vector store. " "Example: mongodb_collection: embedded_movies" ) @@ -145,9 +146,9 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def _query_text(query: str | Sequence[str]) -> str: text: Final = query if isinstance(query, str) else " ".join(query) if not text.strip(): - raise ValueError("query must not be empty") + raise config_error("query must not be empty") if len(text) > MAX_QUERY_CHARACTERS: - raise ValueError(f"query must be at most {MAX_QUERY_CHARACTERS} characters, got {len(text)}") + raise config_error(f"query must be at most {MAX_QUERY_CHARACTERS} characters, got {len(text)}") return text @staticmethod @@ -156,7 +157,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): if requested is None: return DEFAULT_MAX_NUM_RESULTS if not MIN_MAX_NUM_RESULTS <= requested <= MAX_MAX_NUM_RESULTS: - raise ValueError( + raise config_error( f"max_num_results must be between {MIN_MAX_NUM_RESULTS} and {MAX_MAX_NUM_RESULTS}, got {requested}" ) return requested @@ -165,7 +166,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def _num_candidates(limit: int, configured: int | None) -> int: if configured is not None: if not limit <= configured <= MAX_NUM_CANDIDATES: - raise ValueError( + raise config_error( f"mongodb_num_candidates must be between max_num_results ({limit}) and " f"{MAX_NUM_CANDIDATES}, got {configured}" ) @@ -199,7 +200,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, ) -> list[dict[str, object]]: if vector_store_search_optional_params.get("filters") is not None: - raise ValueError( + raise config_error( "MongoDB vector store does not support the filters parameter yet. " "Restrict the collection or the Atlas Vector Search index definition instead." ) @@ -268,7 +269,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def _embedding_vector(embedding_response: EmbeddingResponse) -> Sequence[float]: data: Final = embedding_response.data if not data: - raise ValueError( + raise config_error( "The embedding model returned no embedding for the search query, so there is nothing " "to search MongoDB with. Check the embedding deployment named by litellm_embedding_model." ) diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index c9378015f5a..1e5bce3f0ee 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -5,8 +5,11 @@ from unittest.mock import MagicMock, patch import httpx import pytest +from litellm.exceptions import BadRequestError, Timeout from litellm.llms.mongodb.common_utils import ( MongoClientKey, + index_not_ready_error, + missing_index_error, get_async_client, get_sync_client, reset_client_cache, @@ -219,7 +222,7 @@ def test_num_candidates_can_be_overridden(): def test_num_candidates_below_the_limit_or_above_the_ceiling_is_rejected(configured): config, _, _ = _config() - with pytest.raises(ValueError, match="mongodb_num_candidates"): + with pytest.raises(BadRequestError, match="mongodb_num_candidates"): _search(config, optional_params={"max_num_results": 5}, litellm_params={"mongodb_num_candidates": configured}) @@ -296,7 +299,7 @@ def test_response_stringifies_a_non_string_document_id(): def test_search_requires_an_embedding_model(): config, _, _ = _config() - with pytest.raises(ValueError, match="litellm_embedding_model is required"): + with pytest.raises(BadRequestError, match="litellm_embedding_model is required"): config.execute_search_vector_store_request( vector_store_id=INDEX, query="q", @@ -309,7 +312,7 @@ def test_search_requires_an_embedding_model(): def test_missing_embedding_model_message_names_the_field_being_searched(): config, _, _ = _config() - with pytest.raises(ValueError, match=r"embedded_movies\.embedding"): + with pytest.raises(BadRequestError, match=r"embedded_movies\.embedding"): config.execute_search_vector_store_request( vector_store_id=INDEX, query="q", @@ -322,7 +325,7 @@ def test_missing_embedding_model_message_names_the_field_being_searched(): def test_search_requires_a_connection_string(): config, _, _ = _config() - with pytest.raises(ValueError, match="mongodb_connection_string is required"): + with pytest.raises(BadRequestError, match="mongodb_connection_string is required"): _search(config, litellm_params={"mongodb_connection_string": None}) @@ -330,7 +333,7 @@ def test_search_requires_a_connection_string(): def test_search_rejects_a_non_mongodb_connection_scheme(connection_string): config, _, _ = _config() - with pytest.raises(ValueError, match="must start with 'mongodb://' or 'mongodb\\+srv://'"): + with pytest.raises(BadRequestError, match="must start with 'mongodb://' or 'mongodb\\+srv://'"): _search(config, litellm_params={"mongodb_connection_string": connection_string}) @@ -345,21 +348,21 @@ def test_search_accepts_the_plain_mongodb_scheme(): def test_search_requires_a_database(): config, _, _ = _config() - with pytest.raises(ValueError, match="mongodb_database is required"): + with pytest.raises(BadRequestError, match="mongodb_database is required"): _search(config, litellm_params={"mongodb_database": None}) def test_search_requires_a_collection(): config, _, _ = _config() - with pytest.raises(ValueError, match="mongodb_collection is required"): + with pytest.raises(BadRequestError, match="mongodb_collection is required"): _search(config, litellm_params={"mongodb_collection": None}) def test_search_rejects_filters_rather_than_silently_ignoring_them(): config, _, _ = _config() - with pytest.raises(ValueError, match="does not support the filters parameter"): + with pytest.raises(BadRequestError, match="does not support the filters parameter"): _search(config, optional_params={"filters": {"genre": "sci-fi"}}) @@ -367,7 +370,7 @@ def test_search_rejects_filters_rather_than_silently_ignoring_them(): async def test_async_search_rejects_filters_rather_than_silently_ignoring_them(): config, _, _ = _async_config() - with pytest.raises(ValueError, match="does not support the filters parameter"): + with pytest.raises(BadRequestError, match="does not support the filters parameter"): await _asearch(config, optional_params={"filters": {"genre": "sci-fi"}}) @@ -375,14 +378,14 @@ async def test_async_search_rejects_filters_rather_than_silently_ignoring_them() def test_search_rejects_an_empty_query(query): config, _, _ = _config() - with pytest.raises(ValueError, match="query must not be empty"): + with pytest.raises(BadRequestError, match="query must not be empty"): _search(config, query=query) def test_search_rejects_an_oversized_query(): config, _, _ = _config() - with pytest.raises(ValueError, match="at most 32000 characters"): + with pytest.raises(BadRequestError, match="at most 32000 characters"): _search(config, query="x" * 32_001) @@ -398,7 +401,7 @@ def test_search_accepts_a_query_at_the_size_ceiling(): def test_search_rejects_out_of_range_max_num_results(max_num_results): config, _, _ = _config() - with pytest.raises(ValueError, match="max_num_results must be between 1 and 50"): + with pytest.raises(BadRequestError, match="max_num_results must be between 1 and 50"): _search(config, optional_params={"max_num_results": max_num_results}) @@ -422,7 +425,7 @@ def test_search_treats_an_explicit_null_max_num_results_as_the_default(): def test_search_fails_when_the_embedding_model_returns_nothing(): config, _, _ = _config(embedding=None) - with pytest.raises(ValueError, match="returned no embedding"): + with pytest.raises(BadRequestError, match="returned no embedding"): _search(config) @@ -433,7 +436,7 @@ def test_validation_runs_before_any_connection_is_opened(): sync_client_factory=lambda key: opened.append(key) or FakeClient(FakeCollection([])), ) - with pytest.raises(ValueError, match="query must not be empty"): + with pytest.raises(BadRequestError, match="query must not be empty"): _search(config, query="") assert opened == [] @@ -474,7 +477,7 @@ async def test_async_search_builds_the_same_pipeline_and_maps_the_response(): async def test_async_search_requires_an_embedding_model(): config, _, _ = _async_config() - with pytest.raises(ValueError, match="litellm_embedding_model is required"): + with pytest.raises(BadRequestError, match="litellm_embedding_model is required"): await config.aexecute_search_vector_store_request( vector_store_id=INDEX, query="q", @@ -627,7 +630,7 @@ class TestErrorTranslation: config, _, _ = _config(error=ServerSelectionTimeoutError("no servers")) - with pytest.raises(ValueError, match="IP access list"): + with pytest.raises(Timeout, match="IP access list"): _search(config) @pytest.mark.asyncio @@ -636,7 +639,7 @@ class TestErrorTranslation: config, _, _ = _async_config(error=OperationFailure("auth failed", code=18)) - with pytest.raises(ValueError, match="rejected the credentials"): + with pytest.raises(BadRequestError, match="rejected the credentials"): await _asearch(config) @@ -645,14 +648,14 @@ class TestMissingDriver: from litellm.llms.mongodb.common_utils import import_sync_mongo_client with patch.dict(sys.modules, {"pymongo": None}): - with pytest.raises(ValueError, match=r"pip install litellm\[mongodb\]"): + with pytest.raises(BadRequestError, match=r"pip install litellm\[mongodb\]"): import_sync_mongo_client() def test_the_async_import_names_the_extra_to_install(self): from litellm.llms.mongodb.common_utils import import_async_mongo_client with patch.dict(sys.modules, {"pymongo": None}): - with pytest.raises(ValueError, match=r"pip install litellm\[mongodb\]"): + with pytest.raises(BadRequestError, match=r"pip install litellm\[mongodb\]"): import_async_mongo_client() def test_error_translation_degrades_gracefully_without_the_driver(self): @@ -670,7 +673,7 @@ class TestEmptyResultsAreDisambiguated: def test_a_missing_index_becomes_an_error_rather_than_an_empty_page(self): config, _, collection = _config(documents=[], search_indexes=[]) - with pytest.raises(ValueError, match="No queryable Atlas Vector Search index"): + with pytest.raises(BadRequestError, match="No queryable Atlas Vector Search index"): _search(config) assert collection.listed_indexes == [INDEX] @@ -678,7 +681,7 @@ class TestEmptyResultsAreDisambiguated: def test_the_missing_index_error_explains_why_mongodb_reported_no_results(self): config, _, _ = _config(documents=[], search_indexes=[]) - with pytest.raises(ValueError, match="returns no results rather than an error"): + with pytest.raises(BadRequestError, match="returns no results rather than an error"): _search(config) def test_an_index_still_building_becomes_an_error_naming_its_status(self): @@ -686,7 +689,7 @@ class TestEmptyResultsAreDisambiguated: documents=[], search_indexes=[{"name": INDEX, "status": "PENDING", "queryable": False}] ) - with pytest.raises(ValueError, match="not queryable yet; its status is PENDING"): + with pytest.raises(BadRequestError, match="not queryable yet; its status is PENDING"): _search(config) def test_a_genuine_no_match_against_a_ready_index_returns_an_empty_page(self): @@ -709,7 +712,7 @@ class TestEmptyResultsAreDisambiguated: async def test_async_missing_index_becomes_an_error_rather_than_an_empty_page(self): config, _, collection = _async_config(documents=[], search_indexes=[]) - with pytest.raises(ValueError, match="No queryable Atlas Vector Search index"): + with pytest.raises(BadRequestError, match="No queryable Atlas Vector Search index"): await _asearch(config) assert collection.listed_indexes == [INDEX] @@ -720,7 +723,7 @@ class TestEmptyResultsAreDisambiguated: documents=[], search_indexes=[{"name": INDEX, "status": "PENDING", "queryable": False}] ) - with pytest.raises(ValueError, match="not queryable yet; its status is PENDING"): + with pytest.raises(BadRequestError, match="not queryable yet; its status is PENDING"): await _asearch(config) @pytest.mark.asyncio @@ -752,7 +755,7 @@ class TestEmptyResultsAreDisambiguated: sync_client_factory=lambda key: FakeClient(collection), ) - with pytest.raises(ValueError, match="lacks read access"): + with pytest.raises(BadRequestError, match="lacks read access"): _search(config) @@ -785,3 +788,75 @@ class TestAtlasPlanExecutorErrors: assert "does not match the vector dimensions" in str(translated) assert "mongodb_embedding_field" not in str(translated) + + +class TestErrorsCarryTheRightHttpStatus: + """litellm.exception_type passes a litellm exception through untouched but wraps anything + else into APIConnectionError, which the proxy serves as a 500 with a Python traceback in the + body. A misconfigured connection string is the caller's to fix, so it has to arrive as a 400. + """ + + @pytest.mark.parametrize( + "invoke", + [ + pytest.param(lambda: _search(_config()[0], query=" "), id="empty-query"), + pytest.param( + lambda: _search(_config()[0], optional_params={"max_num_results": 999}), + id="max-num-results-out-of-range", + ), + pytest.param( + lambda: _search(_config()[0], optional_params={"filters": {"genre": "Action"}}), + id="unsupported-filters", + ), + pytest.param( + lambda: _search(_config()[0], litellm_params={"mongodb_connection_string": "postgres://host/db"}), + id="wrong-uri-scheme", + ), + pytest.param( + lambda: _search(_config()[0], litellm_params={"mongodb_database": None}), id="missing-database" + ), + pytest.param( + lambda: _search(_config()[0], litellm_params={"litellm_embedding_model": None}), + id="missing-embedding-model", + ), + ], + ) + def test_configuration_failures_are_400(self, invoke): + with pytest.raises(BadRequestError) as excinfo: + invoke() + assert excinfo.value.status_code == 400 + assert excinfo.value.llm_provider == "mongodb" + + def test_missing_index_is_400(self): + error = missing_index_error("idx", "db", "coll") + assert error.status_code == 400 + assert error.llm_provider == "mongodb" + + def test_index_still_building_is_400(self): + error = index_not_ready_error("idx", "db", "coll", "PENDING") + assert error.status_code == 400 + + def test_unreachable_deployment_is_a_timeout_not_a_bad_request(self): + from pymongo.errors import ServerSelectionTimeoutError + + translated = translate_mongo_error( + ServerSelectionTimeoutError("no servers"), index_name="idx", database="db", collection="coll" + ) + assert isinstance(translated, Timeout) + assert translated.status_code == 408 + + def test_query_execution_timeout_is_a_timeout(self): + from pymongo.errors import ExecutionTimeout + + translated = translate_mongo_error( + ExecutionTimeout("too slow"), index_name="idx", database="db", collection="coll" + ) + assert isinstance(translated, Timeout) + assert translated.status_code == 408 + + def test_unrecognised_errors_are_not_relabelled_as_bad_requests(self): + original = RuntimeError("something else entirely") + assert ( + translate_mongo_error(original, index_name="idx", database="db", collection="coll") + is original + ) From 85431297b91f8b0dec48648e2244c837de6c743e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 10:03:23 -0700 Subject: [PATCH 027/410] fix(vector_stores): name the connection string when Atlas rejects MongoDB credentials Atlas answers a wrong password with code 8000 "AtlasError" rather than the 18 a self-hosted deployment returns, so the code-only check never fired and a bad password came back as a generic "MongoDB rejected the vector search", pointing the reader at the index instead of at their credentials. Verified live against Atlas with a tampered password. --- litellm/llms/mongodb/common_utils.py | 9 +++++-- .../test_mongodb_transformation.py | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 299a9772817..12391b80959 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -110,6 +110,9 @@ def reset_client_cache() -> None: _AUTHENTICATION_FAILED_CODE: Final = 18 _UNAUTHORIZED_CODE: Final = 13 +# Atlas reports a rejected user as code 8000 "AtlasError" rather than 18, so the +# message is the only reliable signal for a serverless or shared-tier deployment. +_AUTHENTICATION_MESSAGE_MARKERS: Final = ("bad auth", "authentication failed", "not authorized") def _index_hint(index_name: str, database: str, collection: str) -> str: @@ -169,12 +172,14 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll ) if isinstance(error, OperationFailure): code: Final = error.code - if code in (_AUTHENTICATION_FAILED_CODE, _UNAUTHORIZED_CODE): + detail: Final = str(error).lower() + if code in (_AUTHENTICATION_FAILED_CODE, _UNAUTHORIZED_CODE) or any( + marker in detail for marker in _AUTHENTICATION_MESSAGE_MARKERS + ): return config_error( "MongoDB rejected the credentials in mongodb_connection_string, or the database user " f"lacks read access to '{database}.{collection}'. Driver detail: {error.details}" ) - detail: Final = str(error).lower() if "dimension" in detail: return config_error( "The query embedding does not match the vector dimensions the Atlas index was built for. " diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 1e5bce3f0ee..15e1b1efab5 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -860,3 +860,30 @@ class TestErrorsCarryTheRightHttpStatus: translate_mongo_error(original, index_name="idx", database="db", collection="coll") is original ) + + +def test_atlas_rejected_credentials_are_named_even_though_the_code_is_8000(): + """Atlas answers a wrong password with code 8000 "AtlasError", not the 18 that a + self-hosted deployment returns, so a code-only check reports it as a generic + rejected search and never tells the caller to look at their connection string.""" + from pymongo.errors import OperationFailure + + error = OperationFailure( + "bad auth : authentication failed", + code=8000, + details={"ok": 0, "errmsg": "bad auth : authentication failed", "code": 8000, "codeName": "AtlasError"}, + ) + translated = translate_mongo_error(error, index_name="idx", database="sample_mflix", collection="embedded_movies") + + assert isinstance(translated, BadRequestError) + assert "mongodb_connection_string" in str(translated) + assert "sample_mflix.embedded_movies" in str(translated) + + +def test_a_rejected_search_that_is_not_an_auth_failure_keeps_the_generic_message(): + from pymongo.errors import OperationFailure + + error = OperationFailure("PlanExecutor error", code=8, details={"errmsg": "PlanExecutor error"}) + translated = translate_mongo_error(error, index_name="idx", database="db", collection="coll") + + assert "mongodb_connection_string" not in str(translated) From 1f8cbee8aa306c5f169d11b49dbf1fe28010ffd4 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 10:22:01 -0700 Subject: [PATCH 028/410] feat(ui): add MongoDB Atlas to the vector store provider dropdown The create form now offers MongoDB Atlas with its connection string, database, collection, embedding model, vector field, text field and candidate count. The connection string renders as a password input because it carries the database user's password, and the embedding model is picked from the proxy's own models, matching how Milvus and Valkey do it. The vector store id doubles as the Atlas Vector Search index name, so the placeholder says so. --- .../public/assets/logos/mongodb.svg | 6 ++ .../_components/VectorStoreForm.test.tsx | 51 ++++++++++++++ .../_components/VectorStoreForm.tsx | 20 +++++- .../vector_store_providers.test.tsx | 41 +++++++++++ .../src/components/vector_store_providers.tsx | 69 +++++++++++++++++++ 5 files changed, 185 insertions(+), 2 deletions(-) create mode 100644 ui/litellm-dashboard/public/assets/logos/mongodb.svg diff --git a/ui/litellm-dashboard/public/assets/logos/mongodb.svg b/ui/litellm-dashboard/public/assets/logos/mongodb.svg new file mode 100644 index 00000000000..fb0d3cbdfab --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/mongodb.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx index 71e2a7224ae..94aa6cc99a0 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx @@ -110,6 +110,57 @@ describe("buildVectorStoreLitellmParams", () => { }); }); + it("renames embedding_model to litellm_embedding_model for mongodb", () => { + const params = buildVectorStoreLitellmParams("mongodb", { + mongodb_connection_string: "mongodb+srv://user:pass@cluster0.mongodb.net", + mongodb_database: "sample_mflix", + mongodb_collection: "embedded_movies", + mongodb_embedding_field: "plot_embedding", + mongodb_text_field: "plot", + mongodb_num_candidates: "200", + embedding_model: "text-embedding-ada-002", + }); + + expect(params).toEqual({ + mongodb_connection_string: "mongodb+srv://user:pass@cluster0.mongodb.net", + mongodb_database: "sample_mflix", + mongodb_collection: "embedded_movies", + mongodb_embedding_field: "plot_embedding", + mongodb_text_field: "plot", + mongodb_num_candidates: "200", + litellm_embedding_model: "text-embedding-ada-002", + }); + }); + + it("sends only mongodb fields when an earlier provider left values in the form", () => { + const params = buildVectorStoreLitellmParams("mongodb", { + mongodb_connection_string: "mongodb+srv://user:pass@cluster0.mongodb.net", + mongodb_database: "sample_mflix", + mongodb_collection: "embedded_movies", + embedding_model: "text-embedding-ada-002", + valkey_host: "left-over-from-valkey.example.com", + valkey_port: "6379", + aws_region_name: "us-west-2", + }); + + expect(params).not.toHaveProperty("valkey_host"); + expect(params).not.toHaveProperty("valkey_port"); + expect(params).not.toHaveProperty("aws_region_name"); + expect(params.mongodb_connection_string).toBe("mongodb+srv://user:pass@cluster0.mongodb.net"); + }); + + it("omits a blank mongodb_num_candidates so litellm picks its own candidate count", () => { + const params = buildVectorStoreLitellmParams("mongodb", { + mongodb_connection_string: "mongodb+srv://user:pass@cluster0.mongodb.net", + mongodb_database: "sample_mflix", + mongodb_collection: "embedded_movies", + embedding_model: "text-embedding-ada-002", + }); + + expect(params.mongodb_num_candidates).toBeUndefined(); + expect(JSON.parse(JSON.stringify(params))).not.toHaveProperty("mongodb_num_candidates"); + }); + it("keeps embedding_model as-is for providers outside the rename set", () => { const params = buildVectorStoreLitellmParams("s3_vectors", { vector_bucket_name: "my-vector-bucket", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx index 9d78b727768..e25dbe30005 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx @@ -34,7 +34,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { useZodForm } from "@/lib/forms/useZodForm"; -const EMBEDDING_MODEL_RENAME_PROVIDERS = new Set(["milvus", "valkey"]); +const EMBEDDING_MODEL_RENAME_PROVIDERS = new Set(["milvus", "valkey", "mongodb"]); export const buildVectorStoreLitellmParams = ( provider: string, @@ -70,6 +70,12 @@ const PROVIDER_FIELD_NAMES = [ "vector_bucket_name", "index_name", "aws_region_name", + "mongodb_connection_string", + "mongodb_database", + "mongodb_collection", + "mongodb_embedding_field", + "mongodb_text_field", + "mongodb_num_candidates", "valkey_host", "valkey_port", "valkey_password", @@ -101,6 +107,12 @@ const vectorStoreShape = { vector_bucket_name: optionalText, index_name: optionalText, aws_region_name: optionalText, + mongodb_connection_string: optionalText, + mongodb_database: optionalText, + mongodb_collection: optionalText, + mongodb_embedding_field: optionalText, + mongodb_text_field: optionalText, + mongodb_num_candidates: optionalText, valkey_host: optionalText, valkey_port: optionalText, valkey_password: optionalText, @@ -130,6 +142,8 @@ const EMPTY_VALUES: VectorStoreFormValues = { custom_llm_provider: "bedrock", vector_store_id: "", vertex_location: "global", + mongodb_embedding_field: "embedding", + mongodb_text_field: "text", valkey_port: "6379", valkey_ssl: "false", valkey_text_field: "text", @@ -262,7 +276,9 @@ const VectorStoreForm: React.FC = ({ : 'my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)' : selectedProvider === "valkey" ? "my-search-index (FT index name in Valkey)" - : "Enter vector store ID from your provider"; + : selectedProvider === "mongodb" + ? "my-vector-index (Atlas Vector Search index name)" + : "Enter vector store ID from your provider"; return ( !open && handleCancel()}> diff --git a/ui/litellm-dashboard/src/components/vector_store_providers.test.tsx b/ui/litellm-dashboard/src/components/vector_store_providers.test.tsx index eaf2a52853f..8e3a3aa3402 100644 --- a/ui/litellm-dashboard/src/components/vector_store_providers.test.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_providers.test.tsx @@ -28,6 +28,47 @@ describe("getVectorStoreProviderLogoAndName", () => { }); }); + it("registers mongodb in the provider, logo, and field maps", () => { + expect(getVectorStoreProviderLogoAndName("mongodb")).toEqual({ + logo: expect.stringContaining("mongodb"), + displayName: VectorStoreProviders.MongoDB, + }); + expect(vectorStoreProviderMap.MongoDB).toBe("mongodb"); + expect(getProviderSpecificFields("mongodb").map((field) => field.name)).toEqual([ + "mongodb_connection_string", + "mongodb_database", + "mongodb_collection", + "embedding_model", + "mongodb_embedding_field", + "mongodb_text_field", + "mongodb_num_candidates", + ]); + }); + + it("hides the mongodb connection string, which carries the database password", () => { + const connectionString = getProviderSpecificFields("mongodb").find( + (field) => field.name === "mongodb_connection_string", + ); + + expect(connectionString).toMatchObject({ type: "password", required: true }); + }); + + it("picks the mongodb embedding model from the proxy's models rather than a fixed list", () => { + const embeddingField = getProviderSpecificFields("mongodb").find((field) => field.name === "embedding_model"); + + expect(embeddingField).toMatchObject({ type: "select", required: true }); + expect(embeddingField).not.toHaveProperty("options"); + }); + + it("defaults the mongodb field names so a standard collection needs no extra input", () => { + const fields = getProviderSpecificFields("mongodb"); + const byName = (name: string) => fields.find((field) => field.name === name); + + expect(byName("mongodb_embedding_field")).toMatchObject({ required: false, initialValue: "embedding" }); + expect(byName("mongodb_text_field")).toMatchObject({ required: false, initialValue: "text" }); + expect(byName("mongodb_num_candidates")).toMatchObject({ required: false }); + }); + it("registers valkey in the provider, logo, and field maps", () => { expect(vectorStoreProviderMap.Valkey).toBe("valkey"); expect(vectorStoreProviderLogoMap[VectorStoreProviders.Valkey]).toContain("valkey"); diff --git a/ui/litellm-dashboard/src/components/vector_store_providers.tsx b/ui/litellm-dashboard/src/components/vector_store_providers.tsx index 35cd5c383f7..a75f10771a8 100644 --- a/ui/litellm-dashboard/src/components/vector_store_providers.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_providers.tsx @@ -1,5 +1,6 @@ import { getProviderLogoAndName, Providers, providerLogoMap } from "@/components/provider_info_helpers"; import milvusLogo from "../../public/assets/logos/milvus.svg"; +import mongodbLogo from "../../public/assets/logos/mongodb.svg"; import postgresqlLogo from "../../public/assets/logos/postgresql.svg"; import s3VectorLogo from "../../public/assets/logos/s3_vector.png"; import valkeyLogo from "../../public/assets/logos/valkey.svg"; @@ -13,6 +14,7 @@ export enum VectorStoreProviders { OpenAI = "OpenAI", Azure = "Azure OpenAI", Milvus = "Milvus", + MongoDB = "MongoDB Atlas", Valkey = "Valkey", } @@ -24,6 +26,7 @@ export const vectorStoreProviderMap: Record = { OpenAI: "openai", Azure: "azure", Milvus: "milvus", + MongoDB: "mongodb", S3Vectors: "s3_vectors", Valkey: "valkey", }; @@ -36,6 +39,7 @@ export const vectorStoreProviderLogoMap: Record = { [VectorStoreProviders.OpenAI]: providerLogoMap[Providers.OpenAI] ?? "", [VectorStoreProviders.Azure]: providerLogoMap[Providers.Azure] ?? "", [VectorStoreProviders.Milvus]: milvusLogo.src, + [VectorStoreProviders.MongoDB]: mongodbLogo.src, [VectorStoreProviders.S3Vectors]: s3VectorLogo.src, [VectorStoreProviders.Valkey]: valkeyLogo.src, }; @@ -169,6 +173,71 @@ export const vectorStoreProviderFields: Record type: "select", }, ], + mongodb: [ + { + name: "mongodb_connection_string", + label: "Connection String", + tooltip: + "The full MongoDB connection string for your Atlas cluster, including the database user and password. Copy it from Atlas under Connect, Drivers (e.g. mongodb+srv://user:password@cluster.mongodb.net)", + placeholder: "mongodb+srv://user:password@cluster.mongodb.net", + required: true, + type: "password", + }, + { + name: "mongodb_database", + label: "Database", + tooltip: "The Atlas database holding the collection you want to search", + placeholder: "sample_mflix", + required: true, + type: "text", + }, + { + name: "mongodb_collection", + label: "Collection", + tooltip: "The collection your Atlas Vector Search index was built on", + placeholder: "embedded_movies", + required: true, + type: "text", + }, + { + name: "embedding_model", + label: "Embedding Model", + tooltip: + "The embedding model on this proxy that created the vectors already stored in your collection. LiteLLM embeds every search query with it, so it must be the same model. A different model of the same size will not error, it will just return wrong results. Add it under Models first if it is not listed", + placeholder: "text-embedding-3-small", + required: true, + type: "select", + }, + { + name: "mongodb_embedding_field", + label: "Vector Field Name", + tooltip: + "The field in each document that holds its embedding. It must match the path your Atlas Vector Search index was created on (default: embedding)", + placeholder: "embedding", + required: false, + type: "text", + initialValue: "embedding", + }, + { + name: "mongodb_text_field", + label: "Text Field", + tooltip: + "The field in each document that holds its readable text. LiteLLM returns this text in search results, and it accepts a dotted path such as metadata.body (default: text)", + placeholder: "text", + required: false, + type: "text", + initialValue: "text", + }, + { + name: "mongodb_num_candidates", + label: "Candidates Considered", + tooltip: + "How many nearest neighbours Atlas examines before returning the top results. Higher is more accurate and slower. Leave blank to let LiteLLM scale it with the requested result count", + placeholder: "100", + required: false, + type: "text", + }, + ], valkey: [ { name: "valkey_host", From a472484291fcc5b5a386c92855130c58e423a7e1 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 10:36:36 -0700 Subject: [PATCH 029/410] fix(vector_stores): stop MongoDB handing a new event loop a closed loop's client The async client cache was keyed on id(loop). CPython recycles those ids so aggressively that a fresh event loop nearly always lands on the id of one already collected, measured at 37 of 40 rounds, so the cache handed the new loop an AsyncMongoClient bound to a closed loop and every operation on it raised "Event loop is closed". The entry now carries a weak reference to the loop it was built on and a hit only counts when that reference still points at the running loop, so a recycled id misses and builds a fresh client. A stale entry can also be replaced once the cache is full, which the old size check prevented. pymongo's own client keeps its loop alive, which is why the sync proxy path never saw this; a script calling asyncio.run() per search, or a test suite with a loop per test, does. --- litellm/llms/mongodb/common_utils.py | 20 ++++++--- .../test_mongodb_transformation.py | 43 +++++++++++++++++++ 2 files changed, 57 insertions(+), 6 deletions(-) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 12391b80959..4aafaf86a5e 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -9,6 +9,8 @@ TLS handshake and topology discovery: measured at ~890ms against Atlas versus """ import asyncio +import weakref +from asyncio import AbstractEventLoop from dataclasses import dataclass from typing import TYPE_CHECKING, Final @@ -53,7 +55,12 @@ class MongoClientKey: _sync_clients: dict[MongoClientKey, "MongoClient"] = {} # mutable-ok: process-level connection cache, see module docstring -_async_clients: dict[tuple[MongoClientKey, int], "AsyncMongoClient"] = {} # mutable-ok: same cache, keyed per event loop +# The value carries a weak reference to the loop the client was built on: CPython recycles +# id() aggressively (measured: 200 of 200 fresh loops landed on an id already in this cache), +# so the id alone would hand a new loop a client bound to a closed one. +_async_clients: dict[ # mutable-ok: same cache, keyed per event loop + tuple[MongoClientKey, int], tuple["weakref.ref[AbstractEventLoop]", "AsyncMongoClient"] +] = {} def import_sync_mongo_client() -> "type[MongoClient]": @@ -93,13 +100,14 @@ def get_sync_client(key: MongoClientKey) -> "MongoClient": def get_async_client(key: MongoClientKey) -> "AsyncMongoClient": """Async clients bind to the loop that created them, so the cache is keyed per loop.""" - loop_key: Final = (key, id(asyncio.get_running_loop())) + loop: Final = asyncio.get_running_loop() + loop_key: Final = (key, id(loop)) cached: Final = _async_clients.get(loop_key) - if cached is not None: - return cached + if cached is not None and cached[0]() is loop: + return cached[1] client: Final = import_async_mongo_client()(key.connection_string, **_client_kwargs(key)) - if len(_async_clients) < _MAX_CACHED_CLIENTS: - _async_clients[loop_key] = client + if len(_async_clients) < _MAX_CACHED_CLIENTS or loop_key in _async_clients: + _async_clients[loop_key] = (weakref.ref(loop), client) return client diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 15e1b1efab5..9e2bccc3f26 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -1,4 +1,7 @@ +import asyncio +import gc import sys +import weakref from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -541,6 +544,46 @@ class TestClientCache: assert first is second + def test_a_new_loop_never_inherits_a_closed_loop_client(self): + """CPython recycles id() so aggressively that a fresh event loop almost always lands on + the id of one already collected: measured at 37 of 40 rounds. Keying the cache on the id + alone therefore hands the new loop an AsyncMongoClient bound to a closed loop, and every + operation on it raises "Event loop is closed".""" + + class LoopAgnosticClient: + """Holds no reference to the loop, unlike pymongo's, whose own reference happens to + keep ids from being recycled and hides the bug until the cache fills.""" + + def __init__(self, *args, **kwargs): + self.built_on = None + + key = self._key() + clients_handed_out = [] + + async def fetch(): + return get_async_client(key) + + with patch("litellm.llms.mongodb.common_utils.import_async_mongo_client") as importer: + importer.return_value = LoopAgnosticClient + + for _ in range(20): + loop = asyncio.new_event_loop() + client = loop.run_until_complete(fetch()) + clients_handed_out.append((client, client.built_on, loop.is_closed())) + client.built_on = weakref.ref(loop) + loop.close() + del loop + gc.collect() + + stale = [ + handed_out + for client, built_on, _ in clients_handed_out + if built_on is not None and (built_on() is None or built_on().is_closed()) + for handed_out in (client,) + ] + assert stale == [], f"{len(stale)} of 20 loops were handed a client built on a closed loop" + + class TestClientKeyDerivation: def test_no_timeout_uses_the_bounded_defaults(self): key = MongoDBVectorStoreConfig._client_key(_MongoDBSearchParams.model_validate(BASE_PARAMS), None) From 1fed1029e01c84f447f52119ef96757a62ae69b0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 11:12:51 -0700 Subject: [PATCH 030/410] fix(vector_stores): report a MongoDB text field that no matched document has Atlas matches on the vector alone, so a mistyped mongodb_text_field still returns confidently scored results whose content is empty, and the model is handed an empty context with nothing to explain it. When every matched document lacks the field the search now says which setting to fix; a sparse document among others that do have it, and a document whose text is genuinely the empty string, both still come back normally. Unrecognised mongodb_* parameters are named too. The params model has to ignore unrelated keys because litellm_params carries plenty of them, which turned a mistyped mongodb_collection into "mongodb_collection is required" pointing the reader at a key they can see they have set. --- .../mongodb/vector_stores/transformation.py | 54 ++++++++++++++++-- .../test_mongodb_transformation.py | 57 ++++++++++++++++++- 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 2570e368990..8b8ca5188cf 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -128,6 +128,12 @@ class _MongoDBSearchParams(BaseModel): return self.mongodb_collection +_MONGODB_PARAM_PREFIX: Final = "mongodb_" +_KNOWN_MONGODB_PARAMS: Final = frozenset( + name for name in _MongoDBSearchParams.model_fields if name.startswith(_MONGODB_PARAM_PREFIX) +) + + class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def __init__( self, @@ -142,6 +148,22 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): self.sync_client_factory = sync_client_factory if sync_client_factory is not None else get_sync_client self.async_client_factory = async_client_factory if async_client_factory is not None else get_async_client + @staticmethod + def _reject_unknown_params(litellm_params: Mapping[str, object]) -> None: + """The params model ignores unrelated keys because litellm_params carries plenty of them, + which would otherwise turn a mistyped mongodb_collection into 'mongodb_collection is + required' pointing at a key the reader can see they have set.""" + unknown: Final = sorted( + key + for key in litellm_params + if key.startswith(_MONGODB_PARAM_PREFIX) and key not in _KNOWN_MONGODB_PARAMS + ) + if unknown: + raise config_error( + f"Unrecognised MongoDB vector store parameter(s): {', '.join(unknown)}. " + f"Supported: {', '.join(sorted(_KNOWN_MONGODB_PARAMS))}." + ) + @staticmethod def _query_text(query: str | Sequence[str]) -> str: text: Final = query if isinstance(query, str) else " ".join(query) @@ -219,20 +241,22 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): ] @staticmethod - def _field_value(document: Mapping[str, object], dotted_path: str) -> str: + def _field_value(document: Mapping[str, object], dotted_path: str) -> str | None: + """None means the path is absent from the document, which is what separates a + mistyped mongodb_text_field from a document whose text is genuinely empty.""" current: object = document for segment in dotted_path.split("."): - if not isinstance(current, Mapping): - return "" - current = current.get(segment) - return "" if current is None else str(current) + if not isinstance(current, Mapping) or segment not in current: + return None + current = current[segment] + return None if current is None else str(current) @classmethod def _to_result(cls, document: Mapping[str, object], text_field: str) -> VectorStoreSearchResult: document_id: Final = document.get("_id") identifier: Final = None if document_id is None else str(document_id) content: Final = [ # mutable-ok: VectorStoreSearchResult declares a list of content parts - VectorStoreResultContent(text=cls._field_value(document, text_field), type="text") + VectorStoreResultContent(text=cls._field_value(document, text_field) or "", type="text") ] raw_score: Final = document.get(SCORE_FIELD_NAME) return VectorStoreSearchResult( @@ -242,6 +266,20 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): filename=identifier, ) + @classmethod + def _raise_for_missing_text_field( + cls, documents: Sequence[Mapping[str, object]], text_field: str, database: str, collection: str + ) -> None: + """Atlas happily matches vectors in documents that carry no text at all, so a mistyped + mongodb_text_field returns well-scored results whose content is empty and feeds an empty + context to the model. Every matched document lacking the field is the misconfiguration.""" + if documents and all(cls._field_value(document, text_field) is None for document in documents): + raise config_error( + f"None of the {len(documents)} matched documents in '{database}.{collection}' has a " + f"'{text_field}' field, so every result would carry empty text. Set mongodb_text_field " + "to the field holding the readable text; it accepts a dotted path such as metadata.body." + ) + @classmethod def _to_response( cls, documents: Sequence[Mapping[str, object]], query_text: str, text_field: str @@ -284,6 +322,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): litellm_params: Mapping[str, object], timeout: float | httpx.Timeout | None = None, ) -> VectorStoreSearchResponse: + self._reject_unknown_params(litellm_params) params: Final = _MongoDBSearchParams.model_validate(litellm_params) query_text: Final = self._query_text(query) key: Final = self._client_key(params, timeout) @@ -315,6 +354,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): e, index_name=vector_store_id, database=database, collection=collection ) from e self._raise_for_unusable_index(catalogue, vector_store_id, database, collection) + self._raise_for_missing_text_field(documents, params.text_field, database, collection) return self._to_response(documents, query_text, params.text_field) async def aexecute_search_vector_store_request( @@ -326,6 +366,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): litellm_params: Mapping[str, object], timeout: float | httpx.Timeout | None = None, ) -> VectorStoreSearchResponse: + self._reject_unknown_params(litellm_params) params: Final = _MongoDBSearchParams.model_validate(litellm_params) query_text: Final = self._query_text(query) key: Final = self._client_key(params, timeout) @@ -359,6 +400,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): e, index_name=vector_store_id, database=database, collection=collection ) from e self._raise_for_unusable_index(catalogue, vector_store_id, database, collection) + self._raise_for_missing_text_field(documents, params.text_field, database, collection) return self._to_response(documents, query_text, params.text_field) def transform_create_vector_store_request( diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 9e2bccc3f26..68437487176 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -275,12 +275,30 @@ def test_response_reads_a_dotted_text_field_path(): assert response["data"][0]["content"][0]["text"] == "nested text" -def test_response_tolerates_a_document_missing_the_text_field(): - config, _, _ = _config(documents=[{"_id": 1, "score": 0.5}]) +def test_response_tolerates_a_sparse_document_missing_the_text_field(): + config, _, _ = _config(documents=[{"_id": 1, "score": 0.5}, {"_id": 2, "text": "has text", "score": 0.4}]) response = _search(config) assert response["data"][0]["content"][0]["text"] == "" + assert response["data"][1]["content"][0]["text"] == "has text" + + +def test_a_present_but_empty_text_field_is_not_treated_as_a_misconfiguration(): + config, _, _ = _config(documents=[{"_id": 1, "text": "", "score": 0.5}]) + + response = _search(config) + + assert response["data"][0]["content"][0]["text"] == "" + + +def test_matches_that_all_lack_the_text_field_name_the_setting_to_fix(): + """Atlas matches on the vector, so a mistyped mongodb_text_field returns confidently + scored results whose content is empty and hands the model an empty context.""" + config, _, _ = _config(documents=[{"_id": 1, "score": 0.9}, {"_id": 2, "score": 0.8}]) + + with pytest.raises(BadRequestError, match="mongodb_text_field"): + _search(config) def test_response_tolerates_a_document_missing_a_score(): @@ -930,3 +948,38 @@ def test_a_rejected_search_that_is_not_an_auth_failure_keeps_the_generic_message translated = translate_mongo_error(error, index_name="idx", database="db", collection="coll") assert "mongodb_connection_string" not in str(translated) + + +class TestUnrecognisedParameters: + """litellm_params carries plenty of keys this provider does not own, so the params model has + to ignore extras. That turns a mistyped mongodb_collection into 'mongodb_collection is + required', pointing the reader at a key they can see they have set.""" + + def test_a_mistyped_parameter_is_named(self): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="mongodb_collectoin"): + _search(config, litellm_params={"mongodb_collectoin": "embedded_movies"}) + + def test_the_supported_names_are_listed(self): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="mongodb_connection_string"): + _search(config, litellm_params={"mongodb_databse": "sample_mflix"}) + + def test_unrelated_litellm_params_are_still_ignored(self): + config, _, _ = _config(documents=[{"_id": 1, "text": "hit", "score": 0.9}]) + + response = _search( + config, + litellm_params={"use_litellm_proxy": False, "use_in_pass_through": False, "vector_store_id": "x"}, + ) + + assert len(response["data"]) == 1 + + @pytest.mark.asyncio + async def test_the_async_path_rejects_them_too(self): + config, _, _ = _async_config() + + with pytest.raises(BadRequestError, match="mongodb_collectoin"): + await _asearch(config, litellm_params={"mongodb_collectoin": "embedded_movies"}) From 9434e563f33bc06165da3e7dce20187bda29e76b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 11:28:33 -0700 Subject: [PATCH 031/410] fix(vector_stores): translate MongoDB client construction failures too Building the client parses the URI and, for mongodb+srv://, performs a DNS SRV lookup, so it fails on exactly the inputs a user is most likely to get wrong. It sat outside the try that translates driver errors, so a malformed URI or an unresolvable cluster escaped as a raw pymongo exception and reached the caller as a 500 with a traceback in the body. The three DNS-shaped failures are also told apart now: a lookup that ran out of time is a Timeout, a cluster name that is not in DNS says so and points at the URI Atlas shows under Connect Drivers, and anything else keeps the generic "not a usable MongoDB connection string". Verified live: a tampered scheme, a nonexistent cluster and a 1ms timeout each come back as their own message instead of a traceback. --- litellm/llms/mongodb/common_utils.py | 14 +++++ .../mongodb/vector_stores/transformation.py | 8 +-- .../test_mongodb_transformation.py | 58 +++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 4aafaf86a5e..48496eee170 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -121,6 +121,8 @@ _UNAUTHORIZED_CODE: Final = 13 # Atlas reports a rejected user as code 8000 "AtlasError" rather than 18, so the # message is the only reliable signal for a serverless or shared-tier deployment. _AUTHENTICATION_MESSAGE_MARKERS: Final = ("bad auth", "authentication failed", "not authorized") +_RESOLUTION_TIMEOUT_MARKERS: Final = ("resolution lifetime expired", "dns operation timed out") +_UNKNOWN_HOSTNAME_MARKERS: Final = ("dns query name does not exist", "name or service not known") def _index_hint(index_name: str, database: str, collection: str) -> str: @@ -206,6 +208,18 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll f"'{index_name}'. Driver detail: {error}" ) if isinstance(error, ConfigurationError): + configuration_detail: Final = str(error).lower() + if any(marker in configuration_detail for marker in _RESOLUTION_TIMEOUT_MARKERS): + return timeout_error( + "The DNS lookup for the cluster in mongodb_connection_string did not finish in time. " + "A mongodb+srv:// URI needs an SRV lookup before any connection is attempted, so this " + f"is DNS or the configured timeout, not MongoDB. Driver detail: {error}" + ) + if any(marker in configuration_detail for marker in _UNKNOWN_HOSTNAME_MARKERS): + return config_error( + "The cluster hostname in mongodb_connection_string does not exist in DNS. Check the " + f"cluster name against the URI Atlas shows under Connect, Drivers. Driver detail: {error}" + ) return config_error( "mongodb_connection_string is not a usable MongoDB connection string. " f"Driver detail: {error}" diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 8b8ca5188cf..4b792accc9f 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -338,9 +338,9 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): vector_store_id, self._embedding_vector(embedding_response), params, vector_store_search_optional_params ) - client: Final = self.sync_client_factory(key) - target: Final = client[database][collection] # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted try: + client: Final = self.sync_client_factory(key) + target: Final = client[database][collection] # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted documents: Final = list(target.aggregate(pipeline)) except Exception as e: raise translate_mongo_error( @@ -382,9 +382,9 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): vector_store_id, self._embedding_vector(embedding_response), params, vector_store_search_optional_params ) - client: Final = self.async_client_factory(key) - target: Final = client[database][collection] # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted try: + client: Final = self.async_client_factory(key) + target: Final = client[database][collection] # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted cursor: Final = await target.aggregate(pipeline) documents: Final = [document async for document in cursor] except Exception as e: diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 68437487176..84de42d2126 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -983,3 +983,61 @@ class TestUnrecognisedParameters: with pytest.raises(BadRequestError, match="mongodb_collectoin"): await _asearch(config, litellm_params={"mongodb_collectoin": "embedded_movies"}) + + +class TestClientConstructionFailures: + """Building the client parses the URI and, for mongodb+srv://, performs a DNS SRV lookup, so it + fails on exactly the inputs a user is most likely to get wrong. Constructing it outside the + translation boundary let those escape as raw pymongo errors, which litellm.exception_type then + wrapped into a 500 with a traceback in the body.""" + + def _config_that_fails_to_connect(self, error): + def factory(_key): + raise error + + return MongoDBVectorStoreConfig( + embedding_fn=FakeEmbeddingFn([0.1, 0.2, 0.3]), sync_client_factory=factory + ) + + def _async_config_that_fails_to_connect(self, error): + def factory(_key): + raise error + + return MongoDBVectorStoreConfig( + aembedding_fn=FakeAsyncEmbeddingFn([0.1, 0.2, 0.3]), async_client_factory=factory + ) + + def test_a_malformed_uri_is_a_bad_request_not_a_500(self): + from pymongo.errors import InvalidURI + + config = self._config_that_fails_to_connect(InvalidURI("Invalid URI scheme")) + + with pytest.raises(BadRequestError, match="not a usable MongoDB connection string"): + _search(config) + + def test_an_unresolvable_cluster_name_says_so(self): + from pymongo.errors import ConfigurationError + + config = self._config_that_fails_to_connect(ConfigurationError("The DNS query name does not exist")) + + with pytest.raises(BadRequestError, match="does not exist in DNS"): + _search(config) + + def test_a_dns_lookup_that_ran_out_of_time_is_a_timeout(self): + from pymongo.errors import ConfigurationError + + config = self._config_that_fails_to_connect( + ConfigurationError("The resolution lifetime expired after 0.291 seconds") + ) + + with pytest.raises(Timeout, match="did not finish in time"): + _search(config) + + @pytest.mark.asyncio + async def test_the_async_path_translates_them_too(self): + from pymongo.errors import InvalidURI + + config = self._async_config_that_fails_to_connect(InvalidURI("Invalid URI scheme")) + + with pytest.raises(BadRequestError, match="not a usable MongoDB connection string"): + await _asearch(config) From 5d7bf187a41386536fa7f5db989738c4abfebfe5 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 11:30:36 -0700 Subject: [PATCH 032/410] refactor(ui): pick the vector store id placeholder from a map The chain had grown to four nested ternaries with a fifth level inside the Vertex Search branch, which no-nested-ternary had two suppressions for. A lookup keyed by provider drops both suppressions and leaves one condition, the Vertex Search case that depends on whether an engine id has been entered. Also hoists the MongoDB form fixtures in the tests, which the inline-object budget counts. --- ui/litellm-dashboard/eslint-suppressions.json | 5 -- .../_components/VectorStoreForm.test.tsx | 47 ++++++++++--------- .../_components/VectorStoreForm.tsx | 25 +++++----- 3 files changed, 38 insertions(+), 39 deletions(-) diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 7de7373b20b..c2bc823ea1f 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1208,11 +1208,6 @@ "count": 1 } }, - "src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx": { - "no-nested-ternary": { - "count": 2 - } - }, "src/app/(dashboard)/vector-stores/_components/index.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx index 94aa6cc99a0..84a9314ecce 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.test.tsx @@ -69,6 +69,15 @@ describe("VectorStoreForm", () => { }); }); +const MONGODB_URI = "mongodb+srv://user:pass@cluster0.mongodb.net"; + +const MONGODB_REQUIRED_FORM_VALUES = { + mongodb_connection_string: MONGODB_URI, + mongodb_database: "sample_mflix", + mongodb_collection: "embedded_movies", + embedding_model: "text-embedding-ada-002", +}; + describe("buildVectorStoreLitellmParams", () => { it("renames embedding_model to litellm_embedding_model for valkey", () => { const valkeyFormValues = { @@ -111,51 +120,43 @@ describe("buildVectorStoreLitellmParams", () => { }); it("renames embedding_model to litellm_embedding_model for mongodb", () => { - const params = buildVectorStoreLitellmParams("mongodb", { - mongodb_connection_string: "mongodb+srv://user:pass@cluster0.mongodb.net", - mongodb_database: "sample_mflix", - mongodb_collection: "embedded_movies", + const formValues = { + ...MONGODB_REQUIRED_FORM_VALUES, mongodb_embedding_field: "plot_embedding", mongodb_text_field: "plot", mongodb_num_candidates: "200", - embedding_model: "text-embedding-ada-002", - }); - - expect(params).toEqual({ - mongodb_connection_string: "mongodb+srv://user:pass@cluster0.mongodb.net", + }; + const expected = { + mongodb_connection_string: MONGODB_URI, mongodb_database: "sample_mflix", mongodb_collection: "embedded_movies", mongodb_embedding_field: "plot_embedding", mongodb_text_field: "plot", mongodb_num_candidates: "200", litellm_embedding_model: "text-embedding-ada-002", - }); + }; + + expect(buildVectorStoreLitellmParams("mongodb", formValues)).toEqual(expected); }); it("sends only mongodb fields when an earlier provider left values in the form", () => { - const params = buildVectorStoreLitellmParams("mongodb", { - mongodb_connection_string: "mongodb+srv://user:pass@cluster0.mongodb.net", - mongodb_database: "sample_mflix", - mongodb_collection: "embedded_movies", - embedding_model: "text-embedding-ada-002", + const formValues = { + ...MONGODB_REQUIRED_FORM_VALUES, valkey_host: "left-over-from-valkey.example.com", valkey_port: "6379", aws_region_name: "us-west-2", - }); + }; + + const params = buildVectorStoreLitellmParams("mongodb", formValues); expect(params).not.toHaveProperty("valkey_host"); expect(params).not.toHaveProperty("valkey_port"); expect(params).not.toHaveProperty("aws_region_name"); - expect(params.mongodb_connection_string).toBe("mongodb+srv://user:pass@cluster0.mongodb.net"); + expect(params.mongodb_connection_string).toBe(MONGODB_URI); }); it("omits a blank mongodb_num_candidates so litellm picks its own candidate count", () => { - const params = buildVectorStoreLitellmParams("mongodb", { - mongodb_connection_string: "mongodb+srv://user:pass@cluster0.mongodb.net", - mongodb_database: "sample_mflix", - mongodb_collection: "embedded_movies", - embedding_model: "text-embedding-ada-002", - }); + const params = buildVectorStoreLitellmParams("mongodb", MONGODB_REQUIRED_FORM_VALUES); expect(params.mongodb_num_candidates).toBeUndefined(); expect(JSON.parse(JSON.stringify(params))).not.toHaveProperty("mongodb_num_candidates"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx index e25dbe30005..61da25874a5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx @@ -138,6 +138,17 @@ const vectorStoreSchema = z.object(vectorStoreShape).superRefine((values, ctx) = type VectorStoreFormValues = z.output; +const VECTOR_STORE_ID_PLACEHOLDERS: Record = { + vertex_rag_engine: '6917529027641081856 (corpus ID from Vertex AI / "RAG Engine" console)', + "vertex_ai/search_api": 'my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)', + valkey: "my-search-index (FT index name in Valkey)", + mongodb: "my-vector-index (Atlas Vector Search index name)", +}; + +const VERTEX_SEARCH_API_WITH_ENGINE_PLACEHOLDER = "Any identifier you'll use to reference this in LiteLLM"; + +const DEFAULT_VECTOR_STORE_ID_PLACEHOLDER = "Enter vector store ID from your provider"; + const EMPTY_VALUES: VectorStoreFormValues = { custom_llm_provider: "bedrock", vector_store_id: "", @@ -268,17 +279,9 @@ const VectorStoreForm: React.FC = ({ }; const vectorStoreIdPlaceholder = - selectedProvider === "vertex_rag_engine" - ? '6917529027641081856 (corpus ID from Vertex AI / "RAG Engine" console)' - : selectedProvider === "vertex_ai/search_api" - ? vertexEngineId - ? "Any identifier you'll use to reference this in LiteLLM" - : 'my-datastore_1234567890 (data store ID from Vertex AI / "Agent Search" console)' - : selectedProvider === "valkey" - ? "my-search-index (FT index name in Valkey)" - : selectedProvider === "mongodb" - ? "my-vector-index (Atlas Vector Search index name)" - : "Enter vector store ID from your provider"; + selectedProvider === "vertex_ai/search_api" && vertexEngineId + ? VERTEX_SEARCH_API_WITH_ENGINE_PLACEHOLDER + : VECTOR_STORE_ID_PLACEHOLDERS[selectedProvider] ?? DEFAULT_VECTOR_STORE_ID_PLACEHOLDER; return ( !open && handleCancel()}> From fdbee3af2527c5fc1869b536deeafb4529a550ad Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 12:09:51 -0700 Subject: [PATCH 033/410] refactor(vector_stores): build the MongoDB pipeline immutably and inject the client class The type-discipline and test-quality gates blamed the branch for 4 LIT001, 12 LIT002 and 5 TQ008 violations. Rather than suppress them: - the $vectorSearch and $project stages are MappingProxyType and the query vector a tuple, verified against live Atlas to encode identically. The outer pipeline stays a list because pymongo's common.validate_list raises "pipeline must be a list, not ", which a unit test now pins. - the client caches are Final[dict[...]] and _client_kwargs returns a MappingProxyType. - _field_value recurses over the dotted path instead of rebinding a local. - _client_key declared Final locals in one branch and reassigned them in the others, so it is split into an early-returning _timeout_ms. - the injected callables carry explicit Final[Callable[...]] annotations, which stops pyright resolving self.embedding_fn against litellm.embedding's overloads. - get_sync_client and get_async_client take an optional client_class, so the cache tests inject a recording double instead of patching the importer, and can assert the connection string and timeouts the client was built with. SensitiveDataMasker is public SDK surface, so extra_sensitive_patterns moves to the end of the signature: in slot two it silently reinterpreted an existing caller's positional override set as extra sensitive patterns. --- .../sensitive_data_masker.py | 14 +-- litellm/llms/mongodb/common_utils.py | 53 +++++---- .../mongodb/vector_stores/transformation.py | 109 ++++++++++------- .../management_endpoints.py | 2 +- .../test_sensitive_data_masker.py | 12 ++ .../test_mongodb_transformation.py | 112 ++++++++++++------ 6 files changed, 192 insertions(+), 110 deletions(-) diff --git a/litellm/litellm_core_utils/sensitive_data_masker.py b/litellm/litellm_core_utils/sensitive_data_masker.py index 3b0806ab069..fcce63e016b 100644 --- a/litellm/litellm_core_utils/sensitive_data_masker.py +++ b/litellm/litellm_core_utils/sensitive_data_masker.py @@ -1,13 +1,13 @@ from collections.abc import Mapping +from collections.abc import Set as AbstractSet from typing import Any, Final from pydantic import BaseModel from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH_SENSITIVE_DATA_MASKER - _DEFAULT_SENSITIVE_PATTERNS: Final = frozenset( - { + ( "password", "secret", "key", @@ -23,20 +23,20 @@ _DEFAULT_SENSITIVE_PATTERNS: Final = frozenset( "certificate", "fingerprint", "tenancy", - } + ) ) class SensitiveDataMasker: def __init__( self, - sensitive_patterns: set[str] | None = None, - extra_sensitive_patterns: set[str] | None = None, - non_sensitive_overrides: set[str] | None = None, + sensitive_patterns: AbstractSet[str] | None = None, + non_sensitive_overrides: AbstractSet[str] | None = None, visible_prefix: int = 4, visible_suffix: int = 4, mask_char: str = "*", mask_short_values: bool = True, + extra_sensitive_patterns: AbstractSet[str] | None = None, ): self.sensitive_patterns = (sensitive_patterns or _DEFAULT_SENSITIVE_PATTERNS) | ( extra_sensitive_patterns or frozenset() @@ -44,7 +44,7 @@ class SensitiveDataMasker: # If any key segment matches one of these, the key is not considered sensitive # even if it also matches a sensitive pattern. For example, "input_cost_per_token" # contains "token" but "cost" overrides that — it's a pricing field, not a secret. - self.non_sensitive_overrides = non_sensitive_overrides or {"cost"} + self.non_sensitive_overrides = non_sensitive_overrides or frozenset(("cost",)) self.visible_prefix = visible_prefix self.visible_suffix = visible_suffix diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 48496eee170..8ac02552ecb 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -11,8 +11,10 @@ TLS handshake and topology discovery: measured at ~890ms against Atlas versus import asyncio import weakref from asyncio import AbstractEventLoop +from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import TYPE_CHECKING, Final +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, TypeAlias from litellm.exceptions import BadRequestError, Timeout @@ -54,13 +56,17 @@ class MongoClientKey: server_selection_timeout_ms: int -_sync_clients: dict[MongoClientKey, "MongoClient"] = {} # mutable-ok: process-level connection cache, see module docstring -# The value carries a weak reference to the loop the client was built on: CPython recycles -# id() aggressively (measured: 200 of 200 fresh loops landed on an id already in this cache), -# so the id alone would hand a new loop a client bound to a closed one. -_async_clients: dict[ # mutable-ok: same cache, keyed per event loop - tuple[MongoClientKey, int], tuple["weakref.ref[AbstractEventLoop]", "AsyncMongoClient"] -] = {} +SyncClientFactory: TypeAlias = Callable[..., "MongoClient"] +AsyncClientFactory: TypeAlias = Callable[..., "AsyncMongoClient"] + +_AsyncClientCacheKey: TypeAlias = tuple[MongoClientKey, int] +# The entry carries a weak reference to the loop the client was built on: CPython recycles id() +# aggressively (measured: 200 of 200 fresh loops landed on an id already in this cache), so the +# id alone would hand a new loop a client bound to a closed one. +_AsyncClientEntry: TypeAlias = tuple["weakref.ref[AbstractEventLoop]", "AsyncMongoClient"] + +_sync_clients: Final[dict[MongoClientKey, "MongoClient"]] = {} # mutable-ok: process-level client cache +_async_clients: Final[dict[_AsyncClientCacheKey, _AsyncClientEntry]] = {} # mutable-ok: same cache, per loop def import_sync_mongo_client() -> "type[MongoClient]": @@ -79,33 +85,39 @@ def import_async_mongo_client() -> "type[AsyncMongoClient]": return AsyncMongoClientClass -def _client_kwargs(key: MongoClientKey) -> dict[str, object]: - return { # mutable-ok: pymongo's client constructor takes keyword arguments - "connectTimeoutMS": key.connect_timeout_ms, - "socketTimeoutMS": key.socket_timeout_ms, - "serverSelectionTimeoutMS": key.server_selection_timeout_ms, - "appname": _APP_NAME, - } +def _client_kwargs(key: MongoClientKey) -> Mapping[str, object]: + return MappingProxyType( + { + "connectTimeoutMS": key.connect_timeout_ms, + "socketTimeoutMS": key.socket_timeout_ms, + "serverSelectionTimeoutMS": key.server_selection_timeout_ms, + "appname": _APP_NAME, + } + ) -def get_sync_client(key: MongoClientKey) -> "MongoClient": +def get_sync_client(key: MongoClientKey, client_class: SyncClientFactory | None = None) -> "MongoClient": + """``client_class`` is the injection seam the tests build fake clients through; left unset the + real pymongo class is imported at call time, keeping pymongo out of import-time dependencies.""" cached: Final = _sync_clients.get(key) if cached is not None: return cached - client: Final = import_sync_mongo_client()(key.connection_string, **_client_kwargs(key)) + build: Final = client_class if client_class is not None else import_sync_mongo_client() + client: Final = build(key.connection_string, **_client_kwargs(key)) if len(_sync_clients) < _MAX_CACHED_CLIENTS: _sync_clients[key] = client return client -def get_async_client(key: MongoClientKey) -> "AsyncMongoClient": +def get_async_client(key: MongoClientKey, client_class: AsyncClientFactory | None = None) -> "AsyncMongoClient": """Async clients bind to the loop that created them, so the cache is keyed per loop.""" loop: Final = asyncio.get_running_loop() loop_key: Final = (key, id(loop)) cached: Final = _async_clients.get(loop_key) if cached is not None and cached[0]() is loop: return cached[1] - client: Final = import_async_mongo_client()(key.connection_string, **_client_kwargs(key)) + build: Final = client_class if client_class is not None else import_async_mongo_client() + client: Final = build(key.connection_string, **_client_kwargs(key)) if len(_async_clients) < _MAX_CACHED_CLIENTS or loop_key in _async_clients: _async_clients[loop_key] = (weakref.ref(loop), client) return client @@ -221,8 +233,7 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll f"cluster name against the URI Atlas shows under Connect, Drivers. Driver detail: {error}" ) return config_error( - "mongodb_connection_string is not a usable MongoDB connection string. " - f"Driver detail: {error}" + f"mongodb_connection_string is not a usable MongoDB connection string. Driver detail: {error}" ) if isinstance(error, InvalidOperation): return config_error(f"The MongoDB client was already closed or is unusable. Driver detail: {error}") diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 4b792accc9f..d0a0f51cd77 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -143,10 +143,18 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): async_client_factory: Callable[[MongoClientKey], object] | None = None, ) -> None: super().__init__() - self.embedding_fn = embedding_fn if embedding_fn is not None else litellm.embedding - self.aembedding_fn = aembedding_fn if aembedding_fn is not None else litellm.aembedding - self.sync_client_factory = sync_client_factory if sync_client_factory is not None else get_sync_client - self.async_client_factory = async_client_factory if async_client_factory is not None else get_async_client + self.embedding_fn: Final[Callable[..., EmbeddingResponse]] = ( + embedding_fn if embedding_fn is not None else litellm.embedding + ) + self.aembedding_fn: Final[Callable[..., Awaitable[EmbeddingResponse]]] = ( + aembedding_fn if aembedding_fn is not None else litellm.aembedding + ) + self.sync_client_factory: Final[Callable[[MongoClientKey], object]] = ( + sync_client_factory if sync_client_factory is not None else get_sync_client + ) + self.async_client_factory: Final[Callable[[MongoClientKey], object]] = ( + async_client_factory if async_client_factory is not None else get_async_client + ) @staticmethod def _reject_unknown_params(litellm_params: Mapping[str, object]) -> None: @@ -154,9 +162,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): which would otherwise turn a mistyped mongodb_collection into 'mongodb_collection is required' pointing at a key the reader can see they have set.""" unknown: Final = sorted( - key - for key in litellm_params - if key.startswith(_MONGODB_PARAM_PREFIX) and key not in _KNOWN_MONGODB_PARAMS + key for key in litellm_params if key.startswith(_MONGODB_PARAM_PREFIX) and key not in _KNOWN_MONGODB_PARAMS ) if unknown: raise config_error( @@ -196,16 +202,20 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): return min(max(limit * NUM_CANDIDATES_MULTIPLIER, MIN_NUM_CANDIDATES), MAX_NUM_CANDIDATES) @staticmethod - def _client_key(params: _MongoDBSearchParams, timeout: float | httpx.Timeout | None) -> MongoClientKey: + def _timeout_ms(timeout: float | httpx.Timeout | None) -> tuple[int, int]: + """The connect and socket budgets pymongo is built with, in that order.""" if isinstance(timeout, httpx.Timeout): - connect_ms: Final = int((timeout.connect or DEFAULT_CONNECT_TIMEOUT_MS / 1000) * 1000) - socket_ms: Final = int((timeout.read or DEFAULT_SOCKET_TIMEOUT_MS / 1000) * 1000) - elif timeout is not None: - connect_ms = min(int(float(timeout) * 1000), DEFAULT_CONNECT_TIMEOUT_MS) - socket_ms = int(float(timeout) * 1000) - else: - connect_ms = DEFAULT_CONNECT_TIMEOUT_MS - socket_ms = DEFAULT_SOCKET_TIMEOUT_MS + return ( + int((timeout.connect or DEFAULT_CONNECT_TIMEOUT_MS / 1000) * 1000), + int((timeout.read or DEFAULT_SOCKET_TIMEOUT_MS / 1000) * 1000), + ) + if timeout is None: + return DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_SOCKET_TIMEOUT_MS + return min(int(float(timeout) * 1000), DEFAULT_CONNECT_TIMEOUT_MS), int(float(timeout) * 1000) + + @classmethod + def _client_key(cls, params: _MongoDBSearchParams, timeout: float | httpx.Timeout | None) -> MongoClientKey: + connect_ms, socket_ms = cls._timeout_ms(timeout) return MongoClientKey( connection_string=params.require_connection_string(), connect_timeout_ms=connect_ms, @@ -220,36 +230,41 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): query_vector: Sequence[float], params: _MongoDBSearchParams, vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, - ) -> list[dict[str, object]]: + ) -> Sequence[Mapping[str, object]]: if vector_store_search_optional_params.get("filters") is not None: raise config_error( "MongoDB vector store does not support the filters parameter yet. " "Restrict the collection or the Atlas Vector Search index definition instead." ) limit: Final = cls._limit(vector_store_search_optional_params) - return [ # mutable-ok: pymongo's aggregate contract is a list of stage dicts + search: Final = MappingProxyType( { - "$vectorSearch": { - "index": vector_store_id, - "path": params.embedding_field, - "queryVector": list(query_vector), - "numCandidates": cls._num_candidates(limit, params.mongodb_num_candidates), - "limit": limit, - } - }, - {"$project": {params.text_field: 1, SCORE_FIELD_NAME: {"$meta": "vectorSearchScore"}}}, + "index": vector_store_id, + "path": params.embedding_field, + "queryVector": tuple(query_vector), + "numCandidates": cls._num_candidates(limit, params.mongodb_num_candidates), + "limit": limit, + } + ) + projection: Final = MappingProxyType( + {params.text_field: 1, SCORE_FIELD_NAME: MappingProxyType({"$meta": "vectorSearchScore"})} + ) + return [ # mutable-ok: pymongo rejects any non-list pipeline in common.validate_list + MappingProxyType({"$vectorSearch": search}), + MappingProxyType({"$project": projection}), ] - @staticmethod - def _field_value(document: Mapping[str, object], dotted_path: str) -> str | None: + @classmethod + def _field_value(cls, document: Mapping[str, object], dotted_path: str) -> str | None: """None means the path is absent from the document, which is what separates a mistyped mongodb_text_field from a document whose text is genuinely empty.""" - current: object = document - for segment in dotted_path.split("."): - if not isinstance(current, Mapping) or segment not in current: - return None - current = current[segment] - return None if current is None else str(current) + head, _, rest = dotted_path.partition(".") + if head not in document: + return None + value: Final = document[head] + if not rest: + return None if value is None else str(value) + return cls._field_value(value, rest) if isinstance(value, Mapping) else None @classmethod def _to_result(cls, document: Mapping[str, object], text_field: str) -> VectorStoreSearchResult: @@ -287,7 +302,9 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): return VectorStoreSearchResponse( object="vector_store.search_results.page", search_query=query_text, - data=[cls._to_result(document, text_field) for document in documents], + data=[ # mutable-ok: VectorStoreSearchResponse declares data as a list + cls._to_result(document, text_field) for document in documents + ], ) @staticmethod @@ -341,14 +358,12 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): try: client: Final = self.sync_client_factory(key) target: Final = client[database][collection] # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted - documents: Final = list(target.aggregate(pipeline)) + documents: Final = tuple(target.aggregate(pipeline)) except Exception as e: - raise translate_mongo_error( - e, index_name=vector_store_id, database=database, collection=collection - ) from e + raise translate_mongo_error(e, index_name=vector_store_id, database=database, collection=collection) from e if not documents: try: - catalogue: Final = list(target.list_search_indexes(vector_store_id)) + catalogue: Final = tuple(target.list_search_indexes(vector_store_id)) except Exception as e: raise translate_mongo_error( e, index_name=vector_store_id, database=database, collection=collection @@ -386,15 +401,17 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): client: Final = self.async_client_factory(key) target: Final = client[database][collection] # pyright: ignore[reportIndexIssue] # factory is typed as returning object so injected doubles are accepted cursor: Final = await target.aggregate(pipeline) - documents: Final = [document async for document in cursor] + documents: Final = [ # mutable-ok: an async comprehension cannot build a tuple directly + document async for document in cursor + ] except Exception as e: - raise translate_mongo_error( - e, index_name=vector_store_id, database=database, collection=collection - ) from e + raise translate_mongo_error(e, index_name=vector_store_id, database=database, collection=collection) from e if not documents: try: index_cursor: Final = await target.list_search_indexes(vector_store_id) - catalogue: Final = [entry async for entry in index_cursor] + catalogue: Final = [ # mutable-ok: an async comprehension cannot build a tuple directly + entry async for entry in index_cursor + ] except Exception as e: raise translate_mongo_error( e, index_name=vector_store_id, database=database, collection=collection diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index a62c0f711cb..9ca0753f354 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -62,7 +62,7 @@ def _row_to_vector_store(row: "_VectorStoreRow") -> LiteLLM_ManagedVectorStore: # "connection" covers wire-protocol providers whose whole credential is a URI # (mongodb_connection_string embeds the username and password), which the # default api_key/secret/token patterns do not match. -_LITELLM_PARAMS_MASKER: Final = SensitiveDataMasker(extra_sensitive_patterns={"connection"}) +_LITELLM_PARAMS_MASKER: Final = SensitiveDataMasker(extra_sensitive_patterns=frozenset(("connection",))) _REDACT_LITELLM_PARAMS_MAX_DEPTH: Final = 10 diff --git a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py index 27a83223864..c2b4042bdba 100644 --- a/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py +++ b/tests/test_litellm/litellm_core_utils/test_sensitive_data_masker.py @@ -331,3 +331,15 @@ def test_extra_sensitive_patterns_do_not_leak_into_other_maskers(): SensitiveDataMasker(extra_sensitive_patterns={"connection"}) assert SensitiveDataMasker().is_sensitive_key("mongodb_connection_string") is False + + +def test_the_second_positional_argument_is_still_the_override_set(): + """SensitiveDataMasker is public SDK surface, so adding a keyword must not shift what an + existing positional call means. Putting extra_sensitive_patterns second would silently turn + an override set into an extra sensitive set and start masking the caller's pricing fields.""" + from litellm.litellm_core_utils.sensitive_data_masker import SensitiveDataMasker + + masker = SensitiveDataMasker({"token"}, {"session"}) + + assert masker.is_sensitive_key("session_token") is False + assert masker.is_sensitive_key("auth_token") is True diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 84de42d2126..e7ed3d77407 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -39,6 +39,15 @@ BASE_PARAMS = { READY_INDEX = [{"name": INDEX, "status": "READY", "queryable": True}] +class RecordingClient: + """Stands in for pymongo's client class so the cache tests inject a fake rather than + patching the importer, and so they can assert what the client was actually built with.""" + + def __init__(self, connection_string, **kwargs): + self.connection_string = connection_string + self.kwargs = kwargs + + class FakeCollection: def __init__(self, documents, error=None, search_indexes=None): self.documents = documents @@ -171,12 +180,22 @@ def test_search_builds_vector_search_stage_against_the_named_index(): assert _stage(collection, "$vectorSearch") == { "index": INDEX, "path": "embedding", - "queryVector": [0.1, 0.2, 0.3], + "queryVector": (0.1, 0.2, 0.3), "numCandidates": 100, "limit": 5, } +def test_the_pipeline_reaches_pymongo_as_a_list(): + """pymongo's common.validate_list rejects any other sequence with + 'pipeline must be a list, not ', so the outer container is part of the contract.""" + config, _, collection = _config() + + _search(config) + + assert isinstance(collection.pipeline, list) + + def test_search_projects_the_text_field_and_the_similarity_score(): config, _, collection = _config() @@ -275,6 +294,38 @@ def test_response_reads_a_dotted_text_field_path(): assert response["data"][0]["content"][0]["text"] == "nested text" +def test_a_dotted_path_resolves_three_levels_deep(): + config, _, _ = _config(documents=[{"_id": 1, "a": {"b": {"c": "deep text"}}, "score": 0.5}]) + + response = _search(config, litellm_params={"mongodb_text_field": "a.b.c"}) + + assert response["data"][0]["content"][0]["text"] == "deep text" + + +def test_a_dotted_path_that_runs_through_a_scalar_counts_as_absent(): + """Walking 'plot.nope' when plot is a string must report the misconfiguration, not + stringify the scalar and hand the model text from the wrong field.""" + config, _, _ = _config(documents=[{"_id": 1, "plot": "a plain string", "score": 0.5}]) + + with pytest.raises(BadRequestError, match=r"has a 'plot\.nope' field"): + _search(config, litellm_params={"mongodb_text_field": "plot.nope"}) + + +def test_a_non_string_text_field_is_stringified(): + config, _, _ = _config(documents=[{"_id": 1, "year": 1979, "score": 0.5}]) + + response = _search(config, litellm_params={"mongodb_text_field": "year"}) + + assert response["data"][0]["content"][0]["text"] == "1979" + + +def test_a_null_text_field_counts_as_absent(): + config, _, _ = _config(documents=[{"_id": 1, "text": None, "score": 0.5}]) + + with pytest.raises(BadRequestError, match="has a 'text' field"): + _search(config) + + def test_response_tolerates_a_sparse_document_missing_the_text_field(): config, _, _ = _config(documents=[{"_id": 1, "score": 0.5}, {"_id": 2, "text": "has text", "score": 0.4}]) @@ -489,7 +540,7 @@ async def test_async_search_builds_the_same_pipeline_and_maps_the_response(): assert client.requested_database == "sample_mflix" assert client.database.requested_collection == "embedded_movies" assert _stage(collection, "$vectorSearch")["limit"] == 3 - assert _stage(collection, "$vectorSearch")["queryVector"] == [0.1, 0.2, 0.3] + assert _stage(collection, "$vectorSearch")["queryVector"] == (0.1, 0.2, 0.3) assert response["data"][0]["content"][0]["text"] == "an astronaut adrift" assert response["data"][0]["score"] == 0.94 @@ -524,42 +575,36 @@ class TestClientCache: ) def test_the_same_connection_reuses_one_client(self): - with patch("litellm.llms.mongodb.common_utils.import_sync_mongo_client") as importer: - importer.return_value = lambda *args, **kwargs: MagicMock() - - first = get_sync_client(self._key()) - second = get_sync_client(self._key()) + first = get_sync_client(self._key(), RecordingClient) + second = get_sync_client(self._key(), RecordingClient) assert first is second - assert importer.return_value + assert first.connection_string == CONNECTION_STRING + assert first.kwargs["socketTimeoutMS"] == 30_000 + assert first.kwargs["connectTimeoutMS"] == 10_000 + assert first.kwargs["appname"] == "litellm" def test_a_different_connection_gets_its_own_client(self): - with patch("litellm.llms.mongodb.common_utils.import_sync_mongo_client") as importer: - importer.return_value = lambda *args, **kwargs: MagicMock() - - first = get_sync_client(self._key()) - second = get_sync_client(self._key(connection_string="mongodb://other.example.test")) + first = get_sync_client(self._key(), RecordingClient) + second = get_sync_client(self._key(connection_string="mongodb://other.example.test"), RecordingClient) assert first is not second + assert second.connection_string == "mongodb://other.example.test" def test_a_different_timeout_gets_its_own_client(self): - with patch("litellm.llms.mongodb.common_utils.import_sync_mongo_client") as importer: - importer.return_value = lambda *args, **kwargs: MagicMock() - - first = get_sync_client(self._key()) - second = get_sync_client(self._key(socket_timeout_ms=5_000)) + first = get_sync_client(self._key(), RecordingClient) + second = get_sync_client(self._key(socket_timeout_ms=5_000), RecordingClient) assert first is not second + assert second.kwargs["socketTimeoutMS"] == 5_000 @pytest.mark.asyncio async def test_async_clients_are_cached_per_event_loop(self): - with patch("litellm.llms.mongodb.common_utils.import_async_mongo_client") as importer: - importer.return_value = lambda *args, **kwargs: MagicMock() - - first = get_async_client(self._key()) - second = get_async_client(self._key()) + first = get_async_client(self._key(), RecordingClient) + second = get_async_client(self._key(), RecordingClient) assert first is second + assert first.connection_string == CONNECTION_STRING def test_a_new_loop_never_inherits_a_closed_loop_client(self): @@ -579,19 +624,16 @@ class TestClientCache: clients_handed_out = [] async def fetch(): - return get_async_client(key) + return get_async_client(key, LoopAgnosticClient) - with patch("litellm.llms.mongodb.common_utils.import_async_mongo_client") as importer: - importer.return_value = LoopAgnosticClient - - for _ in range(20): - loop = asyncio.new_event_loop() - client = loop.run_until_complete(fetch()) - clients_handed_out.append((client, client.built_on, loop.is_closed())) - client.built_on = weakref.ref(loop) - loop.close() - del loop - gc.collect() + for _ in range(20): + loop = asyncio.new_event_loop() + client = loop.run_until_complete(fetch()) + clients_handed_out.append((client, client.built_on, loop.is_closed())) + client.built_on = weakref.ref(loop) + loop.close() + del loop + gc.collect() stale = [ handed_out From 3d0223b661227a122b5aaa42a79fd9b2d66f3420 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 12:51:19 -0700 Subject: [PATCH 034/410] ci: install the mongodb extra for the unit test shard that runs the provider tests/test_litellm/llms/mongodb imports pymongo's exception classes to check the error translation against the real hierarchy, and the shard that runs it (tests/test_litellm/llms, per test-unit.yml) synced --extra google, proxy, semantic-router and saml but not mongodb, so 24 of 109 tests would have errored with ModuleNotFoundError on the first CI run. CircleCI hid this because it syncs --all-groups --all-extras. uv export --frozen ... --extra saml -> no pymongo uv export --frozen ... --extra saml --extra mongodb -> pymongo==4.17.0 Also close the two gaps a mutation run found in the suite: nothing asserted that a short request timeout shortens server selection as well as connect, and the existing code 13 case carried "not authorized", which the message markers match too, so it could not tell whether the code was still being checked. 28 of 28 mutants now die. --- .github/workflows/_test-unit-base.yml | 2 +- .../test_mongodb_transformation.py | 23 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/_test-unit-base.yml b/.github/workflows/_test-unit-base.yml index c4045a08ffb..80d743c5ad7 100644 --- a/.github/workflows/_test-unit-base.yml +++ b/.github/workflows/_test-unit-base.yml @@ -112,7 +112,7 @@ jobs: if: steps.changes.outputs.decision != 'skip' timeout-minutes: 8 run: | - .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml + .github/scripts/uv_sync_with_retries.sh --frozen --group ci --group proxy-dev --extra google --extra proxy --extra semantic-router --extra saml --extra mongodb - name: Cache Prisma binaries if: steps.changes.outputs.decision != 'skip' diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index e7ed3d77407..0e62e7c11d6 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -658,6 +658,19 @@ class TestClientKeyDerivation: assert key.socket_timeout_ms == 3_000 assert key.connect_timeout_ms == 3_000 + def test_a_short_timeout_also_shortens_server_selection(self): + """Server selection runs before the connect attempt, so leaving it at the 10s default + would let a caller asking for a 3s budget block for 10s before anything is tried.""" + key = MongoDBVectorStoreConfig._client_key(_MongoDBSearchParams.model_validate(BASE_PARAMS), 3.0) + + assert key.server_selection_timeout_ms == 3_000 + + def test_a_generous_timeout_does_not_raise_server_selection_above_the_default(self): + key = MongoDBVectorStoreConfig._client_key(_MongoDBSearchParams.model_validate(BASE_PARAMS), 120.0) + + assert key.socket_timeout_ms == 120_000 + assert key.server_selection_timeout_ms == 10_000 + def test_an_httpx_timeout_maps_connect_and_read_separately(self): key = MongoDBVectorStoreConfig._client_key( _MongoDBSearchParams.model_validate(BASE_PARAMS), httpx.Timeout(connect=2.0, read=45.0, write=5.0, pool=5.0) @@ -693,6 +706,16 @@ class TestErrorTranslation: assert "sample_mflix.embedded_movies" in str(translated) + def test_code_13_alone_is_enough_without_a_recognisable_message(self): + """The other unauthorized case carries "not authorized", which the message markers also + match, so it cannot tell whether the code is still being checked at all.""" + from pymongo.errors import OperationFailure + + translated = self._translate(OperationFailure("user lacks privileges on this namespace", code=13)) + + assert "rejected the credentials" in str(translated) + assert "sample_mflix.embedded_movies" in str(translated) + def test_a_missing_index_names_the_index_and_the_collection(self): from pymongo.errors import OperationFailure From 32b501bf74abade544d79a349e200b0b757443c4 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 13:14:56 -0700 Subject: [PATCH 035/410] docs(vector_stores): register mongodb in the provider endpoint support matrix --- provider_endpoints_support.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index ebc220b3496..41ed8e1d975 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -2880,6 +2880,13 @@ "vector_stores_search": true } }, + "mongodb": { + "display_name": "MongoDB Atlas (`mongodb`)", + "url": "https://docs.litellm.ai/docs/providers/mongodb_vector_stores", + "endpoints": { + "vector_stores_search": true + } + }, "valkey": { "display_name": "Valkey (`valkey`)", "url": "https://docs.litellm.ai/docs/providers/valkey_vector_stores", From ed8203757a7af4d7867dc7afce042454cf9b53b4 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 13:21:55 -0700 Subject: [PATCH 036/410] fix(vector_stores): refuse MongoDB vector store create with a 400, not a 500 litellm.exception_type passes only litellm's own exception types through untouched, so the NotImplementedError the search-only refusal raised reached the caller as APIConnectionError. The proxy served that as a 500 with a traceback in the body for what is a plain client mistake. Raising BadRequestError gives the caller a 400 and the message on its own. --- .../llms/mongodb/vector_stores/transformation.py | 4 ++-- .../vector_stores/test_mongodb_transformation.py | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index d0a0f51cd77..9f5e40f69ef 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -425,7 +425,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): vector_store_create_optional_params: VectorStoreCreateOptionalRequestParams, api_base: str, ) -> NoReturn: - raise NotImplementedError(_SEARCH_ONLY_MESSAGE) + raise config_error(_SEARCH_ONLY_MESSAGE) def transform_create_vector_store_response(self, response: httpx.Response) -> NoReturn: - raise NotImplementedError(_SEARCH_ONLY_MESSAGE) + raise config_error(_SEARCH_ONLY_MESSAGE) diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 0e62e7c11d6..140efd53449 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -515,15 +515,27 @@ def test_validation_runs_before_any_connection_is_opened(): def test_create_vector_store_is_not_supported_and_says_why(): + """litellm.exception_type only passes its own exception types through untouched, so a + NotImplementedError here reaches the caller as APIConnectionError, which the proxy serves + as a 500 with a traceback. Refusing an unsupported operation is a client error.""" config = MongoDBVectorStoreConfig() - with pytest.raises(NotImplementedError, match="search-only"): + with pytest.raises(BadRequestError, match="search-only"): config.transform_create_vector_store_request({}, "https://example.test") - with pytest.raises(NotImplementedError, match="search-only"): + with pytest.raises(BadRequestError, match="search-only"): config.transform_create_vector_store_response(httpx.Response(200)) +def test_the_create_refusal_survives_the_public_sdk_error_wrapper(): + import litellm + + with pytest.raises(BadRequestError) as raised: + litellm.vector_stores.create(custom_llm_provider="mongodb", name="anything") + + assert "search-only" in str(raised.value) + + def test_provider_config_manager_returns_the_mongodb_config(): config = ProviderConfigManager.get_provider_vector_stores_config(LlmProviders.MONGODB) From 7c91b0120fadccd2a97c5a8ae5db77d6d8f59ec8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 2 Sep 2026 13:32:51 -0700 Subject: [PATCH 037/410] fix(mistral): ensure /v1 on the Voxtral TTS base URL A host-only api_base or MISTRAL_API_BASE (the documented form, https://api.mistral.ai) built https://api.mistral.ai/audio/speech and 404ed. Match the chat and OCR configs by appending /v1 when the configured base does not already end with it. --- .../mistral/audio_speech/transformation.py | 5 +++-- ...est_mistral_audio_speech_transformation.py | 19 +++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/litellm/llms/mistral/audio_speech/transformation.py b/litellm/llms/mistral/audio_speech/transformation.py index e7f7d510346..7f5a659bd08 100644 --- a/litellm/llms/mistral/audio_speech/transformation.py +++ b/litellm/llms/mistral/audio_speech/transformation.py @@ -115,8 +115,9 @@ class MistralTextToSpeechConfig(BaseTextToSpeechConfig): api_base: str | None, litellm_params: Mapping[str, object], ) -> str: - base_url: Final = api_base or get_secret_str("MISTRAL_API_BASE") or self.TTS_BASE_URL - return f"{base_url.rstrip('/')}/audio/speech" + configured_base: Final = (api_base or get_secret_str("MISTRAL_API_BASE") or self.TTS_BASE_URL).rstrip("/") + versioned_base: Final = configured_base if configured_base.endswith("/v1") else f"{configured_base}/v1" + return f"{versioned_base}/audio/speech" def transform_text_to_speech_request( self, diff --git a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py index 6d250901e50..108d4107db1 100644 --- a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py +++ b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py @@ -91,16 +91,23 @@ def test_get_complete_url_default_base(monkeypatch: pytest.MonkeyPatch): assert url == SPEECH_URL -def test_get_complete_url_custom_base(): +@pytest.mark.parametrize( + "api_base", + ["https://custom.api.example.com/v1/", "https://custom.api.example.com/v1", "https://custom.api.example.com"], +) +def test_get_complete_url_custom_base_always_versioned(api_base: str): config: Final = MistralTextToSpeechConfig() - url: Final = config.get_complete_url( - model="voxtral-mini-tts-2603", - api_base="https://custom.api.example.com/v1/", - litellm_params={}, - ) + url: Final = config.get_complete_url(model="voxtral-mini-tts-2603", api_base=api_base, litellm_params={}) assert url == "https://custom.api.example.com/v1/audio/speech" +def test_get_complete_url_host_only_env_base_gets_v1(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("MISTRAL_API_BASE", "https://api.mistral.ai") + config: Final = MistralTextToSpeechConfig() + url: Final = config.get_complete_url(model="voxtral-mini-tts-2603", api_base=None, litellm_params={}) + assert url == SPEECH_URL + + def test_validate_environment_sets_bearer_header(): config: Final = MistralTextToSpeechConfig() headers: Final = config.validate_environment( From d4b02661925adf261a49ba4a45ee20702aa94e69 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 13:39:14 -0700 Subject: [PATCH 038/410] fix(vector_stores): release MongoDB clients built on closed event loops The async client cache is keyed per event loop, and pymongo's AsyncMongoClient holds a reference to the loop it was built on, so an entry for a closed loop kept that client and its sockets alive for the life of the process. A script that calls asyncio.run once per search fills the cache to its cap this way and then stops caching entirely. Measured live against Atlas over 40 loops: 32 pinned clients and 212 open descriptors before, 1 cached client and no monotonic descriptor growth after. --- litellm/llms/mongodb/common_utils.py | 13 ++++++++++ .../test_mongodb_transformation.py | 24 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 8ac02552ecb..460a2903c60 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -109,6 +109,18 @@ def get_sync_client(key: MongoClientKey, client_class: SyncClientFactory | None return client +def _purge_dead_loops() -> None: + """The cached client holds its loop object alive, so a closed loop's entry would otherwise pin + that client and its sockets for the life of the process. Callers that run one loop per search + (``asyncio.run`` in a script) reach the cap this way and never release what is behind it.""" + for stale in tuple( + cache_key + for cache_key, (loop_ref, _) in _async_clients.items() + if (cached_loop := loop_ref()) is None or cached_loop.is_closed() + ): + del _async_clients[stale] + + def get_async_client(key: MongoClientKey, client_class: AsyncClientFactory | None = None) -> "AsyncMongoClient": """Async clients bind to the loop that created them, so the cache is keyed per loop.""" loop: Final = asyncio.get_running_loop() @@ -116,6 +128,7 @@ def get_async_client(key: MongoClientKey, client_class: AsyncClientFactory | Non cached: Final = _async_clients.get(loop_key) if cached is not None and cached[0]() is loop: return cached[1] + _purge_dead_loops() build: Final = client_class if client_class is not None else import_async_mongo_client() client: Final = build(key.connection_string, **_client_kwargs(key)) if len(_async_clients) < _MAX_CACHED_CLIENTS or loop_key in _async_clients: diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 140efd53449..6d932c05065 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -10,6 +10,8 @@ import pytest from litellm.exceptions import BadRequestError, Timeout from litellm.llms.mongodb.common_utils import ( + _MAX_CACHED_CLIENTS, + _async_clients, MongoClientKey, index_not_ready_error, missing_index_error, @@ -655,6 +657,28 @@ class TestClientCache: ] assert stale == [], f"{len(stale)} of 20 loops were handed a client built on a closed loop" + def test_the_cache_releases_clients_built_on_closed_loops(self): + """pymongo's AsyncMongoClient keeps a reference to the loop it was built on, so an entry + for a closed loop holds that client, and its sockets, for the life of the process. A + script calling asyncio.run per search fills the cache to its cap that way: measured live + against Atlas at 32 pinned clients and 212 open descriptors after 40 loops.""" + + class LoopHoldingClient: + def __init__(self, *args, **kwargs): + self.loop = asyncio.get_running_loop() + + key = self._key() + + async def fetch(): + return get_async_client(key, LoopHoldingClient) + + for _ in range(_MAX_CACHED_CLIENTS + 8): + loop = asyncio.new_event_loop() + loop.run_until_complete(fetch()) + loop.close() + + assert len(_async_clients) == 1, f"{len(_async_clients)} closed-loop clients are still cached" + class TestClientKeyDerivation: def test_no_timeout_uses_the_bounded_defaults(self): From 1b47486724d16798f5bf416067d5d68c63959d8e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 13:50:45 -0700 Subject: [PATCH 039/410] style(vector_stores): cut the explanatory comments down to one line each The repo's rule allows a comment only where the logic stays confusing after the code has been made as clear as it can be, and then only one concise line about why. Three multi-line blocks did not meet that: the reason "connection" joins the sensitive patterns belongs in the commit that added it, and the weakref and Atlas error-code notes each say what they need to in a single line. --- litellm/llms/mongodb/common_utils.py | 7 ++----- .../proxy/vector_store_endpoints/management_endpoints.py | 3 --- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 460a2903c60..c2d081b8d8d 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -60,9 +60,7 @@ SyncClientFactory: TypeAlias = Callable[..., "MongoClient"] AsyncClientFactory: TypeAlias = Callable[..., "AsyncMongoClient"] _AsyncClientCacheKey: TypeAlias = tuple[MongoClientKey, int] -# The entry carries a weak reference to the loop the client was built on: CPython recycles id() -# aggressively (measured: 200 of 200 fresh loops landed on an id already in this cache), so the -# id alone would hand a new loop a client bound to a closed one. +# CPython recycles id() aggressively, so the id alone would hand a new loop a closed loop's client _AsyncClientEntry: TypeAlias = tuple["weakref.ref[AbstractEventLoop]", "AsyncMongoClient"] _sync_clients: Final[dict[MongoClientKey, "MongoClient"]] = {} # mutable-ok: process-level client cache @@ -143,8 +141,7 @@ def reset_client_cache() -> None: _AUTHENTICATION_FAILED_CODE: Final = 18 _UNAUTHORIZED_CODE: Final = 13 -# Atlas reports a rejected user as code 8000 "AtlasError" rather than 18, so the -# message is the only reliable signal for a serverless or shared-tier deployment. +# Atlas reports a rejected user as code 8000 "AtlasError", not 18, so only the message is reliable _AUTHENTICATION_MESSAGE_MARKERS: Final = ("bad auth", "authentication failed", "not authorized") _RESOLUTION_TIMEOUT_MARKERS: Final = ("resolution lifetime expired", "dns operation timed out") _UNKNOWN_HOSTNAME_MARKERS: Final = ("dns query name does not exist", "name or service not known") diff --git a/litellm/proxy/vector_store_endpoints/management_endpoints.py b/litellm/proxy/vector_store_endpoints/management_endpoints.py index 6af2a8b7a6b..8b951556a14 100644 --- a/litellm/proxy/vector_store_endpoints/management_endpoints.py +++ b/litellm/proxy/vector_store_endpoints/management_endpoints.py @@ -59,9 +59,6 @@ def _row_to_vector_store(row: "_VectorStoreRow") -> LiteLLM_ManagedVectorStore: return LiteLLM_ManagedVectorStore(**row.model_dump()) -# "connection" covers wire-protocol providers whose whole credential is a URI -# (mongodb_connection_string embeds the username and password), which the -# default api_key/secret/token patterns do not match. _LITELLM_PARAMS_MASKER: Final = SensitiveDataMasker(extra_sensitive_patterns=frozenset(("connection",))) From 52de1bb1d3380fbbbde5cb4725dae81eb883d457 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 14:19:28 -0700 Subject: [PATCH 040/410] fix(vector_stores): reject MongoDB search params the provider cannot honour filters was already refused, but ranking_options and rewrite_query were accepted and then dropped. A caller asking for score_threshold 0.9 got results scoring 0.5 with a 200 and no indication the threshold never ran, which is the silent-wrong-answer case the filters check exists to prevent. Both now raise the same 400 naming the parameter and what to do instead. --- .../mongodb/vector_stores/transformation.py | 11 +++++++++ .../test_mongodb_transformation.py | 24 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 9f5e40f69ef..5e59fd30f1b 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -236,6 +236,17 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): "MongoDB vector store does not support the filters parameter yet. " "Restrict the collection or the Atlas Vector Search index definition instead." ) + if vector_store_search_optional_params.get("ranking_options") is not None: + raise config_error( + "MongoDB vector store does not support the ranking_options parameter yet. " + "Every result already carries the Atlas vectorSearchScore, so filter or re-rank " + "on that rather than having the threshold silently ignored." + ) + if vector_store_search_optional_params.get("rewrite_query") is not None: + raise config_error( + "MongoDB vector store does not support the rewrite_query parameter. The query is " + "embedded exactly as sent; rewrite it before calling if you need that." + ) limit: Final = cls._limit(vector_store_search_optional_params) search: Final = MappingProxyType( { diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 6d932c05065..668fa676692 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -448,6 +448,30 @@ async def test_async_search_rejects_filters_rather_than_silently_ignoring_them() await _asearch(config, optional_params={"filters": {"genre": "sci-fi"}}) +def test_search_rejects_ranking_options_rather_than_silently_ignoring_them(): + """A score_threshold that is quietly dropped is worse than an error: the caller asked for + results above 0.9, gets results scoring 0.5, and nothing says the threshold never ran.""" + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="does not support the ranking_options parameter"): + _search(config, optional_params={"ranking_options": {"score_threshold": 0.9}}) + + +def test_search_rejects_rewrite_query_rather_than_silently_ignoring_it(): + config, _, _ = _config() + + with pytest.raises(BadRequestError, match="does not support the rewrite_query parameter"): + _search(config, optional_params={"rewrite_query": True}) + + +@pytest.mark.asyncio +async def test_async_search_rejects_ranking_options_rather_than_silently_ignoring_them(): + config, _, _ = _async_config() + + with pytest.raises(BadRequestError, match="does not support the ranking_options parameter"): + await _asearch(config, optional_params={"ranking_options": {"score_threshold": 0.9}}) + + @pytest.mark.parametrize("query", ["", " ", "\n\t", []]) def test_search_rejects_an_empty_query(query): config, _, _ = _config() From 4a646dd9a0d7acd2ea7c3fbd57de1e17ead7cec8 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 14:53:40 -0700 Subject: [PATCH 041/410] ci(e2e): run a PR's changed e2e tests three times behind a human-approved environment Adds a required-check candidate that selects the tests/e2e test files a PR added or modified, boots a stage-mirror stack on the runner (migrations, backend, two gateway processes behind nginx, Postgres, Jaeger, TLS cluster Valkey), and runs those files three times with retries off. The run job sits behind the e2e-changed GitHub environment, so a reviewer approves each run before the OIDC token that reads the provider keys from AWS Secrets Manager exists. Supersedes #34981 --- .github/e2e-stack/down.sh | 17 ++ .github/e2e-stack/secrets_to_env.py | 28 +++ .github/e2e-stack/up.sh | 198 +++++++++++++++++++ .github/workflows/test-e2e-changed.yml | 171 ++++++++++++++++ tests/e2e/CONTRIBUTING.md | 4 + tests/e2e/gateway/stage_mirror_ci_config.yml | 63 ++++++ 6 files changed, 481 insertions(+) create mode 100755 .github/e2e-stack/down.sh create mode 100644 .github/e2e-stack/secrets_to_env.py create mode 100755 .github/e2e-stack/up.sh create mode 100644 .github/workflows/test-e2e-changed.yml create mode 100644 tests/e2e/gateway/stage_mirror_ci_config.yml diff --git a/.github/e2e-stack/down.sh b/.github/e2e-stack/down.sh new file mode 100755 index 00000000000..9f72f2d6e64 --- /dev/null +++ b/.github/e2e-stack/down.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -uo pipefail + +STACK_DIR="${E2E_STACK_DIR:-${RUNNER_TEMP:-/tmp}/litellm-e2e-stack}" + +for pid_file in "${STACK_DIR}"/pids/*.pid; do + [[ -f "${pid_file}" ]] || continue + pkill -TERM -P "$(cat "${pid_file}")" 2>/dev/null + kill -TERM "$(cat "${pid_file}")" 2>/dev/null + rm -f "${pid_file}" +done + +for container in e2e-nginx e2e-valkey e2e-jaeger e2e-postgres; do + docker rm -f "${container}" >/dev/null 2>&1 +done + +exit 0 diff --git a/.github/e2e-stack/secrets_to_env.py b/.github/e2e-stack/secrets_to_env.py new file mode 100644 index 00000000000..25b22555c29 --- /dev/null +++ b/.github/e2e-stack/secrets_to_env.py @@ -0,0 +1,28 @@ +import sys +from pathlib import Path + +from pydantic import TypeAdapter + +secrets_adapter: TypeAdapter[dict[str, str]] = TypeAdapter(dict[str, str]) + + +def main() -> int: + env_path = Path(sys.argv[1]) + secrets = secrets_adapter.validate_json(sys.stdin.read()) + unwritable = tuple( + key for key, value in secrets.items() if "'" in value or "\n" in value or "\r" in value + ) + if unwritable: + _ = sys.stderr.write(f"values contain characters unsafe for both bash and dotenv: {', '.join(unwritable)}\n") + return 1 + lines = tuple(f"{key}='{value}'" for key, value in secrets.items() if value) + with env_path.open("a") as handle: + _ = handle.write("\n".join(lines) + "\n") + for value in secrets.values(): + if value: + _ = sys.stdout.write(f"::add-mask::{value}\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/e2e-stack/up.sh b/.github/e2e-stack/up.sh new file mode 100755 index 00000000000..06fce35a896 --- /dev/null +++ b/.github/e2e-stack/up.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +STACK_DIR="${E2E_STACK_DIR:-${RUNNER_TEMP:-/tmp}/litellm-e2e-stack}" +CERTS_DIR="${STACK_DIR}/certs" +LOGS_DIR="${STACK_DIR}/logs" +PIDS_DIR="${STACK_DIR}/pids" + +POSTGRES_IMAGE="${E2E_POSTGRES_IMAGE:-postgres:16.6}" +VALKEY_IMAGE="${E2E_VALKEY_IMAGE:-valkey/valkey:8.1}" +JAEGER_IMAGE="${E2E_JAEGER_IMAGE:-jaegertracing/jaeger:2.10.0}" +NGINX_IMAGE="${E2E_NGINX_IMAGE:-nginx:1.29-alpine}" + +LB_PORT="${E2E_LB_PORT:-4000}" +GATEWAY_PORT_1="${E2E_GATEWAY_PORT_1:-4010}" +GATEWAY_PORT_2="${E2E_GATEWAY_PORT_2:-4011}" +BACKEND_PORT="${E2E_BACKEND_PORT:-4001}" +REDIS_PORT="${E2E_REDIS_PORT:-6379}" +DATABASE_HOST="${E2E_DATABASE_HOST:-127.0.0.1}" +DATABASE_PORT="${E2E_DATABASE_PORT:-5432}" +DATABASE_USER="${E2E_DATABASE_USER:-litellm}" +DATABASE_PASSWORD="${E2E_DATABASE_PASSWORD:-dbpassword9090}" +DATABASE_NAME="${E2E_DATABASE_NAME:-litellm}" +JAEGER_OTLP_PORT="${E2E_JAEGER_OTLP_PORT:-4318}" +JAEGER_QUERY_PORT="${E2E_JAEGER_QUERY_PORT:-16686}" + +MASTER_KEY="${LITELLM_MASTER_KEY:-sk-e2e-$(openssl rand -hex 16)}" + +mkdir -p "${CERTS_DIR}" "${LOGS_DIR}" "${PIDS_DIR}" + +log() { printf 'e2e-stack: %s\n' "$*"; } + +port_open() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null; } + +wait_for() { + local label="$1" check="$2" deadline=$((SECONDS + ${3:-120})) + until eval "${check}"; do + if ((SECONDS >= deadline)); then + log "timed out waiting for ${label}" + tail -n 60 "${LOGS_DIR}"/*.log 2>/dev/null || true + exit 1 + fi + sleep 2 + done + log "${label} is up" +} + +if [[ -f "${REPO_ROOT}/tests/e2e/.env" ]]; then + set -a + source "${REPO_ROOT}/tests/e2e/.env" + set +a +fi + +if ! port_open "${DATABASE_PORT}"; then + docker run -d --name e2e-postgres -p "${DATABASE_PORT}:5432" \ + -e "POSTGRES_USER=${DATABASE_USER}" -e "POSTGRES_PASSWORD=${DATABASE_PASSWORD}" -e "POSTGRES_DB=${DATABASE_NAME}" \ + "${POSTGRES_IMAGE}" >/dev/null +fi +wait_for "postgres" "port_open ${DATABASE_PORT}" + +if ! port_open "${JAEGER_QUERY_PORT}"; then + docker run -d --name e2e-jaeger -p "${JAEGER_OTLP_PORT}:4318" -p "${JAEGER_QUERY_PORT}:16686" \ + "${JAEGER_IMAGE}" >/dev/null +fi +wait_for "jaeger" "curl -fs http://127.0.0.1:${JAEGER_QUERY_PORT}/api/services >/dev/null" + +openssl genrsa -out "${CERTS_DIR}/ca.key" 2048 2>/dev/null +openssl req -x509 -new -nodes -key "${CERTS_DIR}/ca.key" -sha256 -days 7 \ + -subj "/CN=litellm-e2e-ca" \ + -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign" \ + -out "${CERTS_DIR}/ca.crt" 2>/dev/null +openssl genrsa -out "${CERTS_DIR}/server.key" 2048 2>/dev/null +openssl req -new -key "${CERTS_DIR}/server.key" -subj "/CN=localhost" -out "${CERTS_DIR}/server.csr" 2>/dev/null +openssl x509 -req -in "${CERTS_DIR}/server.csr" -CA "${CERTS_DIR}/ca.crt" -CAkey "${CERTS_DIR}/ca.key" \ + -CAcreateserial -days 7 -sha256 \ + -extfile <(printf 'basicConstraints=CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=serverAuth\nsubjectAltName=DNS:localhost,IP:127.0.0.1\n') \ + -out "${CERTS_DIR}/server.crt" 2>/dev/null +chmod 644 "${CERTS_DIR}"/*.key "${CERTS_DIR}"/*.crt + +CERTIFI_BUNDLE="$(cd "${REPO_ROOT}" && uv run --no-sync python -c 'import certifi; print(certifi.where())')" +cat "${CERTIFI_BUNDLE}" "${CERTS_DIR}/ca.crt" > "${CERTS_DIR}/ca-bundle.pem" + +docker rm -f e2e-valkey >/dev/null 2>&1 || true +docker run -d --name e2e-valkey -p "${REDIS_PORT}:${REDIS_PORT}" -v "${CERTS_DIR}:/certs:ro" \ + "${VALKEY_IMAGE}" valkey-server \ + --cluster-enabled yes --port 0 --tls-port "${REDIS_PORT}" \ + --tls-cert-file /certs/server.crt --tls-key-file /certs/server.key --tls-ca-cert-file /certs/ca.crt \ + --tls-auth-clients no --cluster-announce-ip 127.0.0.1 >/dev/null +VALKEY_CLI="docker exec e2e-valkey valkey-cli --tls --cacert /certs/ca.crt -h 127.0.0.1 -p ${REDIS_PORT}" +wait_for "valkey" "${VALKEY_CLI} ping 2>/dev/null | grep -q PONG" +${VALKEY_CLI} cluster addslotsrange 0 16383 >/dev/null +wait_for "valkey cluster" "${VALKEY_CLI} cluster info 2>/dev/null | grep -q cluster_state:ok" + +CONFIG_SOURCE="${REPO_ROOT}/tests/e2e/gateway/stage_mirror_ci_config.yml" +CONFIG_PATH="${CONFIG_SOURCE}" +if [[ "${REDIS_PORT}" != "6379" ]]; then + CONFIG_PATH="${STACK_DIR}/litellm-config.yml" + sed "s/port: 6379/port: ${REDIS_PORT}/" "${CONFIG_SOURCE}" > "${CONFIG_PATH}" +fi + +SERVER_ENV=( + "LITELLM_MASTER_KEY=${MASTER_KEY}" + "DATABASE_HOST=${DATABASE_HOST}" + "DATABASE_PORT=${DATABASE_PORT}" + "DATABASE_USER=${DATABASE_USER}" + "DATABASE_PASSWORD=${DATABASE_PASSWORD}" + "DATABASE_NAME=${DATABASE_NAME}" + "DISABLE_SCHEMA_UPDATE=true" + "REDIS_HOST=127.0.0.1" + "REDIS_PORT=${REDIS_PORT}" + "REDIS_CLUSTER_NODES=[{\"host\":\"127.0.0.1\",\"port\":${REDIS_PORT}}]" + "CONFIG_FILE_PATH=${CONFIG_PATH}" + "STORE_MODEL_IN_DB=True" + "OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf" + "OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:${JAEGER_OTLP_PORT}" + "SSL_CERT_FILE=${CERTS_DIR}/ca-bundle.pem" + "PYTHONPATH=${REPO_ROOT}" +) +if [[ -n "${VERTEXAI_CREDENTIALS:-}" ]]; then + printf '%s' "${VERTEXAI_CREDENTIALS}" > "${STACK_DIR}/vertex-adc.json" + SERVER_ENV+=("GOOGLE_APPLICATION_CREDENTIALS=${STACK_DIR}/vertex-adc.json") +fi + +cd "${REPO_ROOT}" + +log "running migrations" +env "${SERVER_ENV[@]}" uv run --no-sync python migrations/run.py >"${LOGS_DIR}/migrations.log" 2>&1 + +start_server() { + local name="$1"; shift + env "${SERVER_ENV[@]}" "$@" >"${LOGS_DIR}/${name}.log" 2>&1 & + echo $! > "${PIDS_DIR}/${name}.pid" +} + +start_server backend uv run --no-sync uvicorn backend.main:app --host 0.0.0.0 --port "${BACKEND_PORT}" +start_server gateway-1 uv run --no-sync uvicorn gateway.main:app --workers 1 --host 0.0.0.0 --port "${GATEWAY_PORT_1}" +start_server gateway-2 uv run --no-sync uvicorn gateway.main:app --workers 1 --host 0.0.0.0 --port "${GATEWAY_PORT_2}" + +if [[ "$(uname)" == "Linux" ]]; then + NGINX_UPSTREAM_HOST=127.0.0.1 + NGINX_DOCKER_ARGS=(--network host) +else + NGINX_UPSTREAM_HOST=host.docker.internal + NGINX_DOCKER_ARGS=(-p "${LB_PORT}:${LB_PORT}") +fi + +cat > "${STACK_DIR}/nginx.conf" </dev/null 2>&1 || true +docker run -d --name e2e-nginx "${NGINX_DOCKER_ARGS[@]}" \ + -v "${STACK_DIR}/nginx.conf:/etc/nginx/nginx.conf:ro" "${NGINX_IMAGE}" >/dev/null + +wait_for "backend" "curl -fs http://127.0.0.1:${BACKEND_PORT}/health/liveliness >/dev/null" 300 +wait_for "gateway-1" "curl -fs http://127.0.0.1:${GATEWAY_PORT_1}/health/liveliness >/dev/null" 300 +wait_for "gateway-2" "curl -fs http://127.0.0.1:${GATEWAY_PORT_2}/health/liveliness >/dev/null" 300 +wait_for "load balancer" "curl -fs http://127.0.0.1:${LB_PORT}/health/liveliness >/dev/null" 60 + +cat > "${STACK_DIR}/stack.env" <> "${GITHUB_OUTPUT}" + if [ -n "${tests}" ]; then + echo "any=true" >> "${GITHUB_OUTPUT}" + echo "selected e2e tests: ${tests}" + else + echo "any=false" >> "${GITHUB_OUTPUT}" + echo "no e2e changes; nothing to run" + fi + + run: + name: Run changed e2e tests against the stage-mirror stack + needs: detect + if: needs.detect.outputs.any == 'true' && github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + timeout-minutes: 90 + environment: e2e-changed + permissions: + contents: read + id-token: write + services: + postgres: + image: postgres:16.6 + env: + POSTGRES_USER: litellm + POSTGRES_PASSWORD: dbpassword9090 + POSTGRES_DB: litellm + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U litellm" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + jaeger: + image: jaegertracing/jaeger:2.10.0 + ports: + - 4318:4318 + - 16686:16686 + steps: + - name: Validate configuration + env: + ROLE: ${{ vars.E2E_AWS_ROLE_TO_ASSUME }} + run: test -n "${ROLE}" || { echo "::error::Set repo variable E2E_AWS_ROLE_TO_ASSUME to an OIDC role with read access to the e2e secrets"; exit 1; } + + - name: Checkout + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + + - name: Set up uv + uses: ./.github/actions/setup-uv-with-retries + with: + version: "0.10.9" + + - name: Cache the Rust build + uses: ./.github/actions/cache-cargo-build + + - name: Install dependencies + run: | + .github/scripts/uv_sync_with_retries.sh --frozen \ + --extra proxy --extra proxy-runtime --extra extra_proxy \ + --extra semantic-router --extra bedrock-realtime \ + --group ci --group proxy-dev --group e2e-dev + uv pip install "pipecat-ai[openai]==1.4.0" + + - name: Cache Prisma binaries + uses: ./.github/actions/cache-prisma-binaries + + - name: Generate Prisma client + run: uv run --no-sync prisma generate --schema litellm/proxy/schema.prisma + + - name: Install Playwright chromium + run: uv run --no-sync playwright install --with-deps chromium + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0 + with: + role-to-assume: ${{ vars.E2E_AWS_ROLE_TO_ASSUME }} + aws-region: us-east-1 + role-session-name: litellm-e2e-changed-${{ github.run_id }} + + - name: Fetch provider credentials from AWS Secrets Manager + run: | + aws secretsmanager get-secret-value --secret-id litellm-e2e-changed-provider-keys \ + --query SecretString --output text \ + | uv run --no-sync python .github/e2e-stack/secrets_to_env.py tests/e2e/.env + aws secretsmanager get-secret-value --secret-id litellm-e2e-changed-license \ + --query SecretString --output text \ + | jq -R -s '{"LITELLM_LICENSE": rtrimstr("\n")}' \ + | uv run --no-sync python .github/e2e-stack/secrets_to_env.py tests/e2e/.env + + - name: Boot the stage-mirror stack + run: bash .github/e2e-stack/up.sh + + - name: Export stack environment + run: | + master_key="$(grep '^LITELLM_MASTER_KEY=' "${RUNNER_TEMP}/litellm-e2e-stack/stack.env" | cut -d= -f2-)" + echo "::add-mask::${master_key}" + cat "${RUNNER_TEMP}/litellm-e2e-stack/stack.env" >> "${GITHUB_ENV}" + + - name: Run the selected tests three times with retries off + env: + TESTS: ${{ needs.detect.outputs.tests }} + run: | + read -r -a test_files <<< "${TESTS}" + for pass in 1 2 3; do + echo "::group::pass ${pass} of 3" + set +e + uv run --no-sync pytest "${test_files[@]}" --reruns 0 -v -rA --tb=short -p no:cacheprovider + status=$? + set -e + echo "::endgroup::" + if [ "${status}" = "5" ]; then + echo "selected files collected no runnable tests" + exit 0 + fi + if [ "${status}" != "0" ]; then + echo "::error::pass ${pass} of 3 failed with exit code ${status}" + exit "${status}" + fi + done + + - name: Show stack logs on failure + if: failure() + run: tail -n 200 "${RUNNER_TEMP}/litellm-e2e-stack/logs"/*.log diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 871d6b3904c..e04781ab205 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -52,6 +52,10 @@ The suites run against a live proxy, so bring one up first by running the litell Some suites need extra services the bare proxy does not start. The `logging/` OTEL trace-completeness tests read spans back from a jaeger query API at `http://localhost:16686` (override with `E2E_OTEL_QUERY_URL`); run a `jaegertracing/all-in-one` and point `PHOENIX_COLLECTOR_HTTP_ENDPOINT` at its OTLP ingest. The `mcp/` suite needs the deterministic upstream MCP server in `mcp_tests/mcp_e2e_upstream_server.py` reachable by the proxy +### The pull request check + +Every PR that adds or modifies a `tests/e2e/**/test_*.py` file (outside `ui/`, `claude_code/`, and `load/`, which have their own lanes) runs exactly those files three times, with retries off, against a stage-mirror stack booted on a GitHub Actions runner: migrations, a control-plane backend, two gateway processes behind an nginx load balancer, Postgres, Jaeger, and a TLS cluster-mode Valkey, wired the way stage is deployed. A PR that only touches the harness or the stack itself runs the `access_control/` suite as a smoke instead. Three green passes are the bar because the check exists to catch a flaky test before it reaches the release gate, so a red pass is a failure to fix, not a retry candidate. The same stack boots on a laptop with `bash .github/e2e-stack/up.sh`: it reads provider keys from `tests/e2e/.env`, writes the pytest environment to `${E2E_STACK_DIR:-/tmp/litellm-e2e-stack}/stack.env`, every port is overridable through `E2E_*_PORT` variables, and `down.sh` tears it all down + ### Record and replay Record/replay scopes to the proxy's provider-bound traffic only. In `E2E_FIXTURE_MODE=record` the harness boots a local provider-edge server, edge-wired tests register their deployments with an `api_base` pointing at it, and every provider call the proxy makes is forwarded verbatim and written to a fixture bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`). `E2E_FIXTURE_MODE=replay` runs the same tests against the same live proxy and database, but the edge answers the proxy's provider calls from the bundle instead of the provider, so the run makes zero provider calls and spends nothing while key auth, routing, cost calculation, and spend-log writes all still execute for real. Unset (or `live`) behaves exactly as before the knob existed. Both record and replay need the proxy up; only the provider is taken out of the loop diff --git a/tests/e2e/gateway/stage_mirror_ci_config.yml b/tests/e2e/gateway/stage_mirror_ci_config.yml new file mode 100644 index 00000000000..57a92fd47fb --- /dev/null +++ b/tests/e2e/gateway/stage_mirror_ci_config.yml @@ -0,0 +1,63 @@ +general_settings: + store_prompts_in_spend_logs: true + database_connection_pool_limit: 10 + forward_client_headers_to_llm_api: false + maximum_spend_logs_retention_period: "60d" + maximum_spend_logs_cleanup_cron: "0 1 * * *" + proxy_budget_rescheduler_min_time: 15 + proxy_budget_rescheduler_max_time: 20 + +litellm_settings: + drop_params: true + default_redis_ttl: 20 + request_timeout: 600 + num_retries: 3 + json_logs: true + store_audit_logs: true + cache: true + cache_params: + type: redis + host: 127.0.0.1 + port: 6379 + redis_startup_nodes: + - host: 127.0.0.1 + port: 6379 + ssl: true + callbacks: ["arize_phoenix", "datadog", "smtp_email", "prometheus", "otel"] + require_auth_for_metrics_endpoint: false + +router_settings: + routing_strategy: simple-shuffle + num_retries: 3 + allowed_fails: 5 + cooldown_time: 30 + +model_list: + - model_name: gpt-5.5 + litellm_params: + model: openai/gpt-5.5 + api_key: os.environ/OPENAI_API_KEY + - model_name: claude-haiku-4-5 + litellm_params: + model: anthropic/claude-haiku-4-5 + api_key: os.environ/ANTHROPIC_API_KEY + - model_name: gemini-2.5-flash-vertex + litellm_params: + model: vertex_ai/gemini-2.5-flash + vertex_project: os.environ/VERTEXAI_PROJECT + vertex_location: us-central1 + vertex_credentials: os.environ/VERTEXAI_CREDENTIALS + - model_name: gemini-2.5-flash + litellm_params: + model: gemini/gemini-2.5-flash + api_key: os.environ/GEMINI_API_KEY + - model_name: gemini-2.5-flash + litellm_params: + model: gemini/gemini-2.5-flash + api_key: os.environ/GEMINI_API_KEY + +mcp_servers: + devin: + url: "https://mcp.devin.ai/mcp" + auth_type: api_key + auth_value: os.environ/DEVIN_API_KEY From cfe247ebfe23d41adc2d43b14bf58f278f9ff609 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 14:55:13 -0700 Subject: [PATCH 042/410] fix(vector_stores): translate the two MongoDB driver errors that still reached callers as 500s A connection string whose password holds an unescaped '/' makes pymongo's URI parser raise a plain ValueError, not a PyMongoError, and a URI with no credentials at all makes Atlas close the connection, which surfaces as AutoReconnect. Neither was handled, so both fell through to litellm's generic wrapper and were served as 500s with a traceback for what are routine typos. Both now return a 400 naming the cause. The ConnectionFailure branch sits after the ServerSelectionTimeoutError and NetworkTimeout branches, which subclass it, and two ordering tests pin that. --- litellm/llms/mongodb/common_utils.py | 16 +++++++++ .../test_mongodb_transformation.py | 36 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index c2d081b8d8d..bf3bf953772 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -181,6 +181,7 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll try: from pymongo.errors import ( ConfigurationError, + ConnectionFailure, ExecutionTimeout, InvalidOperation, NetworkTimeout, @@ -202,6 +203,14 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll f"The MongoDB vector search against '{database}.{collection}' timed out before returning. " f"Driver detail: {error}" ) + # ServerSelectionTimeoutError and NetworkTimeout both sit under ConnectionFailure, so this + # only sees what those two branches left: a dropped or refused connection + if isinstance(error, ConnectionFailure): + return config_error( + f"The connection to '{database}.{collection}' was refused or dropped. On Atlas this is " + "usually a connection string with no username and password, or a TLS failure. Confirm " + f"the URI is the one Atlas shows under Connect, Drivers. Driver detail: {error}" + ) if isinstance(error, OperationFailure): code: Final = error.code detail: Final = str(error).lower() @@ -247,4 +256,11 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll ) if isinstance(error, InvalidOperation): return config_error(f"The MongoDB client was already closed or is unusable. Driver detail: {error}") + # pymongo's URI parser raises a plain ValueError, not a PyMongoError, for a password holding an + # unescaped '/', which would otherwise reach the caller as a 500 + if isinstance(error, ValueError): + return config_error( + "mongodb_connection_string could not be parsed. A username or password containing " + f"'@', '/', ':' or '%' has to be percent-encoded per RFC 3986. Driver detail: {error}" + ) return error diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 668fa676692..d60504c31e5 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -759,6 +759,42 @@ class TestErrorTranslation: assert "rejected the credentials" in str(translated) + def test_a_dropped_connection_is_a_400_not_an_unhandled_driver_error(self): + """AutoReconnect sits under ConnectionFailure alongside the two timeout classes, and Atlas + answers a URI with no credentials by closing the connection rather than failing auth. Left + untranslated it is not a litellm exception type, so it reaches the caller as a 500.""" + from pymongo.errors import AutoReconnect + + translated = self._translate(AutoReconnect("connection closed")) + + assert isinstance(translated, BadRequestError) + assert "refused or dropped" in str(translated) + assert "no username and password" in str(translated) + + def test_server_selection_timeout_still_wins_over_the_connection_branch(self): + from pymongo.errors import ServerSelectionTimeoutError + + translated = self._translate(ServerSelectionTimeoutError("no servers")) + + assert isinstance(translated, Timeout) + assert "refused or dropped" not in str(translated) + + def test_network_timeout_still_wins_over_the_connection_branch(self): + from pymongo.errors import NetworkTimeout + + translated = self._translate(NetworkTimeout("socket timed out")) + + assert isinstance(translated, Timeout) + assert "refused or dropped" not in str(translated) + + def test_an_unescaped_password_character_is_a_400_not_a_500(self): + """pymongo's URI parser raises a plain ValueError, not a PyMongoError, when a password + holds an unescaped '/'. That is a routine mistake and it must not be a 500.""" + translated = self._translate(ValueError("Port contains non-digit characters")) + + assert isinstance(translated, BadRequestError) + assert "percent-encoded" in str(translated) + def test_unauthorized_points_at_the_database_user_permissions(self): from pymongo.errors import OperationFailure From b348ed7f09709647d3f9bce38be3ba49741be307 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 14:56:52 -0700 Subject: [PATCH 043/410] ci(e2e): only tail stack logs when the stack actually booted --- .github/workflows/test-e2e-changed.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test-e2e-changed.yml b/.github/workflows/test-e2e-changed.yml index 6ad252e7246..a4466bba3fd 100644 --- a/.github/workflows/test-e2e-changed.yml +++ b/.github/workflows/test-e2e-changed.yml @@ -136,6 +136,7 @@ jobs: | uv run --no-sync python .github/e2e-stack/secrets_to_env.py tests/e2e/.env - name: Boot the stage-mirror stack + id: boot run: bash .github/e2e-stack/up.sh - name: Export stack environment @@ -167,5 +168,5 @@ jobs: done - name: Show stack logs on failure - if: failure() + if: failure() && steps.boot.conclusion != 'skipped' run: tail -n 200 "${RUNNER_TEMP}/litellm-e2e-stack/logs"/*.log From 743f94f82abf9d4963c087809fab782d47761b09 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 14:59:15 -0700 Subject: [PATCH 044/410] ci(e2e): fail when nothing collects and ignore lanes with their own checks in the smoke trigger --- .github/workflows/test-e2e-changed.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-e2e-changed.yml b/.github/workflows/test-e2e-changed.yml index a4466bba3fd..7a638801161 100644 --- a/.github/workflows/test-e2e-changed.yml +++ b/.github/workflows/test-e2e-changed.yml @@ -34,7 +34,7 @@ jobs: | grep -E '^tests/e2e/([A-Za-z0-9_.-]+/)*test_[A-Za-z0-9_.-]+\.py$' \ | grep -vE '^tests/e2e/(ui|claude_code|load)/' \ | sort -u | tr '\n' ' ' | sed 's/ $//')" || true - if [ -z "${tests}" ] && printf '%s\n' "${files}" | grep -v '^tests/e2e/ui/' \ + if [ -z "${tests}" ] && printf '%s\n' "${files}" | grep -vE '^tests/e2e/(ui|claude_code|load)/' \ | grep -qE '^(tests/e2e/|\.github/e2e-stack/|\.github/workflows/test-e2e-changed\.yml$)'; then tests="${SMOKE_TESTS}" echo "harness or stack changed without a test file; running the smoke suite" @@ -158,8 +158,8 @@ jobs: set -e echo "::endgroup::" if [ "${status}" = "5" ]; then - echo "selected files collected no runnable tests" - exit 0 + echo "::error::the selected files collected no runnable tests, so nothing was verified" + exit 1 fi if [ "${status}" != "0" ]; then echo "::error::pass ${pass} of 3 failed with exit code ${status}" From c62643f0cf72c2c02f08c2073a621e81d906b056 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 15:39:21 -0700 Subject: [PATCH 045/410] ci(e2e): drop trailing newlines from fetched secret values before writing the env --- .github/e2e-stack/secrets_to_env.py | 2 +- .github/workflows/test-e2e-changed.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/e2e-stack/secrets_to_env.py b/.github/e2e-stack/secrets_to_env.py index 25b22555c29..65022931d81 100644 --- a/.github/e2e-stack/secrets_to_env.py +++ b/.github/e2e-stack/secrets_to_env.py @@ -8,7 +8,7 @@ secrets_adapter: TypeAdapter[dict[str, str]] = TypeAdapter(dict[str, str]) def main() -> int: env_path = Path(sys.argv[1]) - secrets = secrets_adapter.validate_json(sys.stdin.read()) + secrets = {key: value.rstrip("\r\n") for key, value in secrets_adapter.validate_json(sys.stdin.read()).items()} unwritable = tuple( key for key, value in secrets.items() if "'" in value or "\n" in value or "\r" in value ) diff --git a/.github/workflows/test-e2e-changed.yml b/.github/workflows/test-e2e-changed.yml index 7a638801161..b20c84af02d 100644 --- a/.github/workflows/test-e2e-changed.yml +++ b/.github/workflows/test-e2e-changed.yml @@ -132,7 +132,7 @@ jobs: | uv run --no-sync python .github/e2e-stack/secrets_to_env.py tests/e2e/.env aws secretsmanager get-secret-value --secret-id litellm-e2e-changed-license \ --query SecretString --output text \ - | jq -R -s '{"LITELLM_LICENSE": rtrimstr("\n")}' \ + | jq -R -s '{"LITELLM_LICENSE": .}' \ | uv run --no-sync python .github/e2e-stack/secrets_to_env.py tests/e2e/.env - name: Boot the stage-mirror stack From 2bce27cfa6a0277c7237649c1c322883887e781a Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 15:52:42 -0700 Subject: [PATCH 046/410] docs(e2e): describe the dedicated, least-privilege, capped credentials behind the pull request check --- tests/e2e/CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index e04781ab205..b984791c738 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -56,6 +56,8 @@ Some suites need extra services the bare proxy does not start. The `logging/` OT Every PR that adds or modifies a `tests/e2e/**/test_*.py` file (outside `ui/`, `claude_code/`, and `load/`, which have their own lanes) runs exactly those files three times, with retries off, against a stage-mirror stack booted on a GitHub Actions runner: migrations, a control-plane backend, two gateway processes behind an nginx load balancer, Postgres, Jaeger, and a TLS cluster-mode Valkey, wired the way stage is deployed. A PR that only touches the harness or the stack itself runs the `access_control/` suite as a smoke instead. Three green passes are the bar because the check exists to catch a flaky test before it reaches the release gate, so a red pass is a failure to fix, not a retry candidate. The same stack boots on a laptop with `bash .github/e2e-stack/up.sh`: it reads provider keys from `tests/e2e/.env`, writes the pytest environment to `${E2E_STACK_DIR:-/tmp/litellm-e2e-stack}/stack.env`, every port is overridable through `E2E_*_PORT` variables, and `down.sh` tears it all down +Credentials: the lane never sees the stage keys. Everything the runner writes into `tests/e2e/.env` comes from two AWS Secrets Manager secrets in us-east-1, `litellm-e2e-changed-provider-keys` and `litellm-e2e-changed-license`, and the first holds only the names that the tests and `tests/e2e/gateway/stage_mirror_ci_config.yml` read. Each credential in it is dedicated to this lane and can do only what the tests do: the AWS pair belongs to the `litellm-e2e-changed` IAM user, whose inline policy allows Bedrock inference, the one guardrail the tests use, the batch job APIs, the batch S3 bucket, and assuming the batch test's role; the Vertex key belongs to a service account holding only `roles/aiplatform.user` on the Vertex project; the OpenAI, Anthropic, Gemini, Cohere, and Devin keys are per-lane keys with hard monthly spend caps; and the Datadog application key is scoped to log search. A leaked key can therefore spend at most one month of a small capped budget or call a handful of Bedrock APIs, and rotating one is a single `aws secretsmanager put-secret-value` on the secret, with no workflow change + ### Record and replay Record/replay scopes to the proxy's provider-bound traffic only. In `E2E_FIXTURE_MODE=record` the harness boots a local provider-edge server, edge-wired tests register their deployments with an `api_base` pointing at it, and every provider call the proxy makes is forwarded verbatim and written to a fixture bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`). `E2E_FIXTURE_MODE=replay` runs the same tests against the same live proxy and database, but the edge answers the proxy's provider calls from the bundle instead of the provider, so the run makes zero provider calls and spends nothing while key auth, routing, cost calculation, and spend-log writes all still execute for real. Unset (or `live`) behaves exactly as before the knob existed. Both record and replay need the proxy up; only the provider is taken out of the loop From 459858829e8a01df74c1aee1372f8447967fc43c Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:08:42 +0000 Subject: [PATCH 047/410] refactor(typing): replace Any with proven types in 89 more backend files --- .../proxy/hooks/managed_vector_stores.py | 12 +++-- litellm/_redis_credential_provider.py | 17 ++++++- litellm/_service_logger.py | 46 +++++++++++++++---- litellm/a2a_protocol/card_resolver.py | 3 +- .../watsonx_orchestrate/transformation.py | 14 +++--- litellm/assistants/utils.py | 45 ++++++++++-------- litellm/batches/batch_utils.py | 12 ++--- litellm/compression/compress.py | 12 ++--- litellm/containers/endpoint_factory.py | 14 +++--- litellm/exceptions.py | 4 +- litellm/fine_tuning/main.py | 14 +++--- litellm/images/utils.py | 3 +- .../datadog/datadog_cost_management.py | 5 +- .../dotprompt/dotprompt_manager.py | 7 +-- litellm/integrations/focus/focus_logger.py | 4 +- .../generic_prompt_manager.py | 3 +- litellm/integrations/humanloop.py | 6 +-- .../opentelemetry_utils/gen_ai_semconv.py | 6 +-- .../opik_payload_builder/payload_builders.py | 8 ++-- litellm/integrations/weave/weave_otel.py | 5 +- .../dot_notation_indexing.py | 17 ++++--- .../json_validation_rule.py | 6 +-- litellm/litellm_core_utils/logging_utils.py | 2 +- litellm/litellm_core_utils/safe_json_dumps.py | 2 +- litellm/llms/a2a/common_utils.py | 3 +- .../messages/interceptors/advisor.py | 10 ++-- .../responses_adapters/streaming_iterator.py | 10 ++-- .../llms/anthropic/files/transformation.py | 4 +- .../text_to_speech/transformation.py | 13 +++--- litellm/llms/azure/realtime/handler.py | 14 ++++-- .../llms/azure/responses/transformation.py | 6 +-- .../anthropic/count_tokens/token_counter.py | 8 ++-- .../llms/base_llm/agents/transformation.py | 24 +++++----- .../base_llm/guardrail_translation/utils.py | 12 ++--- .../vector_store_files/transformation.py | 23 +++++----- litellm/llms/bedrock/base_aws_llm.py | 22 +++++++-- .../llms/custom_httpx/container_handler.py | 6 +-- .../gemini/google_genai/transformation.py | 6 +-- litellm/llms/jina_ai/rerank/transformation.py | 6 +-- litellm/llms/litellm_proxy/skills/handler.py | 30 ++++++------ .../litellm_proxy/skills/sandbox_executor.py | 39 ++++++++++++++-- litellm/llms/openai/fine_tuning/handler.py | 17 +++---- .../llms/openai/image_variations/handler.py | 4 +- litellm/llms/openai/realtime/handler.py | 9 ++-- .../responses/count_tokens/token_counter.py | 8 ++-- .../vector_store_files/transformation.py | 25 +++++----- litellm/llms/predibase/chat/transformation.py | 25 ++++++++-- .../audio_transcription/transformation.py | 6 +-- .../llms/vertex_ai/rag_engine/ingestion.py | 8 ++-- .../llms/vertex_ai/videos/transformation.py | 4 +- .../embedding/transformation_multimodal.py | 6 +-- litellm/llms/voyage/rerank/transformation.py | 4 +- litellm/llms/xai/chat/transformation.py | 2 +- litellm/llms/xai/responses/transformation.py | 14 +++--- litellm/proxy/caching_routes.py | 10 ++-- litellm/proxy/client/chat.py | 4 +- litellm/proxy/client/cli/commands/agents.py | 10 ++-- litellm/proxy/client/keys.py | 17 +++---- .../proxy/common_utils/performance_utils.py | 20 ++++++-- .../proxy/container_endpoints/endpoints.py | 8 ++-- litellm/proxy/db/exception_handler.py | 4 +- .../guardrails/guardrail_hooks/azure/base.py | 4 +- .../guardrail_hooks/azure/text_moderation.py | 18 ++++---- .../block_code_execution/__init__.py | 6 +-- .../guardrail_hooks/custom_code/primitives.py | 6 +-- .../generic_guardrail_api.py | 10 ++-- .../model_armor/model_armor.py | 5 +- .../guardrails/guardrail_hooks/noma/noma.py | 4 +- .../guardrail_hooks/pangea/pangea.py | 6 +-- .../panw_prisma_airs/panw_prisma_airs.py | 2 +- litellm/proxy/guardrails/usage_tracking.py | 16 ++++--- .../shared_health_check_manager.py | 9 ++-- litellm/proxy/hooks/batch_rate_limiter.py | 8 ++-- .../proxy/hooks/key_management_event_hooks.py | 6 +-- .../management_endpoints/common_utils.py | 7 +-- litellm/proxy/realtime_endpoints/endpoints.py | 11 +++-- .../proxy/response_polling/polling_handler.py | 2 +- litellm/proxy/vector_store_endpoints/utils.py | 7 +-- litellm/rag/ingestion/bedrock_ingestion.py | 10 ++-- litellm/repositories/table_repositories.py | 2 +- litellm/router_strategy/lowest_latency.py | 2 +- .../encrypted_content_affinity_check.py | 21 ++++++--- litellm/router_utils/prompt_caching_cache.py | 4 +- .../custom_secret_manager_loader.py | 4 +- litellm/types/containers/main.py | 42 +++++++++-------- litellm/types/llms/oci.py | 28 +++++------ litellm/types/llms/openai_evals.py | 33 ++++++------- .../proxy/management_endpoints/scim_v2.py | 12 ++--- litellm/types/videos/main.py | 19 ++++---- 89 files changed, 594 insertions(+), 418 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py b/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py index 254d816039c..3b8c19f0097 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_vector_stores.py @@ -16,6 +16,7 @@ from litellm.llms.base_llm.managed_resources.utils import ( is_base64_encoded_unified_id, ) from litellm.proxy._types import UserAPIKeyAuth +from litellm.types.utils import LLMResponseTypes from litellm.types.vector_stores import ( VectorStoreCreateOptionalRequestParams, VectorStoreCreateResponse, @@ -24,6 +25,7 @@ from litellm.types.vector_stores import ( if TYPE_CHECKING: from opentelemetry.trace import Span as _Span + from litellm.caching.caching import DualCache from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache from litellm.proxy.utils import PrismaClient as _PrismaClient @@ -156,7 +158,7 @@ class _PROXY_LiteLLMManagedVectorStores( # Create vector store for each model # Convert TypedDict to Dict[str, Any] for base class compatibility - request_data_dict: Dict[str, Any] = dict(create_request) + request_data_dict: Dict[str, object] = dict(create_request) responses = await self.create_resource_for_each_model( llm_router=llm_router, request_data=request_data_dict, @@ -209,7 +211,7 @@ class _PROXY_LiteLLMManagedVectorStores( limit: Optional[int] = None, after: Optional[str] = None, order: Optional[str] = None, - ) -> Dict[str, Any]: + ) -> Dict[str, object]: """ List vector stores created by a user. @@ -301,7 +303,7 @@ class _PROXY_LiteLLMManagedVectorStores( async def async_pre_call_hook( self, user_api_key_dict: UserAPIKeyAuth, - cache: Any, + cache: "DualCache", data: Dict, call_type: str, ) -> Union[Exception, str, Dict, None]: @@ -403,8 +405,8 @@ class _PROXY_LiteLLMManagedVectorStores( self, data: Dict, user_api_key_dict: UserAPIKeyAuth, - response: Any, - ) -> Any: + response: LLMResponseTypes, + ) -> LLMResponseTypes: """ Post-call hook to transform responses. diff --git a/litellm/_redis_credential_provider.py b/litellm/_redis_credential_provider.py index 98fa62629a8..ba0398789a6 100644 --- a/litellm/_redis_credential_provider.py +++ b/litellm/_redis_credential_provider.py @@ -1,7 +1,7 @@ import asyncio import threading import time -from typing import Any, Final +from typing import Final, Protocol from redis.credentials import CredentialProvider @@ -18,6 +18,19 @@ _token_cache: Final[dict[str, tuple[str, float]]] = {} _token_cache_lock: Final = threading.Lock() +class AzureAccessToken(Protocol): + """The ``azure.core.credentials.AccessToken`` shape this module reads.""" + + @property + def token(self) -> str: ... + + +class AzureCredential(Protocol): + """The ``azure-identity`` credential surface this module calls.""" + + def get_token(self, *scopes: str) -> AzureAccessToken: ... + + def _generate_gcp_iam_access_token(service_account: str) -> str: """ Generate GCP IAM access token for Redis authentication. @@ -115,7 +128,7 @@ class AzureADCredentialProvider(CredentialProvider): fail authentication after the initial token expired (~1 hour TTL). """ - def __init__(self, credential: Any, username: str | None = None) -> None: + def __init__(self, credential: AzureCredential, username: str | None = None) -> None: self._credential = credential self._username = username diff --git a/litellm/_service_logger.py b/litellm/_service_logger.py index 42a86763b6d..703aa197a63 100644 --- a/litellm/_service_logger.py +++ b/litellm/_service_logger.py @@ -1,6 +1,6 @@ import asyncio from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, Protocol import litellm from litellm._logging import verbose_logger @@ -24,7 +24,30 @@ else: UserAPIKeyAuth = Any -def _get_otel_v2_class() -> type | None: +class _ServiceSpanLogger(Protocol): + """The OTel logger surface this module drives: the two service-span hooks it calls.""" + + async def async_service_success_hook( + self, + payload: ServiceLoggerPayload, + parent_otel_span: Span | None = None, + start_time: datetime | float | None = None, + end_time: datetime | float | None = None, + event_metadata: dict | None = None, + ) -> None: ... + + async def async_service_failure_hook( + self, + payload: ServiceLoggerPayload, + error: str | None = "", + parent_otel_span: Span | None = None, + start_time: datetime | float | None = None, + end_time: datetime | float | None = None, + event_metadata: dict | None = None, + ) -> None: ... + + +def _get_otel_v2_class() -> type[_ServiceSpanLogger] | None: """Return the ``OpenTelemetryV2`` class, or ``None`` if the OTel SDK is absent. Imported lazily: ``litellm.integrations.otel.logger`` imports the OpenTelemetry @@ -54,7 +77,7 @@ class ServiceLogging(CustomLogger): if "prometheus_system" in litellm.service_callback: self.prometheusServicesLogger = PrometheusServicesLogger() - def _resolve_otel_service_logger(self, callback: Any) -> Any | None: + def _resolve_otel_service_logger(self, callback: object) -> _ServiceSpanLogger | None: """Resolve the OTel logger (legacy or V2) to emit a service span on. Returns the logger instance whose ``async_service_*_hook`` should fire for @@ -69,18 +92,21 @@ class ServiceLogging(CustomLogger): """ otel_v2_cls: Final = _get_otel_v2_class() - def _is_otel_logger(obj: Any) -> bool: + def _as_otel_logger(obj: object) -> _ServiceSpanLogger | None: if isinstance(obj, OpenTelemetry): - return True - return otel_v2_cls is not None and isinstance(obj, otel_v2_cls) + return obj + if otel_v2_cls is not None and isinstance(obj, otel_v2_cls): + return obj + return None - if _is_otel_logger(callback): - return callback + resolved_callback: Final = _as_otel_logger(callback) + if resolved_callback is not None: + return resolved_callback if callback == "otel": from litellm.proxy.proxy_server import open_telemetry_logger - if open_telemetry_logger is not None and _is_otel_logger(open_telemetry_logger): - return open_telemetry_logger + if open_telemetry_logger is not None: + return _as_otel_logger(open_telemetry_logger) return None def service_success_hook( diff --git a/litellm/a2a_protocol/card_resolver.py b/litellm/a2a_protocol/card_resolver.py index 25f2e1a9a0d..b663e3085fb 100644 --- a/litellm/a2a_protocol/card_resolver.py +++ b/litellm/a2a_protocol/card_resolver.py @@ -4,6 +4,7 @@ Custom A2A Card Resolver for LiteLLM. Extends the A2A SDK's card resolver to support multiple well-known paths. """ +from collections.abc import Mapping from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final @@ -152,7 +153,7 @@ class LiteLLMA2ACardResolver(_A2ACardResolver): async def get_agent_card( self, relative_card_path: str | None = None, - http_kwargs: dict[str, Any] | None = None, + http_kwargs: Mapping[str, object] | None = None, ) -> "AgentCard": """ Fetch the agent card, trying multiple well-known paths. diff --git a/litellm/a2a_protocol/providers/watsonx_orchestrate/transformation.py b/litellm/a2a_protocol/providers/watsonx_orchestrate/transformation.py index 3748d8043cc..57c4a4677d0 100644 --- a/litellm/a2a_protocol/providers/watsonx_orchestrate/transformation.py +++ b/litellm/a2a_protocol/providers/watsonx_orchestrate/transformation.py @@ -8,7 +8,7 @@ WXO uses a REST API (not A2A/JSON-RPC) with an async-poll execution model: """ import asyncio -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Mapping from typing import Any, Final from uuid import uuid4 @@ -51,9 +51,9 @@ class WatsonxOrchestrateTransformation: wxo_agent_id: str, text: str, thread_id: str | None = None, - ) -> dict[str, Any]: + ) -> dict[str, object]: """Build the WXO POST /v1/orchestrate/runs request body.""" - body: Final[dict[str, Any]] = { + body: Final[dict[str, object]] = { "agent_id": wxo_agent_id, "message": { "role": "user", @@ -70,7 +70,7 @@ class WatsonxOrchestrateTransformation: return body @staticmethod - def extract_text_from_wxo_result(result: Any) -> str: + def extract_text_from_wxo_result(result: object) -> str: """ Extract response text from a WXO run result. @@ -103,7 +103,7 @@ class WatsonxOrchestrateTransformation: return "" @staticmethod - def extract_text_from_a2a_message_response(a2a_response: dict[str, Any]) -> str: + def extract_text_from_a2a_message_response(a2a_response: Mapping[str, object]) -> str: result: Final = a2a_response.get("result") if not isinstance(result, dict): verbose_logger.warning("WXO: A2A response missing result object") @@ -119,7 +119,7 @@ class WatsonxOrchestrateTransformation: return "" @staticmethod - def build_a2a_message_response(request_id: str, text: str) -> dict[str, Any]: + def build_a2a_message_response(request_id: str, text: str) -> dict[str, object]: """ Build a standard A2A non-streaming SendMessageResponse (kind=message). """ @@ -140,7 +140,7 @@ class WatsonxOrchestrateTransformation: request_id: str, chunk_size: int = 50, delay_ms: int = 10, - ) -> AsyncIterator[dict[str, Any]]: + ) -> AsyncIterator[dict[str, object]]: """ Emit standard A2A streaming events from a completed text response. diff --git a/litellm/assistants/utils.py b/litellm/assistants/utils.py index e41cff8419a..a2841e3ff93 100644 --- a/litellm/assistants/utils.py +++ b/litellm/assistants/utils.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping, Sequence from typing import Final import litellm @@ -10,20 +11,22 @@ def get_optional_params_add_message( role: str | None, content: str | list[MessageContentTextObject | MessageContentImageFileObject | MessageContentImageURLObject] | None, attachments: list[Attachment] | None, - metadata: dict | None, + metadata: Mapping[str, object] | None, custom_llm_provider: str, - **kwargs, -): + **kwargs: object, +) -> dict[str, object]: """ Azure doesn't support 'attachments' for creating a message Reference - https://learn.microsoft.com/en-us/azure/ai-services/openai/assistants-reference-messages?tabs=python#create-message """ - passed_params: Final = locals() - custom_llm_provider = passed_params.pop("custom_llm_provider") - special_params: Final = passed_params.pop("kwargs") - for k, v in special_params.items(): - passed_params[k] = v + passed_params: Final[Mapping[str, object]] = { + "role": role, + "content": content, + "attachments": attachments, + "metadata": metadata, + **kwargs, + } default_params: Final = { "role": None, @@ -33,10 +36,10 @@ def get_optional_params_add_message( } non_default_params = {k: v for k, v in passed_params.items() if (k in default_params and v != default_params[k])} - optional_params = {} + optional_params: dict[str, object] = {} ## raise exception if non-default value passed for non-openai/azure embedding calls - def _check_valid_arg(supported_params): + def _check_valid_arg(supported_params: Sequence[str]) -> Mapping[str, object] | None: if len(non_default_params.keys()) > 0: keys: Final = list(non_default_params.keys()) for k in keys: @@ -71,14 +74,18 @@ def get_optional_params_image_gen( style: str | None = None, user: str | None = None, custom_llm_provider: str | None = None, - **kwargs, -): + **kwargs: object, +) -> dict[str, object]: # retrieve all parameters passed to the function - passed_params: Final = locals() - custom_llm_provider = passed_params.pop("custom_llm_provider") - special_params: Final = passed_params.pop("kwargs") - for k, v in special_params.items(): - passed_params[k] = v + passed_params: Final[Mapping[str, object]] = { + "n": n, + "quality": quality, + "response_format": response_format, + "size": size, + "style": style, + "user": user, + **kwargs, + } default_params: Final = { "n": None, @@ -90,10 +97,10 @@ def get_optional_params_image_gen( } non_default_params = {k: v for k, v in passed_params.items() if (k in default_params and v != default_params[k])} - optional_params = {} + optional_params: dict[str, object] = {} ## raise exception if non-default value passed for non-openai/azure embedding calls - def _check_valid_arg(supported_params): + def _check_valid_arg(supported_params: Sequence[str]) -> Mapping[str, object] | None: if len(non_default_params.keys()) > 0: keys: Final = list(non_default_params.keys()) for k in keys: diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index 3831f57a10d..97be5f77d79 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -160,7 +160,7 @@ def _classify_output_line_stats( def _safe_output_line_stats( - entry: Mapping[str, Any], + entry: Mapping[str, object], custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"], model_name: str | None, model_info: ModelInfo | None, @@ -182,7 +182,7 @@ def _safe_output_line_stats( def _compute_output_line_stats( - entry: Mapping[str, Any], + entry: Mapping[str, object], custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"], model_name: str | None, model_info: ModelInfo | None, @@ -213,7 +213,7 @@ def _compute_output_line_stats( def _output_line_cost( - response_body: Mapping[str, Any], + response_body: Mapping[str, object], usage: Usage, custom_llm_provider: Literal["openai", "azure", "vertex_ai", "hosted_vllm", "anthropic", "bedrock"], model_name: str | None, @@ -556,7 +556,7 @@ def _iter_batch_output_entries(file_content: bytes) -> Iterator[dict]: def _parse_batch_output_line(line: bytes) -> dict | None: try: - parsed: Final = json.loads(line) + parsed: Final[object] = json.loads(line) except ValueError as e: verbose_logger.warning("skipping malformed batch output line: %s", str(e)) return None @@ -601,7 +601,7 @@ def _count_entry_tokens( return 0 -def _count_prompt_or_input_tokens(model: str, value: Any) -> int: +def _count_prompt_or_input_tokens(model: str, value: object) -> int: """Token-count a ``prompt`` / ``input`` field that the OpenAI batch schema allows in four shapes: @@ -680,7 +680,7 @@ def _get_anthropic_result_from_batch_results_line(batch_results_line: Mapping[st def _get_response_from_batch_job_output_file( batch_job_output_file: Mapping[str, Any], custom_llm_provider: str = "openai" -) -> Mapping[str, Any]: +) -> Mapping[str, object]: """ Get the response from the batch job output file """ diff --git a/litellm/compression/compress.py b/litellm/compression/compress.py index f844b3a3d7f..c646baf9d9e 100644 --- a/litellm/compression/compress.py +++ b/litellm/compression/compress.py @@ -66,7 +66,7 @@ def _build_retrieval_tools(keys: list[str], call_type: str) -> list[dict]: return cast(list[dict], anthropic_tools) -def _content_to_text(content: Any) -> str: +def _content_to_text(content: object) -> str: """ Convert OpenAI/Anthropic message content blocks to plain text. @@ -78,7 +78,7 @@ def _content_to_text(content: Any) -> str: Implemented iteratively (stack-based) to avoid unbounded recursion. """ parts: Final[list[str]] = [] - stack: Final[list[Any]] = [content] + stack: Final[list[object]] = [content] while stack: item = stack.pop() if isinstance(item, str): @@ -111,7 +111,7 @@ def _normalize_messages_for_compression( f"Unsupported call_type={call_type!r} for compression. Expected one of: {sorted(_SUPPORTED_CALL_TYPES)}." ) - original_messages: Final[list[dict[str, Any]]] = [dict(m) for m in messages] + original_messages: Final[list[dict[str, object]]] = [dict(m) for m in messages] normalized_messages: Final[list[dict]] = [] for msg in original_messages: @@ -132,7 +132,7 @@ def _extract_last_user_message(messages: list[dict]) -> str: return "" -def _extract_tool_use_ids(content: Any) -> list[str]: +def _extract_tool_use_ids(content: object) -> list[str]: if not isinstance(content, list): return [] tool_use_ids: Final[list[str]] = [] @@ -147,7 +147,7 @@ def _extract_tool_use_ids(content: Any) -> list[str]: return tool_use_ids -def _extract_tool_result_ids(content: Any) -> set[str]: +def _extract_tool_result_ids(content: object) -> set[str]: if not isinstance(content, list): return set() tool_result_ids: Final[set[str]] = set() @@ -337,7 +337,7 @@ def compress( compression_trigger: int = 200_000, compression_target: int | None = None, embedding_model: str | None = None, - embedding_model_params: dict[str, Any] | None = None, + embedding_model_params: Mapping[str, object] | None = None, compression_cache: DualCache | None = None, ) -> CompressedResult: """ diff --git a/litellm/containers/endpoint_factory.py b/litellm/containers/endpoint_factory.py index 09bc7eda41f..25fc223cde0 100644 --- a/litellm/containers/endpoint_factory.py +++ b/litellm/containers/endpoint_factory.py @@ -11,7 +11,7 @@ import json from collections.abc import Callable from functools import partial from pathlib import Path -from typing import Any, Final, Literal +from typing import Final, Literal import litellm from litellm.constants import request_timeout as DEFAULT_REQUEST_TIMEOUT @@ -56,9 +56,9 @@ def create_sync_endpoint_function(endpoint_config: dict) -> Callable: def endpoint_func( timeout: int = 600, custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", - extra_headers: dict[str, Any] | None = None, - extra_query: dict[str, Any] | None = None, - extra_body: dict[str, Any] | None = None, + extra_headers: dict[str, object] | None = None, + extra_query: dict[str, object] | None = None, + extra_body: dict[str, object] | None = None, **kwargs, ): local_vars: Final = locals() @@ -145,9 +145,9 @@ def create_async_endpoint_function( async def async_endpoint_func( timeout: int = 600, custom_llm_provider: Literal["openai", "azure", "azure_text"] = "openai", - extra_headers: dict[str, Any] | None = None, - extra_query: dict[str, Any] | None = None, - extra_body: dict[str, Any] | None = None, + extra_headers: dict[str, object] | None = None, + extra_query: dict[str, object] | None = None, + extra_body: dict[str, object] | None = None, **kwargs, ): local_vars: Final = locals() diff --git a/litellm/exceptions.py b/litellm/exceptions.py index 286f7528896..16202321709 100644 --- a/litellm/exceptions.py +++ b/litellm/exceptions.py @@ -85,7 +85,7 @@ _RATE_LIMIT_CATEGORY_VALUES: Final = frozenset(c.value for c in RateLimitErrorCa _RATE_LIMIT_TYPE_VALUES: Final = frozenset(t.value for t in RateLimitType) -def validate_rate_limit_category(value: Any) -> str | None: +def validate_rate_limit_category(value: object) -> str | None: """Return ``value`` only if it matches a known :class:`RateLimitErrorCategory`. Used at duck-typed read sites (StandardLoggingPayload extraction, Prometheus @@ -100,7 +100,7 @@ def validate_rate_limit_category(value: Any) -> str | None: return None -def validate_rate_limit_type(value: Any) -> str | None: +def validate_rate_limit_type(value: object) -> str | None: """Return ``value`` only if it matches a known :class:`RateLimitType`. See :func:`validate_rate_limit_category` for the rationale. diff --git a/litellm/fine_tuning/main.py b/litellm/fine_tuning/main.py index 48bb4cc6380..38be0666008 100644 --- a/litellm/fine_tuning/main.py +++ b/litellm/fine_tuning/main.py @@ -11,7 +11,7 @@ https://platform.openai.com/docs/api-reference/fine-tuning import asyncio import contextvars import os -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping from functools import partial from typing import Any, Final, Literal @@ -37,8 +37,8 @@ vertex_fine_tuning_apis_instance: Final = VertexFineTuningAPI() def _prepare_azure_extra_body( extra_body: dict[str, Any] | None, - kwargs: dict[str, Any], - azure_specific_hyperparams: dict[str, Any], + kwargs: Mapping[str, object], + azure_specific_hyperparams: Mapping[str, object], ) -> dict[str, Any]: """ Prepare extra_body for Azure fine-tuning API by combining Azure-specific parameters. @@ -138,7 +138,7 @@ def _build_fine_tuning_job_data(model, training_file, hyperparameters, suffix, v def _resolve_fine_tuning_timeout( - timeout: Any, + timeout: float | str | httpx.Timeout | None, custom_llm_provider: str, ) -> float | httpx.Timeout: """Normalise a raw timeout value to a float (seconds) or httpx.Timeout for fine-tuning calls.""" @@ -163,7 +163,7 @@ def create_fine_tuning_job( extra_headers: dict[str, str] | None = None, extra_body: dict[str, str] | None = None, **kwargs, -) -> LiteLLMFineTuningJob | Coroutine[Any, Any, LiteLLMFineTuningJob]: +) -> LiteLLMFineTuningJob | Coroutine[object, object, LiteLLMFineTuningJob]: """ Creates a fine-tuning job which begins the process of creating a new model from a given dataset. @@ -375,7 +375,7 @@ def cancel_fine_tuning_job( extra_headers: dict[str, str] | None = None, extra_body: dict[str, str] | None = None, **kwargs, -) -> LiteLLMFineTuningJob | Coroutine[Any, Any, LiteLLMFineTuningJob]: +) -> LiteLLMFineTuningJob | Coroutine[object, object, LiteLLMFineTuningJob]: """ Immediately cancel a fine-tune job. @@ -682,7 +682,7 @@ def retrieve_fine_tuning_job( extra_headers: dict[str, str] | None = None, extra_body: dict[str, str] | None = None, **kwargs, -) -> LiteLLMFineTuningJob | Coroutine[Any, Any, LiteLLMFineTuningJob]: +) -> LiteLLMFineTuningJob | Coroutine[object, object, LiteLLMFineTuningJob]: """ Get info about a fine-tuning job. """ diff --git a/litellm/images/utils.py b/litellm/images/utils.py index 2f080d88de4..49b70870de6 100644 --- a/litellm/images/utils.py +++ b/litellm/images/utils.py @@ -1,3 +1,4 @@ +from collections.abc import Mapping from io import BufferedReader, BytesIO from typing import Any, Final, cast, get_type_hints @@ -61,7 +62,7 @@ class ImageEditRequestUtils: @staticmethod def get_requested_image_edit_optional_param( - params: dict[str, Any], + params: Mapping[str, object], ) -> ImageEditOptionalRequestParams: """ Filter parameters to only include those defined in ImageEditOptionalRequestParams. diff --git a/litellm/integrations/datadog/datadog_cost_management.py b/litellm/integrations/datadog/datadog_cost_management.py index 7255c9c761c..538dd95abdd 100644 --- a/litellm/integrations/datadog/datadog_cost_management.py +++ b/litellm/integrations/datadog/datadog_cost_management.py @@ -1,6 +1,7 @@ import asyncio import os import time +from collections.abc import Mapping from datetime import datetime from typing import Any, Final, cast @@ -181,7 +182,7 @@ class DatadogCostManagementLogger(CustomBatchLogger): # cast because StandardLoggingMetadata is a TypedDict; we iterate it # as a generic mapping below. - metadata: Final[dict[str, Any]] = cast(dict[str, Any], log.get("metadata") or {}) + metadata: Final[Mapping[str, object]] = cast(dict[str, Any], log.get("metadata") or {}) # Backwards-compat: team/user/model_group preserved regardless of allowlist. if metadata.get("user_api_key_alias"): @@ -233,7 +234,7 @@ class DatadogCostManagementLogger(CustomBatchLogger): tags[key] = normalize_datadog_tag_value(value) @staticmethod - def _add_tag(tags: dict[str, str], key: str, value: Any) -> None: + def _add_tag(tags: dict[str, str], key: str, value: object) -> None: if value: tags[key] = str(value) diff --git a/litellm/integrations/dotprompt/dotprompt_manager.py b/litellm/integrations/dotprompt/dotprompt_manager.py index f1ef011cdb7..c646dbf4e2e 100644 --- a/litellm/integrations/dotprompt/dotprompt_manager.py +++ b/litellm/integrations/dotprompt/dotprompt_manager.py @@ -4,6 +4,7 @@ Builds on top of PromptManagementBase to provide .prompt file support. """ import json +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final from litellm.integrations.custom_prompt_management import CustomPromptManagement @@ -347,14 +348,14 @@ class DotpromptManager(CustomPromptManagement): metadata: Final = json_data.get("metadata", {}) self.prompt_manager.add_prompt(prompt_id, content, metadata) - def load_prompts_from_json(self, prompts_data: dict[str, dict[str, Any]]) -> None: + def load_prompts_from_json(self, prompts_data: dict[str, dict[str, object]]) -> None: """Load multiple prompts from JSON data.""" self.prompt_manager.load_prompts_from_json_data(prompts_data) - def get_prompts_as_json(self) -> dict[str, dict[str, Any]]: + def get_prompts_as_json(self) -> dict[str, dict[str, object]]: """Get all prompts in JSON format.""" return self.prompt_manager.get_all_prompts_as_json() - def convert_prompt_file_to_json(self, file_path: str) -> dict[str, Any]: + def convert_prompt_file_to_json(self, file_path: str) -> Mapping[str, object]: """Convert a .prompt file to JSON format.""" return self.prompt_manager.prompt_file_to_json(file_path) diff --git a/litellm/integrations/focus/focus_logger.py b/litellm/integrations/focus/focus_logger.py index 74ef6f70a65..c9b47835948 100644 --- a/litellm/integrations/focus/focus_logger.py +++ b/litellm/integrations/focus/focus_logger.py @@ -102,7 +102,7 @@ class FocusLogger(CustomLogger): # No time bounds → export all available data await self._export_all(limit=limit) - async def dry_run_export_usage_data(self, limit: int | None = DEFAULT_DRY_RUN_LIMIT) -> dict[str, Any]: + async def dry_run_export_usage_data(self, limit: int | None = DEFAULT_DRY_RUN_LIMIT) -> dict[str, object]: """Return transformed data without uploading.""" engine: Final = self._ensure_engine() return await engine.dry_run_export_usage_data(limit=limit) @@ -153,7 +153,7 @@ class FocusLogger(CustomLogger): **trigger_kwargs, ) - def _build_scheduler_trigger(self) -> dict[str, Any]: + def _build_scheduler_trigger(self) -> dict[str, str | int]: """Return scheduler configuration for the selected frequency.""" if self.frequency == "interval": seconds: Final = self.interval_seconds or 60 diff --git a/litellm/integrations/generic_prompt_management/generic_prompt_manager.py b/litellm/integrations/generic_prompt_management/generic_prompt_manager.py index bed3bdb58d1..77d315d0cee 100644 --- a/litellm/integrations/generic_prompt_management/generic_prompt_manager.py +++ b/litellm/integrations/generic_prompt_management/generic_prompt_manager.py @@ -4,6 +4,7 @@ Fetches prompts from any API that implements the /beta/litellm_prompt_management """ import json +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final import httpx @@ -349,7 +350,7 @@ class GenericPromptManager(CustomPromptManagement): def _apply_variables( self, prompt_client: PromptManagementClient, - variables: dict[str, Any], + variables: Mapping[str, object], ) -> PromptManagementClient: """ Apply variables to the prompt template. diff --git a/litellm/integrations/humanloop.py b/litellm/integrations/humanloop.py index 405854b0ce9..9e52ccd3c02 100644 --- a/litellm/integrations/humanloop.py +++ b/litellm/integrations/humanloop.py @@ -4,7 +4,7 @@ Humanloop integration https://humanloop.com/ """ -from typing import Any, Final, cast +from typing import Final, cast import httpx from typing_extensions import TypedDict @@ -24,7 +24,7 @@ class PromptManagementClient(TypedDict): prompt_id: str prompt_template: list[AllMessageValues] model: str | None - optional_params: dict[str, Any] | None + optional_params: dict[str, object] | None class HumanLoopPromptManager(DualCache): @@ -36,7 +36,7 @@ class HumanLoopPromptManager(DualCache): return cast(PromptManagementClient | None, self.get_cache(key=humanloop_prompt_id)) def _compile_prompt_helper( - self, prompt_template: list[AllMessageValues], prompt_variables: dict[str, Any] + self, prompt_template: list[AllMessageValues], prompt_variables: dict[str, object] ) -> list[AllMessageValues]: """ Helper function to compile the prompt by substituting variables in the template. diff --git a/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py b/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py index 0e58cf67795..b5eedc42fe9 100644 --- a/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py +++ b/litellm/integrations/opentelemetry_utils/gen_ai_semconv.py @@ -117,7 +117,7 @@ class OTELGenAISemconvMixin: if TYPE_CHECKING: config: "OpenTelemetryConfig" - def safe_set_attribute(self, span: Span, key: str, value: Any) -> None: ... + def safe_set_attribute(self, span: Span, key: str, value: object) -> None: ... def _capture_in_event(self) -> bool: ... @@ -195,13 +195,13 @@ class OTELGenAISemconvMixin: if value: self.safe_set_attribute(span=span, key=semconv_key, value=value) - def _build_inference_details_attrs(self, kwargs: dict, response_obj: dict, provider: str) -> dict[str, Any]: + def _build_inference_details_attrs(self, kwargs: dict, response_obj: dict, provider: str) -> dict[str, str]: """Build the attribute payload for the inference-details event. Always includes provider/operation; input/output messages are added only when content capture is enabled and non-empty. Mixin-internal. """ - attrs: Final[dict[str, Any]] = { + attrs: Final[dict[str, str]] = { "event_name": _INFERENCE_DETAILS_EVENT_NAME, "gen_ai.provider.name": provider, "gen_ai.operation.name": self._gen_ai_operation_name(kwargs), diff --git a/litellm/integrations/opik/opik_payload_builder/payload_builders.py b/litellm/integrations/opik/opik_payload_builder/payload_builders.py index 855b84ba4c8..3aaf5bfc162 100644 --- a/litellm/integrations/opik/opik_payload_builder/payload_builders.py +++ b/litellm/integrations/opik/opik_payload_builder/payload_builders.py @@ -15,8 +15,8 @@ def build_trace_payload( response_obj: dict[str, Any], start_time: datetime, end_time: datetime, - input_data: Any, - output_data: Any, + input_data: object, + output_data: object, metadata: dict[str, object], tags: list[str], thread_id: str | None, @@ -45,8 +45,8 @@ def build_span_payload( response_obj: dict[str, Any], start_time: datetime, end_time: datetime, - input_data: Any, - output_data: Any, + input_data: object, + output_data: object, metadata: dict[str, object], tags: list[str], usage: dict[str, int], diff --git a/litellm/integrations/weave/weave_otel.py b/litellm/integrations/weave/weave_otel.py index 1fc53d14a54..f2cc64a9ba2 100644 --- a/litellm/integrations/weave/weave_otel.py +++ b/litellm/integrations/weave/weave_otel.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import json import os +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final from opentelemetry.trace import Status, StatusCode @@ -59,7 +60,7 @@ class WeaveLLMObsOTELAttributes(BaseLLMObsOTELAttributes): safe_set_attribute(span, OpenInferenceSpanAttributes.INPUT_VALUE, json.dumps(prompt)) -def _set_weave_specific_attributes(span: Span, kwargs: dict[str, Any], response_obj: Any): +def _set_weave_specific_attributes(span: Span, kwargs: Mapping[str, Any], response_obj: Any): """ Sets Weave-specific metadata attributes onto the OTEL span. @@ -169,7 +170,7 @@ def get_weave_otel_config() -> WeaveOtelConfig: ) -def set_weave_otel_attributes(span: Span, kwargs: dict[str, Any], response_obj: Any): +def set_weave_otel_attributes(span: Span, kwargs: Mapping[str, object], response_obj: object): """ Sets OpenTelemetry span attributes for Weave observability. Uses the same attribute setting logic as other OTEL integrations for consistency. diff --git a/litellm/litellm_core_utils/dot_notation_indexing.py b/litellm/litellm_core_utils/dot_notation_indexing.py index 80a27007329..1dac67ecbf6 100644 --- a/litellm/litellm_core_utils/dot_notation_indexing.py +++ b/litellm/litellm_core_utils/dot_notation_indexing.py @@ -23,12 +23,13 @@ Used by JWT Auth to get the user role from the token, and by additional_drop_params to remove nested fields from optional parameters. """ +from collections.abc import Mapping from typing import Any, Final, TypeVar T = TypeVar("T") -def get_nested_value(data: dict[str, Any], key_path: str, default: T | None = None) -> T | None: +def get_nested_value(data: Mapping[str, object], key_path: str, default: T | None = None) -> T | None: """ Retrieves a value from a nested dictionary using dot notation. @@ -107,7 +108,7 @@ def _parse_path_segments(path: str) -> list: def _delete_nested_value_custom( - data: dict[str, Any] | list[Any], + data: dict[str, object] | list[object], segments: list, segment_index: int = 0, ) -> None: @@ -168,13 +169,15 @@ def _delete_nested_value_custom( if segment in data: next_segment: Final = segments[segment_index + 1] if segment_index + 1 < len(segments) else None + child: Final = data[segment] + # If next segment is array notation, current field should be list if next_segment and (next_segment.startswith("[")): - if isinstance(data[segment], list): - _delete_nested_value_custom(data[segment], segments, segment_index + 1) + if isinstance(child, list): + _delete_nested_value_custom(child, segments, segment_index + 1) # Otherwise navigate into dict - elif isinstance(data[segment], dict): - _delete_nested_value_custom(data[segment], segments, segment_index + 1) + elif isinstance(child, dict): + _delete_nested_value_custom(child, segments, segment_index + 1) def delete_nested_value( @@ -182,7 +185,7 @@ def delete_nested_value( path: str, depth: int = 0, max_depth: int = 20, -) -> dict[str, Any]: +) -> dict[str, object]: """ Delete a field from nested data using JSONPath notation. diff --git a/litellm/litellm_core_utils/json_validation_rule.py b/litellm/litellm_core_utils/json_validation_rule.py index 12f952d1d69..9fd4c03ac9e 100644 --- a/litellm/litellm_core_utils/json_validation_rule.py +++ b/litellm/litellm_core_utils/json_validation_rule.py @@ -5,10 +5,10 @@ from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH def normalize_json_schema_types( - schema: dict[str, Any] | list[Any] | Any, + schema: object, depth: int = 0, max_depth: int = DEFAULT_MAX_RECURSE_DEPTH, -) -> dict[str, Any] | list[Any] | Any: +) -> object: """ Normalize JSON schema types from uppercase to lowercase format. @@ -47,7 +47,7 @@ def normalize_json_schema_types( return [normalize_json_schema_types(item, depth + 1, max_depth) for item in schema] if isinstance(schema, dict): - normalized_schema: Final[dict[str, Any]] = {} + normalized_schema: Final[dict[str, object]] = {} for key, value in schema.items(): if key == "type" and isinstance(value, str) and value in type_mapping: diff --git a/litellm/litellm_core_utils/logging_utils.py b/litellm/litellm_core_utils/logging_utils.py index 91c8ba36b26..f3b1b29a9ad 100644 --- a/litellm/litellm_core_utils/logging_utils.py +++ b/litellm/litellm_core_utils/logging_utils.py @@ -184,7 +184,7 @@ def _get_parent_otel_span_from_logging_obj( def convert_litellm_response_object_to_str( - response_obj: Any | LiteLLMModelResponse, + response_obj: object, ) -> str | None: """ Get the string of the response object from LiteLLM diff --git a/litellm/litellm_core_utils/safe_json_dumps.py b/litellm/litellm_core_utils/safe_json_dumps.py index a1b71593dda..5b99e8cba98 100644 --- a/litellm/litellm_core_utils/safe_json_dumps.py +++ b/litellm/litellm_core_utils/safe_json_dumps.py @@ -30,7 +30,7 @@ def safe_dumps( def _transform(key: str | None, value: str) -> str: return value if value_transform is None else value_transform(key, value) - def _serialize(obj: Any, seen: set, depth: int, key: str | None = None) -> Any: + def _serialize(obj: object, seen: set[int], depth: int, key: str | None = None) -> Any: # Check for maximum depth. if depth > max_depth: return "MaxDepthExceeded" diff --git a/litellm/llms/a2a/common_utils.py b/litellm/llms/a2a/common_utils.py index 178b4c47a0f..57eadfe36d2 100644 --- a/litellm/llms/a2a/common_utils.py +++ b/litellm/llms/a2a/common_utils.py @@ -2,6 +2,7 @@ Common utilities for A2A (Agent-to-Agent) Protocol """ +from collections.abc import Mapping from typing import Any, Final from pydantic import BaseModel @@ -91,7 +92,7 @@ def extract_text_from_a2a_message(message: dict[str, Any], depth: int = 0, max_d return " ".join(text_parts) -def extract_text_from_a2a_response(response_dict: dict[str, Any], max_depth: int = 10) -> str: +def extract_text_from_a2a_response(response_dict: Mapping[str, object], max_depth: int = 10) -> str: """ Extract text content from A2A response result. diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py index 701211049db..4a6b65bb2b1 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/interceptors/advisor.py @@ -266,7 +266,7 @@ def _make_synthetic_advisor_tool() -> dict: } -def _find_advisor_tool_use(response: Any) -> dict | None: +def _find_advisor_tool_use(response: object) -> dict | None: """Return the first tool_use block with name='advisor', or None.""" content: Final = response.get("content") if isinstance(response, dict) else [] if not isinstance(content, list): @@ -277,7 +277,7 @@ def _find_advisor_tool_use(response: Any) -> dict | None: return None -def _extract_response_text(response: Any) -> str: +def _extract_response_text(response: object) -> str: """Extract concatenated text from all text blocks in a response.""" content: Final = response.get("content") if isinstance(response, dict) else [] if not isinstance(content, list): @@ -291,7 +291,7 @@ _PROVIDER_SPECIFIC_KEYS: Final = frozenset({"provider_specific_fields"}) def _build_advisor_context( messages: list[dict], - executor_response: Any, + executor_response: object, advisor_use_block: dict, ) -> list[dict]: """ @@ -327,7 +327,7 @@ def _build_advisor_context( def _inject_advisor_turn( messages: list[dict], - executor_response: Any, + executor_response: object, advisor_use_block: dict, advisor_text: str, ) -> list[dict]: @@ -355,7 +355,7 @@ def _inject_advisor_turn( def _inject_max_uses_error( messages: list[dict], - executor_response: Any, + executor_response: object, advisor_use_block: dict, ) -> list[dict]: """ diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py index 292d2622c7f..b9ab350f221 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py @@ -42,9 +42,9 @@ class AnthropicResponsesStreamWrapper: self._pending_tool_ids: dict[str, str] = {} # item_id -> call_id / name accumulator self._sent_message_start = False self._sent_message_stop = False - self._chunk_queue: deque = deque() + self._chunk_queue: deque[dict[str, object]] = deque() - def _make_message_start(self) -> dict[str, Any]: + def _make_message_start(self) -> dict[str, object]: return { "type": "message_start", "message": { @@ -68,7 +68,7 @@ class AnthropicResponsesStreamWrapper: self._current_block_index += 1 return self._current_block_index - def _open_block(self, item_id: str | None, content_block: Mapping[str, Any]) -> int: + def _open_block(self, item_id: str | None, content_block: Mapping[str, object]) -> int: block_idx = self._next_block_index() if item_id: self._item_id_to_block_index[item_id] = block_idx @@ -81,7 +81,7 @@ class AnthropicResponsesStreamWrapper: ) return block_idx - def _process_event(self, event: Any) -> None: + def _process_event(self, event: object) -> None: """Convert one Responses API event into zero or more Anthropic chunks queued for emission.""" event_type = getattr(event, "type", None) if event_type is None and isinstance(event, dict): @@ -247,7 +247,7 @@ class AnthropicResponsesStreamWrapper: def __aiter__(self) -> "AnthropicResponsesStreamWrapper": return self - async def __anext__(self) -> dict[str, Any]: + async def __anext__(self) -> dict[str, object]: # Return any queued chunks first if self._chunk_queue: return self._chunk_queue.popleft() diff --git a/litellm/llms/anthropic/files/transformation.py b/litellm/llms/anthropic/files/transformation.py index fe7f57d7a13..7b5ab78af8d 100644 --- a/litellm/llms/anthropic/files/transformation.py +++ b/litellm/llms/anthropic/files/transformation.py @@ -14,7 +14,7 @@ Anthropic Files API endpoints: import calendar import time -from typing import Any, Final, cast +from typing import Final, cast import httpx from openai.types.file_deleted import FileDeleted @@ -226,7 +226,7 @@ class AnthropicFilesConfig(BaseFilesConfig): ) -> tuple[str, dict]: api_base: Final = AnthropicModelInfo.get_api_base(litellm_params.get("api_base")) or ANTHROPIC_FILES_API_BASE url: Final = f"{api_base.rstrip('/')}/v1/files" - params: Final[dict[str, Any]] = {} + params: Final[dict[str, str]] = {} if purpose: params["purpose"] = purpose return url, params diff --git a/litellm/llms/aws_polly/text_to_speech/transformation.py b/litellm/llms/aws_polly/text_to_speech/transformation.py index 8f96f80d15e..133e40dc1ab 100644 --- a/litellm/llms/aws_polly/text_to_speech/transformation.py +++ b/litellm/llms/aws_polly/text_to_speech/transformation.py @@ -20,6 +20,7 @@ from litellm.llms.bedrock.base_aws_llm import BaseAWSLLM if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler from litellm.types.llms.openai import HttpxBinaryResponseContent else: LiteLLMLoggingObj = Any @@ -75,15 +76,15 @@ class AWSPollyTextToSpeechConfig(BaseTextToSpeechConfig, BaseAWSLLM): litellm_params_dict: dict, logging_obj: "LiteLLMLoggingObj", timeout: float | httpx.Timeout, - extra_headers: dict[str, Any] | None, - base_llm_http_handler: Any, + extra_headers: dict[str, object] | None, + base_llm_http_handler: "BaseLLMHTTPHandler", aspeech: bool, api_base: str | None, api_key: str | None, - **kwargs: Any, + **kwargs: object, ) -> Union[ "HttpxBinaryResponseContent", - Coroutine[Any, Any, "HttpxBinaryResponseContent"], + Coroutine[object, object, "HttpxBinaryResponseContent"], ]: """ Dispatch method to handle AWS Polly TTS requests @@ -251,7 +252,7 @@ class AWSPollyTextToSpeechConfig(BaseTextToSpeechConfig, BaseAWSLLM): def _sign_polly_request( self, - request_body: dict[str, Any], + request_body: dict[str, object], endpoint_url: str, litellm_params: dict, ) -> tuple[dict[str, str], str]: @@ -337,7 +338,7 @@ class AWSPollyTextToSpeechConfig(BaseTextToSpeechConfig, BaseAWSLLM): engine: Final = optional_params.get("engine", self.DEFAULT_ENGINE) # Build request body - request_body: Final[dict[str, Any]] = { + request_body: Final[dict[str, object]] = { "Engine": engine, "OutputFormat": output_format, "Text": input, diff --git a/litellm/llms/azure/realtime/handler.py b/litellm/llms/azure/realtime/handler.py index 88492ef996e..e9913f0108d 100644 --- a/litellm/llms/azure/realtime/handler.py +++ b/litellm/llms/azure/realtime/handler.py @@ -6,7 +6,7 @@ This requires websockets, and is currently only supported on LiteLLM Proxy. from collections.abc import Mapping from types import MappingProxyType -from typing import Any, Final, cast +from typing import Any, Final, Protocol, cast from litellm._logging import _redact_string, verbose_proxy_logger from litellm.constants import REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES @@ -31,6 +31,12 @@ async def forward_messages(client_ws: Any, backend_ws: Any): pass +class _ProxyClientWebSocket(Protocol): + """Client-facing websocket handle: this path only closes it after a failed handshake.""" + + async def close(self, code: int = ..., reason: str | None = ...) -> None: ... + + class AzureOpenAIRealtime(AzureChatCompletion): @staticmethod def get_auth_headers(api_key: str | None, azure_ad_token: str | None) -> Mapping[str, str]: @@ -104,17 +110,17 @@ class AzureOpenAIRealtime(AzureChatCompletion): async def async_realtime( self, model: str, - websocket: Any, + websocket: _ProxyClientWebSocket, logging_obj: LiteLLMLogging, api_base: str | None = None, api_key: str | None = None, api_version: str | None = None, azure_ad_token: str | None = None, - client: Any | None = None, + client: object | None = None, timeout: float | None = None, realtime_protocol: str | None = None, query_params: RealtimeQueryParams | None = None, - user_api_key_dict: Any | None = None, + user_api_key_dict: object | None = None, litellm_metadata: dict | None = None, ): import websockets diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index 0dd5e87e4ba..2a59cabfaf0 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -96,7 +96,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): # Then filter out status from message items if isinstance(validated_input, list): - filtered_input: Final[list[Any]] = [] + filtered_input: Final[list[object]] = [] for item in validated_input: if isinstance(item, dict) and item.get("type") == "message": # Filter out status field from message items @@ -123,7 +123,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): if "tools" in response_api_optional_request_params and isinstance( response_api_optional_request_params["tools"], list ): - new_tools: Final[list[dict[str, Any]]] = [] + new_tools: Final[list[dict[str, object]]] = [] for tool in response_api_optional_request_params["tools"]: if isinstance(tool, dict) and "function" in tool: new_tool: dict[str, Any] = deepcopy(tool) @@ -291,7 +291,7 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): url: Final = self._construct_url_for_response_id_in_path( api_base=api_base, response_id=response_id, path_suffix="/input_items" ) - params: Final[dict[str, Any]] = {} + params: Final[dict[str, str | int]] = {} if after is not None: params["after"] = after if before is not None: diff --git a/litellm/llms/azure_ai/anthropic/count_tokens/token_counter.py b/litellm/llms/azure_ai/anthropic/count_tokens/token_counter.py index 955090b9b90..c31d2c427bb 100644 --- a/litellm/llms/azure_ai/anthropic/count_tokens/token_counter.py +++ b/litellm/llms/azure_ai/anthropic/count_tokens/token_counter.py @@ -28,12 +28,12 @@ class AzureAIAnthropicTokenCounter(BaseTokenCounter): async def count_tokens( self, model_to_use: str, - messages: list[dict[str, Any]] | None, - contents: list[dict[str, Any]] | None, + messages: list[dict[str, object]] | None, + contents: list[dict[str, object]] | None, deployment: dict[str, Any] | None = None, request_model: str = "", - tools: list[dict[str, Any]] | None = None, - system: Any | None = None, + tools: list[dict[str, object]] | None = None, + system: object | None = None, ) -> TokenCountResponse | None: """ Count tokens using Azure AI Anthropic's CountTokens API. diff --git a/litellm/llms/base_llm/agents/transformation.py b/litellm/llms/base_llm/agents/transformation.py index 970639939f1..9d139b289c4 100644 --- a/litellm/llms/base_llm/agents/transformation.py +++ b/litellm/llms/base_llm/agents/transformation.py @@ -10,7 +10,7 @@ InteractionsHTTPHandler). """ from abc import ABC, abstractmethod -from typing import Any +from collections.abc import Mapping import httpx @@ -35,7 +35,7 @@ class BaseAgentsAPIConfig(ABC): def get_complete_url( self, api_base: str | None, - litellm_params: dict[str, Any], + litellm_params: Mapping[str, object], ) -> str: """Return the full URL for POST /agents (create).""" @@ -43,7 +43,7 @@ class BaseAgentsAPIConfig(ABC): def validate_environment( self, headers: dict[str, str], - litellm_params: dict[str, Any], + litellm_params: dict[str, object], ) -> dict[str, str]: """Validate credentials and return auth headers.""" @@ -51,8 +51,8 @@ class BaseAgentsAPIConfig(ABC): def transform_create_request( self, name: str, - litellm_params: dict[str, Any], - ) -> dict[str, Any]: + litellm_params: Mapping[str, object], + ) -> dict[str, object]: """Map name + litellm_params to the provider's create-agent body.""" @abstractmethod @@ -71,8 +71,8 @@ class BaseAgentsAPIConfig(ABC): def transform_list_request( self, api_base: str | None, - litellm_params: dict[str, Any], - ) -> tuple[str, dict[str, Any]]: + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, object]]: """Return (url, query_params) for GET /agents.""" @abstractmethod @@ -91,8 +91,8 @@ class BaseAgentsAPIConfig(ABC): self, name: str, api_base: str | None, - litellm_params: dict[str, Any], - ) -> tuple[str, dict[str, Any]]: + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, object]]: """Return (url, query_params) for GET /agents/{name}.""" @abstractmethod @@ -112,7 +112,7 @@ class BaseAgentsAPIConfig(ABC): self, name: str, api_base: str | None, - litellm_params: dict[str, Any], + litellm_params: Mapping[str, object], ) -> str: """Return the URL for DELETE /agents/{name}.""" @@ -133,8 +133,8 @@ class BaseAgentsAPIConfig(ABC): self, name: str, api_base: str | None, - litellm_params: dict[str, Any], - ) -> tuple[str, dict[str, Any]]: + litellm_params: Mapping[str, object], + ) -> tuple[str, dict[str, object]]: """Return (url, query_params) for GET /agents/{name}/versions.""" @abstractmethod diff --git a/litellm/llms/base_llm/guardrail_translation/utils.py b/litellm/llms/base_llm/guardrail_translation/utils.py index 9b6f9c47105..a67ca9bffa8 100644 --- a/litellm/llms/base_llm/guardrail_translation/utils.py +++ b/litellm/llms/base_llm/guardrail_translation/utils.py @@ -2,13 +2,13 @@ from __future__ import annotations import json from collections.abc import Callable, Iterator, Sequence -from typing import Any, Final, TypeVar +from typing import Final, TypeVar from litellm.types.llms.anthropic_messages.anthropic_response import AnthropicUsage from litellm.types.llms.openai import AllMessageValues, ResponseAPIUsage -def _anthropic_stream_chunk_events(item: Any) -> list[dict]: +def _anthropic_stream_chunk_events(item: object) -> list[dict]: if isinstance(item, dict): return [item] if isinstance(item, bytes): @@ -36,7 +36,7 @@ def _anthropic_stream_chunk_events(item: Any) -> list[dict]: return events -def _usage_from_anthropic_stream_chunks(original_response: list[Any]) -> AnthropicUsage | None: +def _usage_from_anthropic_stream_chunks(original_response: Sequence[object]) -> AnthropicUsage | None: input_tokens = 0 output_tokens = 0 found_usage = False @@ -79,7 +79,7 @@ def _usage_tokens(usage_obj: object, key: str, fallback_key: str) -> int: return int(getattr(usage_obj, key, getattr(usage_obj, fallback_key, 0)) or 0) -def blocked_response_usage(original_response: Any | None) -> AnthropicUsage: +def blocked_response_usage(original_response: object) -> AnthropicUsage: """ Token usage for a synthetic guardrail-blocked response. @@ -179,7 +179,7 @@ def blocked_responses_stream_usage(original_response: object) -> ResponseAPIUsag return blocked_responses_api_usage(completed) -def effective_skip_system_message_for_guardrail(guardrail_to_apply: Any) -> bool: +def effective_skip_system_message_for_guardrail(guardrail_to_apply: object) -> bool: per: Final = getattr(guardrail_to_apply, "skip_system_message_in_guardrail", None) if per is not None: return bool(per) @@ -188,7 +188,7 @@ def effective_skip_system_message_for_guardrail(guardrail_to_apply: Any) -> bool return bool(getattr(litellm, "skip_system_message_in_guardrail", False)) -def effective_skip_tool_message_for_guardrail(guardrail_to_apply: Any) -> bool: +def effective_skip_tool_message_for_guardrail(guardrail_to_apply: object) -> bool: per: Final = getattr(guardrail_to_apply, "skip_tool_message_in_guardrail", None) if per is not None: return bool(per) diff --git a/litellm/llms/base_llm/vector_store_files/transformation.py b/litellm/llms/base_llm/vector_store_files/transformation.py index 74aa283113c..9fb4d3e9dac 100644 --- a/litellm/llms/base_llm/vector_store_files/transformation.py +++ b/litellm/llms/base_llm/vector_store_files/transformation.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from collections.abc import Mapping from typing import TYPE_CHECKING, Any import httpx @@ -43,10 +44,10 @@ class BaseVectorStoreFilesConfig(ABC): self, *, operation: str, - non_default_params: dict[str, Any], - optional_params: dict[str, Any], + non_default_params: Mapping[str, object], + optional_params: Mapping[str, object], drop_params: bool, - ) -> dict[str, Any]: + ) -> Mapping[str, object]: """Map non-default OpenAI params to provider-specific params.""" return optional_params @@ -87,7 +88,7 @@ class BaseVectorStoreFilesConfig(ABC): vector_store_id: str, create_request: VectorStoreFileCreateRequest, api_base: str, - ) -> tuple[str, dict[str, Any]]: ... + ) -> tuple[str, dict[str, object]]: ... @abstractmethod def transform_create_vector_store_file_response( @@ -103,7 +104,7 @@ class BaseVectorStoreFilesConfig(ABC): vector_store_id: str, query_params: VectorStoreFileListQueryParams, api_base: str, - ) -> tuple[str, dict[str, Any]]: ... + ) -> tuple[str, dict[str, object]]: ... @abstractmethod def transform_list_vector_store_files_response( @@ -119,7 +120,7 @@ class BaseVectorStoreFilesConfig(ABC): vector_store_id: str, file_id: str, api_base: str, - ) -> tuple[str, dict[str, Any]]: ... + ) -> tuple[str, dict[str, object]]: ... @abstractmethod def transform_retrieve_vector_store_file_response( @@ -135,7 +136,7 @@ class BaseVectorStoreFilesConfig(ABC): vector_store_id: str, file_id: str, api_base: str, - ) -> tuple[str, dict[str, Any]]: ... + ) -> tuple[str, dict[str, object]]: ... @abstractmethod def transform_retrieve_vector_store_file_content_response( @@ -152,7 +153,7 @@ class BaseVectorStoreFilesConfig(ABC): file_id: str, update_request: VectorStoreFileUpdateRequest, api_base: str, - ) -> tuple[str, dict[str, Any]]: ... + ) -> tuple[str, dict[str, object]]: ... @abstractmethod def transform_update_vector_store_file_response( @@ -168,7 +169,7 @@ class BaseVectorStoreFilesConfig(ABC): vector_store_id: str, file_id: str, api_base: str, - ) -> tuple[str, dict[str, Any]]: ... + ) -> tuple[str, dict[str, object]]: ... @abstractmethod def transform_delete_vector_store_file_response( @@ -196,8 +197,8 @@ class BaseVectorStoreFilesConfig(ABC): self, *, headers: dict[str, str], - optional_params: dict[str, Any], - request_data: dict[str, Any], + optional_params: Mapping[str, object], + request_data: Mapping[str, object], api_base: str, api_key: str | None = None, ) -> tuple[dict[str, str], bytes | None]: diff --git a/litellm/llms/bedrock/base_aws_llm.py b/litellm/llms/bedrock/base_aws_llm.py index 1e634ced29b..ec98a7a2c8f 100644 --- a/litellm/llms/bedrock/base_aws_llm.py +++ b/litellm/llms/bedrock/base_aws_llm.py @@ -4,7 +4,7 @@ import json import os import re import urllib.parse -from collections.abc import Callable +from collections.abc import Callable, Mapping from datetime import datetime from threading import Lock from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, cast, get_args @@ -125,7 +125,7 @@ class BaseAWSLLM: return get_ssl_verify(ssl_verify=ssl_verify) - def get_cache_key(self, credential_args: dict[str, str | None]) -> str: + def get_cache_key(self, credential_args: Mapping[str, str | bool | None]) -> str: """ Generate a unique cache key based on the credential arguments. """ @@ -135,8 +135,8 @@ class BaseAWSLLM: def _get_or_set_cached_credentials( self, - credential_args: dict[str, str | None], - credential_fetcher: Callable[[], tuple[Any, int | None]], + credential_args: Mapping[str, str | bool | None], + credential_fetcher: Callable[[], tuple[Credentials, int | None]], ) -> Any: """ Read-through IAM cache on the process-wide ``DualCache``. @@ -271,7 +271,19 @@ class BaseAWSLLM: aws_external_id, ) - args: Final = {k: v for k, v in locals().items() if k.startswith("aws_") or k == "ssl_verify"} + args: Final = { + "aws_access_key_id": aws_access_key_id, + "aws_secret_access_key": aws_secret_access_key, + "aws_session_token": aws_session_token, + "aws_region_name": aws_region_name, + "aws_session_name": aws_session_name, + "aws_profile_name": aws_profile_name, + "aws_role_name": aws_role_name, + "aws_web_identity_token": aws_web_identity_token, + "aws_sts_endpoint": aws_sts_endpoint, + "aws_external_id": aws_external_id, + "ssl_verify": ssl_verify, + } ######################################################### # Handle diff boto3 auth flows diff --git a/litellm/llms/custom_httpx/container_handler.py b/litellm/llms/custom_httpx/container_handler.py index dd20a8c2ed4..d4f1f0a4f1b 100644 --- a/litellm/llms/custom_httpx/container_handler.py +++ b/litellm/llms/custom_httpx/container_handler.py @@ -141,7 +141,7 @@ def _build_query_params( def _error_message_from_response(response: httpx.Response) -> str: try: - body: Final = response.json() + body: Final[object] = response.json() except ValueError: return response.text @@ -330,7 +330,7 @@ class GenericContainerHandler: timeout: float | httpx.Timeout = 600, client: HTTPHandler | AsyncHTTPHandler | None = None, **kwargs: object, - ) -> Any: + ) -> ContainerEndpointResponse: """Synchronous request handler.""" endpoint_config: Final = _get_endpoint_config(endpoint_name) if not endpoint_config: @@ -410,7 +410,7 @@ class GenericContainerHandler: timeout: float | httpx.Timeout = 600, client: HTTPHandler | AsyncHTTPHandler | None = None, **kwargs: object, - ) -> Any: + ) -> ContainerEndpointResponse: """Asynchronous request handler.""" endpoint_config: Final = _get_endpoint_config(endpoint_name) if not endpoint_config: diff --git a/litellm/llms/gemini/google_genai/transformation.py b/litellm/llms/gemini/google_genai/transformation.py index d220742b92b..1189af2d6a3 100644 --- a/litellm/llms/gemini/google_genai/transformation.py +++ b/litellm/llms/gemini/google_genai/transformation.py @@ -117,7 +117,7 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM): _snake_to_camel, ) - _generate_content_config_dict: Final[dict[str, Any]] = {} + _generate_content_config_dict: Final[dict[str, object]] = {} supported_google_genai_params: Final = self.get_supported_generate_content_optional_params(model) # Create a set with both camelCase and snake_case versions for faster lookup supported_params_set: Final = set(supported_google_genai_params) @@ -175,7 +175,7 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM): def _get_common_auth_components( self, litellm_params: dict, - ) -> tuple[Any, str | None, str | None]: + ) -> tuple[str | None, str | None, str | None]: """ Get common authentication components used by both sync and async methods. @@ -193,7 +193,7 @@ class GoogleGenAIConfig(BaseGoogleGenAIGenerateContentConfig, VertexLLM): auth_header: str | None, vertex_project: str | None, vertex_location: str | None, - vertex_credentials: Any, + vertex_credentials: str | None, stream: bool, api_base: str | None, litellm_params: dict, diff --git a/litellm/llms/jina_ai/rerank/transformation.py b/litellm/llms/jina_ai/rerank/transformation.py index 199599d6b9c..a8f3388d092 100644 --- a/litellm/llms/jina_ai/rerank/transformation.py +++ b/litellm/llms/jina_ai/rerank/transformation.py @@ -6,8 +6,8 @@ Why separate file? Make it easy to see how transformation works Docs - https://jina.ai/reranker """ -from collections.abc import Mapping -from typing import Any, Final +from collections.abc import Mapping, Sequence +from typing import Final from httpx import URL, Response @@ -39,7 +39,7 @@ class JinaAIRerankConfig(BaseRerankConfig): model: str, drop_params: bool, query: str, - documents: list[str | dict[str, Any]], + documents: Sequence[str | Mapping[str, object]], custom_llm_provider: str | None = None, top_n: int | None = None, rank_fields: list[str] | None = None, diff --git a/litellm/llms/litellm_proxy/skills/handler.py b/litellm/llms/litellm_proxy/skills/handler.py index 9d365572e6f..73f6ed23092 100644 --- a/litellm/llms/litellm_proxy/skills/handler.py +++ b/litellm/llms/litellm_proxy/skills/handler.py @@ -6,7 +6,7 @@ Used by the transformation layer and skills injection hook. """ import uuid -from typing import Any, Final +from typing import Final from litellm._logging import verbose_logger from litellm.caching.in_memory_cache import InMemoryCache @@ -76,7 +76,7 @@ class LiteLLMSkillsHandler: # this module FastAPI-free per the project layering rule. raise ValueError("Unable to record skill ownership: caller has no identity scope.") - skill_data: Final[dict[str, Any]] = { + skill_data: Final[dict[str, object]] = { "skill_id": skill_id, "display_title": data.display_title, "description": data.description, @@ -115,22 +115,24 @@ class LiteLLMSkillsHandler: verbose_logger.debug("LiteLLMSkillsHandler: Listing skills with limit=%s, offset=%s", limit, offset) - find_many_kwargs: Final[dict[str, Any]] = { - "take": limit, - "skip": offset, - "order": {"created_at": "desc"}, - } - if user_api_key_dict is not None and not is_proxy_admin(user_api_key_dict): - owner_scopes: Final = get_resource_owner_scopes(user_api_key_dict) - if not owner_scopes: - return [] - find_many_kwargs["where"] = {"created_by": {"in": owner_scopes}} + owner_scopes: Final = ( + get_resource_owner_scopes(user_api_key_dict) + if user_api_key_dict is not None and not is_proxy_admin(user_api_key_dict) + else None + ) + if owner_scopes is not None and not owner_scopes: + return [] - skills: Final = await SkillsRepository(prisma_client).table.find_many(**find_many_kwargs) + skills: Final = await SkillsRepository(prisma_client).table.find_many( + take=limit, + skip=offset, + order={"created_at": "desc"}, + where={"created_by": {"in": owner_scopes}} if owner_scopes else None, + ) return [_prisma_skill_to_litellm(s) for s in skills] @staticmethod - async def _load_skill(skill_id: str) -> Any | None: + async def _load_skill(skill_id: str) -> object | None: """Cache-first read of the Prisma skill row. Owner-scope filtering happens on the cached row, so the cache is per-skill not per-caller. """ diff --git a/litellm/llms/litellm_proxy/skills/sandbox_executor.py b/litellm/llms/litellm_proxy/skills/sandbox_executor.py index cdf4f8511e2..3e38dd81905 100644 --- a/litellm/llms/litellm_proxy/skills/sandbox_executor.py +++ b/litellm/llms/litellm_proxy/skills/sandbox_executor.py @@ -7,11 +7,40 @@ Supports Docker, Podman, and Kubernetes backends. import base64 import os -from typing import Any, Final +from typing import Any, Final, Protocol, TypedDict + +from typing_extensions import ReadOnly from litellm._logging import verbose_logger +class _SandboxRunResult(Protocol): + """Result of running code inside an llm-sandbox session.""" + + @property + def exit_code(self) -> int: ... + + @property + def stdout(self) -> str | None: ... + + +class _SandboxSession(Protocol): + """The subset of an llm-sandbox session used while collecting generated files.""" + + def run(self, code: str, /) -> _SandboxRunResult: ... + + def copy_from_runtime(self, src: str, dest: str, /) -> object: ... + + +class _GeneratedFile(TypedDict): + """A file produced inside the sandbox and carried back out as base64.""" + + name: ReadOnly[str] + path: ReadOnly[str] + content_base64: ReadOnly[str] + mime_type: ReadOnly[str] + + class SkillsSandboxExecutor: """ Executes skill code in llm-sandbox Docker container. @@ -77,7 +106,7 @@ class SkillsSandboxExecutor: try: # Create sandbox session - session_kwargs: Final[dict[str, Any]] = { + session_kwargs: Final[dict[str, object]] = { "lang": "python", "verbose": False, } @@ -197,9 +226,9 @@ sys.path.insert(0, '/sandbox') def _collect_generated_files( self, - session: Any, + session: _SandboxSession, original_files: dict[str, bytes], - ) -> list[dict[str, Any]]: + ) -> list[_GeneratedFile]: """ Collect files generated during execution. @@ -213,7 +242,7 @@ sys.path.insert(0, '/sandbox') Returns: List of generated files with base64 content """ - generated_files: Final[list[dict[str, Any]]] = [] + generated_files: Final[list[_GeneratedFile]] = [] try: import tempfile diff --git a/litellm/llms/openai/fine_tuning/handler.py b/litellm/llms/openai/fine_tuning/handler.py index 7fb99d61475..1ff5909a103 100644 --- a/litellm/llms/openai/fine_tuning/handler.py +++ b/litellm/llms/openai/fine_tuning/handler.py @@ -1,13 +1,14 @@ -from collections.abc import Coroutine -from typing import Any, Final, cast +from collections.abc import Coroutine, Mapping +from typing import Final, cast import httpx from openai import AsyncAzureOpenAI, AsyncOpenAI, AzureOpenAI, OpenAI +from openai.types.fine_tuning import FineTuningJob from litellm._logging import verbose_logger from litellm.types.utils import LiteLLMFineTuningJob -_AZURE_STATUS_MAP: Final = { +_AZURE_STATUS_MAP: Final[Mapping[object, str]] = { "pending": "queued", "notRunning": "queued", "running": "running", @@ -20,7 +21,7 @@ _AZURE_STATUS_MAP: Final = { # because LiteLLMFineTuningJob schema has no intermediate cancellation state. -def _normalize_fine_tuning_job_dict(data: dict[str, Any], is_azure: bool = False) -> dict[str, Any]: +def _normalize_fine_tuning_job_dict(data: dict[str, object], is_azure: bool = False) -> dict[str, object]: """ Normalize Azure OpenAI FineTuningJob response to match OpenAI schema. @@ -47,7 +48,7 @@ def _normalize_fine_tuning_job_dict(data: dict[str, Any], is_azure: bool = False return normalized -def _litellm_fine_tuning_job_from_response(response: Any, is_azure: bool = False) -> LiteLLMFineTuningJob: +def _litellm_fine_tuning_job_from_response(response: FineTuningJob, is_azure: bool = False) -> LiteLLMFineTuningJob: return LiteLLMFineTuningJob(**_normalize_fine_tuning_job_dict(response.model_dump(), is_azure=is_azure)) @@ -111,7 +112,7 @@ class OpenAIFineTuningAPI: max_retries: int | None, organization: str | None, client: OpenAI | AsyncOpenAI | AzureOpenAI | AsyncAzureOpenAI | None = None, - ) -> LiteLLMFineTuningJob | Coroutine[Any, Any, LiteLLMFineTuningJob]: + ) -> LiteLLMFineTuningJob | Coroutine[object, object, LiteLLMFineTuningJob]: openai_client: Final[OpenAI | AsyncOpenAI | AzureOpenAI | AsyncAzureOpenAI | None] = self.get_openai_client( api_key=api_key, api_base=api_base, @@ -159,7 +160,7 @@ class OpenAIFineTuningAPI: max_retries: int | None, organization: str | None, client: OpenAI | AsyncOpenAI | AzureOpenAI | AsyncAzureOpenAI | None = None, - ) -> LiteLLMFineTuningJob | Coroutine[Any, Any, LiteLLMFineTuningJob]: + ) -> LiteLLMFineTuningJob | Coroutine[object, object, LiteLLMFineTuningJob]: openai_client: Final[OpenAI | AsyncOpenAI | AzureOpenAI | AsyncAzureOpenAI | None] = self.get_openai_client( api_key=api_key, api_base=api_base, @@ -258,7 +259,7 @@ class OpenAIFineTuningAPI: max_retries: int | None, organization: str | None, client: OpenAI | AsyncOpenAI | AzureOpenAI | AsyncAzureOpenAI | None = None, - ) -> LiteLLMFineTuningJob | Coroutine[Any, Any, LiteLLMFineTuningJob]: + ) -> LiteLLMFineTuningJob | Coroutine[object, object, LiteLLMFineTuningJob]: openai_client: Final[OpenAI | AsyncOpenAI | AzureOpenAI | AsyncAzureOpenAI | None] = self.get_openai_client( api_key=api_key, api_base=api_base, diff --git a/litellm/llms/openai/image_variations/handler.py b/litellm/llms/openai/image_variations/handler.py index dba1e9d01d3..bc02d274f24 100644 --- a/litellm/llms/openai/image_variations/handler.py +++ b/litellm/llms/openai/image_variations/handler.py @@ -104,7 +104,7 @@ class OpenAIImageVariationsHandler: status_code: Final = getattr(e, "status_code", 500) error_headers = getattr(e, "headers", None) error_text: Final = getattr(e, "text", str(e)) - error_response: Final = getattr(e, "response", None) + error_response: Final[object] = getattr(e, "response", None) if error_headers is None and error_response: error_headers = getattr(error_response, "headers", None) raise OpenAIError(status_code=status_code, message=error_text, headers=error_headers) @@ -221,7 +221,7 @@ class OpenAIImageVariationsHandler: status_code: Final = getattr(e, "status_code", 500) error_headers = getattr(e, "headers", None) error_text: Final = getattr(e, "text", str(e)) - error_response: Final = getattr(e, "response", None) + error_response: Final[object] = getattr(e, "response", None) if error_headers is None and error_response: error_headers = getattr(error_response, "headers", None) raise OpenAIError(status_code=status_code, message=error_text, headers=error_headers) diff --git a/litellm/llms/openai/realtime/handler.py b/litellm/llms/openai/realtime/handler.py index 0343f22e7d1..e3ecbac1a53 100644 --- a/litellm/llms/openai/realtime/handler.py +++ b/litellm/llms/openai/realtime/handler.py @@ -4,6 +4,7 @@ This file contains the calling OpenAI's `/v1/realtime` endpoint. This requires websockets, and is currently only supported on LiteLLM Proxy. """ +import ssl from typing import Any, Final, cast from litellm._logging import _redact_string, verbose_logger @@ -56,7 +57,7 @@ class OpenAIRealtime(OpenAIChatCompletion): headers["OpenAI-Beta"] = "realtime=v1" return headers - def _get_ssl_config(self, url: str) -> Any: + def _get_ssl_config(self, url: str) -> bool | str | ssl.SSLContext | None: """ Get SSL configuration for WebSocket connection. Override this in subclasses to customize SSL behavior. @@ -111,12 +112,12 @@ class OpenAIRealtime(OpenAIChatCompletion): logging_obj: LiteLLMLogging, api_base: str | None = None, api_key: str | None = None, - client: Any | None = None, + client: object | None = None, timeout: float | None = None, query_params: RealtimeQueryParams | None = None, - user_api_key_dict: Any | None = None, + user_api_key_dict: object | None = None, litellm_metadata: dict | None = None, - **kwargs: Any, + **kwargs: object, ): import websockets from websockets.asyncio.client import ClientConnection diff --git a/litellm/llms/openai/responses/count_tokens/token_counter.py b/litellm/llms/openai/responses/count_tokens/token_counter.py index 64018df8b7a..c05d943b7cf 100644 --- a/litellm/llms/openai/responses/count_tokens/token_counter.py +++ b/litellm/llms/openai/responses/count_tokens/token_counter.py @@ -32,12 +32,12 @@ class OpenAITokenCounter(BaseTokenCounter): async def count_tokens( self, model_to_use: str, - messages: list[dict[str, Any]] | None, - contents: list[dict[str, Any]] | None, + messages: list[dict[str, object]] | None, + contents: list[dict[str, object]] | None, deployment: dict[str, Any] | None = None, request_model: str = "", - tools: list[dict[str, Any]] | None = None, - system: Any | None = None, + tools: list[dict[str, object]] | None = None, + system: object = None, ) -> TokenCountResponse | None: """ Count tokens using OpenAI's Responses API /input_tokens endpoint. diff --git a/litellm/llms/openai/vector_store_files/transformation.py b/litellm/llms/openai/vector_store_files/transformation.py index 8a2064f1823..8519b5f4bc4 100644 --- a/litellm/llms/openai/vector_store_files/transformation.py +++ b/litellm/llms/openai/vector_store_files/transformation.py @@ -1,4 +1,5 @@ -from typing import Any, Final, cast +from collections.abc import Mapping +from typing import Final, cast import httpx @@ -22,7 +23,7 @@ from litellm.types.vector_store_files import ( from litellm.utils import add_openai_metadata -def _clean_dict(source: dict[str, Any]) -> dict[str, Any]: +def _clean_dict(source: Mapping[str, object]) -> dict[str, object]: return {k: v for k, v in source.items() if v is not None} @@ -30,7 +31,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): ASSISTANTS_HEADER_KEY = "OpenAI-Beta" ASSISTANTS_HEADER_VALUE = "assistants=v2" - def get_auth_credentials(self, litellm_params: dict[str, Any]) -> VectorStoreFileAuthCredentials: + def get_auth_credentials(self, litellm_params: Mapping[str, object]) -> VectorStoreFileAuthCredentials: api_key: Final = litellm_params.get("api_key") if api_key is None: raise ValueError("api_key is required") @@ -82,7 +83,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): *, api_base: str | None, vector_store_id: str, - litellm_params: dict[str, Any], + litellm_params: Mapping[str, object], ) -> str: base_url = ( api_base @@ -101,8 +102,8 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): vector_store_id: str, create_request: VectorStoreFileCreateRequest, api_base: str, - ) -> tuple[str, dict[str, Any]]: - payload: Final[dict[str, Any]] = _clean_dict(dict(create_request)) + ) -> tuple[str, dict[str, object]]: + payload: Final[dict[str, object]] = _clean_dict(dict(create_request)) attributes: Final = payload.get("attributes") if isinstance(attributes, dict): filtered_attributes: Final = add_openai_metadata(attributes) @@ -133,7 +134,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): vector_store_id: str, query_params: VectorStoreFileListQueryParams, api_base: str, - ) -> tuple[str, dict[str, Any]]: + ) -> tuple[str, dict[str, object]]: params: Final = _clean_dict(dict(query_params)) return api_base, params @@ -157,7 +158,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): vector_store_id: str, file_id: str, api_base: str, - ) -> tuple[str, dict[str, Any]]: + ) -> tuple[str, dict[str, object]]: encoded_file_id: Final = encode_url_path_segment(file_id, field_name="file_id") return f"{api_base}/{encoded_file_id}", {} @@ -181,7 +182,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): vector_store_id: str, file_id: str, api_base: str, - ) -> tuple[str, dict[str, Any]]: + ) -> tuple[str, dict[str, object]]: encoded_file_id: Final = encode_url_path_segment(file_id, field_name="file_id") return f"{api_base}/{encoded_file_id}/content", {} @@ -206,8 +207,8 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): file_id: str, update_request: VectorStoreFileUpdateRequest, api_base: str, - ) -> tuple[str, dict[str, Any]]: - payload: Final[dict[str, Any]] = dict(update_request) + ) -> tuple[str, dict[str, object]]: + payload: Final[dict[str, object]] = dict(update_request) attributes: Final = payload.get("attributes") if isinstance(attributes, dict): filtered_attributes: Final = add_openai_metadata(attributes) @@ -238,7 +239,7 @@ class OpenAIVectorStoreFilesConfig(BaseVectorStoreFilesConfig): vector_store_id: str, file_id: str, api_base: str, - ) -> tuple[str, dict[str, Any]]: + ) -> tuple[str, dict[str, object]]: encoded_file_id: Final = encode_url_path_segment(file_id, field_name="file_id") return f"{api_base}/{encoded_file_id}", {} diff --git a/litellm/llms/predibase/chat/transformation.py b/litellm/llms/predibase/chat/transformation.py index 3265537d1aa..0ebac5185d7 100644 --- a/litellm/llms/predibase/chat/transformation.py +++ b/litellm/llms/predibase/chat/transformation.py @@ -18,6 +18,8 @@ from litellm.types.utils import Choices, Message, ModelResponse, Usage from ..common_utils import PredibaseError if TYPE_CHECKING: + import tiktoken + from litellm.litellm_core_utils.litellm_logging import Logging as _LiteLLMLoggingObj LiteLLMLoggingObj = _LiteLLMLoggingObj @@ -64,8 +66,23 @@ class PredibaseConfig(BaseConfig): typical_p: float | None = None, watermark: bool | None = None, ) -> None: - locals_: Final = locals().copy() - for key, value in locals_.items(): + locals_: Final = ( + ("best_of", best_of), + ("decoder_input_details", decoder_input_details), + ("details", details), + ("max_new_tokens", max_new_tokens), + ("repetition_penalty", repetition_penalty), + ("return_full_text", return_full_text), + ("seed", seed), + ("stop", stop), + ("temperature", temperature), + ("top_k", top_k), + ("top_p", top_p), + ("truncate", truncate), + ("typical_p", typical_p), + ("watermark", watermark), + ) + for key, value in locals_: if key != "self" and value is not None: setattr(self.__class__, key, value) @@ -133,7 +150,7 @@ class PredibaseConfig(BaseConfig): messages: list[AllMessageValues], optional_params: dict, litellm_params: dict, - encoding: Any, + encoding: "tiktoken.Encoding | None", api_key: str | None = None, json_mode: bool | None = None, ) -> ModelResponse: @@ -217,7 +234,7 @@ class PredibaseConfig(BaseConfig): # Keep usage calculation non-blocking if token counting fails. pass output_text: Final = model_response["choices"][0]["message"].get("content", "") - if output_text is not None and len(output_text) > 0: + if encoding is not None and output_text is not None and len(output_text) > 0: completion_tokens = 0 try: completion_tokens = len(encoding.encode(model_response["choices"][0]["message"].get("content", ""))) diff --git a/litellm/llms/soniox/audio_transcription/transformation.py b/litellm/llms/soniox/audio_transcription/transformation.py index 318444cffec..8507ae73305 100644 --- a/litellm/llms/soniox/audio_transcription/transformation.py +++ b/litellm/llms/soniox/audio_transcription/transformation.py @@ -152,7 +152,7 @@ class SonioxAudioTranscriptionConfig(BaseAudioTranscriptionConfig): and for filling in `file_id`/`audio_url`. This method exists so the config can be exercised in isolation by unit tests. """ - body: Final[dict[str, Any]] = {"model": model} + body: Final[dict[str, object]] = {"model": model} for key in SONIOX_PASSTHROUGH_PARAMS: value = optional_params.get(key) @@ -247,9 +247,9 @@ class SonioxAudioTranscriptionConfig(BaseAudioTranscriptionConfig): # For verbose_json, include word-level timing from tokens. if response_format == "verbose_json" and tokens: - words: Final[list[dict[str, Any]]] = [] + words: Final[list[dict[str, object]]] = [] for token in tokens: - word_entry: dict[str, Any] = {"word": token.get("text", "")} + word_entry: dict[str, object] = {"word": token.get("text", "")} if token.get("start_ms") is not None: word_entry["start"] = float(token["start_ms"]) / 1000.0 if token.get("end_ms") is not None: diff --git a/litellm/llms/vertex_ai/rag_engine/ingestion.py b/litellm/llms/vertex_ai/rag_engine/ingestion.py index 06e525a90ff..d9916209a14 100644 --- a/litellm/llms/vertex_ai/rag_engine/ingestion.py +++ b/litellm/llms/vertex_ai/rag_engine/ingestion.py @@ -14,7 +14,7 @@ Key differences from OpenAI: from __future__ import annotations import os -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Final from litellm import get_secret_str from litellm._logging import verbose_logger @@ -26,12 +26,12 @@ if TYPE_CHECKING: from litellm.types.rag import RAGIngestOptions -def _get_str_or_none(value: Any) -> str | None: +def _get_str_or_none(value: object) -> str | None: """Cast config value to Optional[str].""" return str(value) if value is not None else None -def _get_int(value: Any, default: int) -> int: +def _get_int(value: str | float | None, default: int) -> int: """Cast config value to int with default.""" if value is None: return default @@ -205,7 +205,7 @@ class VertexAIRAGIngestion(BaseRAGIngestion): ) verbose_logger.info("Import started asynchronously") - def _build_transformation_config(self) -> Any: + def _build_transformation_config(self) -> object: """ Build Vertex AI TransformationConfig from unified chunking_strategy. diff --git a/litellm/llms/vertex_ai/videos/transformation.py b/litellm/llms/vertex_ai/videos/transformation.py index e6e3c2739c1..c66ad8e38b0 100644 --- a/litellm/llms/vertex_ai/videos/transformation.py +++ b/litellm/llms/vertex_ai/videos/transformation.py @@ -265,7 +265,9 @@ class VertexAIVideoConfig(BaseVideoConfig, VertexBase): # Extract Vertex AI parameters using safe helpers from VertexBase # Use safe_get_* methods that don't mutate litellm_params dict # Ensure litellm_params is a dict for type checking - params_dict: Final[dict[str, Any]] = cast(dict[str, Any], litellm_params) if litellm_params is not None else {} + params_dict: Final[dict[str, object]] = ( + cast(dict[str, object], litellm_params) if litellm_params is not None else {} + ) vertex_project: Final = VertexBase.safe_get_vertex_ai_project(litellm_params=params_dict) vertex_credentials: Final = VertexBase.safe_get_vertex_ai_credentials(litellm_params=params_dict) diff --git a/litellm/llms/voyage/embedding/transformation_multimodal.py b/litellm/llms/voyage/embedding/transformation_multimodal.py index 4bbb537804c..814d5ab7eb0 100644 --- a/litellm/llms/voyage/embedding/transformation_multimodal.py +++ b/litellm/llms/voyage/embedding/transformation_multimodal.py @@ -6,7 +6,7 @@ containing content blocks, unlike standard Voyage embeddings which use /v1/embeddings and a string/list `input` field. """ -from typing import Any, Final +from typing import Final import httpx @@ -98,7 +98,7 @@ class VoyageMultimodalEmbeddingConfig(BaseEmbeddingConfig): ) return {"Authorization": f"Bearer {api_key}"} - def _normalize_content_item(self, item: dict[str, Any]) -> dict[str, Any]: + def _normalize_content_item(self, item: dict[str, object]) -> dict[str, object]: item_type: Final = item.get("type") if item_type == "image_url": image_url = item.get("image_url") @@ -115,7 +115,7 @@ class VoyageMultimodalEmbeddingConfig(BaseEmbeddingConfig): return {"type": "image_url", "image_url": image_url} return item - def _normalize_input_item(self, item: Any) -> dict[str, Any]: + def _normalize_input_item(self, item: object) -> object: if isinstance(item, str): return {"content": [{"type": "text", "text": item}]} if isinstance(item, dict) and "content" in item: diff --git a/litellm/llms/voyage/rerank/transformation.py b/litellm/llms/voyage/rerank/transformation.py index ee330c92f1a..fea8452d934 100644 --- a/litellm/llms/voyage/rerank/transformation.py +++ b/litellm/llms/voyage/rerank/transformation.py @@ -43,7 +43,7 @@ class VoyageRerankConfig(BaseRerankConfig): instruction: str | None = None, ) -> dict: # Voyage AI uses 'top_k' instead of 'top_n' - optional_params: Final[dict[str, Any]] = {"query": query, "documents": documents} + optional_params: Final[dict[str, object]] = {"query": query, "documents": documents} if top_n is not None: optional_params["top_k"] = top_n if return_documents is not None: @@ -109,7 +109,7 @@ class VoyageRerankConfig(BaseRerankConfig): # Transform to LiteLLM format transformed_results: Final = [] for result in _results: - transformed_result: dict[str, Any] = { + transformed_result: dict[str, object] = { "index": result["index"], "relevance_score": result["relevance_score"], } diff --git a/litellm/llms/xai/chat/transformation.py b/litellm/llms/xai/chat/transformation.py index ae5849812bf..4590bdd5aa3 100644 --- a/litellm/llms/xai/chat/transformation.py +++ b/litellm/llms/xai/chat/transformation.py @@ -196,7 +196,7 @@ class XAIChatConfig(OpenAIGPTConfig): streaming_response: Iterator[str] | AsyncIterator[str] | ModelResponse, sync_stream: bool, json_mode: bool | None = False, - ) -> Any: + ) -> "XAIChatCompletionStreamingHandler": return XAIChatCompletionStreamingHandler( streaming_response=streaming_response, sync_stream=sync_stream, diff --git a/litellm/llms/xai/responses/transformation.py b/litellm/llms/xai/responses/transformation.py index d79e7d4c146..36ae15e1df3 100644 --- a/litellm/llms/xai/responses/transformation.py +++ b/litellm/llms/xai/responses/transformation.py @@ -1,4 +1,5 @@ -from typing import Any, Final +from collections.abc import Mapping +from typing import Final import litellm from litellm._logging import verbose_logger @@ -8,7 +9,6 @@ from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfi from litellm.llms.xai.common_utils import XAIModelInfo from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import ResponsesAPIOptionalRequestParams -from litellm.types.llms.xai import XAIWebSearchTool, XAIXSearchTool from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders @@ -44,7 +44,7 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): return supported_params - def _transform_web_search_tool(self, tool: dict[str, Any]) -> XAIWebSearchTool | dict[str, Any]: + def _transform_web_search_tool(self, tool: Mapping[str, object]) -> Mapping[str, object]: """ Transform web_search tool to XAI format. @@ -55,7 +55,7 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): XAI does NOT support search_context_size (OpenAI-specific). """ - xai_tool: Final[dict[str, Any]] = {"type": "web_search"} + xai_tool: Final[dict[str, object]] = {"type": "web_search"} # Remove search_context_size if present (not supported by XAI) if "search_context_size" in tool: @@ -83,7 +83,7 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): return xai_tool - def _transform_x_search_tool(self, tool: dict[str, Any]) -> XAIXSearchTool | dict[str, Any]: + def _transform_x_search_tool(self, tool: Mapping[str, object]) -> Mapping[str, object]: """ Transform x_search tool to XAI format. @@ -95,7 +95,7 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): - enable_image_understanding - enable_video_understanding """ - xai_tool: Final[dict[str, Any]] = {"type": "x_search"} + xai_tool: Final[dict[str, object]] = {"type": "x_search"} # Handle allowed_x_handles if "allowed_x_handles" in tool: @@ -157,7 +157,7 @@ class XAIResponsesAPIConfig(OpenAIResponsesAPIConfig): if not isinstance(tools_list, list): tools_list = [tools_list] - transformed_tools: Final[list[Any]] = [] + transformed_tools: Final[list[object]] = [] for tool in tools_list: if isinstance(tool, dict): tool_type = tool.get("type") diff --git a/litellm/proxy/caching_routes.py b/litellm/proxy/caching_routes.py index 16acd95af9c..eccbf75667d 100644 --- a/litellm/proxy/caching_routes.py +++ b/litellm/proxy/caching_routes.py @@ -1,4 +1,4 @@ -from typing import Any, Final +from typing import Final from fastapi import APIRouter, Depends, HTTPException, Request @@ -19,7 +19,7 @@ router: Final = APIRouter( ) -def _extract_cache_params() -> dict[str, Any]: +def _extract_cache_params() -> dict[str, object]: """ Safely extracts and cleans cache parameters. @@ -56,8 +56,8 @@ async def cache_ping(): """ Endpoint for checking if cache can be pinged """ - litellm_cache_params: dict[str, Any] = {} - cleaned_cache_params: dict[str, Any] = {} + litellm_cache_params: dict[str, object] = {} + cleaned_cache_params: dict[str, object] = {} if litellm.cache is None: raise ProxyException( message=safe_dumps( @@ -162,7 +162,7 @@ async def cache_delete(request: Request): ) -def _get_redis_client_info(cache_instance) -> tuple[list, int]: +def _get_redis_client_info(cache_instance: RedisCache) -> tuple[list[object], int]: """ Helper function to safely get Redis client list information. diff --git a/litellm/proxy/client/chat.py b/litellm/proxy/client/chat.py index bd4d0df3ed0..a330d057490 100644 --- a/litellm/proxy/client/chat.py +++ b/litellm/proxy/client/chat.py @@ -73,7 +73,7 @@ class ChatClient: url: Final = f"{self._base_url}/chat/completions" # Build request data with required fields - data: Final[dict[str, Any]] = {"model": model, "messages": messages} + data: Final[dict[str, object]] = {"model": model, "messages": messages} # Add optional parameters if provided if temperature is not None: @@ -143,7 +143,7 @@ class ChatClient: url: Final = f"{self._base_url}/chat/completions" # Build request data with required fields - data: Final[dict[str, Any]] = {"model": model, "messages": messages, "stream": True} + data: Final[dict[str, object]] = {"model": model, "messages": messages, "stream": True} # Add optional parameters if provided if temperature is not None: diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index c591cbabee1..5cf0bd6f89f 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -8,7 +8,7 @@ from typing import Final import click import requests -from .auth import context_secret_vault, get_stored_api_key, login +from .auth import CliContextObj, context_secret_vault, get_stored_api_key, login from .cmd_quoting import quote_for_cmd ANTHROPIC_BASE_URL_ENV: Final = "ANTHROPIC_BASE_URL" @@ -289,8 +289,9 @@ def _is_interactive() -> bool: def resolve_api_key(ctx: click.Context) -> str: - base_url: Final = ctx.obj["base_url"] - api_key = ctx.obj.get("api_key") + ctx_obj: Final[CliContextObj] = ctx.obj + base_url: Final = ctx_obj["base_url"] + api_key = ctx_obj.get("api_key") if api_key: return api_key @@ -312,7 +313,8 @@ _SKIP_VERIFY_HELP: Final = "Skip the pre-launch key check against the proxy." def _launch(ctx: click.Context, binary: str, args: Sequence[str], *, skip_verify: bool) -> None: - base_url: Final = ctx.obj["base_url"] + ctx_obj: Final[CliContextObj] = ctx.obj + base_url: Final = ctx_obj["base_url"] started_interactive: Final = _is_interactive() api_key: Final = resolve_api_key(ctx) diff --git a/litellm/proxy/client/keys.py b/litellm/proxy/client/keys.py index fe100c5f676..028b338f412 100644 --- a/litellm/proxy/client/keys.py +++ b/litellm/proxy/client/keys.py @@ -1,4 +1,5 @@ import builtins +from collections.abc import Mapping from typing import Any, Final import requests @@ -72,7 +73,7 @@ class KeysManagementClient: requests.exceptions.RequestException: If the request fails with any other error """ url: Final = f"{self._base_url}/key/list" - params: Final[dict[str, Any]] = {} + params: Final[dict[str, int | str]] = {} # Add optional query parameters if page is not None: @@ -119,9 +120,9 @@ class KeysManagementClient: team_id: str | None = None, user_id: str | None = None, budget_id: str | None = None, - config: dict[str, Any] | None = None, + config: Mapping[str, object] | None = None, return_request: bool = False, - ) -> dict[str, Any] | requests.Request: + ) -> dict[str, object] | requests.Request: """ Generate an API key based on the provided data. @@ -149,7 +150,7 @@ class KeysManagementClient: """ url: Final = f"{self._base_url}/key/generate" - data: Final[dict[str, Any]] = {} + data: Final[dict[str, object]] = {} if models is not None: data["models"] = models if aliases is not None: @@ -189,7 +190,7 @@ class KeysManagementClient: keys: builtins.list[str] | None = None, key_aliases: builtins.list[str] | None = None, return_request: bool = False, - ) -> dict[str, Any] | requests.Request: + ) -> dict[str, object] | requests.Request: """ Delete existing keys @@ -238,7 +239,7 @@ class KeysManagementClient: key_alias: str | None = None, team_id: str | None = None, user_id: str | None = None, - ) -> dict[str, Any] | requests.Request: + ) -> dict[str, object] | requests.Request: """ Update an existing API key's parameters. @@ -261,7 +262,7 @@ class KeysManagementClient: """ url: Final = f"{self._base_url}/key/update" - data: Final[dict[str, Any]] = {"key": key} + data: Final[dict[str, object]] = {"key": key} if key_alias is not None: data["key_alias"] = key_alias @@ -288,7 +289,7 @@ class KeysManagementClient: except Exception: raise Exception(f"Error updating key: {response_text}") - def info(self, key: str, return_request: bool = False) -> dict[str, Any] | requests.Request: + def info(self, key: str, return_request: bool = False) -> dict[str, object] | requests.Request: """ Get information about API keys. diff --git a/litellm/proxy/common_utils/performance_utils.py b/litellm/proxy/common_utils/performance_utils.py index 5d5334f2177..0b79599e8f6 100644 --- a/litellm/proxy/common_utils/performance_utils.py +++ b/litellm/proxy/common_utils/performance_utils.py @@ -15,10 +15,24 @@ import inspect import threading from collections.abc import Callable from pathlib import Path as PathLib -from typing import Any, Final +from types import ModuleType +from typing import Final, Protocol, TextIO from litellm._logging import verbose_proxy_logger + +class _LineProfiler(Protocol): + """The line_profiler.LineProfiler surface this module drives.""" + + def __call__(self, func: Callable[..., object]) -> Callable[..., object]: ... + + def add_function(self, func: Callable[..., object]) -> object: ... + + def dump_stats(self, filename: str) -> object: ... + + def print_stats(self, stream: TextIO) -> object: ... + + # Global profiling state _profile_lock: Final = threading.Lock() _profiler = None @@ -27,7 +41,7 @@ _sample_counter = 0 _sample_counter_lock: Final = threading.Lock() # Global line_profiler state -_line_profiler: Any | None = None +_line_profiler: _LineProfiler | None = None _line_profiler_lock: Final = threading.Lock() _wrapped_functions: Final[dict[str, Callable]] = {} # Store original functions @@ -157,7 +171,7 @@ def enable_line_profiler() -> None: verbose_proxy_logger.info("Line profiler enabled") -def wrap_function_with_line_profiler(module: Any, function_name: str) -> bool: +def wrap_function_with_line_profiler(module: ModuleType, function_name: str) -> bool: """Dynamically wrap a function with line_profiler. Args: diff --git a/litellm/proxy/container_endpoints/endpoints.py b/litellm/proxy/container_endpoints/endpoints.py index 4a088140725..85ef469ee69 100644 --- a/litellm/proxy/container_endpoints/endpoints.py +++ b/litellm/proxy/container_endpoints/endpoints.py @@ -1,6 +1,6 @@ #### Container Endpoints ##### -from typing import Any, Final +from typing import Final from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi.responses import ORJSONResponse @@ -208,7 +208,7 @@ async def list_containers( # Read query parameters query_params: Final = dict(request.query_params) - data: Final[dict[str, Any]] = {"query_params": query_params} + data: Final[dict[str, object]] = {"query_params": query_params} # Extract custom_llm_provider using priority chain custom_llm_provider: Final = ( @@ -312,7 +312,7 @@ async def retrieve_container( ) # Include container_id in request data - data: Final[dict[str, Any]] = {"container_id": container_id} + data: Final[dict[str, object]] = {"container_id": container_id} # Extract custom_llm_provider using priority chain custom_llm_provider = ( @@ -417,7 +417,7 @@ async def delete_container( ) # Include container_id in request data - data: Final[dict[str, Any]] = {"container_id": container_id} + data: Final[dict[str, object]] = {"container_id": container_id} # Extract custom_llm_provider using priority chain custom_llm_provider = ( diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index 5502543b926..f7362d3b809 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -204,7 +204,7 @@ class PrismaDBExceptionHandler: if isinstance(e, prisma.errors.PrismaError): return False - tb = getattr(e, "__traceback__", None) + tb = e.__traceback__ if hasattr(e, "__traceback__") else None while tb is not None: if tb.tb_frame.f_globals.get("__name__", "").startswith("prisma.engine"): return True @@ -318,7 +318,7 @@ _DEFAULT_RECONNECT_TIMEOUT_SECONDS: Final = 2.0 _DEFAULT_RECONNECT_LOCK_TIMEOUT_SECONDS: Final = 0.1 -def _coerce_timeout(value: Any, fallback: float) -> float: +def _coerce_timeout(value: object, fallback: float) -> float: """Return `value` if it is a real int/float, else `fallback`. Guards against tests that mock `prisma_client` and leave the timeout slots as MagicMock instances.""" diff --git a/litellm/proxy/guardrails/guardrail_hooks/azure/base.py b/litellm/proxy/guardrails/guardrail_hooks/azure/base.py index 4d17c6edb31..42f0220cc4d 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/azure/base.py +++ b/litellm/proxy/guardrails/guardrail_hooks/azure/base.py @@ -45,7 +45,7 @@ class AzureGuardrailBase: self.api_base = api_base self.api_version: str = kwargs.get("api_version") or "2024-09-01" - async def _post_to_content_safety(self, endpoint_path: str, request_body: dict[str, Any]) -> dict[str, Any]: + async def _post_to_content_safety(self, endpoint_path: str, request_body: dict[str, object]) -> dict[str, Any]: """POST to an Azure Content Safety endpoint with standard auth headers. Args: @@ -94,7 +94,7 @@ class AzureGuardrailBase: # Tokenize into alternating non-whitespace and whitespace runs so # that original newlines, tabs, and multiple spaces are preserved # within each chunk. - tokens: Final = re.findall(r"\S+|\s+", text) + tokens: Final = [match.group(0) for match in re.finditer(r"\S+|\s+", text)] chunks: Final[list[str]] = [] current_chunk = "" diff --git a/litellm/proxy/guardrails/guardrail_hooks/azure/text_moderation.py b/litellm/proxy/guardrails/guardrail_hooks/azure/text_moderation.py index 07e435c675b..0dca8be3307 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/azure/text_moderation.py +++ b/litellm/proxy/guardrails/guardrail_hooks/azure/text_moderation.py @@ -3,7 +3,7 @@ Azure Text Moderation Native Guardrail Integrationfor LiteLLM """ -from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, Union, cast +from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, cast from fastapi import HTTPException @@ -14,18 +14,18 @@ from litellm.integrations.custom_guardrail import ( ) from litellm.proxy._types import UserAPIKeyAuth from litellm.types.guardrails import GuardrailEventHooks -from litellm.types.utils import CallTypesLiteral, GenericGuardrailAPIInputs +from litellm.types.utils import CallTypesLiteral, GenericGuardrailAPIInputs, LLMResponseTypes from .base import AzureGuardrailBase if TYPE_CHECKING: + from litellm.caching.caching import DualCache from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.types.llms.openai import AllMessageValues from litellm.types.proxy.guardrails.guardrail_hooks.azure.azure_text_moderation import ( AzureTextModerationGuardrailResponse, ) from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel - from litellm.types.utils import EmbeddingResponse, ImageResponse, ModelResponse class AzureContentSafetyTextModerationGuardrail(AzureGuardrailBase, CustomGuardrail): @@ -219,10 +219,10 @@ class AzureContentSafetyTextModerationGuardrail(AzureGuardrailBase, CustomGuardr async def async_pre_call_hook( self, user_api_key_dict: "UserAPIKeyAuth", - cache: Any, + cache: "DualCache", data: dict[str, Any], call_type: CallTypesLiteral, - ) -> dict[str, Any] | None: + ) -> dict[str, object] | None: """ Pre-call hook to scan user prompts before sending to LLM. @@ -251,8 +251,8 @@ class AzureContentSafetyTextModerationGuardrail(AzureGuardrailBase, CustomGuardr self, data: dict, user_api_key_dict: "UserAPIKeyAuth", - response: Union[Any, "ModelResponse", "EmbeddingResponse", "ImageResponse"], - ) -> Any: + response: LLMResponseTypes, + ) -> LLMResponseTypes: from litellm.types.utils import Choices, ModelResponse if isinstance(response, ModelResponse) and response.choices: @@ -267,7 +267,7 @@ class AzureContentSafetyTextModerationGuardrail(AzureGuardrailBase, CustomGuardr ) return response - async def async_post_call_streaming_hook(self, user_api_key_dict: UserAPIKeyAuth, response: str) -> Any: + async def async_post_call_streaming_hook(self, user_api_key_dict: UserAPIKeyAuth, response: str) -> str: try: if response is not None and len(response) > 0: await self.async_make_request( @@ -281,7 +281,7 @@ class AzureContentSafetyTextModerationGuardrail(AzureGuardrailBase, CustomGuardr return f"data: {error_returned}\n\n" -def _message_content_to_text(content: Any) -> str: +def _message_content_to_text(content: object) -> str: if isinstance(content, str): return content if isinstance(content, list): diff --git a/litellm/proxy/guardrails/guardrail_hooks/block_code_execution/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/block_code_execution/__init__.py index 5feeafe8e95..64770fdf0f7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/block_code_execution/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/block_code_execution/__init__.py @@ -1,6 +1,6 @@ """Block Code Execution guardrail: blocks or masks fenced code blocks by language.""" -from typing import TYPE_CHECKING, Any, Final, Literal, cast +from typing import TYPE_CHECKING, Final, Literal, cast from litellm.types.guardrails import GuardrailEventHooks, SupportedGuardrailIntegrations @@ -20,8 +20,8 @@ def _get_param( litellm_params: "LitellmParams", guardrail: "Guardrail", key: str, - default: Any = None, -) -> Any: + default: object = None, +) -> object: """Get a param from litellm_params, with fallback to raw guardrail litellm_params (for extra fields not on LitellmParams).""" value: Final = getattr(litellm_params, key, default) if value is not None: diff --git a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py index 53da8aeed42..24801aa2df1 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py +++ b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py @@ -8,7 +8,7 @@ and provide safe, sandboxed functionality for common guardrail operations. import json import re from collections.abc import Mapping, Sequence -from typing import Any, Final +from typing import Final from urllib.parse import urlparse import httpx @@ -16,7 +16,7 @@ from pydantic import JsonValue from typing_extensions import ReadOnly, TypedDict from litellm._logging import verbose_proxy_logger -from litellm.llms.custom_httpx.http_handler import get_async_httpx_client +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, get_async_httpx_client from litellm.types.llms.custom_http import httpxSpecialProvider # ============================================================================= @@ -508,7 +508,7 @@ async def http_request( async def _execute_http_request( - client: Any, + client: AsyncHTTPHandler, method: str, url: str, headers: dict[str, str] | None, diff --git a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py index e3cf645ceaf..d8296003ae9 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py +++ b/litellm/proxy/guardrails/guardrail_hooks/generic_guardrail_api/generic_guardrail_api.py @@ -7,6 +7,7 @@ import fnmatch import os +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Literal, Optional import httpx @@ -23,6 +24,7 @@ from litellm.llms.custom_httpx.http_handler import ( httpxSpecialProvider, ) from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.llms.openai import ChatCompletionToolParam from litellm.types.proxy.guardrails.guardrail_hooks.generic_guardrail_api import ( GenericGuardrailAPIMetadata, GenericGuardrailAPIRequest, @@ -73,7 +75,7 @@ def _header_value_allowed( def _sanitize_inbound_headers( - headers: Any, + headers: object, extra_allowlist: set[str] | None = None, ) -> dict[str, str] | None: """ @@ -175,7 +177,7 @@ class GenericGuardrailAPI(CustomGuardrail): headers: dict[str, Any] | None = None, api_base: str | None = None, api_key: str | None = None, - additional_provider_specific_params: dict[str, Any] | None = None, + additional_provider_specific_params: Mapping[str, object] | None = None, unreachable_fallback: Literal["fail_closed", "fail_open"] = "fail_closed", fail_on_error: bool | None = True, extra_headers: list | None = None, @@ -318,8 +320,8 @@ class GenericGuardrailAPI(CustomGuardrail): self, *, texts: list, - images: Any, - tools: Any, + images: list[str] | None, + tools: list[ChatCompletionToolParam] | None, guardrail_response: GenericGuardrailAPIResponse, ) -> GenericGuardrailAPIInputs: # Action is NONE or no modifications needed diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index d187b5b12e9..5d11c3643cd 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -33,6 +33,7 @@ from litellm.proxy.guardrails.guardrail_hooks.model_armor.file_scanning import ( ) from litellm.types.guardrails import GuardrailEventHooks, LitellmParams from litellm.types.llms.openai import AllMessageValues +from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES from litellm.types.utils import ( CallTypes, CallTypesLiteral, @@ -97,7 +98,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): template_id: str | None = None, project_id: str | None = None, location: str | None = None, - credentials: Any | None = None, + credentials: VERTEX_CREDENTIALS_TYPES | None = None, api_endpoint: str | None = None, sanitize_error_detail: "bool | None" = True, **kwargs, @@ -147,7 +148,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): else: return {"modelResponseData": {"text": content}} - def _extract_content_from_response(self, response: Any | ModelResponse) -> str: + def _extract_content_from_response(self, response: object) -> str: """ Extract text content from model response. diff --git a/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py b/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py index 385e7d61dee..7ef0a9f73f3 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py +++ b/litellm/proxy/guardrails/guardrail_hooks/noma/noma.py @@ -72,7 +72,7 @@ class NomaBlockedMessage(HTTPException): }, ) - def _is_result_true(self, result_obj: dict[str, Any] | None) -> bool: + def _is_result_true(self, result_obj: dict[str, object] | None) -> bool: """ Check if a result object has a "result" field that is True. @@ -454,7 +454,7 @@ class NomaGuardrail(CustomGuardrail): return False - def _is_result_true(self, result_obj: dict[str, Any] | None) -> bool: + def _is_result_true(self, result_obj: dict[str, object] | None) -> bool: """ Check if a result object has a "result" field that is True. diff --git a/litellm/proxy/guardrails/guardrail_hooks/pangea/pangea.py b/litellm/proxy/guardrails/guardrail_hooks/pangea/pangea.py index 3d5d87e4d17..5acf837cf84 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/pangea/pangea.py +++ b/litellm/proxy/guardrails/guardrail_hooks/pangea/pangea.py @@ -35,14 +35,14 @@ class PangeaGuardrailMissingSecrets(Exception): class _TextCompletionRequest: - def __init__(self, body): + def __init__(self, body: dict[str, object]) -> None: self.body = body def get_messages(self) -> list[dict]: return [{"role": "user", "content": self.body["prompt"]}] # This mutates the original dict, but we'll still return it anyways - def update_original_body(self, prompt_messages: list[dict]) -> Any: + def update_original_body(self, prompt_messages: list[dict]) -> dict[str, object]: assert len(prompt_messages) == 1 self.body["prompt"] = prompt_messages[0]["content"] return self.body @@ -159,7 +159,7 @@ class PangeaHandler(CustomGuardrail): call_type: str, ): transformer = None - messages: Any = None + messages: object = None if call_type == "text_completion" or call_type == "atext_completion": transformer = _TextCompletionRequest(data) messages = transformer.get_messages() diff --git a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py index 5f07f529e7a..b73d3adb99e 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py +++ b/litellm/proxy/guardrails/guardrail_hooks/panw_prisma_airs/panw_prisma_airs.py @@ -721,7 +721,7 @@ class PanwPrismaAirsHandler(CustomGuardrail): } } - def _record_scan_id(self, request_data: dict[str, Any], scan_result: Mapping[str, object]) -> None: + def _record_scan_id(self, request_data: dict[str, object], scan_result: Mapping[str, object]) -> None: """Surface the AIRS scan id on the response, so allowed calls are auditable too.""" scan_id: Final = scan_result.get("scan_id") add_guardrail_scan_id(request_data=request_data, scan_id=str(scan_id) if scan_id else None) diff --git a/litellm/proxy/guardrails/usage_tracking.py b/litellm/proxy/guardrails/usage_tracking.py index b8ae09afc00..4f1d2380520 100644 --- a/litellm/proxy/guardrails/usage_tracking.py +++ b/litellm/proxy/guardrails/usage_tracking.py @@ -172,7 +172,7 @@ def _guardrail_status_to_action(status: str | None) -> str: return "passed" -def _parse_guardrail_info_from_payload(payload: Mapping[str, Any]) -> Sequence[Mapping[str, Any]]: +def _parse_guardrail_info_from_payload(payload: Mapping[str, object]) -> Sequence[Mapping[str, Any]]: """Extract guardrail_information from spend log payload metadata.""" meta = payload.get("metadata") if not meta: @@ -197,7 +197,7 @@ def _date_str(dt: datetime) -> str: return dt.astimezone(timezone.utc).strftime("%Y-%m-%d") -def _parse_payload_start_time(payload: Mapping[str, Any]) -> datetime | None: +def _parse_payload_start_time(payload: Mapping[str, object]) -> datetime | None: start_time: Final = payload.get("startTime") if isinstance(start_time, datetime): return start_time @@ -209,7 +209,9 @@ def _parse_payload_start_time(payload: Mapping[str, Any]) -> datetime | None: return None -def _iter_usage_unit_increments(logs_to_process: Sequence[Mapping[str, Any]]) -> Iterator[tuple[_UsageUnitKey, int]]: +def _iter_usage_unit_increments( + logs_to_process: Sequence[Mapping[str, object]], +) -> Iterator[tuple[_UsageUnitKey, int]]: for payload in logs_to_process: start_time = _parse_payload_start_time(payload) if not payload.get("request_id") or start_time is None: @@ -227,7 +229,7 @@ def _iter_usage_unit_increments(logs_to_process: Sequence[Mapping[str, Any]]) -> yield _UsageUnitKey(guardrail_id, date_key, team_id, api_key, str(unit_name)), units -def _sum_usage_unit_increments(logs_to_process: Sequence[Mapping[str, Any]]) -> Mapping[_UsageUnitKey, int]: +def _sum_usage_unit_increments(logs_to_process: Sequence[Mapping[str, object]]) -> Mapping[_UsageUnitKey, int]: ordered: Final = sorted(_iter_usage_unit_increments(logs_to_process), key=itemgetter(0)) return MappingProxyType( {key: sum(units for _, units in group) for key, group in groupby(ordered, key=itemgetter(0))} @@ -284,7 +286,7 @@ async def _upsert_metrics_row(prisma_client: PrismaClient, key: _MetricsKey, agg async def process_spend_logs_guardrail_usage( prisma_client: PrismaClient, - logs_to_process: list[dict[str, Any]], + logs_to_process: Sequence[Mapping[str, object]], sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, pending: PendingRollups = _PENDING_ROLLUPS, ) -> None: @@ -295,7 +297,7 @@ async def process_spend_logs_guardrail_usage( if not logs_to_process: return # Aggregate daily metrics by (guardrail_id, date). Latency/score metrics dropped. - daily_guardrail: Final[dict[_MetricsKey, dict[str, Any]]] = defaultdict( + daily_guardrail: Final[dict[_MetricsKey, dict[str, int]]] = defaultdict( lambda: { "requests_evaluated": 0, "passed_count": 0, @@ -303,7 +305,7 @@ async def process_spend_logs_guardrail_usage( "flagged_count": 0, } ) - index_rows: Final[list[dict[str, Any]]] = [] + index_rows: Final[list[dict[str, object]]] = [] for payload in logs_to_process: request_id = payload.get("request_id") diff --git a/litellm/proxy/health_check_utils/shared_health_check_manager.py b/litellm/proxy/health_check_utils/shared_health_check_manager.py index f12cee4b636..79d54df97ae 100644 --- a/litellm/proxy/health_check_utils/shared_health_check_manager.py +++ b/litellm/proxy/health_check_utils/shared_health_check_manager.py @@ -1,6 +1,7 @@ import asyncio import json import time +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Final from litellm._logging import verbose_proxy_logger @@ -143,8 +144,8 @@ class SharedHealthCheckManager: async def cache_health_check_results( self, - healthy_endpoints: list[dict[str, Any]], - unhealthy_endpoints: list[dict[str, Any]], + healthy_endpoints: Sequence[Mapping[str, object]], + unhealthy_endpoints: Sequence[Mapping[str, object]], ) -> None: """ Cache health check results in Redis. @@ -336,14 +337,14 @@ class SharedHealthCheckManager: verbose_proxy_logger.error("Error checking health check lock status: %s", str(e)) return False - async def get_health_check_status(self) -> dict[str, Any]: + async def get_health_check_status(self) -> dict[str, object]: """ Get the current status of health check coordination. Returns: Dict containing status information """ - status: Final = { + status: Final[dict[str, object]] = { "pod_id": self.pod_id, "redis_available": self.redis_cache is not None, "lock_ttl": self.lock_ttl, diff --git a/litellm/proxy/hooks/batch_rate_limiter.py b/litellm/proxy/hooks/batch_rate_limiter.py index 5b814ad28fd..dcd34a1d9cb 100644 --- a/litellm/proxy/hooks/batch_rate_limiter.py +++ b/litellm/proxy/hooks/batch_rate_limiter.py @@ -62,6 +62,7 @@ from litellm.proxy.hooks.rate_limiter_utils import resolve_llm_provider_for_rate if TYPE_CHECKING: from opentelemetry.trace import Span as _Span + from litellm.caching.caching import DualCache from litellm.proxy.hooks.parallel_request_limiter_v3 import ( RateLimitDescriptor as _RateLimitDescriptor, ) @@ -73,8 +74,9 @@ if TYPE_CHECKING: ) from litellm.proxy.utils import InternalUsageCache as _InternalUsageCache from litellm.router import Router as _Router + from litellm.types.llms.openai import HttpxBinaryResponseContent - Span = _Span | Any + Span = _Span InternalUsageCache = _InternalUsageCache Router = _Router ParallelRequestLimiter = _ParallelRequestLimiter @@ -1011,7 +1013,7 @@ class _PROXY_BatchRateLimiter(CustomLogger): self, file_id: str, user_api_key_dict: UserAPIKeyAuth, - ) -> Any: + ) -> "HttpxBinaryResponseContent": """ Fetch file content from managed files hook. @@ -1062,7 +1064,7 @@ class _PROXY_BatchRateLimiter(CustomLogger): async def async_pre_call_hook( self, user_api_key_dict: UserAPIKeyAuth, - cache: Any, + cache: "DualCache", data: dict, call_type: str, ) -> Exception | str | dict | None: diff --git a/litellm/proxy/hooks/key_management_event_hooks.py b/litellm/proxy/hooks/key_management_event_hooks.py index 88803d6442d..cdaa6d5a81c 100644 --- a/litellm/proxy/hooks/key_management_event_hooks.py +++ b/litellm/proxy/hooks/key_management_event_hooks.py @@ -1,7 +1,7 @@ import asyncio import json from datetime import datetime, timezone -from typing import Any, Final +from typing import Final import litellm from litellm._logging import verbose_proxy_logger @@ -89,8 +89,8 @@ class KeyManagementEventHooks: @staticmethod async def async_key_updated_hook( data: UpdateKeyRequest, - existing_key_row: Any, - response: Any, + existing_key_row: LiteLLM_VerificationToken, + response: object, user_api_key_dict: UserAPIKeyAuth, litellm_changed_by: str | None = None, ): diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index 2241884faf1..b5773e3e884 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -1,4 +1,5 @@ import math +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Optional, Union from fastapi import HTTPException, status @@ -438,7 +439,7 @@ _TEAM_MEMBER_BUDGET_LIMIT_FIELDS: Final = ( ) -def _is_set_budget_value(value: Any) -> bool: +def _is_set_budget_value(value: object) -> bool: if value is None: return False if isinstance(value, list) and len(value) == 0: @@ -446,7 +447,7 @@ def _is_set_budget_value(value: Any) -> bool: return True -def _has_meaningful_budget_limit(budget_values: dict[str, Any]) -> bool: +def _has_meaningful_budget_limit(budget_values: Mapping[str, object]) -> bool: """A budget is meaningful if at least one limit is actually set; an empty list (no model restriction) and None both count as unset.""" return any(_is_set_budget_value(budget_values.get(field)) for field in _TEAM_MEMBER_BUDGET_LIMIT_FIELDS) @@ -590,7 +591,7 @@ def _update_metadata_field(updated_kv: dict, field_name: str) -> None: updated_kv["metadata"] = {field_name: _value} -def _has_non_empty_value(value: Any) -> bool: +def _has_non_empty_value(value: object) -> bool: """Check if a value has real content (not None, not empty list, not blank string).""" if value is None: return False diff --git a/litellm/proxy/realtime_endpoints/endpoints.py b/litellm/proxy/realtime_endpoints/endpoints.py index 7f9cd251a8a..f41bf4dbd93 100644 --- a/litellm/proxy/realtime_endpoints/endpoints.py +++ b/litellm/proxy/realtime_endpoints/endpoints.py @@ -2,7 +2,7 @@ import json import time -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final import httpx from fastapi import APIRouter, Depends, HTTPException, Request, Response @@ -24,6 +24,9 @@ from litellm.types.realtime import ( RealtimeTranscriptionSessionResponse, ) +if TYPE_CHECKING: + from litellm.router import Router + router: Final = APIRouter() _REALTIME_TOKEN_VERSION: Final = "realtime_v1" @@ -38,7 +41,7 @@ def _coerce_realtime_session_type(session_type: str | None) -> str: return "realtime" -def _append_model_candidate(candidates: list[str], model: Any) -> None: +def _append_model_candidate(candidates: list[str], model: object) -> None: if isinstance(model, str) and model and model not in candidates: candidates.append(model) @@ -116,7 +119,7 @@ async def _prepare_client_secret_session( req: RealtimeClientSecretRequest, user_api_key_dict: UserAPIKeyAuth, llm_model_list: list | None, - llm_router: Any, + llm_router: "Router | None", ) -> tuple[str, dict | None, str]: session_type: Final = _coerce_realtime_session_type(req.session.type if req.session else None) session_data: Final[dict | None] = req.session.model_dump(exclude_none=True) if req.session else None @@ -171,7 +174,7 @@ def _encode_realtime_token_payload( Encode metadata with the upstream ephemeral key so /realtime/calls can route without requiring model as a query param. """ - payload: Final[dict[str, Any]] = { + payload: Final[dict[str, str | int | None]] = { "v": _REALTIME_TOKEN_VERSION, "ephemeral_key": ephemeral_key, "model_id": model_id, diff --git a/litellm/proxy/response_polling/polling_handler.py b/litellm/proxy/response_polling/polling_handler.py index 3dfb67efb50..fe7fa79a3d9 100644 --- a/litellm/proxy/response_polling/polling_handler.py +++ b/litellm/proxy/response_polling/polling_handler.py @@ -89,7 +89,7 @@ class ResponsePollingHandler: error: dict | None = None, incomplete_details: dict | None = None, reasoning: dict | None = None, - tool_choice: Any | None = None, + tool_choice: object | None = None, tools: list | None = None, output: list | None = None, # Additional ResponsesAPIResponse fields diff --git a/litellm/proxy/vector_store_endpoints/utils.py b/litellm/proxy/vector_store_endpoints/utils.py index 93f1510bf22..f224e02db32 100644 --- a/litellm/proxy/vector_store_endpoints/utils.py +++ b/litellm/proxy/vector_store_endpoints/utils.py @@ -1,6 +1,7 @@ import json import re -from typing import Any, Final, Literal +from collections.abc import Mapping +from typing import Final, Literal from fastapi import HTTPException, Request @@ -291,8 +292,8 @@ def _does_endpoint_match(endpoint_path: str, request_path: str) -> bool: def check_vector_store_permission( index_name: str, permission: str, - key_metadata: dict[str, Any] | None, - team_metadata: dict[str, Any] | None, + key_metadata: Mapping[str, object] | None, + team_metadata: Mapping[str, object] | None, ) -> bool: """ Check if a specific permission is allowed for a given vector store index. diff --git a/litellm/rag/ingestion/bedrock_ingestion.py b/litellm/rag/ingestion/bedrock_ingestion.py index 3d7056f8176..e412b7bcc8d 100644 --- a/litellm/rag/ingestion/bedrock_ingestion.py +++ b/litellm/rag/ingestion/bedrock_ingestion.py @@ -14,7 +14,7 @@ from __future__ import annotations import asyncio import json import uuid -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Final from litellm._logging import verbose_logger from litellm.litellm_core_utils.aws_partition import get_aws_arn_prefix @@ -26,12 +26,12 @@ if TYPE_CHECKING: from litellm.types.rag import RAGIngestOptions -def _get_str_or_none(value: Any) -> str | None: +def _get_str_or_none(value: object) -> str | None: """Cast config value to Optional[str].""" return str(value) if value is not None else None -def _get_int(value: Any, default: int) -> int: +def _get_int(value: str | float | None, default: int) -> int: """Cast config value to int with default.""" if value is None: return default @@ -122,7 +122,7 @@ class BedrockRAGIngestion(BaseRAGIngestion, BaseAWSLLM): self._config_initialized = False # Track resources we create (for cleanup if needed) - self._created_resources: dict[str, Any] = {} + self._created_resources: dict[str, object] = {} async def _ensure_config_initialized(self): """Lazily initialize KB config - either detect from existing or create new.""" @@ -233,7 +233,7 @@ class BedrockRAGIngestion(BaseRAGIngestion, BaseAWSLLM): verbose_logger.debug("Creating S3 bucket: %s", bucket_name) - create_params: Final[dict[str, Any]] = {"Bucket": bucket_name} + create_params: Final[dict[str, object]] = {"Bucket": bucket_name} if self.aws_region_name != "us-east-1": create_params["CreateBucketConfiguration"] = {"LocationConstraint": self.aws_region_name} diff --git a/litellm/repositories/table_repositories.py b/litellm/repositories/table_repositories.py index 18cf884f267..b67d9e87831 100644 --- a/litellm/repositories/table_repositories.py +++ b/litellm/repositories/table_repositories.py @@ -21,7 +21,7 @@ class PrismaTableRepository(Generic[RowT_co]): table_name: str - def __init__(self, prisma_client: Any): + def __init__(self, prisma_client: object): self._prisma_client = prisma_client @property diff --git a/litellm/router_strategy/lowest_latency.py b/litellm/router_strategy/lowest_latency.py index eebe81ebba1..a1b67eaeaf9 100644 --- a/litellm/router_strategy/lowest_latency.py +++ b/litellm/router_strategy/lowest_latency.py @@ -14,7 +14,7 @@ from litellm.types.utils import LiteLLMPydanticObjectBase if TYPE_CHECKING: from opentelemetry.trace import Span as _Span - Span = _Span | Any + Span = _Span else: Span = Any diff --git a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py index 3d35751394e..b623e31ce06 100644 --- a/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py +++ b/litellm/router_utils/pre_call_checks/encrypted_content_affinity_check.py @@ -37,7 +37,7 @@ Safe to enable globally: """ import time -from typing import TYPE_CHECKING, Any, Final, Optional, cast +from typing import TYPE_CHECKING, Final, Optional, Protocol, cast import httpx @@ -51,11 +51,20 @@ from litellm.integrations.custom_logger import CustomLogger, Span from litellm.responses.utils import ResponsesAPIRequestUtils from litellm.router_utils.cooldown_cache import CooldownCacheValue from litellm.types.llms.openai import AllMessageValues +from litellm.types.router import Deployment if TYPE_CHECKING: from litellm.router import Router +class _SupportsActiveCooldowns(Protocol): + """Cooldown-cache handle: this check only reads back the currently active cooldowns.""" + + async def async_get_active_cooldowns( + self, model_ids: list[str], parent_otel_span: Span | None + ) -> list[tuple[str, CooldownCacheValue]]: ... + + class EncryptedContentAffinityCheck(CustomLogger): """ Routes follow-up Responses API requests to the deployment that produced @@ -99,7 +108,7 @@ class EncryptedContentAffinityCheck(CustomLogger): ) @staticmethod - def _extract_model_id_from_input(request_input: Any) -> str | None: + def _extract_model_id_from_input(request_input: object) -> str | None: """ Scan ``input`` items for litellm-encoded encrypted-content markers and return the ``model_id`` embedded in the first one found. @@ -151,7 +160,7 @@ class EncryptedContentAffinityCheck(CustomLogger): @staticmethod def _encryption_boundary_key( - litellm_params: Any, + litellm_params: object, ) -> tuple | None: """ ``(api_base, api_key)`` pair identifying an Azure resource. Two @@ -179,7 +188,7 @@ class EncryptedContentAffinityCheck(CustomLogger): self, healthy_deployments: list[dict], model_id: str, - ) -> tuple[list[dict], Any]: + ) -> tuple[list[dict], Deployment | None]: """ Deployments in ``healthy_deployments`` sharing the originating deployment's ``(api_base, api_key)``, alongside the originating @@ -289,7 +298,7 @@ class EncryptedContentAffinityCheck(CustomLogger): self, model: str, model_id: str, - originating: Any, + originating: Deployment | None, parent_otel_span: Span | None, ) -> Exception: # Public error messages intentionally omit the originating ``model_id`` so @@ -347,7 +356,7 @@ class EncryptedContentAffinityCheck(CustomLogger): ) -> CooldownCacheValue | None: if self.router is None: return None - cooldown_cache: Final = getattr(self.router, "cooldown_cache", None) + cooldown_cache: Final[_SupportsActiveCooldowns | None] = getattr(self.router, "cooldown_cache", None) if cooldown_cache is None: return None try: diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py index 817c008fad3..39708e168f5 100644 --- a/litellm/router_utils/prompt_caching_cache.py +++ b/litellm/router_utils/prompt_caching_cache.py @@ -18,7 +18,7 @@ if TYPE_CHECKING: from litellm.router import Router litellm_router = Router - Span = _Span | Any + Span = _Span else: Span = Any litellm_router = Any @@ -34,7 +34,7 @@ class PromptCachingCache: self.in_memory_cache = InMemoryCache() @staticmethod - def serialize_object(obj: Any) -> Any: + def serialize_object(obj: Any) -> object: """Helper function to serialize Pydantic objects, dictionaries, or fallback to string.""" if hasattr(obj, "dict"): # If the object is a Pydantic model, use its `dict()` method diff --git a/litellm/secret_managers/custom_secret_manager_loader.py b/litellm/secret_managers/custom_secret_manager_loader.py index 14144b7230f..32c162ffc11 100644 --- a/litellm/secret_managers/custom_secret_manager_loader.py +++ b/litellm/secret_managers/custom_secret_manager_loader.py @@ -38,7 +38,9 @@ def load_custom_secret_manager(config_file_path: str | None = None) -> None: "CustomSecretManagerException - key_management_settings is required with custom_secret_manager field" ) - custom_secret_manager_path: Final = getattr(litellm._key_management_settings, "custom_secret_manager", None) + custom_secret_manager_path: Final[str | None] = getattr( + litellm._key_management_settings, "custom_secret_manager", None + ) if not custom_secret_manager_path: raise ValueError( diff --git a/litellm/types/containers/main.py b/litellm/types/containers/main.py index 27cbf437430..6a339fd2eac 100644 --- a/litellm/types/containers/main.py +++ b/litellm/types/containers/main.py @@ -1,7 +1,9 @@ +import builtins +from collections.abc import Mapping from typing import Any, Literal from pydantic import BaseModel -from typing_extensions import TypedDict +from typing_extensions import ReadOnly, TypedDict class ExpiresAfter(BaseModel): @@ -23,15 +25,15 @@ class ContainerObject(BaseModel): name: str | None = None _hidden_params: dict[str, Any] = {} - def __contains__(self, key) -> bool: + def __contains__(self, key: str) -> bool: # Define custom behavior for the 'in' operator return hasattr(self, key) - def get(self, key, default=None): + def get(self, key: str, default: builtins.object = None) -> builtins.object: # Custom .get() method to access attributes with a default value if the attribute doesn't exist return getattr(self, key, default) - def __getitem__(self, key): + def __getitem__(self, key: str) -> builtins.object: # Allow dictionary-style access to attributes return getattr(self, key) @@ -50,13 +52,13 @@ class DeleteContainerResult(BaseModel): object: Literal["container.deleted"] deleted: bool - def __contains__(self, key) -> bool: + def __contains__(self, key: str) -> bool: return hasattr(self, key) - def get(self, key, default=None): + def get(self, key: str, default: builtins.object = None) -> builtins.object: return getattr(self, key, default) - def __getitem__(self, key): + def __getitem__(self, key: str) -> builtins.object: return getattr(self, key) def json(self, **kwargs): @@ -75,13 +77,13 @@ class ContainerListResponse(BaseModel): last_id: str | None = None has_more: bool - def __contains__(self, key) -> bool: + def __contains__(self, key: str) -> bool: return hasattr(self, key) - def get(self, key, default=None): + def get(self, key: str, default: builtins.object = None) -> builtins.object: return getattr(self, key, default) - def __getitem__(self, key): + def __getitem__(self, key: str) -> builtins.object: return getattr(self, key) def json(self, **kwargs): @@ -98,7 +100,7 @@ class ContainerCreateOptionalRequestParams(TypedDict, total=False): Params here: https://platform.openai.com/docs/api-reference/containers/create """ - expires_after: dict[str, Any] | None # ExpiresAfter object + expires_after: ReadOnly[Mapping[str, object] | None] # ExpiresAfter object file_ids: list[str] | None extra_headers: dict[str, str] | None extra_body: dict[str, str] | None @@ -140,13 +142,13 @@ class ContainerFileObject(BaseModel): source: str _hidden_params: dict[str, Any] = {} - def __contains__(self, key) -> bool: + def __contains__(self, key: str) -> bool: return hasattr(self, key) - def get(self, key, default=None): + def get(self, key: str, default: builtins.object = None) -> builtins.object: return getattr(self, key, default) - def __getitem__(self, key): + def __getitem__(self, key: str) -> builtins.object: return getattr(self, key) def json(self, **kwargs): @@ -165,13 +167,13 @@ class ContainerFileListResponse(BaseModel): last_id: str | None = None has_more: bool - def __contains__(self, key) -> bool: + def __contains__(self, key: str) -> bool: return hasattr(self, key) - def get(self, key, default=None): + def get(self, key: str, default: builtins.object = None) -> builtins.object: return getattr(self, key, default) - def __getitem__(self, key): + def __getitem__(self, key: str) -> builtins.object: return getattr(self, key) def json(self, **kwargs): @@ -189,13 +191,13 @@ class DeleteContainerFileResponse(BaseModel): object: Literal["container.file.deleted", "container_file.deleted"] deleted: bool - def __contains__(self, key) -> bool: + def __contains__(self, key: str) -> bool: return hasattr(self, key) - def get(self, key, default=None): + def get(self, key: str, default: builtins.object = None) -> builtins.object: return getattr(self, key, default) - def __getitem__(self, key): + def __getitem__(self, key: str) -> builtins.object: return getattr(self, key) def json(self, **kwargs): diff --git a/litellm/types/llms/oci.py b/litellm/types/llms/oci.py index ff56d3d183b..d87e4231337 100644 --- a/litellm/types/llms/oci.py +++ b/litellm/types/llms/oci.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal +from typing import Literal from pydantic import BaseModel, SerializeAsAny @@ -105,9 +105,9 @@ class OCIChatRequestPayload(BaseModel): # Honoured by GPT-5 family, Gemini 2.5, Grok reasoning variants, # Cohere Command-A-Reasoning. Ignored by non-reasoning models. reasoningEffort: str | None = None - responseFormat: dict[str, Any] | None = None - toolChoice: str | dict[str, Any] | None = None - logitBias: dict[str, Any] | None = None + responseFormat: dict[str, object] | None = None + toolChoice: str | dict[str, object] | None = None + logitBias: dict[str, object] | None = None logProbs: int | None = None @@ -163,7 +163,7 @@ class OCIResponseChoice(BaseModel): # reasoning phase without producing any visible content. message: OCIMessage | None = None finishReason: str | None = None - logprobs: dict[str, Any] | None = None + logprobs: dict[str, object] | None = None class OCIChatResponse(BaseModel): @@ -275,7 +275,7 @@ class CohereToolCall(BaseModel): """Tool call made by Cohere model.""" name: str - parameters: dict[str, Any] + parameters: dict[str, object] class CohereToolResult(BaseModel): @@ -286,7 +286,7 @@ class CohereToolResult(BaseModel): """ call: CohereToolCall - outputs: list[dict[str, Any]] + outputs: list[dict[str, object]] class CohereChatRequest(BaseModel): @@ -318,12 +318,12 @@ class CohereChatRequest(BaseModel): # OCI Cohere responseFormat is {"type": "TEXT" | "JSON_OBJECT", "schema"?: ...}; # there is no JSON_SCHEMA type. The shape is built in # OCIChatConfig._normalize_response_format. - responseFormat: dict[str, Any] | None = None + responseFormat: dict[str, object] | None = None preambleOverride: str | None = None - documents: list[dict[str, Any]] | None = None + documents: list[dict[str, object]] | None = None searchQueriesOnly: bool | None = None searchEntryPoint: str | None = None - grounding: dict[str, Any] | None = None + grounding: dict[str, object] | None = None isEcho: bool | None = None isSearchQueriesOnly: bool | None = None isRawPrompting: bool | None = None @@ -333,7 +333,7 @@ class CohereChatRequest(BaseModel): citationQuality: str | None = None maxInputTokens: int | None = None isStream: bool | None = None - streamOptions: dict[str, Any] | None = None + streamOptions: dict[str, object] | None = None class CohereUsage(BaseModel): @@ -342,8 +342,8 @@ class CohereUsage(BaseModel): promptTokens: int completionTokens: int totalTokens: int - promptTokensDetails: dict[str, Any] | None = None - completionTokensDetails: dict[str, Any] | None = None + promptTokensDetails: dict[str, object] | None = None + completionTokensDetails: dict[str, object] | None = None class CohereCitation(BaseModel): @@ -378,7 +378,7 @@ class CohereChatResponse(BaseModel): # Optional fields chatHistory: list[CohereMessage] | None = None citations: list[CohereCitation] | None = None - documents: list[dict[str, Any]] | None = None + documents: list[dict[str, object]] | None = None errorMessage: str | None = None isSearchRequired: bool | None = None prompt: str | None = None diff --git a/litellm/types/llms/openai_evals.py b/litellm/types/llms/openai_evals.py index c96ca515d60..519e3e82fff 100644 --- a/litellm/types/llms/openai_evals.py +++ b/litellm/types/llms/openai_evals.py @@ -2,7 +2,8 @@ Type definitions for OpenAI Evals API """ -from typing import Any, Literal +import builtins +from typing import Literal from pydantic import BaseModel from typing_extensions import Required, TypedDict @@ -15,7 +16,7 @@ class DataSourceConfigCustom(TypedDict, total=False): type: Required[Literal["custom"]] """Data source type - custom""" - item_schema: Required[dict[str, Any]] + item_schema: Required[dict[str, object]] """JSON schema describing the structure of each row in the dataset""" include_sample_schema: bool | None @@ -28,7 +29,7 @@ class DataSourceConfigLogs(TypedDict, total=False): type: Required[Literal["logs"]] """Data source type - logs""" - metadata: dict[str, Any] | None + metadata: dict[str, object] | None """Optional metadata for filtering logs""" @@ -38,7 +39,7 @@ class DataSourceConfigStoredCompletions(TypedDict, total=False): type: Required[Literal["stored_completions"]] """Data source type - stored_completions (deprecated)""" - metadata: dict[str, Any] | None + metadata: dict[str, object] | None """Optional metadata for filtering stored completions""" @@ -93,7 +94,7 @@ class CreateEvalRequest(TypedDict, total=False): testing_criteria: Required[list[GraderConfig]] """List of graders for all eval runs""" - metadata: dict[str, Any] | None + metadata: dict[str, object] | None """Set of 16 key-value pairs that can be attached to an object (max 64 char keys, 512 char values)""" @@ -103,7 +104,7 @@ class UpdateEvalRequest(TypedDict, total=False): name: str | None """Updated name""" - metadata: dict[str, Any] | None + metadata: dict[str, object] | None """Updated metadata""" @@ -145,13 +146,13 @@ class Eval(BaseModel): name: str | None = None """The name of the evaluation""" - data_source_config: dict[str, Any] + data_source_config: dict[str, builtins.object] """Configuration for the data source""" - testing_criteria: list[dict[str, Any]] + testing_criteria: list[dict[str, builtins.object]] """List of graders for the evaluation""" - metadata: dict[str, Any] | None = None + metadata: dict[str, builtins.object] | None = None """Additional metadata""" @@ -227,7 +228,7 @@ class DataSourceInlineConfig(TypedDict, total=False): type: Required[Literal["inline"]] """Data source type - inline""" - samples: Required[list[dict[str, Any]]] + samples: Required[list[dict[str, object]]] """List of inline samples to use for the run""" @@ -259,13 +260,13 @@ class CompletionConfig(TypedDict, total=False): class CreateRunRequest(TypedDict, total=False): """Request parameters for creating a run""" - data_source: Required[dict[str, Any]] + data_source: Required[dict[str, object]] """Data source configuration for the run (can be jsonl, completions, or responses type)""" name: str | None """Optional name for the run""" - metadata: dict[str, Any] | None + metadata: dict[str, object] | None """Optional metadata for the run""" @@ -330,7 +331,7 @@ class Run(BaseModel): status: Literal["queued", "running", "completed", "failed", "cancelled"] """Current status of the run""" - data_source: dict[str, Any] + data_source: dict[str, builtins.object] """Data source configuration used for the run""" eval_id: str @@ -348,7 +349,7 @@ class Run(BaseModel): model: str | None = None """Model used for the run, if any""" - per_model_usage: Any | None = None + per_model_usage: builtins.object | None = None """Model usage details per model, if available""" per_testing_criteria_results: list[PerTestingCriteriaResult] | None = None @@ -363,10 +364,10 @@ class Run(BaseModel): shared_with_openai: bool | None = None """Whether run is shared with OpenAI""" - metadata: dict[str, Any] | None = None + metadata: dict[str, builtins.object] | None = None """Additional metadata""" - error: dict[str, Any] | None = None + error: dict[str, builtins.object] | None = None """Error details if the run failed""" diff --git a/litellm/types/proxy/management_endpoints/scim_v2.py b/litellm/types/proxy/management_endpoints/scim_v2.py index 7825684cfe5..61fd5c36b16 100644 --- a/litellm/types/proxy/management_endpoints/scim_v2.py +++ b/litellm/types/proxy/management_endpoints/scim_v2.py @@ -36,7 +36,7 @@ class SCIMResource(BaseModel): schemas: list[str] id: str | None = None externalId: str | None = None - meta: dict[str, Any] | None = None + meta: dict[str, object] | None = None class SCIMUserName(BaseModel): @@ -119,7 +119,7 @@ class SCIMUser(SCIMResource): ) @model_serializer(mode="wrap") - def _omit_absent_optional_blocks(self, handler: SerializerFunctionWrapHandler) -> dict[str, Any]: + def _omit_absent_optional_blocks(self, handler: SerializerFunctionWrapHandler) -> dict[str, object]: dumped: Final = handler(self) if self.enterprise_user is None: dumped.pop(SCIM_ENTERPRISE_USER_SCHEMA, None) @@ -169,7 +169,7 @@ class SCIMListResponse(BaseModel): class SCIMPatchOperation(BaseModel): op: str path: str | None = None - value: Any | None = None + value: object | None = None @field_validator("op", mode="before") @classmethod @@ -203,7 +203,7 @@ class SCIMServiceProviderConfig(BaseModel): changePassword: SCIMFeature = SCIMFeature(supported=False) sort: SCIMFeature = SCIMFeature(supported=False) etag: SCIMFeature = SCIMFeature(supported=False) - authenticationSchemes: list[dict[str, Any]] | None = None + authenticationSchemes: list[dict[str, object]] | None = None meta: dict[str, Any] | None = None @@ -231,7 +231,7 @@ class SCIMResourceType(BaseModel): schema_: str # "schema" is a reserved name in Pydantic context schemaExtensions: list[SCIMSchemaExtension] | None = None - meta: dict[str, Any] | None = None + meta: dict[str, object] | None = None def model_dump(self, **kwargs): d: Final = super().model_dump(**kwargs) @@ -266,4 +266,4 @@ class SCIMSchema(BaseModel): name: str description: str | None = None attributes: list[SCIMSchemaAttribute] = [] - meta: dict[str, Any] | None = None + meta: dict[str, object] | None = None diff --git a/litellm/types/videos/main.py b/litellm/types/videos/main.py index 99b08f6caf6..f4369fd95af 100644 --- a/litellm/types/videos/main.py +++ b/litellm/types/videos/main.py @@ -1,3 +1,4 @@ +import builtins from typing import Any, Literal from openai.types.audio.transcription_create_params import FileTypes @@ -14,14 +15,14 @@ class VideoObject(BaseModel): created_at: int | None = None completed_at: int | None = None expires_at: int | None = None - error: dict[str, Any] | None = None + error: dict[str, builtins.object] | None = None progress: int | None = None remixed_from_video_id: str | None = None seconds: str | None = None size: str | None = None model: str | None = None usage: dict[str, Any] | None = None - _hidden_params: dict[str, Any] = {} + _hidden_params: dict[str, builtins.object] = {} def __contains__(self, key) -> bool: # Define custom behavior for the 'in' operator @@ -31,7 +32,7 @@ class VideoObject(BaseModel): # Custom .get() method to access attributes with a default value if the attribute doesn't exist return getattr(self, key, default) - def __getitem__(self, key): + def __getitem__(self, key) -> builtins.object: # Allow dictionary-style access to attributes return getattr(self, key) @@ -47,7 +48,7 @@ class VideoResponse(BaseModel): """Response object for video generation requests.""" data: list[VideoObject] - hidden_params: dict[str, Any] = {} + hidden_params: dict[str, object] = {} def __contains__(self, key) -> bool: return hasattr(self, key) @@ -55,7 +56,7 @@ class VideoResponse(BaseModel): def get(self, key, default=None): return getattr(self, key, default) - def __getitem__(self, key): + def __getitem__(self, key) -> object: return getattr(self, key) def json(self, **kwargs): @@ -73,8 +74,8 @@ class VideoCreateOptionalRequestParams(TypedDict, total=False): """ input_reference: FileTypes | None # File reference for input image - image: Any | None # Image for image-to-video; dict with gcsUri/bytesBase64Encoded, or file-like object - parameters: dict[str, Any] | None # Provider-specific parameters block passed directly to the API + image: object | None # Image for image-to-video; dict with gcsUri/bytesBase64Encoded, or file-like object + parameters: dict[str, object] | None # Provider-specific parameters block passed directly to the API model: str | None resolution: ReadOnly[str | None] seconds: str | None @@ -110,7 +111,7 @@ class CharacterObject(BaseModel): object: Literal["character"] = "character" created_at: int name: str - _hidden_params: dict[str, Any] = {} + _hidden_params: dict[str, builtins.object] = {} def __contains__(self, key) -> bool: return hasattr(self, key) @@ -118,7 +119,7 @@ class CharacterObject(BaseModel): def get(self, key, default=None): return getattr(self, key, default) - def __getitem__(self, key): + def __getitem__(self, key) -> builtins.object: return getattr(self, key) def json(self, **kwargs): From 81481bea955701601e3a937c86ed32e2a6070b35 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:21:19 +0000 Subject: [PATCH 048/410] chore(lint): ratchet down Any and strict-typing budgets --- basedpyright-code-budget.json | 24 ++++++++++++------------ ruff-strict-budget.json | 14 +++++++------- type-discipline-budget.json | 10 +++++----- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index da788bf1ce3..813347fdd1a 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,9 +1,9 @@ { "reportAny": { - "limit": 14076 + "limit": 13434 }, "reportArgumentType": { - "limit": 2216 + "limit": 2208 }, "reportAssignmentType": { "limit": 319 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 4128 + "limit": 3370 }, "reportFunctionMemberAccess": { "limit": 7 @@ -48,16 +48,16 @@ "limit": 34 }, "reportInvalidTypeVarUse": { - "limit": 2 + "limit": 1 }, "reportMatchNotExhaustive": { "limit": 0 }, "reportMissingParameterType": { - "limit": 5601 + "limit": 5570 }, "reportMissingTypeArgument": { - "limit": 15306 + "limit": 15303 }, "reportMissingTypeStubs": { "limit": 40 @@ -90,7 +90,7 @@ "limit": 8 }, "reportReturnType": { - "limit": 181 + "limit": 180 }, "reportTypedDictNotRequiredAccess": { "limit": 24 @@ -105,16 +105,16 @@ "limit": 109 }, "reportUnknownMemberType": { - "limit": 38350 + "limit": 38324 }, "reportUnknownParameterType": { - "limit": 19626 + "limit": 19590 }, "reportUnknownVariableType": { - "limit": 29890 + "limit": 29873 }, "reportUnnecessaryCast": { - "limit": 111 + "limit": 110 }, "reportUnnecessaryComparison": { "limit": 692 @@ -123,7 +123,7 @@ "limit": 5 }, "reportUnnecessaryIsInstance": { - "limit": 826 + "limit": 823 }, "reportUntypedBaseClass": { "limit": 0 diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 9b1cc977a64..4bdcf8997c9 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -1,21 +1,21 @@ { "ANN001": { - "limit": 2985 + "limit": 2957 }, "ANN002": { "limit": 71 }, "ANN003": { - "limit": 809 + "limit": 806 }, "ANN201": { - "limit": 2001 + "limit": 1982 }, "ANN202": { - "limit": 835 + "limit": 831 }, "ANN204": { - "limit": 693 + "limit": 683 }, "ANN205": { "limit": 112 @@ -24,7 +24,7 @@ "limit": 133 }, "ANN401": { - "limit": 307 + "limit": 122 }, "ASYNC230": { "limit": 11 @@ -231,7 +231,7 @@ "limit": 5 }, "TID251": { - "limit": 1073 + "limit": 1036 }, "TRY002": { "limit": 524 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 52cb9628252..2318e3391af 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22364 + "limit": 22221 }, "LIT002": { - "limit": 26777 + "limit": 26776 }, "LIT003": { "limit": 269 @@ -15,7 +15,7 @@ "limit": 0 }, "LIT006": { - "limit": 1039 + "limit": 1036 }, "LIT007": { "limit": 0 @@ -30,9 +30,9 @@ "limit": 16507 }, "LIT011": { - "limit": 5535 + "limit": 5533 }, "LIT012": { - "limit": 4495 + "limit": 4494 } } From e8745e9eb37462ee48235bf8aeee0d88a756fe32 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:39:34 +0000 Subject: [PATCH 049/410] feat(cli): add lite debug claude session report and /debug-lite slash command Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- litellm/proxy/client/cli/commands/debug.py | 341 ++++++++++++++++++ litellm/proxy/client/cli/main.py | 3 + .../proxy/client/cli/test_debug_commands.py | 174 +++++++++ 3 files changed, 518 insertions(+) create mode 100644 litellm/proxy/client/cli/commands/debug.py create mode 100644 tests/test_litellm/proxy/client/cli/test_debug_commands.py diff --git a/litellm/proxy/client/cli/commands/debug.py b/litellm/proxy/client/cli/commands/debug.py new file mode 100644 index 00000000000..1163dcf44f5 --- /dev/null +++ b/litellm/proxy/client/cli/commands/debug.py @@ -0,0 +1,341 @@ +"""`lite debug claude`: one-shot debug report for a Claude Code session routed through the proxy. + +Claude Code puts its session id in `metadata.user_id`, which the proxy lifts into +`LiteLLM_SpendLogs.session_id`. This command pulls every turn of that session, plus +the request / response bodies for failures and the most recent turns, and renders a +single markdown report that can be pasted into a bug report or handed to another agent. +""" + +import json +import os +from collections.abc import Mapping, Sequence +from datetime import datetime, timezone +from pathlib import Path +from typing import Final + +import click +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError, field_validator + +from ...http_client import HTTPClient +from ._cli_context import cli_context_values + +CLAUDE_DIR: Final = Path.home() / ".claude" +REPORT_DIR: Final = Path.home() / ".litellm" / "debug" +SESSION_ID_ENV: Final = "CLAUDE_SESSION_ID" +SLASH_COMMAND_NAME: Final = "debug-lite" +SLASH_COMMAND_BODY: Final = """--- +description: Pull the LiteLLM debug report (spend, request, response, error) for this Claude Code session +allowed-tools: Bash(lite debug claude:*) +--- +Below is the LiteLLM debug report for this Claude Code session. Summarize the failing +request(s) in a few sentences (model, error, request id) and tell me the path the full +report was saved to so I can hand it off. If nothing failed, say so. + +!`lite debug claude $ARGUMENTS` +""" + + +class DebugError(Exception): + """Raised for any user-actionable failure while building the report.""" + + +class ErrorInformation(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + error_code: str | None = None + error_class: str | None = None + error_message: str | None = None + llm_provider: str | None = None + + +class SpendLogMetadata(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + status: str | None = None + error_information: ErrorInformation | None = None + + +class SpendLogRow(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore", populate_by_name=True) + + request_id: str + start_time: str | None = Field(default=None, alias="startTime") + end_time: str | None = Field(default=None, alias="endTime") + model: str | None = None + model_group: str | None = None + custom_llm_provider: str | None = None + api_base: str | None = None + call_type: str | None = None + status: str | None = None + spend: float = 0.0 + prompt_tokens: int = 0 + completion_tokens: int = 0 + total_tokens: int = 0 + metadata: SpendLogMetadata = SpendLogMetadata() + + @field_validator("metadata", mode="before") + @classmethod + def _parse_metadata(cls, value: object) -> object: + if value is None: + return SpendLogMetadata() + if isinstance(value, str): + return json.loads(value) if value else SpendLogMetadata() + return value + + @property + def failed(self) -> bool: + return (self.status or self.metadata.status) == "failure" + + @property + def error(self) -> ErrorInformation | None: + return self.metadata.error_information + + +class SessionLogsPage(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + data: tuple[SpendLogRow, ...] + total: int + total_pages: int + + +class RequestResponsePayload(BaseModel): + model_config = ConfigDict(frozen=True, extra="ignore") + + proxy_server_request: JsonValue = None + response: JsonValue = None + messages: JsonValue = None + + +_SESSION_PAGE: Final = TypeAdapter(SessionLogsPage) +_PAYLOAD: Final[TypeAdapter[RequestResponsePayload | None]] = TypeAdapter(RequestResponsePayload | None) +_JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue) + +_SESSION_PAGE_SIZE: Final = 100 + + +def detect_claude_session_id(env: Mapping[str, str], claude_dir: Path) -> str | None: + """Explicit env var first, else the transcript Claude Code touched most recently.""" + explicit: Final = env.get(SESSION_ID_ENV) + if explicit: + return explicit + transcripts: Final = tuple(claude_dir.glob("projects/*/*.jsonl")) + if not transcripts: + return None + newest: Final = max(transcripts, key=lambda p: p.stat().st_mtime) + return newest.stem + + +class SpendLogsFetcher: + """Thin typed wrapper over the two spend-log endpoints the report needs.""" + + def __init__(self, http: HTTPClient) -> None: + self._http = http + + def session_rows(self, session_id: str) -> tuple[SpendLogRow, ...]: + first: Final = self._page(session_id, 1) + rest: Final = tuple( + row for page in range(2, first.total_pages + 1) for row in self._page(session_id, page).data + ) + rows: Final = first.data + rest + return tuple(sorted(rows, key=lambda r: r.start_time or "")) + + def _get(self, uri: str, params: Mapping[str, str | int] | None = None) -> JsonValue: + return _JSON.validate_python(self._http.request("GET", uri, params=params)) # pyright: ignore[reportUnknownMemberType] # HTTPClient.request is untyped + + def _page(self, session_id: str, page: int) -> SessionLogsPage: + raw: Final = self._get( + "/spend/logs/session/ui", + {"session_id": session_id, "page": page, "page_size": _SESSION_PAGE_SIZE}, + ) + try: + return _SESSION_PAGE.validate_python(raw) + except ValidationError as e: + raise DebugError(f"Unexpected /spend/logs/session/ui response: {e}") from e + + def payload(self, request_id: str) -> RequestResponsePayload | None: + raw: Final = self._get(f"/spend/logs/ui/{request_id}") + try: + return _PAYLOAD.validate_python(raw) + except ValidationError as e: + raise DebugError(f"Unexpected /spend/logs/ui/{request_id} response: {e}") from e + + +def _fmt_json(value: JsonValue, max_chars: int) -> str: + text: Final = value if isinstance(value, str) else json.dumps(value, indent=2, default=str) + if len(text) <= max_chars: + return text + return f"{text[:max_chars]}\n... (truncated, {len(text) - max_chars} more chars)" + + +def _row_section(row: SpendLogRow, index: int, payload: RequestResponsePayload | None, max_chars: int) -> str: + err: Final = row.error + error_lines: Final = ( + ( + f"- error: `{err.error_code or '?'}` {err.error_class or ''}".rstrip(), + f"\n```\n{err.error_message or ''}\n```", + ) + if err is not None and row.failed + else () + ) + body_lines: Final = ( + ( + "", + "
request body", + "", + "```json", + _fmt_json(payload.proxy_server_request, max_chars), + "```", + "
", + "", + "
response", + "", + "```json", + _fmt_json(payload.response, max_chars), + "```", + "
", + ) + if payload is not None + else () + ) + header: Final = f"### {index}. {'FAILED' if row.failed else 'ok'} {row.model or row.model_group or '?'}" + facts: Final = ( + f"- request_id: `{row.request_id}`", + f"- time: {row.start_time} -> {row.end_time}", + f"- provider: {row.custom_llm_provider or '?'} ({row.api_base or 'n/a'}), call_type: {row.call_type or '?'}", + f"- spend: ${row.spend:.6f}, tokens: {row.prompt_tokens} in / {row.completion_tokens} out", + ) + return "\n".join((header, *facts, *error_lines, *body_lines)) + + +def render_report( + *, + session_id: str, + base_url: str, + rows: Sequence[SpendLogRow], + payloads: Mapping[str, RequestResponsePayload | None], + max_chars: int, +) -> str: + failures: Final = tuple(r for r in rows if r.failed) + summary: Final = ( + f"# LiteLLM debug report: Claude Code session `{session_id}`", + "", + f"- proxy: {base_url}", + f"- generated: {datetime.now(timezone.utc).isoformat(timespec='seconds')}", + f"- turns: {len(rows)}, failed: {len(failures)}", + f"- total spend: ${sum(r.spend for r in rows):.6f}", + f"- models: {', '.join(sorted({r.model or r.model_group or '?' for r in rows})) or 'n/a'}", + "", + "Bodies are included for failed turns and the most recent turns. " + "Bodies are empty unless the proxy runs with `general_settings.store_prompts_in_spend_logs: true`.", + "", + "## Turns", + "", + ) + sections: Final = tuple( + _row_section(row, i, payloads.get(row.request_id), max_chars) for i, row in enumerate(rows, start=1) + ) + return "\n".join(summary) + "\n\n".join(sections) + "\n" + + +def build_report( + *, + fetcher: SpendLogsFetcher, + session_id: str, + base_url: str, + recent_bodies: int, + max_chars: int, +) -> str: + rows: Final = fetcher.session_rows(session_id) + if not rows: + raise DebugError( + f"No spend logs found for session {session_id!r} on {base_url}. " + "Is Claude Code routed through this proxy (`lite up`), and does your key have log access?" + ) + wanted: Final = frozenset(r.request_id for r in rows if r.failed) | frozenset( + r.request_id for r in rows[-recent_bodies:] if recent_bodies > 0 + ) + payloads: Final = {rid: fetcher.payload(rid) for rid in wanted} + return render_report(session_id=session_id, base_url=base_url, rows=rows, payloads=payloads, max_chars=max_chars) + + +def write_report(report: str, session_id: str, report_dir: Path) -> Path: + report_dir.mkdir(parents=True, exist_ok=True) + path: Final = report_dir / f"claude-{session_id}.md" + path.write_text(report, encoding="utf-8") + path.chmod(0o600) + return path + + +def install_slash_command(claude_dir: Path) -> Path: + commands_dir: Final = claude_dir / "commands" + commands_dir.mkdir(parents=True, exist_ok=True) + path: Final = commands_dir / f"{SLASH_COMMAND_NAME}.md" + path.write_text(SLASH_COMMAND_BODY, encoding="utf-8") + return path + + +@click.group() +def debug() -> None: + """Pull debug reports (spend, request, response, error) for coding-agent sessions""" + + +@debug.command("claude") +@click.option( + "--session-id", + default=None, + help=f"Claude Code session id. Defaults to ${SESSION_ID_ENV}, else the most recently used transcript in ~/.claude", +) +@click.option( + "--recent-bodies", + default=3, + show_default=True, + type=click.IntRange(min=0), + help="Also include request/response bodies for the N most recent turns (failed turns always get bodies)", +) +@click.option( + "--max-body-chars", + default=20_000, + show_default=True, + type=click.IntRange(min=100), + help="Truncate each request/response body to this many characters", +) +@click.option("--no-save", is_flag=True, help="Print only, do not write the report under ~/.litellm/debug") +@click.pass_context +def debug_claude( + ctx: click.Context, session_id: str | None, recent_bodies: int, max_body_chars: int, no_save: bool +) -> None: + """Render a markdown debug report for one Claude Code session routed through the proxy + + Examples: + lite debug claude + lite debug claude --session-id e96634a3-fa28-4083-b354-55542e2dca01 + """ + resolved: Final = session_id or detect_claude_session_id(os.environ, CLAUDE_DIR) + if resolved is None: + raise click.ClickException(f"Could not find a Claude Code session. Pass --session-id or set ${SESSION_ID_ENV}.") + values: Final = cli_context_values(ctx) + base_url: Final = values["base_url"] + fetcher: Final = SpendLogsFetcher(HTTPClient(base_url, values["api_key"])) + try: + report: Final = build_report( + fetcher=fetcher, + session_id=resolved, + base_url=base_url, + recent_bodies=recent_bodies, + max_chars=max_body_chars, + ) + except DebugError as e: + raise click.ClickException(str(e)) from e + click.echo(report) + if not no_save: + path: Final = write_report(report, resolved, REPORT_DIR) + click.echo(f"Saved to {path}", err=True) + + +@debug.command("install-claude-command") +def debug_install_claude_command() -> None: + """Install the /debug-lite slash command into ~/.claude/commands so Claude Code can run `lite debug claude`""" + path: Final = install_slash_command(CLAUDE_DIR) + click.echo(f"Installed /{SLASH_COMMAND_NAME}: {path}") + click.echo("Restart Claude Code (or start a new session), then type /debug-lite.") diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py index 2674bf49ff0..b78d542085a 100644 --- a/litellm/proxy/client/cli/main.py +++ b/litellm/proxy/client/cli/main.py @@ -14,6 +14,7 @@ from .commands.autoroute.commands import autoroute_group from .commands.chat import chat from .commands.config import config_commands, get_config_value, hidden_command_names from .commands.credentials import credentials +from .commands.debug import debug from .commands.encryption import encryption from .commands.http import http from .commands.keys import keys @@ -143,6 +144,8 @@ cli.add_command(encryption) cli.add_command(chat) # Add the http command group cli.add_command(http) +# Add the debug command group (session debug reports for coding agents) +cli.add_command(debug) # Add the keys command group cli.add_command(keys) # Add the teams command group diff --git a/tests/test_litellm/proxy/client/cli/test_debug_commands.py b/tests/test_litellm/proxy/client/cli/test_debug_commands.py new file mode 100644 index 00000000000..6249f105019 --- /dev/null +++ b/tests/test_litellm/proxy/client/cli/test_debug_commands.py @@ -0,0 +1,174 @@ +import json +import os +import time +from unittest.mock import patch + +import pytest +from click.testing import CliRunner + +from litellm.proxy.client.cli import cli +from litellm.proxy.client.cli.commands import debug as debug_module +from litellm.proxy.client.cli.commands.debug import ( + SLASH_COMMAND_NAME, + detect_claude_session_id, + install_slash_command, +) + +SESSION = "e96634a3-fa28-4083-b354-55542e2dca01" + +OK_ROW = { + "request_id": "req-ok", + "startTime": "2026-09-02T10:00:00", + "endTime": "2026-09-02T10:00:02", + "model": "claude-opus-4-1", + "custom_llm_provider": "anthropic", + "status": "success", + "spend": 0.0125, + "prompt_tokens": 100, + "completion_tokens": 20, + "metadata": {"status": "success"}, +} +FAILED_ROW = { + "request_id": "req-failed", + "startTime": "2026-09-02T10:01:00", + "endTime": "2026-09-02T10:01:01", + "model": "claude-opus-4-1", + "custom_llm_provider": "anthropic", + "status": "failure", + "spend": 0.0, + "prompt_tokens": 0, + "completion_tokens": 0, + # query_raw hands metadata back as a JSON string on some paths + "metadata": json.dumps( + { + "status": "failure", + "error_information": { + "error_code": "400", + "error_class": "BadRequestError", + "error_message": "`prompt` is required when `stop` is not true.", + }, + } + ), +} + + +def _fake_http(rows, payloads): + calls = [] + + class FakeHTTP: + def __init__(self, *_args, **_kwargs): + pass + + def request(self, method, uri, **kwargs): + calls.append(uri) + if uri == "/spend/logs/session/ui": + assert kwargs["params"]["session_id"] == SESSION + return {"data": rows, "total": len(rows), "page": 1, "page_size": 100, "total_pages": 1} + request_id = uri.rsplit("/", 1)[1] + return payloads.get(request_id) + + return FakeHTTP, calls + + +@pytest.fixture(autouse=True) +def env(monkeypatch, tmp_path): + monkeypatch.setenv("LITELLM_PROXY_URL", "http://localhost:4000") + monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test") + monkeypatch.setattr(debug_module, "REPORT_DIR", tmp_path / "reports") + monkeypatch.setattr(debug_module, "CLAUDE_DIR", tmp_path / "claude") + + +def test_report_includes_spend_error_and_bodies_for_failed_turn(tmp_path): + payloads = { + "req-failed": { + "proxy_server_request": {"body": {"model": "claude-opus-4-1", "messages": [{"role": "user"}]}}, + "response": {"error": {"message": "`prompt` is required"}}, + }, + "req-ok": {"proxy_server_request": {"body": {"model": "claude-opus-4-1"}}, "response": {"id": "msg_1"}}, + } + FakeHTTP, calls = _fake_http([FAILED_ROW, OK_ROW], payloads) + with patch.object(debug_module, "HTTPClient", FakeHTTP): + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION, "--recent-bodies", "0"]) + + assert result.exit_code == 0, result.output + assert "turns: 2, failed: 1" in result.output + assert "total spend: $0.012500" in result.output + assert "### 1. ok claude-opus-4-1" in result.output + assert "### 2. FAILED claude-opus-4-1" in result.output + assert "`400` BadRequestError" in result.output + assert "`prompt` is required when `stop` is not true." in result.output + assert '"messages"' in result.output + assert "msg_1" not in result.output + assert calls == ["/spend/logs/session/ui", "/spend/logs/ui/req-failed"] + saved = tmp_path / "reports" / f"claude-{SESSION}.md" + assert result.stdout.startswith(saved.read_text()) + assert "### 2. FAILED" in saved.read_text() + + +def test_recent_bodies_fetches_latest_turns_even_when_successful(): + payloads = {"req-ok": {"proxy_server_request": {"body": {"x": 1}}, "response": {"id": "msg_1"}}} + FakeHTTP, calls = _fake_http([OK_ROW], payloads) + with patch.object(debug_module, "HTTPClient", FakeHTTP): + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION, "--no-save"]) + + assert result.exit_code == 0, result.output + assert "msg_1" in result.output + assert calls == ["/spend/logs/session/ui", "/spend/logs/ui/req-ok"] + + +def test_bodies_are_truncated_to_max_chars(): + payloads = {"req-ok": {"proxy_server_request": {"body": "a" * 5000}, "response": None}} + FakeHTTP, _ = _fake_http([OK_ROW], payloads) + with patch.object(debug_module, "HTTPClient", FakeHTTP): + result = CliRunner().invoke( + cli, ["debug", "claude", "--session-id", SESSION, "--no-save", "--max-body-chars", "200"] + ) + + assert result.exit_code == 0, result.output + assert "truncated" in result.output + assert "a" * 300 not in result.output + + +def test_no_rows_is_a_clear_error(): + FakeHTTP, _ = _fake_http([], {}) + with patch.object(debug_module, "HTTPClient", FakeHTTP): + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION]) + + assert result.exit_code != 0 + assert "No spend logs found for session" in result.output + + +def test_no_session_id_anywhere_is_a_clear_error(monkeypatch): + monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False) + result = CliRunner().invoke(cli, ["debug", "claude"]) + assert result.exit_code != 0 + assert "Could not find a Claude Code session" in result.output + + +def test_detect_session_id_prefers_env_then_newest_transcript(tmp_path): + project = tmp_path / "projects" / "-Users-me-repo" + project.mkdir(parents=True) + old = project / "old-session.jsonl" + new = project / "new-session.jsonl" + old.write_text("{}") + new.write_text("{}") + now = time.time() + os.utime(old, (now - 100, now - 100)) + os.utime(new, (now, now)) + + assert detect_claude_session_id({}, tmp_path) == "new-session" + assert detect_claude_session_id({"CLAUDE_SESSION_ID": "from-env"}, tmp_path) == "from-env" + assert detect_claude_session_id({}, tmp_path / "missing") is None + + +def test_install_slash_command_writes_runnable_command_file(tmp_path): + path = install_slash_command(tmp_path) + assert path == tmp_path / "commands" / f"{SLASH_COMMAND_NAME}.md" + body = path.read_text() + assert body.startswith("---\n") + assert "allowed-tools: Bash(lite debug claude:*)" in body + assert "!`lite debug claude $ARGUMENTS`" in body + + result = CliRunner().invoke(cli, ["debug", "install-claude-command"]) + assert result.exit_code == 0, result.output + assert "/debug-lite" in result.output From bc2640ee0ef49b416812f089244c75e8f5201a7a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:44:20 +0000 Subject: [PATCH 050/410] fix(cli): freeze collections in debug report builder to satisfy LIT002 Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- litellm/proxy/client/cli/commands/debug.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/client/cli/commands/debug.py b/litellm/proxy/client/cli/commands/debug.py index 1163dcf44f5..b5774822df7 100644 --- a/litellm/proxy/client/cli/commands/debug.py +++ b/litellm/proxy/client/cli/commands/debug.py @@ -11,6 +11,7 @@ import os from collections.abc import Mapping, Sequence from datetime import datetime, timezone from pathlib import Path +from types import MappingProxyType from typing import Final import click @@ -146,7 +147,7 @@ class SpendLogsFetcher: def _page(self, session_id: str, page: int) -> SessionLogsPage: raw: Final = self._get( "/spend/logs/session/ui", - {"session_id": session_id, "page": page, "page_size": _SESSION_PAGE_SIZE}, + MappingProxyType({"session_id": session_id, "page": page, "page_size": _SESSION_PAGE_SIZE}), ) try: return _SESSION_PAGE.validate_python(raw) @@ -224,7 +225,7 @@ def render_report( f"- generated: {datetime.now(timezone.utc).isoformat(timespec='seconds')}", f"- turns: {len(rows)}, failed: {len(failures)}", f"- total spend: ${sum(r.spend for r in rows):.6f}", - f"- models: {', '.join(sorted({r.model or r.model_group or '?' for r in rows})) or 'n/a'}", + f"- models: {', '.join(sorted(frozenset(r.model or r.model_group or '?' for r in rows))) or 'n/a'}", "", "Bodies are included for failed turns and the most recent turns. " "Bodies are empty unless the proxy runs with `general_settings.store_prompts_in_spend_logs: true`.", @@ -255,7 +256,7 @@ def build_report( wanted: Final = frozenset(r.request_id for r in rows if r.failed) | frozenset( r.request_id for r in rows[-recent_bodies:] if recent_bodies > 0 ) - payloads: Final = {rid: fetcher.payload(rid) for rid in wanted} + payloads: Final = MappingProxyType({rid: fetcher.payload(rid) for rid in wanted}) return render_report(session_id=session_id, base_url=base_url, rows=rows, payloads=payloads, max_chars=max_chars) From b2ed6eaa059a9295595cfb691d1c7b9bfedd1398 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:44:43 +0000 Subject: [PATCH 051/410] chore(lint): ratchet down Any and strict-typing budgets --- basedpyright-code-budget.json | 24 ++++++++++++------------ ruff-strict-budget.json | 14 +++++++------- type-discipline-budget.json | 10 +++++----- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index d094c98f5ec..030632a3102 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,9 +1,9 @@ { "reportAny": { - "limit": 14074 + "limit": 13432 }, "reportArgumentType": { - "limit": 2216 + "limit": 2208 }, "reportAssignmentType": { "limit": 319 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 4125 + "limit": 3367 }, "reportFunctionMemberAccess": { "limit": 7 @@ -48,16 +48,16 @@ "limit": 34 }, "reportInvalidTypeVarUse": { - "limit": 2 + "limit": 1 }, "reportMatchNotExhaustive": { "limit": 0 }, "reportMissingParameterType": { - "limit": 5601 + "limit": 5570 }, "reportMissingTypeArgument": { - "limit": 15306 + "limit": 15303 }, "reportMissingTypeStubs": { "limit": 40 @@ -90,7 +90,7 @@ "limit": 8 }, "reportReturnType": { - "limit": 181 + "limit": 180 }, "reportTypedDictNotRequiredAccess": { "limit": 24 @@ -105,16 +105,16 @@ "limit": 109 }, "reportUnknownMemberType": { - "limit": 38350 + "limit": 38324 }, "reportUnknownParameterType": { - "limit": 19625 + "limit": 19589 }, "reportUnknownVariableType": { - "limit": 29877 + "limit": 29860 }, "reportUnnecessaryCast": { - "limit": 111 + "limit": 110 }, "reportUnnecessaryComparison": { "limit": 692 @@ -123,7 +123,7 @@ "limit": 5 }, "reportUnnecessaryIsInstance": { - "limit": 826 + "limit": 823 }, "reportUntypedBaseClass": { "limit": 0 diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index ae91b711e13..3ffdce1b0e4 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -1,21 +1,21 @@ { "ANN001": { - "limit": 2985 + "limit": 2957 }, "ANN002": { "limit": 71 }, "ANN003": { - "limit": 809 + "limit": 806 }, "ANN201": { - "limit": 2000 + "limit": 1981 }, "ANN202": { - "limit": 835 + "limit": 831 }, "ANN204": { - "limit": 693 + "limit": 683 }, "ANN205": { "limit": 112 @@ -24,7 +24,7 @@ "limit": 133 }, "ANN401": { - "limit": 307 + "limit": 122 }, "ASYNC230": { "limit": 11 @@ -231,7 +231,7 @@ "limit": 5 }, "TID251": { - "limit": 1073 + "limit": 1036 }, "TRY002": { "limit": 524 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 6273fbce595..4cd8fec5aae 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22358 + "limit": 22215 }, "LIT002": { - "limit": 26774 + "limit": 26773 }, "LIT003": { "limit": 269 @@ -15,7 +15,7 @@ "limit": 0 }, "LIT006": { - "limit": 1039 + "limit": 1036 }, "LIT007": { "limit": 0 @@ -30,9 +30,9 @@ "limit": 16494 }, "LIT011": { - "limit": 5535 + "limit": 5533 }, "LIT012": { - "limit": 4495 + "limit": 4494 } } From 306427f5e4879bf77720b167bcd9ff7e623c3f9a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:50:11 +0000 Subject: [PATCH 052/410] test(cli): mock the HTTP boundary with responses instead of patching HTTPClient Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- .../proxy/client/cli/test_debug_commands.py | 63 +++++++++---------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/tests/test_litellm/proxy/client/cli/test_debug_commands.py b/tests/test_litellm/proxy/client/cli/test_debug_commands.py index 6249f105019..42ce3aaf4d2 100644 --- a/tests/test_litellm/proxy/client/cli/test_debug_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_debug_commands.py @@ -1,9 +1,9 @@ import json import os import time -from unittest.mock import patch import pytest +import responses from click.testing import CliRunner from litellm.proxy.client.cli import cli @@ -52,32 +52,32 @@ FAILED_ROW = { } -def _fake_http(rows, payloads): - calls = [] +PROXY = "http://localhost:4000" - class FakeHTTP: - def __init__(self, *_args, **_kwargs): - pass - def request(self, method, uri, **kwargs): - calls.append(uri) - if uri == "/spend/logs/session/ui": - assert kwargs["params"]["session_id"] == SESSION - return {"data": rows, "total": len(rows), "page": 1, "page_size": 100, "total_pages": 1} - request_id = uri.rsplit("/", 1)[1] - return payloads.get(request_id) +def _mock_proxy(rows, payloads): + responses.get( + f"{PROXY}/spend/logs/session/ui", + json={"data": rows, "total": len(rows), "page": 1, "page_size": 100, "total_pages": 1}, + match=[responses.matchers.query_param_matcher({"session_id": SESSION}, strict_match=False)], + ) + for request_id, payload in payloads.items(): + responses.get(f"{PROXY}/spend/logs/ui/{request_id}", json=payload) - return FakeHTTP, calls + +def _called_paths(): + return [c.request.path_url.split("?")[0] for c in responses.calls] @pytest.fixture(autouse=True) def env(monkeypatch, tmp_path): - monkeypatch.setenv("LITELLM_PROXY_URL", "http://localhost:4000") + monkeypatch.setenv("LITELLM_PROXY_URL", PROXY) monkeypatch.setenv("LITELLM_PROXY_API_KEY", "sk-test") monkeypatch.setattr(debug_module, "REPORT_DIR", tmp_path / "reports") monkeypatch.setattr(debug_module, "CLAUDE_DIR", tmp_path / "claude") +@responses.activate def test_report_includes_spend_error_and_bodies_for_failed_turn(tmp_path): payloads = { "req-failed": { @@ -86,9 +86,8 @@ def test_report_includes_spend_error_and_bodies_for_failed_turn(tmp_path): }, "req-ok": {"proxy_server_request": {"body": {"model": "claude-opus-4-1"}}, "response": {"id": "msg_1"}}, } - FakeHTTP, calls = _fake_http([FAILED_ROW, OK_ROW], payloads) - with patch.object(debug_module, "HTTPClient", FakeHTTP): - result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION, "--recent-bodies", "0"]) + _mock_proxy([FAILED_ROW, OK_ROW], payloads) + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION, "--recent-bodies", "0"]) assert result.exit_code == 0, result.output assert "turns: 2, failed: 1" in result.output @@ -99,40 +98,38 @@ def test_report_includes_spend_error_and_bodies_for_failed_turn(tmp_path): assert "`prompt` is required when `stop` is not true." in result.output assert '"messages"' in result.output assert "msg_1" not in result.output - assert calls == ["/spend/logs/session/ui", "/spend/logs/ui/req-failed"] + assert _called_paths() == ["/spend/logs/session/ui", "/spend/logs/ui/req-failed"] saved = tmp_path / "reports" / f"claude-{SESSION}.md" assert result.stdout.startswith(saved.read_text()) assert "### 2. FAILED" in saved.read_text() +@responses.activate def test_recent_bodies_fetches_latest_turns_even_when_successful(): - payloads = {"req-ok": {"proxy_server_request": {"body": {"x": 1}}, "response": {"id": "msg_1"}}} - FakeHTTP, calls = _fake_http([OK_ROW], payloads) - with patch.object(debug_module, "HTTPClient", FakeHTTP): - result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION, "--no-save"]) + _mock_proxy([OK_ROW], {"req-ok": {"proxy_server_request": {"body": {"x": 1}}, "response": {"id": "msg_1"}}}) + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION, "--no-save"]) assert result.exit_code == 0, result.output assert "msg_1" in result.output - assert calls == ["/spend/logs/session/ui", "/spend/logs/ui/req-ok"] + assert _called_paths() == ["/spend/logs/session/ui", "/spend/logs/ui/req-ok"] +@responses.activate def test_bodies_are_truncated_to_max_chars(): - payloads = {"req-ok": {"proxy_server_request": {"body": "a" * 5000}, "response": None}} - FakeHTTP, _ = _fake_http([OK_ROW], payloads) - with patch.object(debug_module, "HTTPClient", FakeHTTP): - result = CliRunner().invoke( - cli, ["debug", "claude", "--session-id", SESSION, "--no-save", "--max-body-chars", "200"] - ) + _mock_proxy([OK_ROW], {"req-ok": {"proxy_server_request": {"body": "a" * 5000}, "response": None}}) + result = CliRunner().invoke( + cli, ["debug", "claude", "--session-id", SESSION, "--no-save", "--max-body-chars", "200"] + ) assert result.exit_code == 0, result.output assert "truncated" in result.output assert "a" * 300 not in result.output +@responses.activate def test_no_rows_is_a_clear_error(): - FakeHTTP, _ = _fake_http([], {}) - with patch.object(debug_module, "HTTPClient", FakeHTTP): - result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION]) + _mock_proxy([], {}) + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION]) assert result.exit_code != 0 assert "No spend logs found for session" in result.output From 1c4e46f17e813d5eb8e5fa0c2577f7bef3a32b9a Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 17:15:36 -0700 Subject: [PATCH 053/410] ci(e2e): reload the stack config every 7s so the harness propagation budget holds, and only mask credential-named values --- .github/e2e-stack/secrets_to_env.py | 7 +++++-- tests/e2e/gateway/stage_mirror_ci_config.yml | 1 + 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/e2e-stack/secrets_to_env.py b/.github/e2e-stack/secrets_to_env.py index 65022931d81..d2d6675d690 100644 --- a/.github/e2e-stack/secrets_to_env.py +++ b/.github/e2e-stack/secrets_to_env.py @@ -1,9 +1,12 @@ +import re import sys from pathlib import Path +from typing import Final from pydantic import TypeAdapter secrets_adapter: TypeAdapter[dict[str, str]] = TypeAdapter(dict[str, str]) +SECRET_NAME: Final = re.compile(r"KEY|SECRET|TOKEN|PASS|CREDENTIAL|LICENSE|AUTH") def main() -> int: @@ -18,8 +21,8 @@ def main() -> int: lines = tuple(f"{key}='{value}'" for key, value in secrets.items() if value) with env_path.open("a") as handle: _ = handle.write("\n".join(lines) + "\n") - for value in secrets.values(): - if value: + for key, value in secrets.items(): + if value and SECRET_NAME.search(key): _ = sys.stdout.write(f"::add-mask::{value}\n") return 0 diff --git a/tests/e2e/gateway/stage_mirror_ci_config.yml b/tests/e2e/gateway/stage_mirror_ci_config.yml index 57a92fd47fb..21664c2d0a1 100644 --- a/tests/e2e/gateway/stage_mirror_ci_config.yml +++ b/tests/e2e/gateway/stage_mirror_ci_config.yml @@ -1,4 +1,5 @@ general_settings: + proxy_config_reload_interval_seconds: 7 store_prompts_in_spend_logs: true database_connection_pool_limit: 10 forward_client_headers_to_llm_api: false From 3ead9d16884c652ccbabe618f7526d74b3f4743e Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Fri, 19 Jun 2026 16:43:02 -0500 Subject: [PATCH 054/410] feat(vertex): add Lyria model support --- .../llms/vertex_ai/interactions/__init__.py | 3 + ...odel_prices_and_context_window_backup.json | 81 +++++++++++++++++++ .../vertex_passthrough_logging_handler.py | 69 ++++++++++++++++ model_prices_and_context_window.json | 81 +++++++++++++++++++ ...test_vertex_passthrough_logging_handler.py | 68 ++++++++++++++++ tests/test_litellm/test_utils.py | 37 +++++++++ 6 files changed, 339 insertions(+) create mode 100644 tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py diff --git a/litellm/llms/vertex_ai/interactions/__init__.py b/litellm/llms/vertex_ai/interactions/__init__.py index e69de29bb2d..3e2309d21ef 100644 --- a/litellm/llms/vertex_ai/interactions/__init__.py +++ b/litellm/llms/vertex_ai/interactions/__init__.py @@ -0,0 +1,3 @@ +from .transformation import VertexAIInteractionsConfig + +__all__ = ["VertexAIInteractionsConfig"] diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d8a8f84b032..e854e8fc9bb 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45511,6 +45511,87 @@ "output_cost_per_token": 4e-07, "supports_tool_choice": true }, + "vertex_ai/lyria-002": { + "litellm_provider": "vertex_ai", + "max_audio_length_hours": 0.009111111111111111, + "max_audio_per_prompt": 4, + "mode": "chat", + "output_cost_per_second": 0.002, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_output": true + }, + "vertex_ai/lyria-3-clip-preview": { + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_image": 0.04, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1beta/interactions" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "audio" + ], + "supported_regions": [ + "global" + ], + "supports_audio_input": false, + "supports_audio_output": true, + "supports_function_calling": false, + "supports_image_input": true, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_system_messages": false, + "supports_vision": true, + "supports_web_search": false + }, + "vertex_ai/lyria-3-pro-preview": { + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_image": 0.08, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1beta/interactions" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "audio" + ], + "supported_regions": [ + "global" + ], + "supports_audio_input": false, + "supports_audio_output": true, + "supports_function_calling": false, + "supports_image_input": true, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_system_messages": false, + "supports_vision": true, + "supports_web_search": false + }, "vertex_ai/meta/llama-3.1-405b-instruct-maas": { "input_cost_per_token": 5e-06, "litellm_provider": "vertex_ai-llama_models", diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py index 49ec18013b5..a9f68f8380b 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py @@ -46,6 +46,7 @@ else: # Define EndpointType locally to avoid import issues EndpointType = Any +_LYRIA_SECONDS_PER_AUDIO_PREDICTION = 30 class VertexPassthroughLoggingHandler: @@ -270,6 +271,16 @@ class VertexPassthroughLoggingHandler: _json_response: Final[dict[str, object]] = httpx_response.json() litellm_prediction_response: ModelResponse | EmbeddingResponse | ImageResponse = ModelResponse() + if VertexPassthroughLoggingHandler._is_lyria_predict_response( + model=model, + json_response=_json_response, + ): + return VertexPassthroughLoggingHandler._handle_lyria_predict_response( + json_response=_json_response, + logging_obj=logging_obj, + model=model, + kwargs=kwargs, + ) if vertex_image_generation_class.is_image_generation_response(_json_response): litellm_prediction_response = vertex_image_generation_class.process_image_generation_response( _json_response, @@ -323,6 +334,64 @@ class VertexPassthroughLoggingHandler: "kwargs": kwargs, } + @staticmethod + def _handle_lyria_predict_response( + json_response: dict, + logging_obj: LiteLLMLoggingObj, + model: str, + kwargs: dict, + ) -> PassThroughEndpointLoggingTypedDict: + prediction_count: Final = ( + VertexPassthroughLoggingHandler._get_lyria_audio_prediction_count( + json_response=json_response + ) + ) + model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}", {}) + response_cost: Final = ( + model_info.get("output_cost_per_second", 0.0) + * _LYRIA_SECONDS_PER_AUDIO_PREDICTION + * prediction_count + ) + + logging_obj.model = model + logging_obj.model_call_details["model"] = model + logging_obj.model_call_details["custom_llm_provider"] = "vertex_ai" + logging_obj.custom_llm_provider = "vertex_ai" + logging_obj.model_call_details["response_cost"] = response_cost + + kwargs["response_cost"] = response_cost + kwargs["model"] = model + kwargs["custom_llm_provider"] = "vertex_ai" + + standard_pass_through_response_object: Final[StandardPassThroughResponseObject] = { + "response": json_response, + } + return { + "result": standard_pass_through_response_object, + "kwargs": kwargs, + } + + @staticmethod + def _is_lyria_predict_response(model: str, json_response: dict) -> bool: + return ( + model == "lyria-002" + and VertexPassthroughLoggingHandler._get_lyria_audio_prediction_count( + json_response=json_response + ) + > 0 + ) + + @staticmethod + def _get_lyria_audio_prediction_count(json_response: dict) -> int: + predictions: Final = json_response.get("predictions") + if not isinstance(predictions, list): + return 0 + return sum( + 1 + for prediction in predictions + if isinstance(prediction, dict) and prediction.get("audioContent") + ) + @staticmethod def _extract_embed_content_input(request_body: dict | None, batch: bool) -> str: """Extract raw input text from an :embedContent or :batchEmbedContents request body for token counting.""" diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d8a8f84b032..e854e8fc9bb 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45511,6 +45511,87 @@ "output_cost_per_token": 4e-07, "supports_tool_choice": true }, + "vertex_ai/lyria-002": { + "litellm_provider": "vertex_ai", + "max_audio_length_hours": 0.009111111111111111, + "max_audio_per_prompt": 4, + "mode": "chat", + "output_cost_per_second": 0.002, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_modalities": [ + "text" + ], + "supported_output_modalities": [ + "audio" + ], + "supports_audio_output": true + }, + "vertex_ai/lyria-3-clip-preview": { + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_image": 0.04, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1beta/interactions" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "audio" + ], + "supported_regions": [ + "global" + ], + "supports_audio_input": false, + "supports_audio_output": true, + "supports_function_calling": false, + "supports_image_input": true, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_system_messages": false, + "supports_vision": true, + "supports_web_search": false + }, + "vertex_ai/lyria-3-pro-preview": { + "input_cost_per_token": 0, + "litellm_provider": "vertex_ai", + "max_input_tokens": 131072, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_image": 0.08, + "output_cost_per_token": 0, + "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1beta/interactions" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "audio" + ], + "supported_regions": [ + "global" + ], + "supports_audio_input": false, + "supports_audio_output": true, + "supports_function_calling": false, + "supports_image_input": true, + "supports_prompt_caching": false, + "supports_response_schema": false, + "supports_system_messages": false, + "supports_vision": true, + "supports_web_search": false + }, "vertex_ai/meta/llama-3.1-405b-instruct-maas": { "input_cost_per_token": 5e-06, "litellm_provider": "vertex_ai-llama_models", diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py new file mode 100644 index 00000000000..7af67cc0795 --- /dev/null +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py @@ -0,0 +1,68 @@ +from datetime import datetime +from unittest.mock import MagicMock + +import httpx +import litellm +import pytest + +from litellm.proxy.pass_through_endpoints.llm_provider_handlers.vertex_passthrough_logging_handler import ( + VertexPassthroughLoggingHandler, +) + + +def test_lyria_predict_response_preserves_audio_response_and_logs_cost( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem( + litellm.model_cost, + "vertex_ai/lyria-002", + {"output_cost_per_second": 0.002}, + ) + logging_obj = MagicMock() + logging_obj.model_call_details = {} + response = httpx.Response( + status_code=200, + json={ + "predictions": [ + { + "audioContent": "clip-1", + "mimeType": "audio/wav", + }, + { + "audioContent": "clip-2", + "mimeType": "audio/wav", + }, + ] + }, + ) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route="/v1/projects/test/locations/us-central1/publishers/google/models/lyria-002:predict", + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"instances": [{"prompt": "ambient piano"}]}, + ) + + assert result["result"] == { + "response": { + "predictions": [ + { + "audioContent": "clip-1", + "mimeType": "audio/wav", + }, + { + "audioContent": "clip-2", + "mimeType": "audio/wav", + }, + ] + } + } + assert result["kwargs"]["model"] == "lyria-002" + assert result["kwargs"]["custom_llm_provider"] == "vertex_ai" + assert result["kwargs"]["response_cost"] == pytest.approx(0.12) + assert logging_obj.model == "lyria-002" + assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.12) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 200cfd02197..d8255e9ff1b 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -1067,6 +1067,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "/v1/images/variations", "/v1/images/edits", "/v1/batch", + "/v1beta/interactions", "/v1/audio/transcriptions", "/v1/audio/speech", "/v1/ocr", @@ -2833,6 +2834,42 @@ def test_gemini_lyria_3_preview_models_in_cost_map(): assert clip["output_cost_per_image"] == 0.04 +def test_vertex_ai_lyria_models_in_cost_map(): + import json + from pathlib import Path + + json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json" + with open(json_path) as f: + model_cost = json.load(f) + + lyria_2 = model_cost.get("vertex_ai/lyria-002") + clip = model_cost.get("vertex_ai/lyria-3-clip-preview") + pro = model_cost.get("vertex_ai/lyria-3-pro-preview") + + assert lyria_2 is not None + assert clip is not None + assert pro is not None + assert lyria_2["litellm_provider"] == "vertex_ai" + assert clip["litellm_provider"] == "vertex_ai" + assert pro["litellm_provider"] == "vertex_ai" + assert lyria_2["output_cost_per_second"] == 0.002 + assert lyria_2["supported_modalities"] == ["text"] + assert lyria_2["supported_output_modalities"] == ["audio"] + assert lyria_2["supports_audio_output"] is True + assert clip["output_cost_per_image"] == 0.04 + assert pro["output_cost_per_image"] == 0.08 + assert clip["supported_endpoints"] == ["/v1beta/interactions"] + assert pro["supported_endpoints"] == ["/v1beta/interactions"] + assert clip["supported_modalities"] == ["text", "image"] + assert pro["supported_modalities"] == ["text", "image"] + assert clip["supported_regions"] == ["global"] + assert pro["supported_regions"] == ["global"] + assert clip["supports_audio_output"] is True + assert pro["supports_audio_output"] is True + assert clip["supports_image_input"] is True + assert pro["supports_image_input"] is True + + def test_model_info_for_fireworks_short_form_models(): """ Test that fireworks_ai short-form model entries (fireworks_ai/) From 514e9a1ee62e52480343d084b8f87b54c67f5a41 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Fri, 19 Jun 2026 17:44:54 -0500 Subject: [PATCH 055/410] fix(vertex): address lyria review feedback --- ...odel_prices_and_context_window_backup.json | 1 + .../vertex_passthrough_logging_handler.py | 44 ++++++++++-------- model_prices_and_context_window.json | 1 + ...test_vertex_passthrough_logging_handler.py | 46 ++++++++++++++++++- tests/test_litellm/test_utils.py | 2 + 5 files changed, 75 insertions(+), 19 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index e854e8fc9bb..1a1d92805e2 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45512,6 +45512,7 @@ "supports_tool_choice": true }, "vertex_ai/lyria-002": { + "audio_seconds_per_prediction": 30, "litellm_provider": "vertex_ai", "max_audio_length_hours": 0.009111111111111111, "max_audio_per_prompt": 4, diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py index a9f68f8380b..6b2d7763fa0 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py @@ -44,9 +44,7 @@ else: PassThroughEndpointLogging = Any LiteLLMBatch = Any -# Define EndpointType locally to avoid import issues EndpointType = Any -_LYRIA_SECONDS_PER_AUDIO_PREDICTION = 30 class VertexPassthroughLoggingHandler: @@ -271,11 +269,11 @@ class VertexPassthroughLoggingHandler: _json_response: Final[dict[str, object]] = httpx_response.json() litellm_prediction_response: ModelResponse | EmbeddingResponse | ImageResponse = ModelResponse() - if VertexPassthroughLoggingHandler._is_lyria_predict_response( + if VertexPassthroughLoggingHandler._is_audio_predict_response( model=model, json_response=_json_response, ): - return VertexPassthroughLoggingHandler._handle_lyria_predict_response( + return VertexPassthroughLoggingHandler._handle_audio_predict_response( json_response=_json_response, logging_obj=logging_obj, model=model, @@ -335,23 +333,19 @@ class VertexPassthroughLoggingHandler: } @staticmethod - def _handle_lyria_predict_response( + def _handle_audio_predict_response( json_response: dict, logging_obj: LiteLLMLoggingObj, model: str, kwargs: dict, ) -> PassThroughEndpointLoggingTypedDict: - prediction_count: Final = ( - VertexPassthroughLoggingHandler._get_lyria_audio_prediction_count( - json_response=json_response - ) + prediction_count: Final = VertexPassthroughLoggingHandler._get_audio_prediction_count( + json_response=json_response ) - model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}", {}) response_cost: Final = ( - model_info.get("output_cost_per_second", 0.0) - * _LYRIA_SECONDS_PER_AUDIO_PREDICTION - * prediction_count - ) + VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) + or 0.0 + ) * prediction_count logging_obj.model = model logging_obj.model_call_details["model"] = model @@ -372,17 +366,31 @@ class VertexPassthroughLoggingHandler: } @staticmethod - def _is_lyria_predict_response(model: str, json_response: dict) -> bool: + def _is_audio_predict_response(model: str, json_response: dict) -> bool: return ( - model == "lyria-002" - and VertexPassthroughLoggingHandler._get_lyria_audio_prediction_count( + VertexPassthroughLoggingHandler._get_audio_prediction_count( json_response=json_response ) > 0 + and VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost( + model=model + ) + is not None ) @staticmethod - def _get_lyria_audio_prediction_count(json_response: dict) -> int: + def _get_audio_prediction_unit_cost(model: str) -> float | None: + model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}", {}) + output_cost_per_second: Final = model_info.get("output_cost_per_second") + audio_seconds_per_prediction: Final = model_info.get("audio_seconds_per_prediction") + if not isinstance(output_cost_per_second, (int, float)) or not isinstance( + audio_seconds_per_prediction, (int, float) + ): + return None + return float(output_cost_per_second * audio_seconds_per_prediction) + + @staticmethod + def _get_audio_prediction_count(json_response: dict) -> int: predictions: Final = json_response.get("predictions") if not isinstance(predictions, list): return 0 diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e854e8fc9bb..1a1d92805e2 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45512,6 +45512,7 @@ "supports_tool_choice": true }, "vertex_ai/lyria-002": { + "audio_seconds_per_prediction": 30, "litellm_provider": "vertex_ai", "max_audio_length_hours": 0.009111111111111111, "max_audio_per_prompt": 4, diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py index 7af67cc0795..9f65fa09b1b 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py @@ -16,7 +16,10 @@ def test_lyria_predict_response_preserves_audio_response_and_logs_cost( monkeypatch.setitem( litellm.model_cost, "vertex_ai/lyria-002", - {"output_cost_per_second": 0.002}, + { + "audio_seconds_per_prediction": 30, + "output_cost_per_second": 0.002, + }, ) logging_obj = MagicMock() logging_obj.model_call_details = {} @@ -66,3 +69,44 @@ def test_lyria_predict_response_preserves_audio_response_and_logs_cost( assert result["kwargs"]["response_cost"] == pytest.approx(0.12) assert logging_obj.model == "lyria-002" assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.12) + + +def test_audio_predict_response_uses_model_map_metadata( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem( + litellm.model_cost, + "vertex_ai/music-audio-preview", + { + "audio_seconds_per_prediction": 12, + "output_cost_per_second": 0.5, + }, + ) + logging_obj = MagicMock() + logging_obj.model_call_details = {} + response = httpx.Response( + status_code=200, + json={ + "predictions": [ + { + "audioContent": "clip", + "mimeType": "audio/wav", + } + ] + }, + ) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route="/v1/projects/test/locations/us-central1/publishers/google/models/music-audio-preview:predict", + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"instances": [{"prompt": "ambient piano"}]}, + ) + + assert result["kwargs"]["model"] == "music-audio-preview" + assert result["kwargs"]["response_cost"] == pytest.approx(6.0) + assert logging_obj.model_call_details["response_cost"] == pytest.approx(6.0) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index d8255e9ff1b..6ae7c27b758 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -949,6 +949,7 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "max_tokens": {"type": "number"}, "metadata": {"type": "object"}, "provider_specific_entry": {"type": "object"}, + "audio_seconds_per_prediction": {"type": "number"}, "mode": { "type": "string", "enum": [ @@ -2852,6 +2853,7 @@ def test_vertex_ai_lyria_models_in_cost_map(): assert lyria_2["litellm_provider"] == "vertex_ai" assert clip["litellm_provider"] == "vertex_ai" assert pro["litellm_provider"] == "vertex_ai" + assert lyria_2["audio_seconds_per_prediction"] == 30 assert lyria_2["output_cost_per_second"] == 0.002 assert lyria_2["supported_modalities"] == ["text"] assert lyria_2["supported_output_modalities"] == ["audio"] From e00fe023a973860345f56278233398a3f8a5b4ff Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 15 Jul 2026 19:25:19 -0500 Subject: [PATCH 056/410] test(models): validate Lyria audio metadata --- tests/test_litellm/test_utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 6ae7c27b758..7bec649099a 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -944,6 +944,8 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "code_interpreter_cost_per_session": {"type": "number"}, "inference_geo": {"type": "string"}, "litellm_provider": {"type": "string"}, + "max_audio_length_hours": {"type": "number"}, + "max_audio_per_prompt": {"type": "number"}, "max_input_tokens": {"type": "number"}, "max_output_tokens": {"type": "number"}, "max_tokens": {"type": "number"}, From b96844dd0c23a084108191df7ff37423a40f862f Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 15 Jul 2026 19:56:53 -0500 Subject: [PATCH 057/410] feat(vertex): expose Lyria through audio speech --- litellm/cost_calculator.py | 12 +- .../text_to_speech/transformation.py | 160 ++++++++++ litellm/main.py | 6 +- ...odel_prices_and_context_window_backup.json | 15 +- .../vertex_passthrough_logging_handler.py | 12 +- litellm/proxy/proxy_server.py | 11 +- litellm/types/utils.py | 4 + litellm/utils.py | 6 + model_prices_and_context_window.json | 15 +- ...test_vertex_passthrough_logging_handler.py | 33 ++ .../text_to_speech/test_transformation.py | 298 +++++++++++++++++- tests/test_litellm/test_cost_calculator.py | 18 ++ tests/test_litellm/test_utils.py | 15 +- 13 files changed, 565 insertions(+), 40 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index b83e9b395a8..2bac5e234bc 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -496,9 +496,19 @@ def cost_per_token( # see this https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models if call_type == "speech" or call_type == "aspeech": speech_model_info = litellm.get_model_info(model=model_without_prefix, custom_llm_provider=custom_llm_provider) - cost_metric: Final = select_cost_metric_for_model(speech_model_info) prompt_cost: float = 0.0 completion_cost: float = 0.0 + if not speech_model_info.get("input_cost_per_character") and not speech_model_info.get( + "input_cost_per_token" + ): + output_cost_per_generation: Final = speech_model_info.get("output_cost_per_image") + output_cost_per_second: Final = speech_model_info.get("output_cost_per_second") + audio_seconds_per_prediction: Final = speech_model_info.get("audio_seconds_per_prediction") + if output_cost_per_generation is not None: + return prompt_cost, float(output_cost_per_generation) + if output_cost_per_second is not None and audio_seconds_per_prediction is not None: + return prompt_cost, float(output_cost_per_second) * float(audio_seconds_per_prediction) + cost_metric: Final = select_cost_metric_for_model(speech_model_info) if cost_metric == "cost_per_character": if prompt_characters is None: raise ValueError( diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 332f892ae6b..ad39aa35f8c 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -12,6 +12,8 @@ from typing import TYPE_CHECKING, Any, Final, Union import httpx +import litellm +from litellm.exceptions import UnsupportedParamsError from litellm.litellm_core_utils.audio_utils.utils import ( speech_media_type_from_audio_bytes, ) @@ -471,3 +473,161 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): # Initialize the HttpxBinaryResponseContent instance return HttpxBinaryResponseContent(response) + + +class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): + LYRIA_MODELS = { + "lyria-002", + "lyria-3-clip-preview", + "lyria-3-pro-preview", + } + + @classmethod + def is_lyria_model(cls, model: str) -> bool: + return model.removeprefix("vertex_ai/") in cls.LYRIA_MODELS + + def get_supported_openai_params(self, model: str) -> list: + return ["response_format"] + + def map_openai_params( + self, + model: str, + optional_params: dict, + voice: str | dict | None = None, + drop_params: bool = False, + kwargs: dict = {}, + ) -> tuple[str | None, dict]: + mapped_params = dict(optional_params) + base_model = model.removeprefix("vertex_ai/") + unsupported_params = [param for param in ("speed", "instructions") if mapped_params.get(param) is not None] + if unsupported_params: + if drop_params or litellm.drop_params: + for param in unsupported_params: + mapped_params.pop(param, None) + else: + raise UnsupportedParamsError( + status_code=400, + message=( + f"Vertex AI {base_model} does not support the OpenAI parameters: " + f"{', '.join(unsupported_params)}. To drop unsupported openai params " + "from the call, set `litellm.drop_params = True`" + ), + ) + response_format = mapped_params.get("response_format") + supported_formats = ( + {"wav"} if base_model == "lyria-002" else {"mp3", "wav"} if base_model == "lyria-3-pro-preview" else {"mp3"} + ) + if response_format is not None and response_format not in supported_formats: + if drop_params or litellm.drop_params: + mapped_params.pop("response_format", None) + else: + raise UnsupportedParamsError( + status_code=400, + message=( + f"Vertex AI {base_model} does not support response_format={response_format!r}. " + f"Supported values: {', '.join(sorted(supported_formats))}. " + "To drop unsupported openai params from the call, set `litellm.drop_params = True`" + ), + ) + return voice if isinstance(voice, str) else None, mapped_params + + def get_complete_url( + self, + model: str, + api_base: str | None, + litellm_params: dict, + ) -> str: + base_model = model.removeprefix("vertex_ai/") + project = self.safe_get_vertex_ai_project(litellm_params) + if project is None: + _, project = self._ensure_access_token( + credentials=self.safe_get_vertex_ai_credentials(litellm_params), + project_id=None, + custom_llm_provider="vertex_ai", + ) + if base_model.startswith("lyria-3-"): + from litellm.llms.vertex_ai.interactions.transformation import ( + VertexAIInteractionsConfig, + ) + + return VertexAIInteractionsConfig().get_complete_url( + api_base=api_base, + model=base_model, + litellm_params={**litellm_params, "vertex_project": project}, + ) + location = self.safe_get_vertex_ai_location(litellm_params) or self.get_default_vertex_location() + base_url = self.get_api_base(api_base=api_base, vertex_location=location).rstrip("/") + return f"{base_url}/v1/projects/{project}/locations/{location}/publishers/google/models/{base_model}:predict" + + def transform_text_to_speech_request( + self, + model: str, + input: str, + voice: str | None, + optional_params: dict, + litellm_params: dict, + headers: dict, + ) -> TextToSpeechRequestData: + access_token, project = self._ensure_access_token( + credentials=self.safe_get_vertex_ai_credentials(litellm_params), + project_id=self.safe_get_vertex_ai_project(litellm_params), + custom_llm_provider="vertex_ai", + ) + headers.update( + { + "Authorization": f"Bearer {access_token}", + "x-goog-user-project": project, + "Content-Type": "application/json", + } + ) + base_model = model.removeprefix("vertex_ai/") + if base_model == "lyria-002": + request_body = { + "instances": [{"prompt": input}], + "parameters": {"sample_count": 1}, + } + else: + request_body = {"model": base_model, "input": input} + if optional_params.get("response_format") == "wav": + request_body["response_format"] = { + "type": "audio", + "mime_type": "audio/wav", + } + return TextToSpeechRequestData(dict_body=request_body, headers=headers) + + def transform_text_to_speech_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: "LiteLLMLoggingObj", + ) -> "HttpxBinaryResponseContent": + from litellm.types.llms.openai import HttpxBinaryResponseContent + + response_json = raw_response.json() + base_model = model.removeprefix("vertex_ai/") + audio_data: str | None = None + mime_type: str | None = None + if base_model == "lyria-002": + predictions = response_json.get("predictions") or [] + if predictions: + audio_data = predictions[0].get("audioContent") or predictions[0].get("bytesBase64Encoded") + mime_type = predictions[0].get("mimeType") + else: + for step in response_json.get("steps") or response_json.get("outputs") or []: + content_items = step.get("content") or [] if step.get("type") == "model_output" else [step] + for content in content_items: + if content.get("type") == "audio" and content.get("data"): + audio_data = content["data"] + mime_type = content.get("mime_type") + if audio_data is None: + raise ValueError(f"No generated audio found in Vertex AI {base_model} response") + mime_type = mime_type or ("audio/wav" if base_model == "lyria-002" else "audio/mpeg") + response = HttpxBinaryResponseContent( + httpx.Response( + status_code=raw_response.status_code, + content=base64.b64decode(audio_data), + headers={"content-type": mime_type}, + ) + ) + response._hidden_params = {"audio_mime_type": mime_type} + return response diff --git a/litellm/main.py b/litellm/main.py index 0128e4defe5..55db92d44b3 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8235,6 +8235,7 @@ def speech( ) elif custom_llm_provider == "vertex_ai" or custom_llm_provider == "vertex_ai_beta": from litellm.llms.vertex_ai.text_to_speech.transformation import ( + VertexAILyriaTextToSpeechConfig, VertexAITextToSpeechConfig, ) @@ -8259,7 +8260,10 @@ def speech( # Vertex AI Text-to-Speech (Google Cloud TTS) if text_to_speech_provider_config is None: - text_to_speech_provider_config = VertexAITextToSpeechConfig() + if VertexAILyriaTextToSpeechConfig.is_lyria_model(model): + text_to_speech_provider_config = VertexAILyriaTextToSpeechConfig() + else: + text_to_speech_provider_config = VertexAITextToSpeechConfig() # Cast to specific Vertex AI config type to access dispatch method vertex_config: Final = cast(VertexAITextToSpeechConfig, text_to_speech_provider_config) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 1a1d92805e2..2955a0ffa4e 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45516,9 +45516,12 @@ "litellm_provider": "vertex_ai", "max_audio_length_hours": 0.009111111111111111, "max_audio_per_prompt": 4, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_second": 0.002, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1/audio/speech" + ], "supported_modalities": [ "text" ], @@ -45533,12 +45536,13 @@ "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_image": 0.04, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_endpoints": [ - "/v1beta/interactions" + "/v1beta/interactions", + "/v1/audio/speech" ], "supported_modalities": [ "text", @@ -45566,12 +45570,13 @@ "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_image": 0.08, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_endpoints": [ - "/v1beta/interactions" + "/v1beta/interactions", + "/v1/audio/speech" ], "supported_modalities": [ "text", diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py index 6b2d7763fa0..ab3b24f470f 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py @@ -368,14 +368,8 @@ class VertexPassthroughLoggingHandler: @staticmethod def _is_audio_predict_response(model: str, json_response: dict) -> bool: return ( - VertexPassthroughLoggingHandler._get_audio_prediction_count( - json_response=json_response - ) - > 0 - and VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost( - model=model - ) - is not None + VertexPassthroughLoggingHandler._get_audio_prediction_count(json_response=json_response) > 0 + and VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) is not None ) @staticmethod @@ -397,7 +391,7 @@ class VertexPassthroughLoggingHandler: return sum( 1 for prediction in predictions - if isinstance(prediction, dict) and prediction.get("audioContent") + if isinstance(prediction, dict) and (prediction.get("audioContent") or prediction.get("bytesBase64Encoded")) ) @staticmethod diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 27132c90e05..f11cbc92224 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11176,9 +11176,14 @@ async def audio_speech( upstream_content_type: Final = ( response.response.headers.get("content-type") if isinstance(response, HttpxBinaryResponseContent) else None ) - media_type: Final = resolve_speech_media_type( - upstream_content_type=upstream_content_type, - response_format=requested_format if isinstance(requested_format, str) else None, + hidden_audio_mime_type: Final = hidden_params.get("audio_mime_type") + media_type: Final = ( + hidden_audio_mime_type + if isinstance(hidden_audio_mime_type, str) + else resolve_speech_media_type( + upstream_content_type=upstream_content_type, + response_format=requested_format if isinstance(requested_format, str) else None, + ) ) return StreamingResponse( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index ee6f09e05dc..d2a09639362 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -310,6 +310,9 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): output_cost_per_video_per_second: float | None # only for vertex ai models output_cost_per_audio_per_second: float | None # only for vertex ai models output_cost_per_second: float | None # for OpenAI Speech models + audio_seconds_per_prediction: float | None + max_audio_length_hours: float | None + max_audio_per_prompt: int | None output_cost_per_second_1080p: ( float | None ) # video_generation tier: key output_cost_per_second_ (e.g. 1080p, 720p) @@ -333,6 +336,7 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): "image_generation", "chat", "audio_transcription", + "audio_speech", "responses", "ocr", "realtime", diff --git a/litellm/utils.py b/litellm/utils.py index ba456fc353b..7c8974906ed 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5880,6 +5880,9 @@ def _get_model_info_helper( "output_cost_per_token_above_512k_tokens", None ), output_cost_per_second=_model_info.get("output_cost_per_second", None), + audio_seconds_per_prediction=_model_info.get("audio_seconds_per_prediction", None), + max_audio_length_hours=_model_info.get("max_audio_length_hours", None), + max_audio_per_prompt=_model_info.get("max_audio_per_prompt", None), output_cost_per_second_1080p=_model_info.get("output_cost_per_second_1080p", None), output_cost_per_second_480p=_model_info.get("output_cost_per_second_480p", None), output_cost_per_second_4k=_model_info.get("output_cost_per_second_4k", None), @@ -9415,9 +9418,12 @@ class ProviderConfigManager: # mapping would drop response_format before the bridge sees it (LIT-6501) return None from litellm.llms.vertex_ai.text_to_speech.transformation import ( + VertexAILyriaTextToSpeechConfig, VertexAITextToSpeechConfig, ) + if VertexAILyriaTextToSpeechConfig.is_lyria_model(model): + return VertexAILyriaTextToSpeechConfig() return VertexAITextToSpeechConfig() elif litellm.LlmProviders.MINIMAX == provider: from litellm.llms.minimax.text_to_speech.transformation import ( diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 1a1d92805e2..2955a0ffa4e 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45516,9 +45516,12 @@ "litellm_provider": "vertex_ai", "max_audio_length_hours": 0.009111111111111111, "max_audio_per_prompt": 4, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_second": 0.002, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_endpoints": [ + "/v1/audio/speech" + ], "supported_modalities": [ "text" ], @@ -45533,12 +45536,13 @@ "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_image": 0.04, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_endpoints": [ - "/v1beta/interactions" + "/v1beta/interactions", + "/v1/audio/speech" ], "supported_modalities": [ "text", @@ -45566,12 +45570,13 @@ "max_input_tokens": 131072, "max_output_tokens": 8192, "max_tokens": 8192, - "mode": "chat", + "mode": "audio_speech", "output_cost_per_image": 0.08, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", "supported_endpoints": [ - "/v1beta/interactions" + "/v1beta/interactions", + "/v1/audio/speech" ], "supported_modalities": [ "text", diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py index 9f65fa09b1b..ccce19f1634 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py @@ -110,3 +110,36 @@ def test_audio_predict_response_uses_model_map_metadata( assert result["kwargs"]["model"] == "music-audio-preview" assert result["kwargs"]["response_cost"] == pytest.approx(6.0) assert logging_obj.model_call_details["response_cost"] == pytest.approx(6.0) + + +def test_audio_predict_response_supports_bytes_base64_encoded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setitem( + litellm.model_cost, + "vertex_ai/lyria-002", + { + "audio_seconds_per_prediction": 30, + "output_cost_per_second": 0.002, + }, + ) + logging_obj = MagicMock() + logging_obj.model_call_details = {} + response = httpx.Response( + status_code=200, + json={"predictions": [{"bytesBase64Encoded": "clip"}]}, + ) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route="/v1/projects/test/locations/us-central1/publishers/google/models/lyria-002:predict", + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"instances": [{"prompt": "ambient piano"}]}, + ) + + assert result["kwargs"]["response_cost"] == pytest.approx(0.06) + assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) diff --git a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py index fba337b5f2c..910bc977a79 100644 --- a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -4,11 +4,13 @@ from unittest.mock import MagicMock, Mock, patch import httpx import pytest - import litellm from litellm.llms.vertex_ai.text_to_speech.transformation import ( + VertexAILyriaTextToSpeechConfig, VertexAITextToSpeechConfig, ) +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager class TestVertexAITextToSpeechConfig: @@ -41,9 +43,7 @@ class TestVertexAITextToSpeechConfig: @patch.object(VertexAITextToSpeechConfig, "_ensure_access_token") @patch.object(VertexAITextToSpeechConfig, "_get_token_and_url") - def test_transform_text_to_speech_request_body( - self, mock_get_token, mock_ensure_token - ): + def test_transform_text_to_speech_request_body(self, mock_get_token, mock_ensure_token): """Test that transform_text_to_speech_request generates correct request body""" # Mock authentication mock_ensure_token.return_value = ("mock-token", "test-project") @@ -104,9 +104,7 @@ class TestVertexAITextToSpeechConfig: config = VertexAITextToSpeechConfig() # Test with a Chirp3 HD voice - voice_str, voice_dict = config._map_voice_to_vertex_format( - "en-US-Chirp3-HD-Charon" - ) + voice_str, voice_dict = config._map_voice_to_vertex_format("en-US-Chirp3-HD-Charon") assert voice_str == "en-US-Chirp3-HD-Charon" assert voice_dict is not None @@ -169,6 +167,284 @@ def test_transform_text_to_speech_response_leaves_unknown_bytes_unlabeled(): assert result.response.content == raw_pcm +class TestVertexAILyriaTextToSpeechConfig: + @pytest.mark.parametrize( + "model", + ["lyria-002", "vertex_ai/lyria-3-clip-preview", "lyria-3-pro-preview"], + ) + def test_provider_config_manager_selects_lyria_config(self, model): + config = ProviderConfigManager.get_provider_text_to_speech_config( + model=model, + provider=LlmProviders.VERTEX_AI, + ) + + assert isinstance(config, VertexAILyriaTextToSpeechConfig) + + def test_get_complete_url_for_lyria_2(self): + config = VertexAILyriaTextToSpeechConfig() + + url = config.get_complete_url( + model="lyria-002", + api_base=None, + litellm_params={ + "vertex_project": "music-project", + "vertex_location": "europe-west4", + }, + ) + + assert url == ( + "https://europe-west4-aiplatform.googleapis.com/v1/projects/music-project/" + "locations/europe-west4/publishers/google/models/lyria-002:predict" + ) + + def test_get_complete_url_for_lyria_3(self): + config = VertexAILyriaTextToSpeechConfig() + + url = config.get_complete_url( + model="lyria-3-pro-preview", + api_base=None, + litellm_params={"vertex_project": "music-project"}, + ) + + assert url == ("https://aiplatform.googleapis.com/v1beta1/projects/music-project/locations/global/interactions") + + @pytest.mark.parametrize( + ("model", "response_format", "expected_body"), + [ + ( + "lyria-002", + "wav", + { + "instances": [{"prompt": "A bright synth track"}], + "parameters": {"sample_count": 1}, + }, + ), + ( + "lyria-3-clip-preview", + "mp3", + { + "model": "lyria-3-clip-preview", + "input": "A bright synth track", + }, + ), + ( + "lyria-3-pro-preview", + "wav", + { + "model": "lyria-3-pro-preview", + "input": "A bright synth track", + "response_format": { + "type": "audio", + "mime_type": "audio/wav", + }, + }, + ), + ], + ) + @patch.object(VertexAILyriaTextToSpeechConfig, "_ensure_access_token") + def test_transform_request( + self, + mock_ensure_token, + model, + response_format, + expected_body, + ): + mock_ensure_token.return_value = ("mock-token", "music-project") + config = VertexAILyriaTextToSpeechConfig() + + request = config.transform_text_to_speech_request( + model=model, + input="A bright synth track", + voice="alloy", + optional_params={"response_format": response_format}, + litellm_params={"vertex_project": "music-project"}, + headers={}, + ) + + assert request["dict_body"] == expected_body + assert request["headers"]["Authorization"] == "Bearer mock-token" + assert request["headers"]["x-goog-user-project"] == "music-project" + + @pytest.mark.parametrize( + ("model", "response_json", "expected_audio", "expected_mime_type"), + [ + ( + "lyria-002", + { + "predictions": [ + { + "bytesBase64Encoded": "bHlyaWEtMi1hdWRpbw==", + } + ] + }, + b"lyria-2-audio", + "audio/wav", + ), + ( + "lyria-3-pro-preview", + { + "steps": [ + { + "type": "model_output", + "content": [ + {"type": "text", "text": "Generated lyrics"}, + { + "type": "audio", + "data": "bHlyaWEtMy1hdWRpbw==", + "mime_type": "audio/mpeg", + }, + ], + } + ] + }, + b"lyria-3-audio", + "audio/mpeg", + ), + ( + "lyria-3-clip-preview", + { + "outputs": [ + {"type": "text", "text": "Generated lyrics"}, + { + "type": "audio", + "data": "bHlyaWEtMy1hdWRpbw==", + "mime_type": "audio/mpeg", + }, + ] + }, + b"lyria-3-audio", + "audio/mpeg", + ), + ], + ) + def test_transform_response( + self, + model, + response_json, + expected_audio, + expected_mime_type, + ): + config = VertexAILyriaTextToSpeechConfig() + raw_response = httpx.Response(200, json=response_json) + + response = config.transform_text_to_speech_response( + model=model, + raw_response=raw_response, + logging_obj=MagicMock(), + ) + + assert response.content == expected_audio + assert response._hidden_params["audio_mime_type"] == expected_mime_type + + @pytest.mark.parametrize( + ("model", "response_format"), + [ + ("lyria-002", "mp3"), + ("lyria-3-clip-preview", "wav"), + ("lyria-3-pro-preview", "opus"), + ], + ) + def test_rejects_unsupported_response_format(self, model, response_format): + config = VertexAILyriaTextToSpeechConfig() + + with pytest.raises(litellm.UnsupportedParamsError): + config.map_openai_params( + model=model, + optional_params={"response_format": response_format}, + ) + + @pytest.mark.parametrize("param", ["speed", "instructions"]) + def test_rejects_unsupported_openai_params(self, param): + config = VertexAILyriaTextToSpeechConfig() + + with pytest.raises(litellm.UnsupportedParamsError): + config.map_openai_params( + model="lyria-3-pro-preview", + optional_params={param: "unsupported"}, + ) + + @pytest.mark.parametrize( + ("model", "response_format", "response_json", "expected_url", "expected_body"), + [ + ( + "lyria-002", + "wav", + { + "predictions": [ + { + "audioContent": "bHlyaWEtMi1hdWRpbw==", + "mimeType": "audio/wav", + } + ] + }, + "https://us-central1-aiplatform.googleapis.com/v1/projects/music-project/locations/us-central1/publishers/google/models/lyria-002:predict", + { + "instances": [{"prompt": "A bright synth track"}], + "parameters": {"sample_count": 1}, + }, + ), + ( + "lyria-3-pro-preview", + "mp3", + { + "steps": [ + { + "type": "model_output", + "content": [ + { + "type": "audio", + "data": "bHlyaWEtMy1hdWRpbw==", + "mime_type": "audio/mpeg", + } + ], + } + ] + }, + "https://aiplatform.googleapis.com/v1beta1/projects/music-project/locations/global/interactions", + { + "model": "lyria-3-pro-preview", + "input": "A bright synth track", + }, + ), + ], + ) + def test_litellm_speech_dispatches_to_lyria_api( + self, + model, + response_format, + response_json, + expected_url, + expected_body, + ): + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json.return_value = response_json + with ( + patch.object( + VertexAILyriaTextToSpeechConfig, + "_ensure_access_token", + return_value=("mock-token", "music-project"), + ), + patch( + "litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post", + return_value=mock_response, + ) as mock_post, + ): + response = litellm.speech( + model=f"vertex_ai/{model}", + input="A bright synth track", + voice="alloy", + response_format=response_format, + vertex_project="music-project", + vertex_location="us-central1", + ) + + assert response.content in {b"lyria-2-audio", b"lyria-3-audio"} + mock_post.assert_called_once() + assert mock_post.call_args.kwargs["url"] == expected_url + assert mock_post.call_args.kwargs["json"] == expected_body + + @patch("litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post") @patch.object(VertexAITextToSpeechConfig, "_ensure_access_token") @patch.object(VertexAITextToSpeechConfig, "_get_token_and_url") @@ -182,9 +458,7 @@ def test_litellm_speech_vertex_ai_chirp(mock_get_token, mock_ensure_token, mock_ # Mock HTTP response mock_response = Mock(spec=httpx.Response) - mock_response.content = ( - b'{"audioContent": "SGVsbG8gV29ybGQ="}' # base64 encoded "Hello World" - ) + mock_response.content = b'{"audioContent": "SGVsbG8gV29ybGQ="}' # base64 encoded "Hello World" mock_response.status_code = 200 mock_response.headers = {"content-type": "application/json"} mock_response.json.return_value = {"audioContent": "SGVsbG8gV29ybGQ="} @@ -203,9 +477,7 @@ def test_litellm_speech_vertex_ai_chirp(mock_get_token, mock_ensure_token, mock_ call_kwargs = mock_post.call_args.kwargs # Verify the URL is the Google Cloud TTS API - assert ( - call_kwargs["url"] == "https://texttospeech.googleapis.com/v1/text:synthesize" - ) + assert call_kwargs["url"] == "https://texttospeech.googleapis.com/v1/text:synthesize" # Verify request body structure assert "json" in call_kwargs diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 7c2174018e8..ffc71c76d03 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -146,6 +146,24 @@ def test_cost_calculator_with_response_cost_in_additional_headers(): assert result == 1000 +@pytest.mark.parametrize( + ("model", "expected_cost"), + [ + ("vertex_ai/lyria-002", 0.06), + ("vertex_ai/lyria-3-clip-preview", 0.04), + ("vertex_ai/lyria-3-pro-preview", 0.08), + ], +) +def test_vertex_lyria_speech_cost(model, expected_cost, _local_model_cost_map): + cost = completion_cost( + model=model, + prompt="A bright synth track", + call_type="speech", + ) + + assert cost == pytest.approx(expected_cost) + + def test_baseten_model_api_pricing_entries(_local_model_cost_map): expected_pricing = { diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 7bec649099a..1b0b27032ce 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -2855,15 +2855,25 @@ def test_vertex_ai_lyria_models_in_cost_map(): assert lyria_2["litellm_provider"] == "vertex_ai" assert clip["litellm_provider"] == "vertex_ai" assert pro["litellm_provider"] == "vertex_ai" + assert lyria_2["mode"] == "audio_speech" + assert clip["mode"] == "audio_speech" + assert pro["mode"] == "audio_speech" assert lyria_2["audio_seconds_per_prediction"] == 30 assert lyria_2["output_cost_per_second"] == 0.002 assert lyria_2["supported_modalities"] == ["text"] assert lyria_2["supported_output_modalities"] == ["audio"] assert lyria_2["supports_audio_output"] is True + assert lyria_2["supported_endpoints"] == ["/v1/audio/speech"] assert clip["output_cost_per_image"] == 0.04 assert pro["output_cost_per_image"] == 0.08 - assert clip["supported_endpoints"] == ["/v1beta/interactions"] - assert pro["supported_endpoints"] == ["/v1beta/interactions"] + assert clip["supported_endpoints"] == [ + "/v1beta/interactions", + "/v1/audio/speech", + ] + assert pro["supported_endpoints"] == [ + "/v1beta/interactions", + "/v1/audio/speech", + ] assert clip["supported_modalities"] == ["text", "image"] assert pro["supported_modalities"] == ["text", "image"] assert clip["supported_regions"] == ["global"] @@ -2873,7 +2883,6 @@ def test_vertex_ai_lyria_models_in_cost_map(): assert clip["supports_image_input"] is True assert pro["supports_image_input"] is True - def test_model_info_for_fireworks_short_form_models(): """ Test that fireworks_ai short-form model entries (fireworks_ai/) From f18cb0cdb48ce0338a30af940d557842eee4fb19 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 15 Jul 2026 20:28:00 -0500 Subject: [PATCH 058/410] fix(vertex): make Lyria routing and billing data-driven --- litellm/llms/vertex_ai/common_utils.py | 35 +++++++++++ .../text_to_speech/transformation.py | 36 ++++++----- ...odel_prices_and_context_window_backup.json | 19 +++++- litellm/types/utils.py | 2 + litellm/utils.py | 2 + model_prices_and_context_window.json | 19 +++++- .../text_to_speech/test_transformation.py | 62 +++++++++++++++++++ tests/test_litellm/test_utils.py | 17 +++++ 8 files changed, 172 insertions(+), 20 deletions(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index a36c920dda0..8a4c1e68623 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -1,9 +1,12 @@ import re from copy import deepcopy from enum import Enum +from functools import lru_cache from typing import Any, Final, Literal, cast, get_type_hints import httpx +from pydantic import TypeAdapter, ValidationError +from typing_extensions import NotRequired, TypedDict import litellm from litellm._logging import verbose_logger @@ -21,6 +24,38 @@ from litellm.types.utils import TokenCountResponse from litellm.utils import supports_response_schema, supports_system_messages +class VertexAILyriaModelInfo(TypedDict): + vertex_ai_audio_api: Literal["lyria_predict", "lyria_interactions"] + supported_audio_formats: tuple[Literal["mp3", "wav"], ...] + output_cost_per_image: NotRequired[float] + + +_VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER = TypeAdapter(VertexAILyriaModelInfo) + + +def _validate_vertex_ai_lyria_model_info(raw_model_info: object) -> VertexAILyriaModelInfo | None: + if raw_model_info is None: + return None + try: + return _VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER.validate_python(raw_model_info) + except ValidationError: + return None + + +@lru_cache(maxsize=32) +def _get_bundled_vertex_ai_lyria_model_info(model_key: str) -> VertexAILyriaModelInfo | None: + from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap + + bundled_model_info = GetModelCostMap.load_local_model_cost_map().get(model_key) + return _validate_vertex_ai_lyria_model_info(bundled_model_info) + + +def get_vertex_ai_lyria_model_info(model: str) -> VertexAILyriaModelInfo | None: + model_key = model if model.startswith("vertex_ai/") else f"vertex_ai/{model}" + runtime_model_info = _validate_vertex_ai_lyria_model_info(litellm.model_cost.get(model_key)) + return runtime_model_info or _get_bundled_vertex_ai_lyria_model_info(model_key) + + class VertexAIError(BaseLLMException): def __init__( self, diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index ad39aa35f8c..d56f151a8c2 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -21,6 +21,10 @@ from litellm.llms.base_llm.text_to_speech.transformation import ( BaseTextToSpeechConfig, TextToSpeechRequestData, ) +from litellm.llms.vertex_ai.common_utils import ( + VertexAILyriaModelInfo, + get_vertex_ai_lyria_model_info, +) from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.types.llms.vertex_ai import VERTEX_CREDENTIALS_TYPES from litellm.types.llms.vertex_ai_text_to_speech import ( @@ -476,15 +480,16 @@ class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): - LYRIA_MODELS = { - "lyria-002", - "lyria-3-clip-preview", - "lyria-3-pro-preview", - } - @classmethod def is_lyria_model(cls, model: str) -> bool: - return model.removeprefix("vertex_ai/") in cls.LYRIA_MODELS + return get_vertex_ai_lyria_model_info(model=model) is not None + + @staticmethod + def _get_model_info(model: str) -> VertexAILyriaModelInfo: + model_info = get_vertex_ai_lyria_model_info(model=model) + if model_info is None: + raise ValueError(f"Vertex AI model {model!r} does not declare a Lyria audio API") + return model_info def get_supported_openai_params(self, model: str) -> list: return ["response_format"] @@ -499,6 +504,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): ) -> tuple[str | None, dict]: mapped_params = dict(optional_params) base_model = model.removeprefix("vertex_ai/") + model_info = self._get_model_info(model=model) unsupported_params = [param for param in ("speed", "instructions") if mapped_params.get(param) is not None] if unsupported_params: if drop_params or litellm.drop_params: @@ -514,9 +520,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): ), ) response_format = mapped_params.get("response_format") - supported_formats = ( - {"wav"} if base_model == "lyria-002" else {"mp3", "wav"} if base_model == "lyria-3-pro-preview" else {"mp3"} - ) + supported_formats = frozenset(model_info["supported_audio_formats"]) if response_format is not None and response_format not in supported_formats: if drop_params or litellm.drop_params: mapped_params.pop("response_format", None) @@ -538,6 +542,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): litellm_params: dict, ) -> str: base_model = model.removeprefix("vertex_ai/") + model_info = self._get_model_info(model=model) project = self.safe_get_vertex_ai_project(litellm_params) if project is None: _, project = self._ensure_access_token( @@ -545,7 +550,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): project_id=None, custom_llm_provider="vertex_ai", ) - if base_model.startswith("lyria-3-"): + if model_info["vertex_ai_audio_api"] == "lyria_interactions": from litellm.llms.vertex_ai.interactions.transformation import ( VertexAIInteractionsConfig, ) @@ -581,7 +586,8 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): } ) base_model = model.removeprefix("vertex_ai/") - if base_model == "lyria-002": + model_info = self._get_model_info(model=model) + if model_info["vertex_ai_audio_api"] == "lyria_predict": request_body = { "instances": [{"prompt": input}], "parameters": {"sample_count": 1}, @@ -605,9 +611,10 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): response_json = raw_response.json() base_model = model.removeprefix("vertex_ai/") + model_info = self._get_model_info(model=model) audio_data: str | None = None mime_type: str | None = None - if base_model == "lyria-002": + if model_info["vertex_ai_audio_api"] == "lyria_predict": predictions = response_json.get("predictions") or [] if predictions: audio_data = predictions[0].get("audioContent") or predictions[0].get("bytesBase64Encoded") @@ -621,7 +628,8 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): mime_type = content.get("mime_type") if audio_data is None: raise ValueError(f"No generated audio found in Vertex AI {base_model} response") - mime_type = mime_type or ("audio/wav" if base_model == "lyria-002" else "audio/mpeg") + default_format = model_info["supported_audio_formats"][0] + mime_type = mime_type or {"mp3": "audio/mpeg", "wav": "audio/wav"}[default_format] response = HttpxBinaryResponseContent( httpx.Response( status_code=raw_response.status_code, diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 2955a0ffa4e..9986d5056d1 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -45519,6 +45519,9 @@ "mode": "audio_speech", "output_cost_per_second": 0.002, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "wav" + ], "supported_endpoints": [ "/v1/audio/speech" ], @@ -45528,7 +45531,8 @@ "supported_output_modalities": [ "audio" ], - "supports_audio_output": true + "supports_audio_output": true, + "vertex_ai_audio_api": "lyria_predict" }, "vertex_ai/lyria-3-clip-preview": { "input_cost_per_token": 0, @@ -45540,6 +45544,9 @@ "output_cost_per_image": 0.04, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "mp3" + ], "supported_endpoints": [ "/v1beta/interactions", "/v1/audio/speech" @@ -45562,7 +45569,8 @@ "supports_response_schema": false, "supports_system_messages": false, "supports_vision": true, - "supports_web_search": false + "supports_web_search": false, + "vertex_ai_audio_api": "lyria_interactions" }, "vertex_ai/lyria-3-pro-preview": { "input_cost_per_token": 0, @@ -45574,6 +45582,10 @@ "output_cost_per_image": 0.08, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "mp3", + "wav" + ], "supported_endpoints": [ "/v1beta/interactions", "/v1/audio/speech" @@ -45596,7 +45608,8 @@ "supports_response_schema": false, "supports_system_messages": false, "supports_vision": true, - "supports_web_search": false + "supports_web_search": false, + "vertex_ai_audio_api": "lyria_interactions" }, "vertex_ai/meta/llama-3.1-405b-instruct-maas": { "input_cost_per_token": 5e-06, diff --git a/litellm/types/utils.py b/litellm/types/utils.py index d2a09639362..bb0485be7c1 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -168,6 +168,8 @@ class ProviderSpecificModelInfo(TypedDict, total=False): default_reasoning_effort: ReadOnly[Literal["none", "minimal", "low", "medium", "high", "xhigh"] | None] supports_output_config: bool | None supports_image_size: bool | None + supported_audio_formats: list[Literal["mp3", "wav"]] | None + vertex_ai_audio_api: Literal["lyria_predict", "lyria_interactions"] | None bedrock_output_config_effort_ceiling: Literal["low", "medium", "high", "max", "xhigh"] | None bedrock_converse_supports_strict_tools: bool | None diff --git a/litellm/utils.py b/litellm/utils.py index 7c8974906ed..d6a68b3ce6e 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -5940,6 +5940,8 @@ def _get_model_info_helper( provider_specific_entry=_model_info.get("provider_specific_entry", None), uses_embed_content=_model_info.get("uses_embed_content", None), supports_image_size=_model_info.get("supports_image_size", None), + supported_audio_formats=_model_info.get("supported_audio_formats", None), + vertex_ai_audio_api=_model_info.get("vertex_ai_audio_api", None), ) for cost_key, cost_value in _model_info.items(): if cost_key not in returned_model_info and _ABOVE_THRESHOLD_COST_KEY.search(cost_key) is not None: diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 2955a0ffa4e..9986d5056d1 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -45519,6 +45519,9 @@ "mode": "audio_speech", "output_cost_per_second": 0.002, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "wav" + ], "supported_endpoints": [ "/v1/audio/speech" ], @@ -45528,7 +45531,8 @@ "supported_output_modalities": [ "audio" ], - "supports_audio_output": true + "supports_audio_output": true, + "vertex_ai_audio_api": "lyria_predict" }, "vertex_ai/lyria-3-clip-preview": { "input_cost_per_token": 0, @@ -45540,6 +45544,9 @@ "output_cost_per_image": 0.04, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "mp3" + ], "supported_endpoints": [ "/v1beta/interactions", "/v1/audio/speech" @@ -45562,7 +45569,8 @@ "supports_response_schema": false, "supports_system_messages": false, "supports_vision": true, - "supports_web_search": false + "supports_web_search": false, + "vertex_ai_audio_api": "lyria_interactions" }, "vertex_ai/lyria-3-pro-preview": { "input_cost_per_token": 0, @@ -45574,6 +45582,10 @@ "output_cost_per_image": 0.08, "output_cost_per_token": 0, "source": "https://cloud.google.com/gemini-enterprise-agent-platform/generative-ai/pricing#lyria", + "supported_audio_formats": [ + "mp3", + "wav" + ], "supported_endpoints": [ "/v1beta/interactions", "/v1/audio/speech" @@ -45596,7 +45608,8 @@ "supports_response_schema": false, "supports_system_messages": false, "supports_vision": true, - "supports_web_search": false + "supports_web_search": false, + "vertex_ai_audio_api": "lyria_interactions" }, "vertex_ai/meta/llama-3.1-405b-instruct-maas": { "input_cost_per_token": 5e-06, diff --git a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py index 910bc977a79..a1bb203e67e 100644 --- a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -180,6 +180,68 @@ class TestVertexAILyriaTextToSpeechConfig: assert isinstance(config, VertexAILyriaTextToSpeechConfig) + @pytest.mark.parametrize( + ("model", "vertex_ai_audio_api", "supported_audio_formats", "expected_url"), + [ + ( + "future-lyria-predict", + "lyria_predict", + ["wav"], + "https://us-central1-aiplatform.googleapis.com/v1/projects/music-project/locations/" + "us-central1/publishers/google/models/future-lyria-predict:predict", + ), + ( + "future-music-interactions", + "lyria_interactions", + ["mp3", "wav"], + "https://aiplatform.googleapis.com/v1beta1/projects/music-project/locations/global/interactions", + ), + ], + ) + def test_dispatches_from_model_metadata( + self, + monkeypatch, + model, + vertex_ai_audio_api, + supported_audio_formats, + expected_url, + ): + monkeypatch.setitem( + litellm.model_cost, + f"vertex_ai/{model}", + { + "vertex_ai_audio_api": vertex_ai_audio_api, + "supported_audio_formats": supported_audio_formats, + }, + ) + + config = ProviderConfigManager.get_provider_text_to_speech_config( + model=model, + provider=LlmProviders.VERTEX_AI, + ) + + assert isinstance(config, VertexAILyriaTextToSpeechConfig) + assert ( + config.get_complete_url( + model=model, + api_base=None, + litellm_params={ + "vertex_project": "music-project", + "vertex_location": "us-central1", + }, + ) + == expected_url + ) + + def test_vertex_chirp_does_not_select_lyria_config(self): + config = ProviderConfigManager.get_provider_text_to_speech_config( + model="chirp", + provider=LlmProviders.VERTEX_AI, + ) + + assert isinstance(config, VertexAITextToSpeechConfig) + assert not isinstance(config, VertexAILyriaTextToSpeechConfig) + def test_get_complete_url_for_lyria_2(self): config = VertexAILyriaTextToSpeechConfig() diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py index 1b0b27032ce..a179a82fcb2 100644 --- a/tests/test_litellm/test_utils.py +++ b/tests/test_litellm/test_utils.py @@ -1048,6 +1048,17 @@ def test_aaamodel_prices_and_context_window_json_is_valid(): "supports_sampling_params": {"type": "boolean"}, "supports_output_config": {"type": "boolean"}, "supports_speed": {"type": "boolean"}, + "supported_audio_formats": { + "type": "array", + "items": { + "type": "string", + "enum": ["mp3", "wav"], + }, + }, + "vertex_ai_audio_api": { + "type": "string", + "enum": ["lyria_predict", "lyria_interactions"], + }, "bedrock_output_config_effort_ceiling": { "type": "string", "enum": ["low", "medium", "high", "max", "xhigh"], @@ -2863,9 +2874,15 @@ def test_vertex_ai_lyria_models_in_cost_map(): assert lyria_2["supported_modalities"] == ["text"] assert lyria_2["supported_output_modalities"] == ["audio"] assert lyria_2["supports_audio_output"] is True + assert lyria_2["supported_audio_formats"] == ["wav"] + assert lyria_2["vertex_ai_audio_api"] == "lyria_predict" assert lyria_2["supported_endpoints"] == ["/v1/audio/speech"] assert clip["output_cost_per_image"] == 0.04 assert pro["output_cost_per_image"] == 0.08 + assert clip["supported_audio_formats"] == ["mp3"] + assert pro["supported_audio_formats"] == ["mp3", "wav"] + assert clip["vertex_ai_audio_api"] == "lyria_interactions" + assert pro["vertex_ai_audio_api"] == "lyria_interactions" assert clip["supported_endpoints"] == [ "/v1beta/interactions", "/v1/audio/speech", From e9e2fbb4385e412273ce03c6e0038d013316ce8a Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 12 Aug 2026 12:31:44 -0500 Subject: [PATCH 059/410] style(vertex-ai): modernize Lyria tests --- .../llms/vertex_ai/test_vertex_passthrough_logging_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py index ccce19f1634..1e8d569d3d1 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py @@ -2,9 +2,9 @@ from datetime import datetime from unittest.mock import MagicMock import httpx -import litellm import pytest +import litellm from litellm.proxy.pass_through_endpoints.llm_provider_handlers.vertex_passthrough_logging_handler import ( VertexPassthroughLoggingHandler, ) From bd5123564c66d1eb68c253ae6d2405c1e383104c Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 12 Aug 2026 12:56:06 -0500 Subject: [PATCH 060/410] fix(vertex-ai): classify Lyria model metadata --- ci_cd/generate_model_prices_schema.py | 21 ++++++++++++ .../text_to_speech/transformation.py | 2 +- .../vertex_passthrough_logging_handler.py | 3 +- model_prices_and_context_window.schema.json | 33 +++++++++++++++++++ 4 files changed, 56 insertions(+), 3 deletions(-) diff --git a/ci_cd/generate_model_prices_schema.py b/ci_cd/generate_model_prices_schema.py index 57cc742d5c4..ee0dad25c81 100644 --- a/ci_cd/generate_model_prices_schema.py +++ b/ci_cd/generate_model_prices_schema.py @@ -58,6 +58,11 @@ OBJECT_KEYS: dict[str, JsonSchema] = { } ARRAY_KEYS: dict[str, JsonSchema] = { + "supported_audio_formats": { + "type": "array", + "description": "Audio container formats the model can return.", + "items": {"type": "string", "enum": ["mp3", "wav"]}, + }, "supported_endpoints": { "type": "array", "description": "OpenAI-style API routes this model can be called through, e.g. /v1/chat/completions.", @@ -116,6 +121,10 @@ ARRAY_KEYS: dict[str, JsonSchema] = { } INTEGER_KEYS: dict[str, JsonSchema] = { + "max_audio_per_prompt": { + **NONNEG_INTEGER, + "description": "Maximum number of audio outputs accepted or generated per prompt.", + }, "max_tokens": { **NONNEG_INTEGER, "description": "Legacy field: max output tokens if the provider specifies it, else max input tokens.", @@ -141,6 +150,14 @@ INTEGER_KEYS: dict[str, JsonSchema] = { } NUMBER_KEYS: dict[str, JsonSchema] = { + "audio_seconds_per_prediction": { + **NONNEG_NUMBER, + "description": "Audio duration, in seconds, produced by one prediction.", + }, + "max_audio_length_hours": { + **NONNEG_NUMBER, + "description": "Maximum generated audio duration, expressed in hours.", + }, "regional_processing_uplift_multiplier_eu": { "type": "number", "minimum": 1, @@ -231,6 +248,10 @@ def string_key_schemas(modes: tuple) -> dict[str, JsonSchema]: }, "comment": STRING, "audio_transcription_config": STRING, + "vertex_ai_audio_api": { + "type": "string", + "enum": ["lyria_predict", "lyria_interactions"], + }, } diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index d56f151a8c2..373f6c28f9e 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -500,7 +500,7 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): optional_params: dict, voice: str | dict | None = None, drop_params: bool = False, - kwargs: dict = {}, + kwargs: dict | None = None, ) -> tuple[str | None, dict]: mapped_params = dict(optional_params) base_model = model.removeprefix("vertex_ai/") diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py index ab3b24f470f..4d348b51055 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py @@ -343,8 +343,7 @@ class VertexPassthroughLoggingHandler: json_response=json_response ) response_cost: Final = ( - VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) - or 0.0 + VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) or 0.0 ) * prediction_count logging_obj.model = model diff --git a/model_prices_and_context_window.schema.json b/model_prices_and_context_window.schema.json index 9e370e5406a..58ca91f3977 100644 --- a/model_prices_and_context_window.schema.json +++ b/model_prices_and_context_window.schema.json @@ -53,6 +53,11 @@ "type": "number", "minimum": 0 }, + "audio_seconds_per_prediction": { + "type": "number", + "minimum": 0, + "description": "Audio duration, in seconds, produced by one prediction." + }, "audio_transcription_config": { "type": "string" }, @@ -363,6 +368,16 @@ "type": "string", "description": "LiteLLM provider slug; one of https://docs.litellm.ai/docs/providers." }, + "max_audio_length_hours": { + "type": "number", + "minimum": 0, + "description": "Maximum generated audio duration, expressed in hours." + }, + "max_audio_per_prompt": { + "type": "integer", + "minimum": 0, + "description": "Maximum number of audio outputs accepted or generated per prompt." + }, "max_input_tokens": { "type": "integer", "minimum": 0, @@ -603,6 +618,17 @@ "type": "string", "description": "URL of the provider pricing/model page this entry was taken from." }, + "supported_audio_formats": { + "type": "array", + "description": "Audio container formats the model can return.", + "items": { + "type": "string", + "enum": [ + "mp3", + "wav" + ] + } + }, "supported_endpoints": { "type": "array", "description": "OpenAI-style API routes this model can be called through, e.g. /v1/chat/completions.", @@ -826,6 +852,13 @@ "uses_embed_content": { "type": "boolean" }, + "vertex_ai_audio_api": { + "type": "string", + "enum": [ + "lyria_predict", + "lyria_interactions" + ] + }, "web_search_billing_unit": { "type": "string", "description": "Whether web search is billed per query or per prompt.", From 6ec53f284617cc52adfb78f5c8e0b03b5a3c125e Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Wed, 12 Aug 2026 13:33:33 -0500 Subject: [PATCH 061/410] style(vertex-ai): satisfy Lyria quality gates --- litellm/cost_calculator.py | 10 +- litellm/llms/vertex_ai/common_utils.py | 16 +- .../llms/vertex_ai/interactions/__init__.py | 2 +- .../text_to_speech/transformation.py | 147 +++++++++++------- litellm/main.py | 8 +- .../vertex_passthrough_logging_handler.py | 49 ++++-- litellm/types/utils.py | 10 +- 7 files changed, 153 insertions(+), 89 deletions(-) diff --git a/litellm/cost_calculator.py b/litellm/cost_calculator.py index 2bac5e234bc..5dcbb1d5f37 100644 --- a/litellm/cost_calculator.py +++ b/litellm/cost_calculator.py @@ -498,16 +498,14 @@ def cost_per_token( speech_model_info = litellm.get_model_info(model=model_without_prefix, custom_llm_provider=custom_llm_provider) prompt_cost: float = 0.0 completion_cost: float = 0.0 - if not speech_model_info.get("input_cost_per_character") and not speech_model_info.get( - "input_cost_per_token" - ): + if not speech_model_info.get("input_cost_per_character") and not speech_model_info.get("input_cost_per_token"): output_cost_per_generation: Final = speech_model_info.get("output_cost_per_image") - output_cost_per_second: Final = speech_model_info.get("output_cost_per_second") + speech_output_cost_per_second: Final = speech_model_info.get("output_cost_per_second") audio_seconds_per_prediction: Final = speech_model_info.get("audio_seconds_per_prediction") if output_cost_per_generation is not None: return prompt_cost, float(output_cost_per_generation) - if output_cost_per_second is not None and audio_seconds_per_prediction is not None: - return prompt_cost, float(output_cost_per_second) * float(audio_seconds_per_prediction) + if speech_output_cost_per_second is not None and audio_seconds_per_prediction is not None: + return prompt_cost, float(speech_output_cost_per_second) * float(audio_seconds_per_prediction) cost_metric: Final = select_cost_metric_for_model(speech_model_info) if cost_metric == "cost_per_character": if prompt_characters is None: diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 8a4c1e68623..8885d19c1c0 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -6,7 +6,7 @@ from typing import Any, Final, Literal, cast, get_type_hints import httpx from pydantic import TypeAdapter, ValidationError -from typing_extensions import NotRequired, TypedDict +from typing_extensions import NotRequired, ReadOnly, TypedDict import litellm from litellm._logging import verbose_logger @@ -25,12 +25,12 @@ from litellm.utils import supports_response_schema, supports_system_messages class VertexAILyriaModelInfo(TypedDict): - vertex_ai_audio_api: Literal["lyria_predict", "lyria_interactions"] - supported_audio_formats: tuple[Literal["mp3", "wav"], ...] - output_cost_per_image: NotRequired[float] + vertex_ai_audio_api: ReadOnly[Literal["lyria_predict", "lyria_interactions"]] + supported_audio_formats: ReadOnly[tuple[Literal["mp3", "wav"], ...]] + output_cost_per_image: NotRequired[ReadOnly[float]] -_VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER = TypeAdapter(VertexAILyriaModelInfo) +_VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER: Final = TypeAdapter(VertexAILyriaModelInfo) def _validate_vertex_ai_lyria_model_info(raw_model_info: object) -> VertexAILyriaModelInfo | None: @@ -46,13 +46,13 @@ def _validate_vertex_ai_lyria_model_info(raw_model_info: object) -> VertexAILyri def _get_bundled_vertex_ai_lyria_model_info(model_key: str) -> VertexAILyriaModelInfo | None: from litellm.litellm_core_utils.get_model_cost_map import GetModelCostMap - bundled_model_info = GetModelCostMap.load_local_model_cost_map().get(model_key) + bundled_model_info: Final = GetModelCostMap.load_local_model_cost_map().get(model_key) return _validate_vertex_ai_lyria_model_info(bundled_model_info) def get_vertex_ai_lyria_model_info(model: str) -> VertexAILyriaModelInfo | None: - model_key = model if model.startswith("vertex_ai/") else f"vertex_ai/{model}" - runtime_model_info = _validate_vertex_ai_lyria_model_info(litellm.model_cost.get(model_key)) + model_key: Final = model if model.startswith("vertex_ai/") else f"vertex_ai/{model}" + runtime_model_info: Final = _validate_vertex_ai_lyria_model_info(litellm.model_cost.get(model_key)) return runtime_model_info or _get_bundled_vertex_ai_lyria_model_info(model_key) diff --git a/litellm/llms/vertex_ai/interactions/__init__.py b/litellm/llms/vertex_ai/interactions/__init__.py index 3e2309d21ef..f6f86f65e87 100644 --- a/litellm/llms/vertex_ai/interactions/__init__.py +++ b/litellm/llms/vertex_ai/interactions/__init__.py @@ -1,3 +1,3 @@ from .transformation import VertexAIInteractionsConfig -__all__ = ["VertexAIInteractionsConfig"] +__all__ = ("VertexAIInteractionsConfig",) diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 373f6c28f9e..81f1cbd5157 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -8,7 +8,7 @@ Reference: https://cloud.google.com/text-to-speech/docs/reference/rest/v1/text/s import base64 from collections.abc import Coroutine from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Union +from typing import TYPE_CHECKING, Any, Final, TypeAlias, Union import httpx @@ -40,6 +40,10 @@ else: LiteLLMLoggingObj = Any HttpxBinaryResponseContent = Any +_LyriaVoice: TypeAlias = ( + str | dict | None +) # mutable-ok: inherited interface supports structured provider voice dictionaries + class VertexAITextToSpeechConfig(BaseTextToSpeechConfig, VertexBase): """ @@ -486,26 +490,34 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): @staticmethod def _get_model_info(model: str) -> VertexAILyriaModelInfo: - model_info = get_vertex_ai_lyria_model_info(model=model) + model_info: Final = get_vertex_ai_lyria_model_info(model=model) if model_info is None: raise ValueError(f"Vertex AI model {model!r} does not declare a Lyria audio API") return model_info - def get_supported_openai_params(self, model: str) -> list: - return ["response_format"] + def get_supported_openai_params( + self, model: str + ) -> list: # mutable-ok: inherited provider interface returns a concrete parameter list + return [ # mutable-ok: inherited provider interface requires a concrete parameter list + "response_format" + ] def map_openai_params( self, model: str, - optional_params: dict, - voice: str | dict | None = None, + optional_params: dict, # mutable-ok: inherited provider interface accepts a concrete parameter dictionary + voice: _LyriaVoice = None, drop_params: bool = False, - kwargs: dict | None = None, - ) -> tuple[str | None, dict]: - mapped_params = dict(optional_params) - base_model = model.removeprefix("vertex_ai/") - model_info = self._get_model_info(model=model) - unsupported_params = [param for param in ("speed", "instructions") if mapped_params.get(param) is not None] + kwargs: dict | None = None, # mutable-ok: inherited provider interface accepts a concrete keyword dictionary + ) -> tuple[str | None, dict]: # mutable-ok: inherited provider interface returns concrete mapped parameters + mapped_params: Final = dict( # mutable-ok: mapping drops unsupported parameters before provider dispatch + optional_params + ) + base_model: Final = model.removeprefix("vertex_ai/") + model_info: Final = self._get_model_info(model=model) + unsupported_params: Final = tuple( + param for param in ("speed", "instructions") if mapped_params.get(param) is not None + ) if unsupported_params: if drop_params or litellm.drop_params: for param in unsupported_params: @@ -519,8 +531,8 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): "from the call, set `litellm.drop_params = True`" ), ) - response_format = mapped_params.get("response_format") - supported_formats = frozenset(model_info["supported_audio_formats"]) + response_format: Final = mapped_params.get("response_format") + supported_formats: Final = frozenset(model_info["supported_audio_formats"]) if response_format is not None and response_format not in supported_formats: if drop_params or litellm.drop_params: mapped_params.pop("response_format", None) @@ -539,17 +551,20 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): self, model: str, api_base: str | None, - litellm_params: dict, + litellm_params: dict, # mutable-ok: inherited provider interface accepts concrete LiteLLM parameters ) -> str: - base_model = model.removeprefix("vertex_ai/") - model_info = self._get_model_info(model=model) - project = self.safe_get_vertex_ai_project(litellm_params) - if project is None: - _, project = self._ensure_access_token( + base_model: Final = model.removeprefix("vertex_ai/") + model_info: Final = self._get_model_info(model=model) + configured_project: Final = self.safe_get_vertex_ai_project(litellm_params) + project: Final = ( + self._ensure_access_token( credentials=self.safe_get_vertex_ai_credentials(litellm_params), project_id=None, custom_llm_provider="vertex_ai", - ) + )[1] + if configured_project is None + else configured_project + ) if model_info["vertex_ai_audio_api"] == "lyria_interactions": from litellm.llms.vertex_ai.interactions.transformation import ( VertexAIInteractionsConfig, @@ -558,10 +573,13 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): return VertexAIInteractionsConfig().get_complete_url( api_base=api_base, model=base_model, - litellm_params={**litellm_params, "vertex_project": project}, + litellm_params={ # mutable-ok: interactions dispatch expects a concrete parameter dictionary + **litellm_params, + "vertex_project": project, + }, ) - location = self.safe_get_vertex_ai_location(litellm_params) or self.get_default_vertex_location() - base_url = self.get_api_base(api_base=api_base, vertex_location=location).rstrip("/") + location: Final = self.safe_get_vertex_ai_location(litellm_params) or self.get_default_vertex_location() + base_url: Final = self.get_api_base(api_base=api_base, vertex_location=location).rstrip("/") return f"{base_url}/v1/projects/{project}/locations/{location}/publishers/google/models/{base_model}:predict" def transform_text_to_speech_request( @@ -569,9 +587,9 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): model: str, input: str, voice: str | None, - optional_params: dict, - litellm_params: dict, - headers: dict, + optional_params: dict, # mutable-ok: inherited provider interface accepts concrete mapped parameters + litellm_params: dict, # mutable-ok: inherited provider interface accepts concrete LiteLLM parameters + headers: dict, # mutable-ok: inherited provider interface accepts and updates concrete HTTP headers ) -> TextToSpeechRequestData: access_token, project = self._ensure_access_token( credentials=self.safe_get_vertex_ai_credentials(litellm_params), @@ -579,23 +597,32 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): custom_llm_provider="vertex_ai", ) headers.update( - { + { # mutable-ok: HTTP dispatch requires a concrete header dictionary "Authorization": f"Bearer {access_token}", "x-goog-user-project": project, "Content-Type": "application/json", } ) - base_model = model.removeprefix("vertex_ai/") - model_info = self._get_model_info(model=model) + base_model: Final = model.removeprefix("vertex_ai/") + model_info: Final = self._get_model_info(model=model) if model_info["vertex_ai_audio_api"] == "lyria_predict": - request_body = { - "instances": [{"prompt": input}], - "parameters": {"sample_count": 1}, + request_body = { # mutable-ok: predict dispatch requires a concrete provider request dictionary; rebind-ok: exactly one provider API shape initializes the request + "instances": [ # mutable-ok: predict dispatch requires a concrete instances list + {"prompt": input} # mutable-ok: predict dispatch requires a concrete instance dictionary + ], + "parameters": { # mutable-ok: predict dispatch requires a concrete parameters dictionary + "sample_count": 1 + }, } else: - request_body = {"model": base_model, "input": input} + request_body = { # mutable-ok: interactions dispatch requires a concrete provider request dictionary; rebind-ok: exactly one provider API shape initializes the request + "model": base_model, + "input": input, + } if optional_params.get("response_format") == "wav": - request_body["response_format"] = { + request_body[ + "response_format" + ] = { # mutable-ok: interactions dispatch requires a nested response-format dictionary "type": "audio", "mime_type": "audio/wav", } @@ -609,33 +636,49 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): ) -> "HttpxBinaryResponseContent": from litellm.types.llms.openai import HttpxBinaryResponseContent - response_json = raw_response.json() - base_model = model.removeprefix("vertex_ai/") - model_info = self._get_model_info(model=model) - audio_data: str | None = None - mime_type: str | None = None + response_json: Final = raw_response.json() + base_model: Final = model.removeprefix("vertex_ai/") + model_info: Final = self._get_model_info(model=model) + audio_data: str | None = None # rebind-ok: response parsing discovers audio data in provider-specific shapes + mime_type: str | None = None # rebind-ok: response parsing discovers the MIME type beside the audio payload if model_info["vertex_ai_audio_api"] == "lyria_predict": - predictions = response_json.get("predictions") or [] + predictions: Final = response_json.get("predictions") or () if predictions: - audio_data = predictions[0].get("audioContent") or predictions[0].get("bytesBase64Encoded") - mime_type = predictions[0].get("mimeType") + audio_data = predictions[0].get("audioContent") or predictions[0].get( + "bytesBase64Encoded" + ) # rebind-ok: predict response supplies the generated audio value + mime_type = predictions[0].get("mimeType") # rebind-ok: predict response supplies its audio MIME type else: - for step in response_json.get("steps") or response_json.get("outputs") or []: - content_items = step.get("content") or [] if step.get("type") == "model_output" else [step] + for step in response_json.get("steps") or response_json.get("outputs") or (): + content_items = step.get("content") or () if step.get("type") == "model_output" else (step,) for content in content_items: if content.get("type") == "audio" and content.get("data"): - audio_data = content["data"] - mime_type = content.get("mime_type") + audio_data = content[ + "data" + ] # rebind-ok: interactions response supplies the generated audio value + mime_type = content.get( + "mime_type" + ) # rebind-ok: interactions response supplies its audio MIME type if audio_data is None: raise ValueError(f"No generated audio found in Vertex AI {base_model} response") - default_format = model_info["supported_audio_formats"][0] - mime_type = mime_type or {"mp3": "audio/mpeg", "wav": "audio/wav"}[default_format] - response = HttpxBinaryResponseContent( + default_format: Final = model_info["supported_audio_formats"][0] + mime_type = ( + mime_type + or { # mutable-ok: short-lived lookup selects the default response MIME type; rebind-ok: absent provider MIME type falls back to model metadata + "mp3": "audio/mpeg", + "wav": "audio/wav", + }[default_format] + ) + response: Final = HttpxBinaryResponseContent( httpx.Response( status_code=raw_response.status_code, content=base64.b64decode(audio_data), - headers={"content-type": mime_type}, + headers={ # mutable-ok: httpx requires a concrete response header dictionary + "content-type": mime_type + }, ) ) - response._hidden_params = {"audio_mime_type": mime_type} + response._hidden_params = { # mutable-ok: response metadata is a concrete dictionary + "audio_mime_type": mime_type + } return response diff --git a/litellm/main.py b/litellm/main.py index 55db92d44b3..040af897256 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -8261,9 +8261,13 @@ def speech( # Vertex AI Text-to-Speech (Google Cloud TTS) if text_to_speech_provider_config is None: if VertexAILyriaTextToSpeechConfig.is_lyria_model(model): - text_to_speech_provider_config = VertexAILyriaTextToSpeechConfig() + text_to_speech_provider_config = ( + VertexAILyriaTextToSpeechConfig() + ) # rebind-ok: model metadata selects the Lyria provider implementation else: - text_to_speech_provider_config = VertexAITextToSpeechConfig() + text_to_speech_provider_config = ( + VertexAITextToSpeechConfig() + ) # rebind-ok: non-Lyria Vertex models use the standard TTS implementation # Cast to specific Vertex AI config type to access dispatch method vertex_config: Final = cast(VertexAITextToSpeechConfig, text_to_speech_provider_config) diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py index 4d348b51055..53df964e541 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py @@ -334,10 +334,10 @@ class VertexPassthroughLoggingHandler: @staticmethod def _handle_audio_predict_response( - json_response: dict, + json_response: dict, # mutable-ok: passthrough logging receives the decoded provider response dictionary logging_obj: LiteLLMLoggingObj, model: str, - kwargs: dict, + kwargs: dict, # mutable-ok: passthrough logging enriches the shared callback metadata dictionary ) -> PassThroughEndpointLoggingTypedDict: prediction_count: Final = VertexPassthroughLoggingHandler._get_audio_prediction_count( json_response=json_response @@ -346,26 +346,41 @@ class VertexPassthroughLoggingHandler: VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) or 0.0 ) * prediction_count - logging_obj.model = model - logging_obj.model_call_details["model"] = model - logging_obj.model_call_details["custom_llm_provider"] = "vertex_ai" - logging_obj.custom_llm_provider = "vertex_ai" - logging_obj.model_call_details["response_cost"] = response_cost + logging_obj.model = model # rebind-ok: passthrough attribution records the resolved Vertex model + logging_obj.model_call_details[ # rebind-ok: passthrough attribution enriches callback metadata + "model" + ] = model + logging_obj.model_call_details[ # rebind-ok: passthrough attribution enriches callback metadata + "custom_llm_provider" + ] = "vertex_ai" + logging_obj.custom_llm_provider = ( # rebind-ok: attribution records the resolved provider + "vertex_ai" + ) + logging_obj.model_call_details[ # rebind-ok: passthrough attribution enriches callback metadata + "response_cost" + ] = response_cost - kwargs["response_cost"] = response_cost - kwargs["model"] = model - kwargs["custom_llm_provider"] = "vertex_ai" + kwargs[ # rebind-ok: callback metadata is enriched for downstream hooks + "response_cost" + ] = response_cost + kwargs["model"] = model # rebind-ok: callback metadata records the resolved model + kwargs["custom_llm_provider"] = "vertex_ai" # rebind-ok: callback metadata records the resolved provider - standard_pass_through_response_object: Final[StandardPassThroughResponseObject] = { + standard_pass_through_response_object: Final[ + StandardPassThroughResponseObject + ] = { # mutable-ok: callback contract requires a concrete response dictionary "response": json_response, } - return { + return { # mutable-ok: passthrough logging contract requires a concrete result dictionary "result": standard_pass_through_response_object, "kwargs": kwargs, } @staticmethod - def _is_audio_predict_response(model: str, json_response: dict) -> bool: + def _is_audio_predict_response( + model: str, + json_response: dict, # mutable-ok: predicate inspects the decoded provider response dictionary without mutation + ) -> bool: return ( VertexPassthroughLoggingHandler._get_audio_prediction_count(json_response=json_response) > 0 and VertexPassthroughLoggingHandler._get_audio_prediction_unit_cost(model=model) is not None @@ -373,7 +388,9 @@ class VertexPassthroughLoggingHandler: @staticmethod def _get_audio_prediction_unit_cost(model: str) -> float | None: - model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}", {}) + model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}") + if model_info is None: + return None output_cost_per_second: Final = model_info.get("output_cost_per_second") audio_seconds_per_prediction: Final = model_info.get("audio_seconds_per_prediction") if not isinstance(output_cost_per_second, (int, float)) or not isinstance( @@ -383,7 +400,9 @@ class VertexPassthroughLoggingHandler: return float(output_cost_per_second * audio_seconds_per_prediction) @staticmethod - def _get_audio_prediction_count(json_response: dict) -> int: + def _get_audio_prediction_count( + json_response: dict, # mutable-ok: counter inspects the decoded provider response dictionary without mutation + ) -> int: predictions: Final = json_response.get("predictions") if not isinstance(predictions, list): return 0 diff --git a/litellm/types/utils.py b/litellm/types/utils.py index bb0485be7c1..6c645e70bee 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -168,8 +168,8 @@ class ProviderSpecificModelInfo(TypedDict, total=False): default_reasoning_effort: ReadOnly[Literal["none", "minimal", "low", "medium", "high", "xhigh"] | None] supports_output_config: bool | None supports_image_size: bool | None - supported_audio_formats: list[Literal["mp3", "wav"]] | None - vertex_ai_audio_api: Literal["lyria_predict", "lyria_interactions"] | None + supported_audio_formats: ReadOnly[Sequence[Literal["mp3", "wav"]] | None] + vertex_ai_audio_api: ReadOnly[Literal["lyria_predict", "lyria_interactions"] | None] bedrock_output_config_effort_ceiling: Literal["low", "medium", "high", "max", "xhigh"] | None bedrock_converse_supports_strict_tools: bool | None @@ -312,9 +312,9 @@ class ModelInfoBase(ProviderSpecificModelInfo, total=False): output_cost_per_video_per_second: float | None # only for vertex ai models output_cost_per_audio_per_second: float | None # only for vertex ai models output_cost_per_second: float | None # for OpenAI Speech models - audio_seconds_per_prediction: float | None - max_audio_length_hours: float | None - max_audio_per_prompt: int | None + audio_seconds_per_prediction: ReadOnly[float | None] + max_audio_length_hours: ReadOnly[float | None] + max_audio_per_prompt: ReadOnly[int | None] output_cost_per_second_1080p: ( float | None ) # video_generation tier: key output_cost_per_second_ (e.g. 1080p, 720p) From abb655daa8851f15ce534a378761c268c811d942 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Sun, 30 Aug 2026 09:58:28 -0500 Subject: [PATCH 062/410] fix(vertex-ai): encode Lyria predict URL path segments Percent-encode project, location, and model as single path segments. Lyria 3 speech builds the interactions URL through staging's minter with the already resolved project --- .../text_to_speech/transformation.py | 20 ++++++++++-- .../text_to_speech/test_transformation.py | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 81f1cbd5157..17c4f0ed0c0 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -17,6 +17,7 @@ from litellm.exceptions import UnsupportedParamsError from litellm.litellm_core_utils.audio_utils.utils import ( speech_media_type_from_audio_bytes, ) +from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.text_to_speech.transformation import ( BaseTextToSpeechConfig, TextToSpeechRequestData, @@ -570,17 +571,32 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): VertexAIInteractionsConfig, ) - return VertexAIInteractionsConfig().get_complete_url( + resolved_project: Final = project + + def mint_access_token( + _credentials: VERTEX_CREDENTIALS_TYPES | None, + project_id: str | None, + ) -> tuple[str, str]: + return "", project_id or resolved_project + + return VertexAIInteractionsConfig(mint_access_token=mint_access_token).get_complete_url( api_base=api_base, model=base_model, litellm_params={ # mutable-ok: interactions dispatch expects a concrete parameter dictionary **litellm_params, "vertex_project": project, + "vertex_location": "global", }, ) location: Final = self.safe_get_vertex_ai_location(litellm_params) or self.get_default_vertex_location() base_url: Final = self.get_api_base(api_base=api_base, vertex_location=location).rstrip("/") - return f"{base_url}/v1/projects/{project}/locations/{location}/publishers/google/models/{base_model}:predict" + encoded_project: Final = encode_url_path_segment(project, field_name="project") + encoded_location: Final = encode_url_path_segment(location, field_name="location") + encoded_model: Final = encode_url_path_segment(base_model, field_name="model") + return ( + f"{base_url}/v1/projects/{encoded_project}/locations/{encoded_location}" + f"/publishers/google/models/{encoded_model}:predict" + ) def transform_text_to_speech_request( self, diff --git a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py index a1bb203e67e..3fc5278d3dd 100644 --- a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -1,4 +1,5 @@ import base64 +from typing import Final from unittest.mock import MagicMock, Mock, patch import httpx @@ -259,6 +260,37 @@ class TestVertexAILyriaTextToSpeechConfig: "locations/europe-west4/publishers/google/models/lyria-002:predict" ) + def test_get_complete_url_encodes_injected_predict_path_segments(self, monkeypatch: pytest.MonkeyPatch) -> None: + injected: Final = ( + "victim-project/locations/us-central1/publishers/google/models/other-model:predict?ignored=" + ) + encoded: Final = ( + "victim-project%2Flocations%2Fus-central1%2Fpublishers%2Fgoogle" + "%2Fmodels%2Fother-model%3Apredict%3Fignored%3D" + ) + monkeypatch.setitem( + litellm.model_cost, + f"vertex_ai/{injected}", + { + "vertex_ai_audio_api": "lyria_predict", + "supported_audio_formats": ["wav"], + }, + ) + + url: Final = VertexAILyriaTextToSpeechConfig().get_complete_url( + model=injected, + api_base="https://us-central1-aiplatform.googleapis.com", + litellm_params={ + "vertex_project": injected, + "vertex_location": injected, + }, + ) + + assert url == ( + "https://us-central1-aiplatform.googleapis.com" + f"/v1/projects/{encoded}/locations/{encoded}/publishers/google/models/{encoded}:predict" + ) + def test_get_complete_url_for_lyria_3(self): config = VertexAILyriaTextToSpeechConfig() From e18df8f09e7aef60ddb76979ec7a4ddeae5f5aeb Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Sun, 30 Aug 2026 09:58:28 -0500 Subject: [PATCH 063/410] fix(vertex-ai): fall back to bundled Lyria costs Prefer runtime model_cost when both numeric fields are present, then bundled Lyria metadata so stale maps still bill 0.002 * 30 --- litellm/llms/vertex_ai/common_utils.py | 2 + .../vertex_passthrough_logging_handler.py | 20 ++++++++-- ...test_vertex_passthrough_logging_handler.py | 39 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/litellm/llms/vertex_ai/common_utils.py b/litellm/llms/vertex_ai/common_utils.py index 8885d19c1c0..b649d8dac0e 100644 --- a/litellm/llms/vertex_ai/common_utils.py +++ b/litellm/llms/vertex_ai/common_utils.py @@ -28,6 +28,8 @@ class VertexAILyriaModelInfo(TypedDict): vertex_ai_audio_api: ReadOnly[Literal["lyria_predict", "lyria_interactions"]] supported_audio_formats: ReadOnly[tuple[Literal["mp3", "wav"], ...]] output_cost_per_image: NotRequired[ReadOnly[float]] + output_cost_per_second: NotRequired[ReadOnly[float]] + audio_seconds_per_prediction: NotRequired[ReadOnly[float]] _VERTEX_AI_LYRIA_MODEL_INFO_ADAPTER: Final = TypeAdapter(VertexAILyriaModelInfo) diff --git a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py index 53df964e541..f7625d71168 100644 --- a/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py +++ b/litellm/proxy/pass_through_endpoints/llm_provider_handlers/vertex_passthrough_logging_handler.py @@ -1,5 +1,6 @@ import asyncio import re +from collections.abc import Mapping from datetime import datetime from typing import TYPE_CHECKING, Any, Final, cast from urllib.parse import urlparse @@ -10,7 +11,10 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.constants import VERTEX_BATCH_PREDICTION_JOBS_ROUTE from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj -from litellm.llms.vertex_ai.common_utils import get_vertex_location_from_url +from litellm.llms.vertex_ai.common_utils import ( + get_vertex_ai_lyria_model_info, + get_vertex_location_from_url, +) from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( ModelResponseIterator as VertexModelResponseIterator, ) @@ -388,8 +392,18 @@ class VertexPassthroughLoggingHandler: @staticmethod def _get_audio_prediction_unit_cost(model: str) -> float | None: - model_info: Final = litellm.model_cost.get(f"vertex_ai/{model}") - if model_info is None: + runtime_unit_cost: Final = VertexPassthroughLoggingHandler._audio_prediction_unit_cost_from_model_info( + model_info=litellm.model_cost.get(f"vertex_ai/{model}") + ) + if runtime_unit_cost is not None: + return runtime_unit_cost + return VertexPassthroughLoggingHandler._audio_prediction_unit_cost_from_model_info( + model_info=get_vertex_ai_lyria_model_info(model=model) + ) + + @staticmethod + def _audio_prediction_unit_cost_from_model_info(model_info: object) -> float | None: + if not isinstance(model_info, Mapping): return None output_cost_per_second: Final = model_info.get("output_cost_per_second") audio_seconds_per_prediction: Final = model_info.get("audio_seconds_per_prediction") diff --git a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py index 1e8d569d3d1..f3d28d58c22 100644 --- a/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py +++ b/tests/test_litellm/llms/vertex_ai/test_vertex_passthrough_logging_handler.py @@ -1,4 +1,5 @@ from datetime import datetime +from typing import Final from unittest.mock import MagicMock import httpx @@ -143,3 +144,41 @@ def test_audio_predict_response_supports_bytes_base64_encoded( assert result["kwargs"]["response_cost"] == pytest.approx(0.06) assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) + + +def test_lyria_predict_cost_falls_back_to_bundled_map_when_runtime_map_omits_model( + monkeypatch: pytest.MonkeyPatch, +) -> None: + stale_runtime_model_cost: Final = { + key: value for key, value in litellm.model_cost.items() if key != "vertex_ai/lyria-002" + } + monkeypatch.setattr(litellm, "model_cost", stale_runtime_model_cost) + logging_obj = MagicMock() + logging_obj.model_call_details = {} + response = httpx.Response( + status_code=200, + json={ + "predictions": [ + { + "audioContent": "clip", + "mimeType": "audio/wav", + } + ] + }, + ) + + result = VertexPassthroughLoggingHandler.vertex_passthrough_handler( + httpx_response=response, + logging_obj=logging_obj, + url_route="/v1/projects/test/locations/us-central1/publishers/google/models/lyria-002:predict", + result=response.text, + start_time=datetime.now(), + end_time=datetime.now(), + cache_hit=False, + request_body={"instances": [{"prompt": "ambient piano"}]}, + ) + + assert "vertex_ai/lyria-002" not in litellm.model_cost + assert result["kwargs"]["model"] == "lyria-002" + assert result["kwargs"]["response_cost"] == pytest.approx(0.06) + assert logging_obj.model_call_details["response_cost"] == pytest.approx(0.06) From c784e604acc7791e882391a512b4836366fef0e1 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Sun, 30 Aug 2026 10:15:50 -0500 Subject: [PATCH 064/410] test(vertex-ai): drop Lyria auth patches from transform tests Subclass the Lyria transformer to stub token minting, and mark the remaining litellm.speech patches so TQ008 stays within budget. --- .../vertex_ai/text_to_speech/test_transformation.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py index 3fc5278d3dd..457c3f76dbb 100644 --- a/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/text_to_speech/test_transformation.py @@ -335,16 +335,17 @@ class TestVertexAILyriaTextToSpeechConfig: ), ], ) - @patch.object(VertexAILyriaTextToSpeechConfig, "_ensure_access_token") def test_transform_request( self, - mock_ensure_token, model, response_format, expected_body, ): - mock_ensure_token.return_value = ("mock-token", "music-project") - config = VertexAILyriaTextToSpeechConfig() + class _LyriaConfig(VertexAILyriaTextToSpeechConfig): + def _ensure_access_token(self, *args: object, **kwargs: object) -> tuple[str, str]: + return "mock-token", "music-project" + + config = _LyriaConfig() request = config.transform_text_to_speech_request( model=model, @@ -514,12 +515,12 @@ class TestVertexAILyriaTextToSpeechConfig: mock_response.status_code = 200 mock_response.json.return_value = response_json with ( - patch.object( + patch.object( # test-quality-ok: litellm.speech has no seam for Vertex token minting VertexAILyriaTextToSpeechConfig, "_ensure_access_token", return_value=("mock-token", "music-project"), ), - patch( + patch( # test-quality-ok: litellm.speech has no seam for the HTTP handler "litellm.llms.custom_httpx.llm_http_handler.HTTPHandler.post", return_value=mock_response, ) as mock_post, From cab3801f23aff8c5ac73f0f38d212ab74c9392b6 Mon Sep 17 00:00:00 2001 From: Emerson Gomes Date: Sun, 30 Aug 2026 12:00:42 -0500 Subject: [PATCH 065/410] fix(vertex-ai): simplify Lyria provider typing --- .../llms/vertex_ai/interactions/__init__.py | 3 -- .../text_to_speech/transformation.py | 30 ++++++++++--------- litellm/types/llms/openai.py | 3 ++ 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/litellm/llms/vertex_ai/interactions/__init__.py b/litellm/llms/vertex_ai/interactions/__init__.py index f6f86f65e87..e69de29bb2d 100644 --- a/litellm/llms/vertex_ai/interactions/__init__.py +++ b/litellm/llms/vertex_ai/interactions/__init__.py @@ -1,3 +0,0 @@ -from .transformation import VertexAIInteractionsConfig - -__all__ = ("VertexAIInteractionsConfig",) diff --git a/litellm/llms/vertex_ai/text_to_speech/transformation.py b/litellm/llms/vertex_ai/text_to_speech/transformation.py index 17c4f0ed0c0..2047be2ea37 100644 --- a/litellm/llms/vertex_ai/text_to_speech/transformation.py +++ b/litellm/llms/vertex_ai/text_to_speech/transformation.py @@ -621,8 +621,8 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): ) base_model: Final = model.removeprefix("vertex_ai/") model_info: Final = self._get_model_info(model=model) - if model_info["vertex_ai_audio_api"] == "lyria_predict": - request_body = { # mutable-ok: predict dispatch requires a concrete provider request dictionary; rebind-ok: exactly one provider API shape initializes the request + request_body: Final[dict[str, object]] = ( # mutable-ok: HTTP dispatch requires a concrete provider payload + { # mutable-ok: predict dispatch requires a concrete provider request dictionary "instances": [ # mutable-ok: predict dispatch requires a concrete instances list {"prompt": input} # mutable-ok: predict dispatch requires a concrete instance dictionary ], @@ -630,18 +630,22 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): "sample_count": 1 }, } - else: - request_body = { # mutable-ok: interactions dispatch requires a concrete provider request dictionary; rebind-ok: exactly one provider API shape initializes the request + if model_info["vertex_ai_audio_api"] == "lyria_predict" + else { # mutable-ok: interactions dispatch requires a concrete provider request dictionary "model": base_model, "input": input, + **( + { # mutable-ok: interactions dispatch requires a nested response-format dictionary + "response_format": { # mutable-ok: interactions response format is a concrete provider payload + "type": "audio", + "mime_type": "audio/wav", + } + } + if optional_params.get("response_format") == "wav" + else {} # mutable-ok: no response override is merged for non-WAV output + ), } - if optional_params.get("response_format") == "wav": - request_body[ - "response_format" - ] = { # mutable-ok: interactions dispatch requires a nested response-format dictionary - "type": "audio", - "mime_type": "audio/wav", - } + ) return TextToSpeechRequestData(dict_body=request_body, headers=headers) def transform_text_to_speech_response( @@ -694,7 +698,5 @@ class VertexAILyriaTextToSpeechConfig(VertexAITextToSpeechConfig): }, ) ) - response._hidden_params = { # mutable-ok: response metadata is a concrete dictionary - "audio_mime_type": mime_type - } + response.set_audio_mime_type(mime_type) return response diff --git a/litellm/types/llms/openai.py b/litellm/types/llms/openai.py index 32d88da0085..0b13191d977 100644 --- a/litellm/types/llms/openai.py +++ b/litellm/types/llms/openai.py @@ -119,6 +119,9 @@ class HttpxBinaryResponseContent(_HttpxBinaryResponseContent): return self._hidden_params["response_cost"] = response_cost + def set_audio_mime_type(self, audio_mime_type: str) -> None: + self._hidden_params["audio_mime_type"] = audio_mime_type + class NotGiven: """ From 1af9c229fa452c1a226bcc22f34f74cfffd54c54 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 17:29:56 -0700 Subject: [PATCH 066/410] ci(e2e): leave the managed-files opt-in file to its own lane instead of failing on an empty collection --- .github/workflows/test-e2e-changed.yml | 5 +++-- tests/e2e/CONTRIBUTING.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test-e2e-changed.yml b/.github/workflows/test-e2e-changed.yml index b20c84af02d..c1a74f007ce 100644 --- a/.github/workflows/test-e2e-changed.yml +++ b/.github/workflows/test-e2e-changed.yml @@ -27,14 +27,15 @@ jobs: REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} SMOKE_TESTS: tests/e2e/access_control + OWN_LANE: '^tests/e2e/(ui|claude_code|load)/|^tests/e2e/batches/test_managed_files_enforcement_e2e\.py$' run: | files="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate \ --jq '.[] | select(.status != "removed") | .filename')" tests="$(printf '%s\n' "${files}" \ | grep -E '^tests/e2e/([A-Za-z0-9_.-]+/)*test_[A-Za-z0-9_.-]+\.py$' \ - | grep -vE '^tests/e2e/(ui|claude_code|load)/' \ + | grep -vE "${OWN_LANE}" \ | sort -u | tr '\n' ' ' | sed 's/ $//')" || true - if [ -z "${tests}" ] && printf '%s\n' "${files}" | grep -vE '^tests/e2e/(ui|claude_code|load)/' \ + if [ -z "${tests}" ] && printf '%s\n' "${files}" | grep -vE "${OWN_LANE}" \ | grep -qE '^(tests/e2e/|\.github/e2e-stack/|\.github/workflows/test-e2e-changed\.yml$)'; then tests="${SMOKE_TESTS}" echo "harness or stack changed without a test file; running the smoke suite" diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index b984791c738..81fcdf84e74 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -54,7 +54,7 @@ Some suites need extra services the bare proxy does not start. The `logging/` OT ### The pull request check -Every PR that adds or modifies a `tests/e2e/**/test_*.py` file (outside `ui/`, `claude_code/`, and `load/`, which have their own lanes) runs exactly those files three times, with retries off, against a stage-mirror stack booted on a GitHub Actions runner: migrations, a control-plane backend, two gateway processes behind an nginx load balancer, Postgres, Jaeger, and a TLS cluster-mode Valkey, wired the way stage is deployed. A PR that only touches the harness or the stack itself runs the `access_control/` suite as a smoke instead. Three green passes are the bar because the check exists to catch a flaky test before it reaches the release gate, so a red pass is a failure to fix, not a retry candidate. The same stack boots on a laptop with `bash .github/e2e-stack/up.sh`: it reads provider keys from `tests/e2e/.env`, writes the pytest environment to `${E2E_STACK_DIR:-/tmp/litellm-e2e-stack}/stack.env`, every port is overridable through `E2E_*_PORT` variables, and `down.sh` tears it all down +Every PR that adds or modifies a `tests/e2e/**/test_*.py` file (outside `ui/`, `claude_code/`, `load/`, and `batches/test_managed_files_enforcement_e2e.py`, which have their own lanes or need a differently configured proxy) runs exactly those files three times, with retries off, against a stage-mirror stack booted on a GitHub Actions runner: migrations, a control-plane backend, two gateway processes behind an nginx load balancer, Postgres, Jaeger, and a TLS cluster-mode Valkey, wired the way stage is deployed. A PR that only touches the harness or the stack itself runs the `access_control/` suite as a smoke instead. Three green passes are the bar because the check exists to catch a flaky test before it reaches the release gate, so a red pass is a failure to fix, not a retry candidate. The same stack boots on a laptop with `bash .github/e2e-stack/up.sh`: it reads provider keys from `tests/e2e/.env`, writes the pytest environment to `${E2E_STACK_DIR:-/tmp/litellm-e2e-stack}/stack.env`, every port is overridable through `E2E_*_PORT` variables, and `down.sh` tears it all down Credentials: the lane never sees the stage keys. Everything the runner writes into `tests/e2e/.env` comes from two AWS Secrets Manager secrets in us-east-1, `litellm-e2e-changed-provider-keys` and `litellm-e2e-changed-license`, and the first holds only the names that the tests and `tests/e2e/gateway/stage_mirror_ci_config.yml` read. Each credential in it is dedicated to this lane and can do only what the tests do: the AWS pair belongs to the `litellm-e2e-changed` IAM user, whose inline policy allows Bedrock inference, the one guardrail the tests use, the batch job APIs, the batch S3 bucket, and assuming the batch test's role; the Vertex key belongs to a service account holding only `roles/aiplatform.user` on the Vertex project; the OpenAI, Anthropic, Gemini, Cohere, and Devin keys are per-lane keys with hard monthly spend caps; and the Datadog application key is scoped to log search. A leaked key can therefore spend at most one month of a small capped budget or call a handful of Bedrock APIs, and rotating one is a single `aws secretsmanager put-secret-value` on the secret, with no workflow change From a3ee6b25667d7640214465b58364d04554c02f20 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 17:50:35 -0700 Subject: [PATCH 067/410] ci(e2e): fail a pass whose every collected test was skipped --- .github/e2e-stack/assert_tests_ran.py | 21 +++++++++++++++++++++ .github/workflows/test-e2e-changed.yml | 4 +++- tests/e2e/CONTRIBUTING.md | 2 +- 3 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 .github/e2e-stack/assert_tests_ran.py diff --git a/.github/e2e-stack/assert_tests_ran.py b/.github/e2e-stack/assert_tests_ran.py new file mode 100644 index 00000000000..092a9db7256 --- /dev/null +++ b/.github/e2e-stack/assert_tests_ran.py @@ -0,0 +1,21 @@ +import sys +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Final + + +def main() -> int: + report: Final = ET.parse(Path(sys.argv[1])).getroot() + suites: Final = tuple(report.iter("testsuite")) + collected: Final = sum(int(suite.get("tests", "0")) for suite in suites) + skipped: Final = sum(int(suite.get("skipped", "0")) for suite in suites) + executed: Final = collected - skipped + _ = sys.stdout.write(f"executed {executed} of {collected} collected tests ({skipped} skipped)\n") + if executed > 0: + return 0 + _ = sys.stdout.write("::error::every selected test was skipped, so nothing was verified\n") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/test-e2e-changed.yml b/.github/workflows/test-e2e-changed.yml index c1a74f007ce..e08660d0412 100644 --- a/.github/workflows/test-e2e-changed.yml +++ b/.github/workflows/test-e2e-changed.yml @@ -152,9 +152,10 @@ jobs: run: | read -r -a test_files <<< "${TESTS}" for pass in 1 2 3; do + report="${RUNNER_TEMP}/e2e-pass-${pass}.xml" echo "::group::pass ${pass} of 3" set +e - uv run --no-sync pytest "${test_files[@]}" --reruns 0 -v -rA --tb=short -p no:cacheprovider + uv run --no-sync pytest "${test_files[@]}" --reruns 0 -v -rA --tb=short -p no:cacheprovider --junitxml="${report}" status=$? set -e echo "::endgroup::" @@ -166,6 +167,7 @@ jobs: echo "::error::pass ${pass} of 3 failed with exit code ${status}" exit "${status}" fi + uv run --no-sync python .github/e2e-stack/assert_tests_ran.py "${report}" done - name: Show stack logs on failure diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 81fcdf84e74..ef6fbb5405d 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -54,7 +54,7 @@ Some suites need extra services the bare proxy does not start. The `logging/` OT ### The pull request check -Every PR that adds or modifies a `tests/e2e/**/test_*.py` file (outside `ui/`, `claude_code/`, `load/`, and `batches/test_managed_files_enforcement_e2e.py`, which have their own lanes or need a differently configured proxy) runs exactly those files three times, with retries off, against a stage-mirror stack booted on a GitHub Actions runner: migrations, a control-plane backend, two gateway processes behind an nginx load balancer, Postgres, Jaeger, and a TLS cluster-mode Valkey, wired the way stage is deployed. A PR that only touches the harness or the stack itself runs the `access_control/` suite as a smoke instead. Three green passes are the bar because the check exists to catch a flaky test before it reaches the release gate, so a red pass is a failure to fix, not a retry candidate. The same stack boots on a laptop with `bash .github/e2e-stack/up.sh`: it reads provider keys from `tests/e2e/.env`, writes the pytest environment to `${E2E_STACK_DIR:-/tmp/litellm-e2e-stack}/stack.env`, every port is overridable through `E2E_*_PORT` variables, and `down.sh` tears it all down +Every PR that adds or modifies a `tests/e2e/**/test_*.py` file (outside `ui/`, `claude_code/`, `load/`, and `batches/test_managed_files_enforcement_e2e.py`, which have their own lanes or need a differently configured proxy) runs exactly those files three times, with retries off, against a stage-mirror stack booted on a GitHub Actions runner: migrations, a control-plane backend, two gateway processes behind an nginx load balancer, Postgres, Jaeger, and a TLS cluster-mode Valkey, wired the way stage is deployed. A PR that only touches the harness or the stack itself runs the `access_control/` suite as a smoke instead. Three green passes are the bar because the check exists to catch a flaky test before it reaches the release gate, so a red pass is a failure to fix, not a retry candidate. A pass that executes nothing is also red: each pass writes a JUnit report and fails when every collected test was skipped, so a skipped-out file cannot pass on paper. The same stack boots on a laptop with `bash .github/e2e-stack/up.sh`: it reads provider keys from `tests/e2e/.env`, writes the pytest environment to `${E2E_STACK_DIR:-/tmp/litellm-e2e-stack}/stack.env`, every port is overridable through `E2E_*_PORT` variables, and `down.sh` tears it all down Credentials: the lane never sees the stage keys. Everything the runner writes into `tests/e2e/.env` comes from two AWS Secrets Manager secrets in us-east-1, `litellm-e2e-changed-provider-keys` and `litellm-e2e-changed-license`, and the first holds only the names that the tests and `tests/e2e/gateway/stage_mirror_ci_config.yml` read. Each credential in it is dedicated to this lane and can do only what the tests do: the AWS pair belongs to the `litellm-e2e-changed` IAM user, whose inline policy allows Bedrock inference, the one guardrail the tests use, the batch job APIs, the batch S3 bucket, and assuming the batch test's role; the Vertex key belongs to a service account holding only `roles/aiplatform.user` on the Vertex project; the OpenAI, Anthropic, Gemini, Cohere, and Devin keys are per-lane keys with hard monthly spend caps; and the Datadog application key is scoped to log search. A leaked key can therefore spend at most one month of a small capped budget or call a handful of Bedrock APIs, and rotating one is a single `aws secretsmanager put-secret-value` on the secret, with no workflow change From a9bef8d370ebd553071729b32a9f27f729f1d17e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 18:01:19 -0700 Subject: [PATCH 068/410] docs(e2e): drop the spend-cap claim from the credentials paragraph --- tests/e2e/CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index ef6fbb5405d..1e88f6505cf 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -56,7 +56,7 @@ Some suites need extra services the bare proxy does not start. The `logging/` OT Every PR that adds or modifies a `tests/e2e/**/test_*.py` file (outside `ui/`, `claude_code/`, `load/`, and `batches/test_managed_files_enforcement_e2e.py`, which have their own lanes or need a differently configured proxy) runs exactly those files three times, with retries off, against a stage-mirror stack booted on a GitHub Actions runner: migrations, a control-plane backend, two gateway processes behind an nginx load balancer, Postgres, Jaeger, and a TLS cluster-mode Valkey, wired the way stage is deployed. A PR that only touches the harness or the stack itself runs the `access_control/` suite as a smoke instead. Three green passes are the bar because the check exists to catch a flaky test before it reaches the release gate, so a red pass is a failure to fix, not a retry candidate. A pass that executes nothing is also red: each pass writes a JUnit report and fails when every collected test was skipped, so a skipped-out file cannot pass on paper. The same stack boots on a laptop with `bash .github/e2e-stack/up.sh`: it reads provider keys from `tests/e2e/.env`, writes the pytest environment to `${E2E_STACK_DIR:-/tmp/litellm-e2e-stack}/stack.env`, every port is overridable through `E2E_*_PORT` variables, and `down.sh` tears it all down -Credentials: the lane never sees the stage keys. Everything the runner writes into `tests/e2e/.env` comes from two AWS Secrets Manager secrets in us-east-1, `litellm-e2e-changed-provider-keys` and `litellm-e2e-changed-license`, and the first holds only the names that the tests and `tests/e2e/gateway/stage_mirror_ci_config.yml` read. Each credential in it is dedicated to this lane and can do only what the tests do: the AWS pair belongs to the `litellm-e2e-changed` IAM user, whose inline policy allows Bedrock inference, the one guardrail the tests use, the batch job APIs, the batch S3 bucket, and assuming the batch test's role; the Vertex key belongs to a service account holding only `roles/aiplatform.user` on the Vertex project; the OpenAI, Anthropic, Gemini, Cohere, and Devin keys are per-lane keys with hard monthly spend caps; and the Datadog application key is scoped to log search. A leaked key can therefore spend at most one month of a small capped budget or call a handful of Bedrock APIs, and rotating one is a single `aws secretsmanager put-secret-value` on the secret, with no workflow change +Credentials: everything the runner writes into `tests/e2e/.env` comes from two AWS Secrets Manager secrets in us-east-1, `litellm-e2e-changed-provider-keys` and `litellm-e2e-changed-license`, and the first holds only the names that the tests and `tests/e2e/gateway/stage_mirror_ci_config.yml` read. The cloud credentials in it are dedicated to this lane and can do only what the tests do: the AWS pair belongs to the `litellm-e2e-changed` IAM user, whose inline policy allows Bedrock inference, the one guardrail the tests use, the batch job APIs, the batch S3 bucket, and assuming the batch test's role, and the Vertex key belongs to a service account holding only `roles/aiplatform.user` on the Vertex project. The provider API keys carry no lane-specific spend cap, since a cap that trips mid-month would fail every run until it resets, so the reviewer's approval of the `e2e-changed` environment is the control on how a PR's tests use them. Rotating any value is a single `aws secretsmanager put-secret-value` on the secret, with no workflow change ### Record and replay From 63482cfdbd4d6e623b984c9b65ab98d1f224d879 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 2 Sep 2026 18:25:51 -0700 Subject: [PATCH 069/410] chore(deps): lower the pymongo floor for the mongodb extra to 4.9 4.17 was picked on the belief that dnspython only became a core pymongo dependency there, which is wrong: pymongo has declared dnspython>=1.16.0,<3.0.0 as a core requirement since well before that, so mongodb+srv:// URIs resolve at 4.9 too. The real floor is 4.9, the release AsyncMongoClient landed in, and 4.8 has no AsyncMongoClient at all. Verified against live Atlas on 4.9: sync and async search, list_search_indexes, same top hit and score as 4.17. Resolution is unchanged, pymongo 4.17.0 either way, so this only widens what an existing environment is allowed to bring. --- pyproject.toml | 9 +++------ uv.lock | 4 ++-- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c2f0dc6ced7..e3e103e6d49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -112,12 +112,9 @@ utils = [ ] caching = ["diskcache>=5.6.3,<6.0"] mcp = ["mcp>=1.28.1,<2.0"] -# Driver for the MongoDB Atlas vector store. Atlas Vector Search has no HTTP query -# API, so that provider talks to the cluster over the wire protocol. Imported lazily -# and kept out of the base install, which never needs a MongoDB driver. The floor is -# 4.17 because that is where dnspython became a core dependency rather than the `srv` -# extra, and Atlas hands out mongodb+srv:// URIs that do not resolve without it. -mongodb = ["pymongo>=4.17,<5.0"] +# Driver for the MongoDB Atlas vector store; Atlas Vector Search has no HTTP query API. +# The floor is 4.9 because that is the release AsyncMongoClient landed in. +mongodb = ["pymongo>=4.9,<5.0"] # SAML SSO for the admin UI. python3-saml pulls in xmlsec/lxml, whose wheels # bundle the native libxmlsec1/libxml2 libraries, so no system packages are # required. Kept out of the base `proxy` extra so it stays optional. diff --git a/uv.lock b/uv.lock index 362bb490a2a..bb1927ce093 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-08-30T17:51:25.171404Z" +exclude-newer = "2026-08-31T00:55:41.895302Z" exclude-newer-span = "P3D" [manifest] @@ -4554,7 +4554,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.10.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.14.1,<3.0" }, { name = "pyjwt", marker = "extra == 'proxy'", specifier = ">=2.13.0,<3.0" }, - { name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.17,<5.0" }, + { name = "pymongo", marker = "extra == 'mongodb'", specifier = ">=4.9,<5.0" }, { name = "pynacl", marker = "extra == 'proxy'", specifier = ">=1.6.2,<2.0" }, { name = "pypdf", marker = "extra == 'proxy-runtime'", specifier = ">=6.16.1,<7.0" }, { name = "pyroscope-io", marker = "sys_platform != 'win32' and extra == 'proxy'", specifier = ">=0.8.16,<1.0" }, From b6c10d31e8f95240386f40e0ee93277e530c71e7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 01:33:11 +0000 Subject: [PATCH 070/410] chore(lint): ratchet down Any and strict-typing budgets --- basedpyright-code-budget.json | 24 ++++++++++++------------ ruff-strict-budget.json | 14 +++++++------- type-discipline-budget.json | 10 +++++----- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 3f96531cf6f..9365c7daad5 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,9 +1,9 @@ { "reportAny": { - "limit": 14074 + "limit": 13431 }, "reportArgumentType": { - "limit": 2215 + "limit": 2207 }, "reportAssignmentType": { "limit": 319 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 4124 + "limit": 3371 }, "reportFunctionMemberAccess": { "limit": 7 @@ -48,16 +48,16 @@ "limit": 34 }, "reportInvalidTypeVarUse": { - "limit": 2 + "limit": 1 }, "reportMatchNotExhaustive": { "limit": 0 }, "reportMissingParameterType": { - "limit": 5601 + "limit": 5570 }, "reportMissingTypeArgument": { - "limit": 15290 + "limit": 15287 }, "reportMissingTypeStubs": { "limit": 40 @@ -90,7 +90,7 @@ "limit": 8 }, "reportReturnType": { - "limit": 181 + "limit": 180 }, "reportTypedDictNotRequiredAccess": { "limit": 24 @@ -105,16 +105,16 @@ "limit": 109 }, "reportUnknownMemberType": { - "limit": 38332 + "limit": 38306 }, "reportUnknownParameterType": { - "limit": 19625 + "limit": 19589 }, "reportUnknownVariableType": { - "limit": 29861 + "limit": 29846 }, "reportUnnecessaryCast": { - "limit": 111 + "limit": 110 }, "reportUnnecessaryComparison": { "limit": 687 @@ -123,7 +123,7 @@ "limit": 4 }, "reportUnnecessaryIsInstance": { - "limit": 823 + "limit": 820 }, "reportUntypedBaseClass": { "limit": 0 diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 4fcf650a8bc..204ed2929ee 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -1,21 +1,21 @@ { "ANN001": { - "limit": 2985 + "limit": 2957 }, "ANN002": { "limit": 71 }, "ANN003": { - "limit": 809 + "limit": 806 }, "ANN201": { - "limit": 2000 + "limit": 1981 }, "ANN202": { - "limit": 835 + "limit": 831 }, "ANN204": { - "limit": 693 + "limit": 683 }, "ANN205": { "limit": 112 @@ -24,7 +24,7 @@ "limit": 133 }, "ANN401": { - "limit": 304 + "limit": 119 }, "ASYNC230": { "limit": 11 @@ -231,7 +231,7 @@ "limit": 5 }, "TID251": { - "limit": 1071 + "limit": 1034 }, "TRY002": { "limit": 524 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index f3c4c7760c6..c9a1f28438e 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22334 + "limit": 22192 }, "LIT002": { - "limit": 26763 + "limit": 26762 }, "LIT003": { "limit": 261 @@ -15,7 +15,7 @@ "limit": 0 }, "LIT006": { - "limit": 1038 + "limit": 1035 }, "LIT007": { "limit": 0 @@ -30,9 +30,9 @@ "limit": 16480 }, "LIT011": { - "limit": 5520 + "limit": 5518 }, "LIT012": { - "limit": 4489 + "limit": 4488 } } From e5c1133a7942a7875e00ba263bb6dabf64f6a4ea Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 06:53:19 +0000 Subject: [PATCH 071/410] chore(lint): re-ratchet lint budgets after merging staging --- basedpyright-code-budget.json | 24 ++++++++++++------------ ruff-strict-budget.json | 14 +++++++------- type-discipline-budget.json | 10 +++++----- 3 files changed, 24 insertions(+), 24 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 2967fc2a505..eb7f484901f 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,9 +1,9 @@ { "reportAny": { - "limit": 14074 + "limit": 13431 }, "reportArgumentType": { - "limit": 2215 + "limit": 2207 }, "reportAssignmentType": { "limit": 319 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 4124 + "limit": 3371 }, "reportFunctionMemberAccess": { "limit": 7 @@ -48,16 +48,16 @@ "limit": 34 }, "reportInvalidTypeVarUse": { - "limit": 2 + "limit": 1 }, "reportMatchNotExhaustive": { "limit": 0 }, "reportMissingParameterType": { - "limit": 5601 + "limit": 5570 }, "reportMissingTypeArgument": { - "limit": 15288 + "limit": 15285 }, "reportMissingTypeStubs": { "limit": 40 @@ -90,7 +90,7 @@ "limit": 8 }, "reportReturnType": { - "limit": 181 + "limit": 180 }, "reportTypedDictNotRequiredAccess": { "limit": 24 @@ -105,16 +105,16 @@ "limit": 109 }, "reportUnknownMemberType": { - "limit": 38324 + "limit": 38298 }, "reportUnknownParameterType": { - "limit": 19625 + "limit": 19589 }, "reportUnknownVariableType": { - "limit": 29861 + "limit": 29846 }, "reportUnnecessaryCast": { - "limit": 111 + "limit": 110 }, "reportUnnecessaryComparison": { "limit": 687 @@ -123,7 +123,7 @@ "limit": 4 }, "reportUnnecessaryIsInstance": { - "limit": 819 + "limit": 816 }, "reportUntypedBaseClass": { "limit": 0 diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index be2b30fc189..f9360a2308e 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -1,21 +1,21 @@ { "ANN001": { - "limit": 2984 + "limit": 2956 }, "ANN002": { "limit": 71 }, "ANN003": { - "limit": 809 + "limit": 806 }, "ANN201": { - "limit": 2000 + "limit": 1981 }, "ANN202": { - "limit": 835 + "limit": 831 }, "ANN204": { - "limit": 693 + "limit": 683 }, "ANN205": { "limit": 112 @@ -24,7 +24,7 @@ "limit": 133 }, "ANN401": { - "limit": 304 + "limit": 119 }, "ASYNC230": { "limit": 11 @@ -231,7 +231,7 @@ "limit": 5 }, "TID251": { - "limit": 1071 + "limit": 1035 }, "TRY002": { "limit": 524 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index d5bf3883be4..071f3418101 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22330 + "limit": 22188 }, "LIT002": { - "limit": 26763 + "limit": 26762 }, "LIT003": { "limit": 261 @@ -15,7 +15,7 @@ "limit": 0 }, "LIT006": { - "limit": 1038 + "limit": 1035 }, "LIT007": { "limit": 0 @@ -30,9 +30,9 @@ "limit": 16477 }, "LIT011": { - "limit": 5519 + "limit": 5517 }, "LIT012": { - "limit": 4489 + "limit": 4488 } } From 0a2581c14caaa43f76f1db2399aa66a9812d63bf Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 00:30:07 -0700 Subject: [PATCH 072/410] fix(mistral): keep the deployment voice default and drop the unreachable api base fallback Review turned up two real problems in the TTS path. Router.aspeech forwarded voice=None whenever the caller omitted it, which overwrote a voice set in the deployment's litellm_params, so a configured fallback voice was ignored on voice-less requests. It now leaves the key alone when no voice is passed. get_complete_url also fell back to MISTRAL_API_BASE, but speech() always receives a non-null api_base from get_llm_provider, whose mistral branch only reads MISTRAL_AZURE_API_BASE and otherwise hardcodes the public host. That branch could never run, and its unit test asserted a behavior the real path does not have. The working override is api_base on the deployment, now pinned by an end-to-end test --- .../mistral/audio_speech/transformation.py | 2 +- litellm/router.py | 2 +- ...est_mistral_audio_speech_transformation.py | 10 +--- tests/test_litellm/test_main.py | 18 +++++++ tests/test_litellm/test_router.py | 50 +++++++++++++++++++ 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/litellm/llms/mistral/audio_speech/transformation.py b/litellm/llms/mistral/audio_speech/transformation.py index 7f5a659bd08..2b3264dc756 100644 --- a/litellm/llms/mistral/audio_speech/transformation.py +++ b/litellm/llms/mistral/audio_speech/transformation.py @@ -115,7 +115,7 @@ class MistralTextToSpeechConfig(BaseTextToSpeechConfig): api_base: str | None, litellm_params: Mapping[str, object], ) -> str: - configured_base: Final = (api_base or get_secret_str("MISTRAL_API_BASE") or self.TTS_BASE_URL).rstrip("/") + configured_base: Final = (api_base or self.TTS_BASE_URL).rstrip("/") versioned_base: Final = configured_base if configured_base.endswith("/v1") else f"{configured_base}/v1" return f"{versioned_base}/audio/speech" diff --git a/litellm/router.py b/litellm/router.py index ee3a3ced023..b1357374f5c 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -4422,7 +4422,7 @@ class Router: **{ **data, "input": input, - "voice": voice, + **({"voice": voice} if voice is not None else {}), "client": model_client, **kwargs, } diff --git a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py index 108d4107db1..d819a79cef1 100644 --- a/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py +++ b/tests/test_litellm/llms/mistral/audio_speech/test_mistral_audio_speech_transformation.py @@ -84,8 +84,7 @@ def test_transform_request_omits_voice_for_ref_audio_cloning(): } -def test_get_complete_url_default_base(monkeypatch: pytest.MonkeyPatch): - monkeypatch.delenv("MISTRAL_API_BASE", raising=False) +def test_get_complete_url_default_base(): config: Final = MistralTextToSpeechConfig() url: Final = config.get_complete_url(model="voxtral-mini-tts-2603", api_base=None, litellm_params={}) assert url == SPEECH_URL @@ -101,13 +100,6 @@ def test_get_complete_url_custom_base_always_versioned(api_base: str): assert url == "https://custom.api.example.com/v1/audio/speech" -def test_get_complete_url_host_only_env_base_gets_v1(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setenv("MISTRAL_API_BASE", "https://api.mistral.ai") - config: Final = MistralTextToSpeechConfig() - url: Final = config.get_complete_url(model="voxtral-mini-tts-2603", api_base=None, litellm_params={}) - assert url == SPEECH_URL - - def test_validate_environment_sets_bearer_header(): config: Final = MistralTextToSpeechConfig() headers: Final = config.validate_environment( diff --git a/tests/test_litellm/test_main.py b/tests/test_litellm/test_main.py index 9b5122c3f75..e01679048e6 100644 --- a/tests/test_litellm/test_main.py +++ b/tests/test_litellm/test_main.py @@ -3287,3 +3287,21 @@ def test_speech_mistral_dispatches_and_decodes_audio(respx_mock: respx.MockRoute } assert mock_route.calls.last.request.headers["authorization"] == "Bearer sk-mistral-test" assert response.content == audio_bytes + + +def test_speech_mistral_routes_to_configured_api_base(respx_mock: respx.MockRouter, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test") + audio_bytes: Final = b"ID3-gateway-bytes" + gateway_route: Final = respx_mock.post("https://mistral.gateway.internal/v1/audio/speech").mock( + return_value=httpx.Response(200, json={"audio_data": base64.b64encode(audio_bytes).decode()}) + ) + + response: Final = litellm.speech( + model="mistral/voxtral-mini-tts-2603", + input="hello from litellm", + voice="en_paul_neutral", + api_base="https://mistral.gateway.internal", + ) + + assert gateway_route.called + assert response.content == audio_bytes diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 048536d887f..014937b35cd 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -12278,6 +12278,56 @@ async def test_router_aspeech_without_voice_dispatches_ref_audio_cloning(respx_m assert response.content == audio_bytes +@pytest.mark.asyncio +async def test_router_aspeech_without_voice_keeps_deployment_default_voice(respx_mock, monkeypatch): + import base64 + + monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + audio_bytes = b"RIFFfake-wav-bytes" + respx_mock.post("https://api.mistral.ai/v1/audio/speech").respond( + json={"audio_data": base64.b64encode(audio_bytes).decode()} + ) + router = Router( + model_list=[ + { + "model_name": "voxtral-tts", + "litellm_params": {"model": "mistral/voxtral-mini-tts-2603", "voice": "en_paul_neutral"}, + } + ] + ) + + await router.aspeech(model="voxtral-tts", input="use my default") + + request_body = json.loads(respx_mock.calls.last.request.content) + assert request_body["voice_id"] == "en_paul_neutral" + + +@pytest.mark.asyncio +async def test_router_aspeech_request_voice_overrides_deployment_default(respx_mock, monkeypatch): + import base64 + + monkeypatch.setenv("MISTRAL_API_KEY", "sk-mistral-test") + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + audio_bytes = b"RIFFfake-wav-bytes" + respx_mock.post("https://api.mistral.ai/v1/audio/speech").respond( + json={"audio_data": base64.b64encode(audio_bytes).decode()} + ) + router = Router( + model_list=[ + { + "model_name": "voxtral-tts", + "litellm_params": {"model": "mistral/voxtral-mini-tts-2603", "voice": "en_paul_neutral"}, + } + ] + ) + + await router.aspeech(model="voxtral-tts", input="override me", voice="gb_oliver_neutral") + + request_body = json.loads(respx_mock.calls.last.request.content) + assert request_body["voice_id"] == "gb_oliver_neutral" + + class TestPreRoutingTierDrivesFallbacks: """#38832: a complexity/auto router picks a tier behind the router name, but fallback lookup stayed on the router name, so the tier's configured chain never ran and a From 3190f42abf65e25053e59c2e8b5c9a96dd21c220 Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 3 Sep 2026 08:09:51 +0000 Subject: [PATCH 073/410] refactor: clear fresh tech debt from the last 24 hours (2026-09-03) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../model_armor/model_armor.py | 5 +--- litellm/responses/streaming_iterator.py | 19 +++++------- litellm/rust_bridge/runtime.py | 30 ------------------- type-discipline-budget.json | 4 +-- 4 files changed, 10 insertions(+), 48 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index 7d88a037f4f..f0de6ee0ac7 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -1095,10 +1095,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): add_guardrail_to_applied_guardrails_header, ) - # Collect all chunks - all_chunks: Final[list[Any]] = [] - async for chunk in response: - all_chunks.append(chunk) + all_chunks: Final[Sequence[Any]] = tuple([chunk async for chunk in response]) if not all_chunks or self._is_terminal_error_stream(all_chunks): for chunk in all_chunks: diff --git a/litellm/responses/streaming_iterator.py b/litellm/responses/streaming_iterator.py index f271655f5e3..9f9016c5a7f 100644 --- a/litellm/responses/streaming_iterator.py +++ b/litellm/responses/streaming_iterator.py @@ -496,10 +496,9 @@ class BaseResponsesAPIStreamingIterator: if logging_response is self.completed_response: return target: Final[object] = getattr(logging_response, "response", None) - existing_hidden: Final[object] = getattr(target, "_hidden_params", None) - if not isinstance(existing_hidden, Mapping): + if not isinstance(target, ResponsesAPIResponse): return - existing: Final[Mapping[str, object]] = existing_hidden + existing: Final[Mapping[str, object]] = target._hidden_params source_hidden: Final[object] = getattr( getattr(self.completed_response, "response", None), "_hidden_params", None ) @@ -510,15 +509,11 @@ class BaseResponsesAPIStreamingIterator: raw_headers: Final[Mapping[str, object]] = raw if isinstance(raw, Mapping) else EMPTY_MAPPING # rebuild by value and let existing keys win: sharing the source dicts would alias what the proxy # splats into the client's HTTP headers, and copying non-header keys would carry response_cost - setattr( # noqa: B010 # target is typed object here, so a plain attribute store does not type check - target, - "_hidden_params", - { # mutable-ok: the cost calculator writes optional_params into _hidden_params - "additional_headers": {**headers}, # mutable-ok: fresh copy, logging callbacks may mutate it - "headers": {**raw_headers}, # mutable-ok: fresh copy, logging callbacks may mutate it - **existing, - }, - ) + target._hidden_params = { # mutable-ok: the cost calculator writes optional_params into _hidden_params + "additional_headers": {**headers}, # mutable-ok: fresh copy, logging callbacks may mutate it + "headers": {**raw_headers}, # mutable-ok: fresh copy, logging callbacks may mutate it + **existing, + } def _handle_logging_completed_response(self): """Base implementation - should be overridden by subclasses""" diff --git a/litellm/rust_bridge/runtime.py b/litellm/rust_bridge/runtime.py index 00f06c046a2..d411673439f 100644 --- a/litellm/rust_bridge/runtime.py +++ b/litellm/rust_bridge/runtime.py @@ -116,28 +116,6 @@ async def aattempt( return RustHandled(adapt(value)) -def call(operation: Callable[[], ResultT], context: BridgeErrorContext) -> ResultT: - exceptions: Final = native_exception_types() - if exceptions is None: - return operation() - upstream: Final = exceptions[1] - try: - return operation() - except upstream as error: - _raise_upstream(error, context) - - -async def acall(operation: Callable[[], Awaitable[ResultT]], context: BridgeErrorContext) -> ResultT: - exceptions: Final = native_exception_types() - if exceptions is None: - return await operation() - upstream: Final = exceptions[1] - try: - return await operation() - except upstream as error: - _raise_upstream(error, context) - - def _decline_reason(error: BaseException) -> str: reason: Final[object] = error.args[0] if error.args else str(error) return reason if isinstance(reason, str) else str(reason) @@ -170,11 +148,3 @@ def _raise_upstream(error: BaseException, context: BridgeErrorContext) -> NoRetu llm_provider=context.provider, model=context.model, ) from error - - -def identity(value: ResultT) -> ResultT: - return value - - -async def async_none() -> None: - return None diff --git a/type-discipline-budget.json b/type-discipline-budget.json index d5bf3883be4..704d7e8a596 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22330 + "limit": 22329 }, "LIT002": { - "limit": 26763 + "limit": 26762 }, "LIT003": { "limit": 261 From 4e9c6b5dd436680b7c39b3df427c3f6651668f4c Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 3 Sep 2026 08:26:43 +0000 Subject: [PATCH 074/410] refactor(model_armor): type the buffered stream chunks as object Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- basedpyright-code-budget.json | 2 +- .../guardrails/guardrail_hooks/model_armor/model_armor.py | 2 +- type-discipline-budget.json | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 2967fc2a505..45a3856d246 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 4124 + "limit": 4123 }, "reportFunctionMemberAccess": { "limit": 7 diff --git a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py index f0de6ee0ac7..4a60f092bfb 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py +++ b/litellm/proxy/guardrails/guardrail_hooks/model_armor/model_armor.py @@ -1095,7 +1095,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase): add_guardrail_to_applied_guardrails_header, ) - all_chunks: Final[Sequence[Any]] = tuple([chunk async for chunk in response]) + all_chunks: Final[Sequence[object]] = tuple([chunk async for chunk in response]) if not all_chunks or self._is_terminal_error_stream(all_chunks): for chunk in all_chunks: diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 704d7e8a596..972bc3315f2 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22329 + "limit": 22328 }, "LIT002": { - "limit": 26762 + "limit": 26761 }, "LIT003": { "limit": 261 From c79d1d12ae75a8eaec1de15fe4f0de01330cc553 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:15:57 -0700 Subject: [PATCH 075/410] fix(ai-gateway): install a rustls crypto provider before dialing upstream WebSockets The gateway's dependency graph turns on two rustls crypto backends at once: reqwest's rustls-tls pulls in ring, and litellm-core's bedrock-auth pulls in aws-lc-rs through aws-config. rustls 0.23 refuses to guess between them, so ClientConfig::builder panics, and that is exactly how tokio-tungstenite builds its TLS config. Every outbound WebSocket dial killed its tokio worker and the client saw the socket vanish with no close frame. reqwest and the AWS SDK both pick a provider explicitly, so only the tungstenite path was affected. Route all three dial sites through one helper that installs ring once per process before connecting. --- litellm-rust/Cargo.lock | 1 + litellm-rust/Cargo.toml | 1 + litellm-rust/crates/ai-gateway/Cargo.toml | 3 + litellm-rust/crates/ai-gateway/src/io/mod.rs | 1 + .../crates/ai-gateway/src/io/realtime.rs | 6 +- .../crates/ai-gateway/src/io/responses_ws.rs | 12 ++-- litellm-rust/crates/ai-gateway/src/io/tls.rs | 67 +++++++++++++++++++ 7 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 litellm-rust/crates/ai-gateway/src/io/tls.rs diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index b3dac5ca935..2ed998174e4 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -1415,6 +1415,7 @@ dependencies = [ "litellm-core", "pyo3", "reqwest", + "rustls 0.23.42", "serde", "serde_json", "sha2 0.10.9", diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index a13dd4c04b0..15074082981 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -26,6 +26,7 @@ pythonize = "0.29.0" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "http2", "stream"] } rstest = "0.26.1" +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" diff --git a/litellm-rust/crates/ai-gateway/Cargo.toml b/litellm-rust/crates/ai-gateway/Cargo.toml index e3dbdf24ce6..414abc2356d 100644 --- a/litellm-rust/crates/ai-gateway/Cargo.toml +++ b/litellm-rust/crates/ai-gateway/Cargo.toml @@ -19,6 +19,9 @@ litellm-core = { workspace = true, features = ["bedrock-auth"] } # reqwest (rustls + json) is used by io/ocr and ships realtime logs to the # Python proxy callbacks API. reqwest.workspace = true +# rustls is a direct dependency so `io::tls` can install a process-level +# crypto provider; see that module for why the graph needs one. +rustls.workspace = true # `sync` powers the bounded mpsc channel the realtime logger drains. tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time", "sync"] } tokio-tungstenite.workspace = true diff --git a/litellm-rust/crates/ai-gateway/src/io/mod.rs b/litellm-rust/crates/ai-gateway/src/io/mod.rs index cce56dd2121..7098d67993f 100644 --- a/litellm-rust/crates/ai-gateway/src/io/mod.rs +++ b/litellm-rust/crates/ai-gateway/src/io/mod.rs @@ -3,3 +3,4 @@ pub mod ocr; pub mod realtime; pub mod realtime_pool; pub mod responses_ws; +pub(crate) mod tls; diff --git a/litellm-rust/crates/ai-gateway/src/io/realtime.rs b/litellm-rust/crates/ai-gateway/src/io/realtime.rs index 662f7328982..53d87848342 100644 --- a/litellm-rust/crates/ai-gateway/src/io/realtime.rs +++ b/litellm-rust/crates/ai-gateway/src/io/realtime.rs @@ -23,10 +23,12 @@ use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; use litellm_core::providers::openai::realtime::transformation::OPENAI_REALTIME_CONFIG; +use crate::io::tls::connect_upstream; + /// Environment variable holding the OpenAI API key (last-resort fallback). const OPENAI_API_KEY_ENV: &str = "OPENAI_API_KEY"; @@ -84,7 +86,7 @@ pub(crate) async fn dial_upstream( .map_err(|err| Error::Auth(err.to_string()))?, ); - let (upstream, _response) = connect_async(request) + let (upstream, _response) = connect_upstream(request) .await .map_err(|err| Error::Network(err.to_string()))?; Ok(upstream) diff --git a/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs b/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs index 0b01747b1a5..ee181791413 100644 --- a/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs +++ b/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs @@ -14,7 +14,9 @@ use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::http::HeaderValue; use tokio_tungstenite::tungstenite::http::header::{AUTHORIZATION, HeaderName}; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; + +use crate::io::tls::connect_upstream; use crate::constants::{ DEFAULT_RESPONSES_WS_CONNECT_TIMEOUT_SECS, DEFAULT_RESPONSES_WS_IDLE_TIMEOUT_SECS, @@ -49,14 +51,14 @@ impl ResponsesWebSocketConnection { .map_err(|error| Error::InvalidRequest(error.to_string()))?; request.headers_mut().insert(header_name, header_value); } - let connect = connect_async(request); + let connect = connect_upstream(request); let result = match timeout { Some(timeout) => tokio::time::timeout(timeout, connect).await.map_err(|_| { Error::Network("Responses WebSocket connection timed out".to_string()) })?, None => connect.await, }; - let (socket, _) = result.map_err(|error| match error { + let (socket, _) = result.map_err(|error| match *error { tokio_tungstenite::tungstenite::Error::Http(response) => Error::Http { status: response.status().as_u16(), body: String::new(), @@ -138,13 +140,13 @@ async fn dial_upstream( ); let result = tokio::time::timeout( Duration::from_secs(DEFAULT_RESPONSES_WS_CONNECT_TIMEOUT_SECS), - connect_async(request), + connect_upstream(request), ) .await .map_err(|_| Error::Network("Responses WebSocket connection timed out".to_string()))?; result .map(|(socket, _)| socket) - .map_err(|error| match error { + .map_err(|error| match *error { tokio_tungstenite::tungstenite::Error::Http(response) => Error::Http { status: response.status().as_u16(), body: String::new(), diff --git a/litellm-rust/crates/ai-gateway/src/io/tls.rs b/litellm-rust/crates/ai-gateway/src/io/tls.rs new file mode 100644 index 00000000000..e3754e44002 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/src/io/tls.rs @@ -0,0 +1,67 @@ +//! Outbound WebSocket dials, with the rustls crypto provider settled first. +//! +//! `reqwest/rustls-tls` enables `rustls/ring` and `litellm-core`'s `bedrock-auth` +//! enables `rustls/aws-lc-rs`, so `ClientConfig::builder()` — which is how +//! `tokio-tungstenite` builds its TLS config — panics rather than guess between +//! them. reqwest and the AWS SDK pick a provider explicitly and never panic. +//! +//! Installing from the dial rather than from a `main` also covers the `cdylib` +//! the Python bridge loads, the tests, and the benches, none of which have one. +//! ring is what reqwest already falls back to, so installing it changes no +//! working path, and an embedder that installed its own provider first keeps it. + +use std::sync::Once; + +use tokio::net::TcpStream; +use tokio_tungstenite::tungstenite::Error; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::handshake::client::Response; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; + +static INSTALL_CRYPTO_PROVIDER: Once = Once::new(); + +pub(crate) fn ensure_crypto_provider() { + INSTALL_CRYPTO_PROVIDER.call_once(|| { + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} + +pub(crate) async fn connect_upstream( + request: R, +) -> Result<(WebSocketStream>, Response), Box> +where + R: IntoClientRequest + Unpin, +{ + ensure_crypto_provider(); + connect_async(request).await.map_err(Box::new) +} + +#[cfg(test)] +mod tests { + use super::ensure_crypto_provider; + + #[test] + fn client_config_builder_works_with_both_provider_features_enabled() { + ensure_crypto_provider(); + + assert!(rustls::crypto::CryptoProvider::get_default().is_some()); + + let config = rustls::ClientConfig::builder() + .with_root_certificates(rustls::RootCertStore::empty()) + .with_no_client_auth(); + + assert!(!config.crypto_provider().cipher_suites.is_empty()); + } + + #[test] + fn ensure_crypto_provider_is_idempotent() { + ensure_crypto_provider(); + let first = rustls::crypto::CryptoProvider::get_default().cloned(); + + ensure_crypto_provider(); + let second = rustls::crypto::CryptoProvider::get_default().cloned(); + + assert!(first.is_some()); + assert!(std::sync::Arc::ptr_eq(&first.unwrap(), &second.unwrap())); + } +} From 2c08e7abf8bbf1d32aab48a330f4aadd494b5dd0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 02:27:00 -0700 Subject: [PATCH 076/410] test(ai-gateway): pin the crypto provider to ring and prove the dial installs it The two tests that shipped with the fix both called ensure_crypto_provider themselves, so deleting the call from connect_upstream left the whole suite green, and swapping ring for aws-lc-rs did too. Adds an integration test, which gets its own process, that dials wss:// at a local plain-TCP listener through the public Responses WebSocket entrypoint and asserts an Err plus an installed provider. Without the install in the dial it panics with the original CryptoProvider message. A unit test now compares the installed provider's cipher suites and key-exchange groups against ring's, so the choice of backend is pinned rather than assumed. Also names tls12 in the workspace rustls features: it already arrives through reqwest and tokio-rustls, so the graph is unchanged, but a direct dependency should say it needs TLS 1.2 rather than inherit it. --- litellm-rust/Cargo.toml | 2 +- litellm-rust/crates/ai-gateway/src/io/tls.rs | 43 +++++++++++++++++-- .../tests/crypto_provider_wiring.rs | 41 ++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 15074082981..2e2e8809b7f 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -26,7 +26,7 @@ pythonize = "0.29.0" rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "http2", "stream"] } rstest = "0.26.1" -rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" diff --git a/litellm-rust/crates/ai-gateway/src/io/tls.rs b/litellm-rust/crates/ai-gateway/src/io/tls.rs index e3754e44002..96544adffb7 100644 --- a/litellm-rust/crates/ai-gateway/src/io/tls.rs +++ b/litellm-rust/crates/ai-gateway/src/io/tls.rs @@ -8,7 +8,7 @@ //! Installing from the dial rather than from a `main` also covers the `cdylib` //! the Python bridge loads, the tests, and the benches, none of which have one. //! ring is what reqwest already falls back to, so installing it changes no -//! working path, and an embedder that installed its own provider first keeps it. +//! working path, and whoever installs into this rustls build first still wins. use std::sync::Once; @@ -38,13 +38,32 @@ where #[cfg(test)] mod tests { + use rustls::crypto::CryptoProvider; + use super::ensure_crypto_provider; + fn fingerprint( + provider: &CryptoProvider, + ) -> (Vec, Vec) { + ( + provider + .cipher_suites + .iter() + .map(|suite| suite.suite()) + .collect(), + provider + .kx_groups + .iter() + .map(|group| group.name()) + .collect(), + ) + } + #[test] fn client_config_builder_works_with_both_provider_features_enabled() { ensure_crypto_provider(); - assert!(rustls::crypto::CryptoProvider::get_default().is_some()); + assert!(CryptoProvider::get_default().is_some()); let config = rustls::ClientConfig::builder() .with_root_certificates(rustls::RootCertStore::empty()) @@ -53,13 +72,29 @@ mod tests { assert!(!config.crypto_provider().cipher_suites.is_empty()); } + #[test] + fn installs_ring_rather_than_aws_lc_rs() { + ensure_crypto_provider(); + + let installed = CryptoProvider::get_default().expect("a provider is installed"); + + assert_eq!( + fingerprint(installed), + fingerprint(&rustls::crypto::ring::default_provider()) + ); + assert_ne!( + fingerprint(installed), + fingerprint(&rustls::crypto::aws_lc_rs::default_provider()) + ); + } + #[test] fn ensure_crypto_provider_is_idempotent() { ensure_crypto_provider(); - let first = rustls::crypto::CryptoProvider::get_default().cloned(); + let first = CryptoProvider::get_default().cloned(); ensure_crypto_provider(); - let second = rustls::crypto::CryptoProvider::get_default().cloned(); + let second = CryptoProvider::get_default().cloned(); assert!(first.is_some()); assert!(std::sync::Arc::ptr_eq(&first.unwrap(), &second.unwrap())); diff --git a/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs b/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs new file mode 100644 index 00000000000..db5698f8460 --- /dev/null +++ b/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs @@ -0,0 +1,41 @@ +//! Guards the wiring, not just the helper: the dial itself has to install the +//! rustls provider, in a test binary where nothing else has installed one. + +use std::collections::HashMap; +use std::time::Duration; + +use litellm_ai_gateway::io::responses_ws::ResponsesWebSocketConnection; +use tokio::net::TcpListener; + +#[tokio::test] +async fn dialing_wss_returns_an_error_instead_of_panicking() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind a loopback port"); + let port = listener + .local_addr() + .expect("read the bound address") + .port(); + + tokio::spawn(async move { + while let Ok((stream, _peer)) = listener.accept().await { + drop(stream); + } + }); + + let result = ResponsesWebSocketConnection::connect_url( + &format!("wss://127.0.0.1:{port}/"), + &HashMap::new(), + Some(Duration::from_secs(10)), + ) + .await; + + assert!( + result.is_err(), + "a plain TCP server cannot finish a TLS handshake" + ); + assert!( + rustls::crypto::CryptoProvider::get_default().is_some(), + "the dial is what installs the process-wide provider" + ); +} From 753bea360e8b0921b9a5fe02ba860f558a147e39 Mon Sep 17 00:00:00 2001 From: mateo Date: Thu, 3 Sep 2026 10:38:20 +0000 Subject: [PATCH 077/410] test: deflake guardrail mapping leak, tag routing randomness, and liveliness timing TestStreamingScanDedup restored the reduced module-level translation mapping on teardown via monkeypatch, so under --dist=loadscope the worker that ran only that class carried the reduced mapping into the streaming block test modules. Tag routing tests now assert the eligible deployment set directly instead of sampling ten random picks. The liveliness latency check measures steady-state polls after a warm-up request rather than the first request through a fresh app. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../test_unified_guardrail.py | 10 +++--- .../health_endpoints/test_health_endpoints.py | 30 +++++++--------- .../test_router_tag_routing.py | 36 +++++++++---------- 3 files changed, 33 insertions(+), 43 deletions(-) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py index a28a2a71613..ceaf7c49595 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/unified_guardrails/test_unified_guardrail.py @@ -2037,12 +2037,10 @@ class TestStreamingScanDedup: guardrail already cleared. Regression for LIT-6692.""" @pytest.fixture(autouse=True) - def _use_real_mappings(self, monkeypatch): - monkeypatch.setattr( - unified_module, - "endpoint_guardrail_translation_mappings", - load_guardrail_translation_mappings(), - ) + def _use_real_mappings(self): + unified_module.endpoint_guardrail_translation_mappings = load_guardrail_translation_mappings() + yield + unified_module.endpoint_guardrail_translation_mappings = None @pytest.mark.asyncio async def test_chat_terminal_chunk_on_sampled_index_is_scanned_once(self): diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index e3f71692c78..619f6736ea3 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -1,6 +1,7 @@ import asyncio import json import time +from typing import Final from datetime import datetime, timedelta from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -1189,27 +1190,22 @@ def test_health_liveliness_endpoint(proxy_client): Test that /health/liveliness endpoint returns 200 OK with "I'm alive!" message. This is a critical orchestration endpoint that must be simple and fast. """ - # Measure the time taken for the health check call - start_time = time.perf_counter() + warm_up: Final = proxy_client.get("/health/liveliness") + assert warm_up.status_code == 200, f"Expected 200 OK, got {warm_up.status_code}: {warm_up.text}" - # Make GET request to /health/liveliness - response = proxy_client.get("/health/liveliness") + def _timed_poll() -> tuple[float, httpx.Response]: + start_time: Final = time.perf_counter() + response: Final = proxy_client.get("/health/liveliness") + return (time.perf_counter() - start_time) * 1000, response - end_time = time.perf_counter() - duration_ms = (end_time - start_time) * 1000 + polls: Final = tuple(_timed_poll() for _ in range(5)) - # Assert response status - assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}: {response.text}" + for _, response in polls: + assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}: {response.text}" + assert response.json() == "I'm alive!", f"Expected 'I'm alive!' message, got: {response.json()}" - # Assert response content (FastAPI JSON-encodes the string) - assert response.json() == "I'm alive!", f"Expected 'I'm alive!' message, got: {response.json()}" - - # Verify response is fast (should be < 100ms for a simple endpoint) - # This is critical for orchestration systems that poll frequently - assert duration_ms < 100, f"Health check took {duration_ms:.2f}ms, expected < 100ms for a simple endpoint" - - # Log the duration for visibility (useful for CI/CD monitoring) - print(f"\n/health/liveliness response time: {duration_ms:.2f}ms") + fastest_ms: Final = min(duration_ms for duration_ms, _ in polls) + assert fastest_ms < 100, f"Fastest of {len(polls)} health checks took {fastest_ms:.2f}ms, expected < 100ms" def test_health_liveness_endpoint(proxy_client): diff --git a/tests/test_litellm/router_strategy/test_router_tag_routing.py b/tests/test_litellm/router_strategy/test_router_tag_routing.py index b33bd912be9..27f871ed39f 100644 --- a/tests/test_litellm/router_strategy/test_router_tag_routing.py +++ b/tests/test_litellm/router_strategy/test_router_tag_routing.py @@ -5,10 +5,22 @@ import pytest import logging +from typing import Final import litellm from litellm._logging import verbose_logger +from litellm.router_strategy.tag_based_routing import get_deployments_for_tag + + +async def _eligible_deployment_ids(router: litellm.Router, model: str, tags: list[str]) -> set[str]: + eligible: Final = await get_deployments_for_tag( + llm_router_instance=router, + model=model, + healthy_deployments=router.get_model_list(model_name=model) or [], + request_kwargs={"metadata": {"tags": tags}}, + ) + return {deployment["model_info"]["id"] for deployment in eligible} @pytest.mark.asyncio() @@ -850,17 +862,9 @@ async def test_negation_regex_pattern_treated_as_literal(): # The regex-like string matches no deployment tag literally, so all # candidates survive and both model IDs are reachable. - seen_ids = set() - for _ in range(10): - response = await router.acompletion( - model="gpt-4", - messages=[{"role": "user", "content": "hi"}], - metadata={"tags": ["!provider:(anthropic|openai)"]}, - mock_response="hi", - ) - seen_ids.add(response._hidden_params["model_id"]) + eligible_ids: Final = await _eligible_deployment_ids(router, "gpt-4", ["!provider:(anthropic|openai)"]) - assert seen_ids == {"anthropic-model", "openai-model"} + assert eligible_ids == {"anthropic-model", "openai-model"} @pytest.mark.asyncio() @@ -1281,17 +1285,9 @@ async def test_chain_enable_tag_filtering_false_overrides_router_level_true(): enable_tag_filtering=True, ) - seen_ids = set() - for _ in range(10): - response = await router.acompletion( - model="gpt-4", - messages=[{"role": "user", "content": "hi"}], - metadata={"tags": ["teamA"]}, - mock_response="hi", - ) - seen_ids.add(response._hidden_params["model_id"]) + eligible_ids: Final = await _eligible_deployment_ids(router, "gpt-4", ["teamA"]) - assert seen_ids == {"team-a-deployment", "team-b-deployment"} + assert eligible_ids == {"team-a-deployment", "team-b-deployment"} @pytest.mark.asyncio() From 35c6a768c0cdce2a618c9579c3215279b7d6c8cd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:42:55 +0000 Subject: [PATCH 078/410] fix(spend-tracking): keep internal service-account key names readable in spend logs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../spend_tracking/spend_tracking_utils.py | 13 ++++-- .../test_spend_tracking_utils.py | 43 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 7442d71bd96..a1f0dbfcefe 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -14,6 +14,8 @@ from litellm.constants import ( LITELLM_PROXY_MASTER_KEY_ALIAS, LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + LITTELM_CLI_SERVICE_ACCOUNT_NAME, + LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, REDACTED_BY_LITELM_STRING, ) from litellm.constants import ( @@ -72,13 +74,18 @@ def _is_master_key(api_key: str | None, _master_key: str | None) -> bool: _HASHED_JWT_RE = re.compile(r"hashed-jwt-[a-fA-F0-9]{64}") +_NON_SECRET_KEY_ALIASES: Final = frozenset( + { + LITELLM_PROXY_MASTER_KEY_ALIAS, + LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, + LITTELM_CLI_SERVICE_ACCOUNT_NAME, + } +) def _is_non_secret_key_value(value: str) -> bool: return ( - value == LITELLM_PROXY_MASTER_KEY_ALIAS - or is_valid_sha256_hash(value) - or _HASHED_JWT_RE.fullmatch(value) is not None + value in _NON_SECRET_KEY_ALIASES or is_valid_sha256_hash(value) or _HASHED_JWT_RE.fullmatch(value) is not None ) diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py index 9e5917637a8..f2164547a6f 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_tracking_utils.py @@ -13,9 +13,13 @@ import litellm from litellm.constants import ( LITELLM_TRUNCATED_PAYLOAD_FIELD, LITELLM_TRUNCATION_DB_SAFEGUARD_NOTE, + LITTELM_CLI_SERVICE_ACCOUNT_NAME, + LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, REDACTED_BY_LITELM_STRING, ) from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.litellm_pre_call_utils import LiteLLMProxyRequestSetup from litellm.proxy.spend_tracking.spend_tracking_utils import ( _get_messages_for_spend_logs_payload, _get_proxy_server_request_for_spend_logs_payload, @@ -3044,6 +3048,45 @@ def test_get_logging_payload_keeps_master_key_alias_readable(): assert parsed_meta["user_api_key"] == LITELLM_PROXY_MASTER_KEY_ALIAS +@pytest.mark.parametrize( + "service_account", + [LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME, LITTELM_CLI_SERVICE_ACCOUNT_NAME], +) +def test_get_logging_payload_keeps_internal_service_account_key_readable(service_account: str): + data = LiteLLMProxyRequestSetup.add_user_api_key_auth_to_request_metadata( + data={"metadata": {}}, + user_api_key_dict=UserAPIKeyAuth( + api_key=service_account, + team_id=service_account, + key_alias=service_account, + team_alias=service_account, + ), + _metadata_variable_name="metadata", + ) + kwargs = { + "model": "openai/gpt-4.1", + "messages": [{"role": "user", "content": "Hello"}], + "call_type": "acompletion", + "litellm_params": {"metadata": data["metadata"]}, + } + payload = get_logging_payload( + kwargs=kwargs, + response_obj=Exception("error"), + start_time=datetime.datetime.now(timezone.utc), + end_time=datetime.datetime.now(timezone.utc), + ) + + assert payload["api_key"] == service_account + parsed_meta = json.loads(payload["metadata"]) + assert parsed_meta["user_api_key"] == service_account + assert parsed_meta["user_api_key_alias"] == service_account + + +def test_redact_logged_api_key_service_account_name_without_provenance_is_hashed(): + result = _redact_logged_api_key(LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME) + assert result == hash_token(LITTELM_INTERNAL_HEALTH_SERVICE_ACCOUNT_NAME) + + @patch("litellm.proxy.proxy_server.master_key", None) @patch("litellm.proxy.proxy_server.general_settings", {}) def test_get_logging_payload_hashes_bearer_prefixed_api_key(): From 35d3478818c3b26aeac18fbea63ed7da512d0514 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 13:27:48 -0700 Subject: [PATCH 079/410] fix(responses/mcp): keep reasoning order and caller previous_response_id on stateless follow-ups --- litellm/responses/main.py | 2 +- .../mcp/litellm_proxy_mcp_handler.py | 36 ++------- .../responses/mcp/mcp_streaming_iterator.py | 2 - .../mcp/test_litellm_proxy_mcp_handler.py | 74 +++++++++++++++++-- .../mcp/test_mcp_streaming_iterator.py | 8 +- 5 files changed, 81 insertions(+), 41 deletions(-) diff --git a/litellm/responses/main.py b/litellm/responses/main.py index abf8fefe78a..ed2d6a216fd 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -352,7 +352,7 @@ async def aresponses_api_with_mcp( follow_up_input=follow_up_input, model=model, all_tools=all_tools, - response_id=None if persistence_disabled else response.id, + response_id=previous_response_id if persistence_disabled else response.id, **follow_up_call_params, ) diff --git a/litellm/responses/mcp/litellm_proxy_mcp_handler.py b/litellm/responses/mcp/litellm_proxy_mcp_handler.py index 7656dbd38df..15434bedbb7 100644 --- a/litellm/responses/mcp/litellm_proxy_mcp_handler.py +++ b/litellm/responses/mcp/litellm_proxy_mcp_handler.py @@ -965,22 +965,9 @@ class LiteLLM_Proxy_MCP_Handler: @staticmethod def _is_persistence_disabled(call_params: Mapping[str, object]) -> bool: - """Whether the caller opted out of server-side response persistence (store=false). - - Zero data retention callers send store=false, so the provider never persisted the - first response and previous_response_id cannot be used to link the follow-up call. - """ + """store=false means the provider kept nothing, so the follow-up call cannot chain on a response id.""" return call_params.get("store") is False - @staticmethod - def _extract_reasoning_items(response: ResponsesAPIResponse) -> tuple[Mapping[str, object], ...]: - """Reasoning output items, kept whole so reasoning.encrypted_content survives replay.""" - normalized: Final = tuple( - output_item if isinstance(output_item, dict) else output_item.model_dump(exclude_none=True) - for output_item in response.output - ) - return tuple(item for item in normalized if item.get("type") == "reasoning") - @staticmethod def _create_follow_up_input( response: ResponsesAPIResponse, @@ -1002,11 +989,11 @@ class LiteLLM_Proxy_MCP_Handler: # Add the assistant message with function calls assistant_message_content: Final[list[object]] = [] - function_calls: Final[list[dict[str, object]]] = [] + turn_items: Final[list[Mapping[str, object]]] = [] for output_item in response.output: if not isinstance(output_item, dict) and hasattr(output_item, "model_dump"): - output_item = output_item.model_dump() + output_item = output_item.model_dump(exclude_none=True) if isinstance(output_item, dict): if output_item.get("type") == "function_call": @@ -1016,7 +1003,7 @@ class LiteLLM_Proxy_MCP_Handler: # Only add if we have required fields if call_id and name: - function_calls.append( + turn_items.append( { "type": "function_call", "call_id": call_id, @@ -1024,6 +1011,8 @@ class LiteLLM_Proxy_MCP_Handler: "arguments": arguments, } ) + elif output_item.get("type") == "reasoning" and preserve_reasoning: + turn_items.append(output_item) elif output_item.get("type") == "message": # Extract content from message content = output_item.get("content", []) @@ -1044,12 +1033,7 @@ class LiteLLM_Proxy_MCP_Handler: } ) - if preserve_reasoning: - follow_up_input.extend(LiteLLM_Proxy_MCP_Handler._extract_reasoning_items(response)) - - # Add function calls (these can come directly after user message for LLM) - for function_call in function_calls: - follow_up_input.append(function_call) + follow_up_input.extend(turn_items) # Add tool results (function call outputs) for tool_result in tool_results: @@ -1071,11 +1055,7 @@ class LiteLLM_Proxy_MCP_Handler: response_id: str | None, **call_params: Any, ) -> ResponsesAPIResponse | BaseResponsesAPIStreamingIterator: - """Make follow-up response API call with tool results. - - response_id is None for stateless (store=false) requests, where the whole prior - turn is replayed in follow_up_input instead of linked by previous_response_id. - """ + """Make follow-up response API call with tool results.""" return await aresponses( input=follow_up_input, model=model, diff --git a/litellm/responses/mcp/mcp_streaming_iterator.py b/litellm/responses/mcp/mcp_streaming_iterator.py index 83322b8d837..ca12b3e7cc3 100644 --- a/litellm/responses/mcp/mcp_streaming_iterator.py +++ b/litellm/responses/mcp/mcp_streaming_iterator.py @@ -800,8 +800,6 @@ class MCPEnhancedStreamingIterator(BaseResponsesAPIStreamingIterator): "stream": True, } ) - if persistence_disabled: - follow_up_params.pop("previous_response_id", None) else: return # Remove tool_choice to avoid forcing more tool calls diff --git a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py index 8ebea685d5a..80151d0cba8 100644 --- a/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py +++ b/tests/test_litellm/responses/mcp/test_litellm_proxy_mcp_handler.py @@ -785,6 +785,59 @@ def test_create_follow_up_input_preserves_reasoning_when_stateless(): } +def _response_with_interleaved_reasoning_and_tool_calls() -> Any: + """A first-turn response that reasons before each of two function calls.""" + return ResponsesAPIResponse( + id="resp_first", + created_at=1234567890, + model="gpt-5", + object="response", + status="completed", + output=[ + {"type": "reasoning", "id": "rs_1", "summary": [], "encrypted_content": "blob-1"}, + {"type": "function_call", "id": "fc_1", "call_id": "call-1", "name": "foo", "arguments": "{}"}, + {"type": "reasoning", "id": "rs_2", "summary": [], "encrypted_content": "blob-2"}, + {"type": "function_call", "id": "fc_2", "call_id": "call-2", "name": "bar", "arguments": "{}"}, + ], + parallel_tool_calls=False, + tool_choice="auto", + tools=[], + ) + + +def test_create_follow_up_input_keeps_each_reasoning_item_before_its_function_call(): + """ + Regression test (LIT-5427): the provider pairs a replayed reasoning item with the + item that follows it, so the replay has to keep the response's output order instead + of grouping every reasoning item ahead of every function call. + """ + follow_up = LiteLLM_Proxy_MCP_Handler._create_follow_up_input( + response=_response_with_interleaved_reasoning_and_tool_calls(), + tool_results=[ + {"tool_call_id": "call-1", "name": "foo", "result": "one"}, + {"tool_call_id": "call-2", "name": "bar", "result": "two"}, + ], + original_input="hi", + preserve_reasoning=True, + ) + + assert [cast(dict[str, Any], item)["type"] for item in follow_up] == [ + "message", + "reasoning", + "function_call", + "reasoning", + "function_call", + "function_call_output", + "function_call_output", + ] + assert [cast(dict[str, Any], item).get("id") or cast(dict[str, Any], item).get("call_id") for item in follow_up[1:5]] == [ + "rs_1", + "call-1", + "rs_2", + "call-2", + ] + + def test_create_follow_up_input_omits_reasoning_when_stateful(): """With store=true the provider still holds the reasoning item, so don't resend it.""" follow_up = LiteLLM_Proxy_MCP_Handler._create_follow_up_input( @@ -810,17 +863,25 @@ def test_is_persistence_disabled(call_params: dict[str, Any], expected: bool): @pytest.mark.parametrize( - "store, expected_previous_response_id", - [(False, None), (True, "resp_first")], + "store, caller_previous_response_id, expected_previous_response_id", + [ + (False, None, None), + (False, "resp_caller", "resp_caller"), + (True, None, "resp_first"), + (True, "resp_caller", "resp_first"), + ], ) @pytest.mark.asyncio async def test_mcp_follow_up_call_is_stateless_when_store_is_false( - monkeypatch: pytest.MonkeyPatch, store: bool, expected_previous_response_id: str | None + monkeypatch: pytest.MonkeyPatch, + store: bool, + caller_previous_response_id: str | None, + expected_previous_response_id: str | None, ): """ - Regression test (LIT-5427): linking the MCP follow-up call with - previous_response_id fails for zero data retention callers, because store=false - means the first response was never persisted. + Regression test (LIT-5427): linking the MCP follow-up call to the first response's id + fails for zero data retention callers, because store=false means it was never persisted. + The caller's own previous_response_id was valid for the first call, so it stays. """ captured_calls: list[dict[str, Any]] = [] first_response = _response_with_reasoning_and_tool_call() @@ -857,6 +918,7 @@ async def test_mcp_follow_up_call_is_stateless_when_store_is_false( model="gpt-5", tools=[{"type": "mcp", "server_url": "litellm_proxy", "require_approval": "never"}], store=store, + previous_response_id=caller_previous_response_id, ) assert len(captured_calls) == 2 diff --git a/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py index ac0c5ef6392..5001589ce54 100644 --- a/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py +++ b/tests/test_litellm/responses/mcp/test_mcp_streaming_iterator.py @@ -265,11 +265,11 @@ def _reasoning_item(encrypted_content: str): @pytest.mark.asyncio -async def test_streaming_follow_up_is_stateless_when_store_is_false(monkeypatch): +async def test_streaming_follow_up_replays_reasoning_when_store_is_false(monkeypatch): """ Regression test (LIT-5427): with store=false the provider persisted nothing, so the - streaming follow-up must drop previous_response_id and replay the reasoning item - (carrying reasoning.encrypted_content) instead of pointing at a response id. + streaming follow-up must replay the reasoning item (carrying reasoning.encrypted_content). + The caller's own previous_response_id was valid for the first call and stays on the follow-up. """ _mock_mcp_environment(monkeypatch) @@ -300,7 +300,7 @@ async def test_streaming_follow_up_is_stateless_when_store_is_false(monkeypatch) assert aresponses_mock.call_count == 1 follow_up_kwargs = aresponses_mock.call_args_list[0].kwargs - assert "previous_response_id" not in follow_up_kwargs + assert follow_up_kwargs["previous_response_id"] == "resp_prev" assert _reasoning_item("gAAAAA-opaque-blob") in follow_up_kwargs["input"] From 534ab1628dc462912179660774af395fdce50839 Mon Sep 17 00:00:00 2001 From: Riddhi04 Date: Wed, 22 Jul 2026 15:58:37 +0400 Subject: [PATCH 080/410] fix(proxy): enforce model access checks on Bedrock passthrough routes get_model_from_request could not resolve a model for /bedrock/... routes since it only checked the JSON body's model field and a small set of URL regexes, none matching Bedrock's passthrough path. This let common_checks skip the key/project model allowlist entirely for any Bedrock passthrough action (invoke, converse, and their streaming variants), while the same model was correctly blocked on /v1/chat/completions Extract the model from the Bedrock endpoint path using the same helper the passthrough handler itself relies on, so the existing allowlist check applies uniformly across auth methods and call paths --- litellm/proxy/auth/auth_utils.py | 11 ++++ .../proxy/auth/test_auth_utils.py | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index d6007a2d56e..43e60e49e3b 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -1981,6 +1981,17 @@ def get_model_from_request( if vertex_match: model = vertex_match.group(1) + if model is None and route.lower().startswith("/bedrock"): + from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( + _extract_model_from_bedrock_endpoint, + ) + + bedrock_endpoint = re.sub(r"^/bedrock/", "", route, flags=re.IGNORECASE) + try: + model = _extract_model_from_bedrock_endpoint(bedrock_endpoint) + except ValueError: + model = None + return model diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index f513f397b64..f3426534c28 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -428,6 +428,56 @@ def test_get_model_from_request_openai_deployment_route_still_works(): ) +def test_get_model_from_request_bedrock_converse_passthrough(): + assert ( + get_model_from_request( + request_data={}, + route="/bedrock/model/us.anthropic.claude-sonnet-4-6/converse", + ) + == "us.anthropic.claude-sonnet-4-6" + ) + + +def test_get_model_from_request_bedrock_invoke_passthrough(): + assert ( + get_model_from_request( + request_data={}, + route="/bedrock/model/us.anthropic.claude-sonnet-4-6/invoke", + ) + == "us.anthropic.claude-sonnet-4-6" + ) + + +def test_get_model_from_request_bedrock_v2_converse_stream_passthrough(): + assert ( + get_model_from_request( + request_data={}, + route="/bedrock/v2/model/us.anthropic.claude-sonnet-4-6/converse-stream", + ) + == "us.anthropic.claude-sonnet-4-6" + ) + + +def test_get_model_from_request_bedrock_model_id_with_slashes(): + assert ( + get_model_from_request( + request_data={}, + route="/bedrock/model/aws/anthropic/model-name/invoke", + ) + == "aws/anthropic/model-name" + ) + + +def test_get_model_from_request_bedrock_unparseable_endpoint_returns_none(): + assert ( + get_model_from_request( + request_data={}, + route="/bedrock/agents/some-agent-route", + ) + is None + ) + + def test_get_model_from_request_includes_file_endpoint_header_model(): assert ( get_model_from_request( From 6981ccf0ca1c3e1937f71c3fce287c898a15feba Mon Sep 17 00:00:00 2001 From: Riddhi04 Date: Fri, 24 Jul 2026 16:38:34 +0400 Subject: [PATCH 081/410] fix(proxy): make URL model authoritative for Bedrock path-routed passthrough actions The allowlist check read model from the request body first, while bedrock_llm_proxy_route dispatches purely on the path model for invoke, converse, and their streaming variants. A caller could put an allowed model in the JSON body while targeting a disallowed model in the URL and slip past the check. count_tokens keeps reading from the body since its route has no model segment in the path. --- litellm/proxy/auth/auth_utils.py | 22 +++++----- .../proxy/auth/test_auth_utils.py | 40 +++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 43e60e49e3b..fd746d62e76 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -1981,16 +1981,20 @@ def get_model_from_request( if vertex_match: model = vertex_match.group(1) - if model is None and route.lower().startswith("/bedrock"): - from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( - _extract_model_from_bedrock_endpoint, - ) - + if route.lower().startswith("/bedrock"): bedrock_endpoint = re.sub(r"^/bedrock/", "", route, flags=re.IGNORECASE) - try: - model = _extract_model_from_bedrock_endpoint(bedrock_endpoint) - except ValueError: - model = None + is_bedrock_count_tokens_route = ( + "count_tokens" in bedrock_endpoint.lower() or "count-tokens" in bedrock_endpoint.lower() + ) + if not is_bedrock_count_tokens_route: + from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( + _extract_model_from_bedrock_endpoint, + ) + + try: + model = _extract_model_from_bedrock_endpoint(bedrock_endpoint) + except ValueError: + pass return model diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index f3426534c28..61d1a308b9d 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -478,6 +478,46 @@ def test_get_model_from_request_bedrock_unparseable_endpoint_returns_none(): ) +def test_get_model_from_request_bedrock_url_model_overrides_body_model(): + assert ( + get_model_from_request( + request_data={"model": "us.anthropic.claude-sonnet-4-6"}, + route="/bedrock/model/us.anthropic.claude-opus-4-6-v1/converse", + ) + == "us.anthropic.claude-opus-4-6-v1" + ) + + +def test_get_model_from_request_bedrock_invoke_url_model_overrides_body_model(): + assert ( + get_model_from_request( + request_data={"model": "us.anthropic.claude-sonnet-4-6"}, + route="/bedrock/model/us.anthropic.claude-opus-4-6-v1/invoke", + ) + == "us.anthropic.claude-opus-4-6-v1" + ) + + +def test_get_model_from_request_bedrock_count_tokens_uses_body_model(): + assert ( + get_model_from_request( + request_data={"model": "us.anthropic.claude-sonnet-4-6"}, + route="/bedrock/v1/messages/count_tokens", + ) + == "us.anthropic.claude-sonnet-4-6" + ) + + +def test_get_model_from_request_bedrock_unparseable_endpoint_keeps_body_model(): + assert ( + get_model_from_request( + request_data={"model": "us.anthropic.claude-sonnet-4-6"}, + route="/bedrock/agents/some-agent-route", + ) + == "us.anthropic.claude-sonnet-4-6" + ) + + def test_get_model_from_request_includes_file_endpoint_header_model(): assert ( get_model_from_request( From 32293295f8fe9811a18179b556e12cdb6c4a11bc Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:40:35 -0700 Subject: [PATCH 082/410] refactor(proxy): resolve the Bedrock route model through an early return The Bedrock branch of get_model_from_request reassigned the already resolved model binding. Move the route parsing into a helper that returns the URL model or None so the caller picks between it and the body model without rebinding. --- litellm/proxy/auth/auth_utils.py | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index fd746d62e76..12e6f75889e 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -1982,23 +1982,26 @@ def get_model_from_request( model = vertex_match.group(1) if route.lower().startswith("/bedrock"): - bedrock_endpoint = re.sub(r"^/bedrock/", "", route, flags=re.IGNORECASE) - is_bedrock_count_tokens_route = ( - "count_tokens" in bedrock_endpoint.lower() or "count-tokens" in bedrock_endpoint.lower() - ) - if not is_bedrock_count_tokens_route: - from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( - _extract_model_from_bedrock_endpoint, - ) - - try: - model = _extract_model_from_bedrock_endpoint(bedrock_endpoint) - except ValueError: - pass + bedrock_model: Final = _model_from_bedrock_route(route) + return model if bedrock_model is None else bedrock_model return model +def _model_from_bedrock_route(route: str) -> str | None: + bedrock_endpoint: Final = re.sub(r"^/bedrock/", "", route, flags=re.IGNORECASE) + if "count_tokens" in bedrock_endpoint.lower() or "count-tokens" in bedrock_endpoint.lower(): + return None + from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( + _extract_model_from_bedrock_endpoint, + ) + + try: + return _extract_model_from_bedrock_endpoint(bedrock_endpoint) + except ValueError: + return None + + def abbreviate_api_key(api_key: str) -> str: if len(api_key) < MINIMUM_CUSTOM_KEY_LENGTH: return "sk-..." From 361170f9d4ec67d92eec187710f93ec81de9f729 Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 3 Sep 2026 23:19:04 +0000 Subject: [PATCH 083/410] feat(organization): expose PATCH /v2/organization/{organization_id} in the OpenAPI spec Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/organization_endpoints.py | 1 - .../test_organization_endpoints.py | 14 ++++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 5e38a016099..35a1380a619 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -764,7 +764,6 @@ async def handle_update_object_permission( tags=["organization management"], dependencies=[Depends(user_api_key_auth)], response_model=LiteLLM_OrganizationTableWithMembers, - include_in_schema=False, ) async def update_organization_v2( organization_id: str, 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 e2d89a660c2..5289f4f2d8f 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py @@ -1063,3 +1063,17 @@ async def test_find_member_if_email_missing_row_raises_documented_400(): "non-existent user_email in LiteLLM_UserTable. Use 'user_id' instead." ) } + + +def test_v2_update_organization_is_in_openapi_schema(): + """PATCH /v2/organization/{organization_id} is documented in the generated OpenAPI spec.""" + from fastapi import FastAPI + + from litellm.proxy.management_endpoints.organization_endpoints import router + + app = FastAPI() + app.include_router(router) + + v2_path = app.openapi()["paths"]["/v2/organization/{organization_id}"] + assert v2_path["patch"]["tags"] == ["organization management"] + assert "OrganizationUpdateRequestV2" in json.dumps(v2_path["patch"]["requestBody"]) From bd9593b74d4a05e265225a43afa0fd6c8d837484 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 16:41:31 -0700 Subject: [PATCH 084/410] fix(proxy): share the Bedrock count-tokens predicate between auth and the passthrough handler --- litellm/proxy/auth/auth_utils.py | 7 ++++--- .../llm_passthrough_endpoints.py | 7 +++++-- tests/test_litellm/proxy/auth/test_auth_utils.py | 10 ++++++++++ 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/auth/auth_utils.py b/litellm/proxy/auth/auth_utils.py index 12e6f75889e..1e4836654a1 100644 --- a/litellm/proxy/auth/auth_utils.py +++ b/litellm/proxy/auth/auth_utils.py @@ -1989,13 +1989,14 @@ def get_model_from_request( def _model_from_bedrock_route(route: str) -> str | None: - bedrock_endpoint: Final = re.sub(r"^/bedrock/", "", route, flags=re.IGNORECASE) - if "count_tokens" in bedrock_endpoint.lower() or "count-tokens" in bedrock_endpoint.lower(): - return None from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( _extract_model_from_bedrock_endpoint, + is_bedrock_count_tokens_endpoint, ) + bedrock_endpoint: Final = re.sub(r"^/bedrock/", "", route, flags=re.IGNORECASE) + if is_bedrock_count_tokens_endpoint(bedrock_endpoint): + return None try: return _extract_model_from_bedrock_endpoint(bedrock_endpoint) except ValueError: diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 29f216fd450..820312ac0fd 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -703,6 +703,10 @@ BEDROCK_ENDPOINT_ACTIONS: Final = { BEDROCK_STREAMING_ACTIONS: Final = {"invoke-with-response-stream", "converse-stream"} +def is_bedrock_count_tokens_endpoint(endpoint: str) -> bool: + return "count_tokens" in endpoint or "count-tokens" in endpoint + + def _extract_model_from_bedrock_endpoint(endpoint: str) -> str: """ Extract model name from Bedrock endpoint path. @@ -977,8 +981,7 @@ async def bedrock_llm_proxy_route( request_body: Final = await _read_request_body(request=request) - # Special handling for count_tokens endpoints - if "count_tokens" in endpoint or "count-tokens" in endpoint: + if is_bedrock_count_tokens_endpoint(endpoint): return await handle_bedrock_count_tokens( endpoint=endpoint, request=request, diff --git a/tests/test_litellm/proxy/auth/test_auth_utils.py b/tests/test_litellm/proxy/auth/test_auth_utils.py index 61d1a308b9d..a996de4d40c 100644 --- a/tests/test_litellm/proxy/auth/test_auth_utils.py +++ b/tests/test_litellm/proxy/auth/test_auth_utils.py @@ -508,6 +508,16 @@ def test_get_model_from_request_bedrock_count_tokens_uses_body_model(): ) +def test_get_model_from_request_bedrock_uppercase_count_tokens_segment_is_not_count_tokens(): + assert ( + get_model_from_request( + request_data={"model": "us.anthropic.claude-haiku-4-5-20251001-v1:0"}, + route="/bedrock/model/us.anthropic.claude-sonnet-4-6/invoke/COUNT_TOKENS", + ) + == "us.anthropic.claude-sonnet-4-6" + ) + + def test_get_model_from_request_bedrock_unparseable_endpoint_keeps_body_model(): assert ( get_model_from_request( From 9ba6cab889c01edd360cac5a4b38e6a942bcafbf Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 3 Sep 2026 23:59:58 +0000 Subject: [PATCH 085/410] fix(ui): make Admin UI table pagination honor the selected page size All Models now pushes the model group, access group and wildcard filters into /v2/model/info (new optional access_group and wildcard_only params) so the server total_count matches the rendered rows. Request Logs defaults to 25, uses the shared page size options and counts rendered rows in the footer. Deleted Teams gets the shared DataTable server pagination footer instead of a hard-coded page size of 100. Per-user usage and the remaining unbounded list tables get paginationMode so the size selector renders. Resolves LIT-4738 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/proxy_server.py | 36 +++++++- .../proxy_server/test_routes_model_info.py | 89 +++++++++++++++++++ .../agents/_components/AgentsTable.tsx | 1 + .../_components/guardrail_table.tsx | 1 + .../app/(dashboard)/hooks/models/useModels.ts | 6 ++ .../(dashboard)/hooks/teams/useTeams.test.ts | 24 ++++- .../app/(dashboard)/hooks/teams/useTeams.ts | 21 +++-- .../_components/MCPToolsetsTab.tsx | 1 + .../components/AllModelsTab.test.tsx | 49 ++++++++-- .../components/AllModelsTab.tsx | 32 ++----- .../panels/AccessGroupBudgetsPanel.tsx | 1 + .../_components/OrganizationsTable.test.tsx | 17 ++++ .../_components/OrganizationsTable.tsx | 1 + .../policies/_components/AttachmentTable.tsx | 1 + .../policies/_components/PolicyTable.tsx | 1 + .../prompts/_components/PromptTable.tsx | 1 + .../_components/SearchToolTable.tsx | 1 + .../skills/_components/PluginTable.tsx | 1 + .../tag-management/_components/TagTable.tsx | 1 + .../_components/IndexesTable.tsx | 1 + .../_components/VectorStoreTable.tsx | 1 + .../src/components/AIHub/ModelHubTable.tsx | 3 + .../components/AIHub/SkillHubDashboard.tsx | 1 + .../DeletedTeamsPage.test.tsx | 48 +++++++++- .../DeletedTeamsPage/DeletedTeamsPage.tsx | 17 +++- .../DeletedTeamsTable.test.tsx | 32 ++++++- .../DeletedTeamsTable/DeletedTeamsTable.tsx | 17 +++- .../PassThroughEndpointsTable.tsx | 1 + .../components/model_add/CredentialsTable.tsx | 1 + .../src/components/networking.tsx | 8 ++ .../src/components/per_user_usage.test.tsx | 19 ++++ .../src/components/per_user_usage.tsx | 53 +++-------- .../src/components/public_model_hub.tsx | 3 + .../routing_groups/RoutingGroupsTable.tsx | 1 + .../components/team/AvailableTeamsTable.tsx | 1 + .../view_logs/RequestLogsPanel.test.tsx | 51 ++++++++++- .../components/view_logs/RequestLogsPanel.tsx | 10 ++- .../components/view_logs/RequestLogsTable.tsx | 2 - .../src/components/view_logs/constants.ts | 3 - ui/litellm-dashboard/src/lib/http/schema.d.ts | 6 ++ 40 files changed, 462 insertions(+), 102 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index c43cc510990..1d3455615fd 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -13474,6 +13474,27 @@ def _is_auto_router_model(model: Mapping[str, object]) -> bool: return isinstance(litellm_model, str) and litellm_model.startswith("auto_router/") +def _model_in_access_group(model: Mapping[str, object], access_group: str) -> bool: + model_info: Final = model.get("model_info") + if not isinstance(model_info, Mapping): + return False + access_groups: Final = model_info.get("access_groups") + return isinstance(access_groups, (list, tuple)) and access_group in access_groups + + +def _matches_model_info_filters( + model: Mapping[str, object], + exclude_auto_routers: bool | None, + access_group: str | None, + wildcard_only: bool | None, +) -> bool: + if exclude_auto_routers is True and _is_auto_router_model(model): + return False + if isinstance(access_group, str) and not _model_in_access_group(model, access_group): + return False + return wildcard_only is not True or "*" in str(model.get("model_name") or "") + + def _paginate_models_response( all_models: list[dict[str, Any]], page: int, @@ -13784,6 +13805,14 @@ async def model_info_v2( "existing callers are unaffected" ), ), + access_group: str | None = fastapi.Query( + None, + description="Only return deployments whose `model_info.access_groups` contains this access group", + ), + wildcard_only: bool | None = fastapi.Query( + False, + description="Only return wildcard deployments, i.e. those whose `model_name` contains `*`", + ), ): """ Paginated model metadata for proxy deployments (pricing, provider, team access). @@ -13801,6 +13830,8 @@ async def model_info_v2( modelId: Return a single deployment by LiteLLM model id. teamId: Filter to models with direct access or team membership for this team id. sortBy / sortOrder: Sort by model_name, created_at, updated_at, costs, or status. + access_group: Only return deployments in this model access group. + wildcard_only: Only return deployments whose `model_name` contains `*`. Example request: ``` @@ -13954,8 +13985,9 @@ async def model_info_v2( # `is True` because direct-call tests bypass FastAPI, so the Query default arrives as a # truthy sentinel object rather than False. - if exclude_auto_routers is True: - all_models = [m for m in all_models if not _is_auto_router_model(m)] + all_models = [ + m for m in all_models if _matches_model_info_filters(m, exclude_auto_routers, access_group, wildcard_only) + ] # Update total count to include agents search_total_count = len(all_models) diff --git a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py index cb38e7edbe2..4c141bcf698 100644 --- a/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py +++ b/tests/test_litellm/proxy/proxy_server/test_routes_model_info.py @@ -452,3 +452,92 @@ async def test_model_info_v2_query_sentinel_does_not_filter(monkeypatch, mixed_a ) assert "tri-tier-router" in [m["model_name"] for m in resp["data"]] + + +# --------------------------------------------------------------------------- +# GET /v2/model/info?access_group / ?wildcard_only +# --------------------------------------------------------------------------- + + +@pytest.fixture +def access_group_router(monkeypatch): + """Router with one sales-team deployment, one wildcard sales-team deployment and one ungrouped one.""" + model_list = [ + { + "model_name": "gpt-4o-mini", + "litellm_params": {"model": "openai/gpt-4o-mini"}, + "model_info": {"id": "sales-1", "db_model": False, "access_groups": ["sales-team"]}, + }, + { + "model_name": "openai/*", + "litellm_params": {"model": "openai/*"}, + "model_info": {"id": "sales-wildcard", "db_model": False, "access_groups": ["sales-team", "eng"]}, + }, + { + "model_name": "claude-opus", + "litellm_params": {"model": "anthropic/claude-opus-4-6"}, + "model_info": {"id": "plain-1", "db_model": False}, + }, + ] + from unittest.mock import AsyncMock + + router = MagicMock() + router.model_list = model_list + monkeypatch.setattr(proxy_server, "llm_router", router) + monkeypatch.setattr(proxy_server, "llm_model_list", model_list) + monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) + monkeypatch.setattr(proxy_server, "user_model", None) + monkeypatch.setattr(proxy_server.proxy_config, "get_config", AsyncMock(return_value={})) + monkeypatch.setattr( + proxy_server, + "_apply_search_filter_to_models", + AsyncMock(side_effect=lambda all_models, **kw: (all_models, len(all_models))), + ) + monkeypatch.setattr(proxy_server, "_enrich_model_info_with_litellm_data", lambda model, **kw: model) + + import litellm.proxy.agent_endpoints.model_list_helpers as mlh + + monkeypatch.setattr(mlh, "append_agents_to_model_info", AsyncMock(side_effect=lambda models, **kw: models)) + yield router + + +def test_v2_model_info_without_new_filters_returns_everything(client, auth_as, access_group_router): + with auth_as(): + response = client.get("/v2/model/info") + payload = response.json() + assert payload["total_count"] == 3 + assert len(payload["data"]) == 3 + + +def test_v2_model_info_access_group_filters_rows_and_total(client, auth_as, access_group_router): + """The table pages off total_count, so the filter must shrink the total, not only the page.""" + with auth_as(): + response = client.get("/v2/model/info", params={"access_group": "sales-team"}) + payload = response.json() + assert _model_names(payload) == ["gpt-4o-mini", "openai/*"] + assert payload["total_count"] == 2 + + +def test_v2_model_info_unknown_access_group_is_empty(client, auth_as, access_group_router): + with auth_as(): + response = client.get("/v2/model/info", params={"access_group": "nobody"}) + payload = response.json() + assert payload["data"] == [] + assert payload["total_count"] == 0 + + +def test_v2_model_info_wildcard_only_filters_rows_and_total(client, auth_as, access_group_router): + with auth_as(): + response = client.get("/v2/model/info", params={"wildcard_only": "true"}) + payload = response.json() + assert _model_names(payload) == ["openai/*"] + assert payload["total_count"] == 1 + + +def test_v2_model_info_access_group_paginates_over_the_filtered_set(client, auth_as, access_group_router): + with auth_as(): + response = client.get("/v2/model/info", params={"access_group": "sales-team", "page": 2, "size": 1}) + payload = response.json() + assert _model_names(payload) == ["openai/*"] + assert payload["total_count"] == 2 + assert payload["total_pages"] == 2 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx index aceb07e2e9a..d737a9250eb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/AgentsTable.tsx @@ -72,6 +72,7 @@ const AgentsTable: React.FC = ({ return ( agent.agent_id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx index e6a14b2b2f4..bbab01e346d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_table.tsx @@ -46,6 +46,7 @@ const GuardrailTable: React.FC = ({ return ( guardrail.guardrail_id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts index a9f7c54698a..b3a783a71dc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.ts @@ -39,6 +39,8 @@ export const useModelsInfo = ( sortOrder?: string, excludeAutoRouters: boolean = false, modelName?: string, + accessGroup?: string, + wildcardOnly: boolean = false, ) => { const { accessToken, userId, userRole } = useAuthorized(); return useQuery({ @@ -57,6 +59,8 @@ export const useModelsInfo = ( // Part of the key: callers that exclude auto-routers must not share a cache entry // with callers that keep them. ...(excludeAutoRouters && { excludeAutoRouters: "true" }), + ...(accessGroup && { accessGroup }), + ...(wildcardOnly && { wildcardOnly: "true" }), }, }), queryFn: async () => @@ -73,6 +77,8 @@ export const useModelsInfo = ( sortOrder, excludeAutoRouters, modelName, + accessGroup, + wildcardOnly, ), enabled: Boolean(accessToken && userId && userRole), }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts index fa3f15124cf..eccd8a80748 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts @@ -671,7 +671,7 @@ describe("useDeletedTeams", () => { it("should return deleted teams data when query is successful", async () => { (global.fetch as any).mockResolvedValue({ ok: true, - json: async () => ({ teams: mockDeletedTeams }), + json: async () => ({ teams: mockDeletedTeams, total: 2, page: 1, page_size: 10, total_pages: 1 }), }); const { result } = renderHook(() => useDeletedTeams(1, 10, {}), { wrapper }); @@ -684,10 +684,26 @@ describe("useDeletedTeams", () => { expect(result.current.isSuccess).toBe(true); }); - expect(result.current.data).toEqual(mockDeletedTeams); + expect(result.current.data).toEqual({ teams: mockDeletedTeams, total: 2 }); expect(result.current.error).toBeNull(); }); + it("should keep the server total so the table can paginate beyond the current page", async () => { + (global.fetch as any).mockResolvedValue({ + ok: true, + json: async () => ({ teams: mockDeletedTeams, total: 137, page: 1, page_size: 2, total_pages: 69 }), + }); + + const { result } = renderHook(() => useDeletedTeams(1, 2, {}), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + + expect(result.current.data?.total).toBe(137); + expect((global.fetch as any).mock.calls[0][0]).toContain("page_size=2"); + }); + it("should handle error when API call fails", async () => { (global.fetch as any).mockResolvedValue({ ok: false, @@ -744,7 +760,7 @@ describe("useDeletedTeams", () => { rerender({ page: 2 }); - expect(result.current.data).toEqual(mockDeletedTeams); + expect(result.current.data?.teams).toEqual(mockDeletedTeams); }); it("should pass options to API call", async () => { @@ -785,7 +801,7 @@ describe("useDeletedTeams", () => { expect(result.current.isSuccess).toBe(true); }); - expect(result.current.data).toEqual(mockDeletedTeams); + expect(result.current.data).toEqual({ teams: mockDeletedTeams, total: 2 }); expect(result.current.error).toBeNull(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts index e209a1d7273..14e95bcd543 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts @@ -20,6 +20,11 @@ export interface DeletedTeam extends Team { deleted_by: string; } +export interface DeletedTeamsResponse { + teams: DeletedTeam[]; + total: number; +} + export interface TeamListCallOptions { organizationID?: string | null; teamID?: string | null; @@ -209,7 +214,7 @@ const deletedTeamListCall = async ( page: number, pageSize: number, options: TeamListCallOptions = {}, -) => { +): Promise => { /** * Get deleted teams from proxy */ @@ -251,14 +256,12 @@ const deletedTeamListCall = async ( throw new Error(errorMessage); } - const data = await response.json(); + const data: DeletedTeam[] | (Partial & { teams: DeletedTeam[] }) = await response.json(); - // Extract teams array from response if it's wrapped in a response object - // Otherwise return the data directly if it's already an array - if (data && typeof data === "object" && "teams" in data) { - return data.teams as DeletedTeam[]; + if (Array.isArray(data)) { + return { teams: data, total: data.length }; } - return data as DeletedTeam[]; + return { teams: data.teams, total: data.total ?? data.teams.length }; } catch (error) { console.error("Failed to list deleted teams:", error); throw error; @@ -270,10 +273,10 @@ export const useDeletedTeams = ( page: number, pageSize: number, options: TeamListCallOptions = {}, -): UseQueryResult => { +): UseQueryResult => { const { accessToken } = useAuthorized(); - return useQuery({ + return useQuery({ queryKey: deletedTeamKeys.list({ page, limit: pageSize, ...options }), queryFn: async () => await deletedTeamListCall(accessToken!, page, pageSize, options), enabled: Boolean(accessToken), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx index c637655d665..60a1da40d08 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/mcp-servers/_components/MCPToolsetsTab.tsx @@ -451,6 +451,7 @@ export function MCPToolsetsTab({ accessToken, userRole }: MCPToolsetsTabProps) { toolset.toolset_id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index 65faa85e29e..7e47be3f5d1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -34,6 +34,8 @@ interface ModelsInfoArgs { sortBy?: string; sortOrder?: string; modelName?: string; + accessGroup?: string; + wildcardOnly?: boolean; } const modelsInfoCalls: ModelsInfoArgs[] = []; @@ -50,12 +52,24 @@ type UseModelsInfoArgs = [ sortOrder?: string, excludeAutoRouters?: boolean, modelName?: string, + accessGroup?: string, + wildcardOnly?: boolean, ]; vi.mock("../../hooks/models/useModels", () => ({ useModelsInfo: (...args: UseModelsInfoArgs) => { - const [page, size, search, , teamId, sortBy, sortOrder, , modelName] = args; - const call: ModelsInfoArgs = { page, size, search, teamId, sortBy, sortOrder, modelName }; + const [page, size, search, , teamId, sortBy, sortOrder, , modelName, accessGroup, wildcardOnly] = args; + const call: ModelsInfoArgs = { + page, + size, + search, + teamId, + sortBy, + sortOrder, + modelName, + accessGroup, + wildcardOnly, + }; modelsInfoCalls.push(call); return { ...modelsInfoResult, refetch: mockRefetch }; }, @@ -254,13 +268,38 @@ describe("AllModelsTab", () => { }); }); - it("filters the fetched page down to the selected model group", () => { - setModelsInfo([makeRow(), { ...makeRow(), model_name: "claude-opus" }], 2); + it("renders every row the server returned for the selected model group so rows match the footer total", () => { + setModelsInfo([makeRow(), { ...makeRow({ model_info: { id: "model-2" } }), model_name: "claude-opus" }], 2); render(); const table = screen.getByRole("table"); expect(within(table).getByText("claude-opus")).toBeInTheDocument(); - expect(within(table).queryByText("gpt-4")).not.toBeInTheDocument(); + expect(within(table).getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-2 of 2"); + }); + + it("asks the server for wildcard deployments instead of hiding rows client-side", () => { + setModelsInfo([makeRow(), { ...makeRow({ model_info: { id: "model-2" } }), model_name: "openai/*" }], 2); + render(); + + expect(lastModelsInfoCall().wildcardOnly).toBe(true); + expect(within(screen.getByRole("table")).getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-2 of 2"); + }); + + it("asks the server for the selected access group instead of hiding rows client-side", async () => { + const user = userEvent.setup(); + render(); + expect(lastModelsInfoCall().wildcardOnly).toBe(false); + + await user.click(screen.getByTestId("datatable-filters-trigger")); + await user.click(await screen.findByPlaceholderText("Filter by Model Access Group")); + await user.click(await screen.findByRole("option", { name: "sales-team" })); + await user.click(screen.getByTestId("filter-drawer-apply")); + + await waitFor(() => expect(lastModelsInfoCall().accessGroup).toBe("sales-team")); + expect(within(screen.getByRole("table")).getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-1 of 1"); }); it("asks the server for the exact selected model group so deployments beyond the first page are found", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index be2cf22d71a..3b4058a28fa 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -86,6 +86,11 @@ const AllModelsTab = ({ selectedModelGroup !== ALL_MODEL_GROUPS_VALUE && selectedModelGroup !== WILDCARD_MODEL_GROUP_VALUE; const modelNameForQuery = isConcreteModelGroup ? selectedModelGroup ?? undefined : undefined; + const accessGroupForQuery = + selectedModelAccessGroupFilter && selectedModelAccessGroupFilter !== ALL_MODEL_GROUPS_VALUE + ? selectedModelAccessGroupFilter + : undefined; + const wildcardOnlyForQuery = selectedModelGroup === WILDCARD_MODEL_GROUP_VALUE; const sortBy = useMemo(() => { if (sorting.length === 0) return undefined; @@ -114,6 +119,8 @@ const AllModelsTab = ({ // lists and manages them. Excluded server-side so total_count stays honest. true, modelNameForQuery, + accessGroupForQuery, + wildcardOnlyForQuery, ); const isLoading = isLoadingModelsInfo || isLoadingModelCostMap; @@ -129,32 +136,11 @@ const AllModelsTab = ({ [modelCostMapData], ); - const modelData = useMemo(() => { + const modelData = useMemo<{ data: ModelData[] }>(() => { if (!rawModelData) return { data: [] }; return transformModelData(rawModelData, getProviderFromModel); }, [rawModelData, getProviderFromModel]); - const filteredData = useMemo(() => { - if (!modelData || !modelData.data || modelData.data.length === 0) { - return []; - } - - return modelData.data.filter((model: ModelData) => { - const modelNameMatch = - selectedModelGroup === ALL_MODEL_GROUPS_VALUE || - model.model_name === selectedModelGroup || - !selectedModelGroup || - (selectedModelGroup === WILDCARD_MODEL_GROUP_VALUE && model.model_name?.includes("*")); - - const accessGroupMatch = - selectedModelAccessGroupFilter === ALL_MODEL_GROUPS_VALUE || - model.model_info["access_groups"]?.includes(selectedModelAccessGroupFilter ?? "") || - !selectedModelAccessGroupFilter; - - return modelNameMatch && accessGroupMatch; - }); - }, [modelData, selectedModelGroup, selectedModelAccessGroupFilter]); - const columnFilters = useMemo( () => [ @@ -270,7 +256,7 @@ const AllModelsTab = ({
group.access_group} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx index 1ac33a27186..4bf465b847b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.test.tsx @@ -192,6 +192,23 @@ describe("OrganizationsTable", () => { expect(screen.queryByText("ShouldNotShow")).not.toBeInTheDocument(); }); + it("pages long lists client-side with the shared size selector and footer", async () => { + const user = userEvent.setup(); + const organizations = Array.from({ length: 30 }, (_, index) => + makeOrganization({ organization_id: `org-${index}`, organization_alias: `Org ${index}` }), + ); + render(); + + expect(screen.getAllByRole("row")).toHaveLength(26); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 30"); + + await user.click(screen.getByTestId("pagination-page-size")); + await user.click(await screen.findByRole("option", { name: "50" })); + + expect(screen.getAllByRole("row")).toHaveLength(31); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-30 of 30"); + }); + it("uses a search-aware empty state", () => { const { rerender } = render(); expect(screen.getByText("No organizations yet")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx index 8e68a57d2f7..dbf516d75ae 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/organizations/_components/OrganizationsTable.tsx @@ -59,6 +59,7 @@ const OrganizationsTable: React.FC = ({ return ( organization.organization_id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.tsx index bd8458e6f96..a432a53bca4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/AttachmentTable.tsx @@ -50,6 +50,7 @@ const AttachmentTable: React.FC = ({ return ( row.attachment_id} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.tsx index 3405ac6b6bb..d78ec28c486 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/policies/_components/PolicyTable.tsx @@ -71,6 +71,7 @@ const PolicyTable: React.FC = ({ return ( `${row.primaryPolicy.definition_location ?? "db"}:${row.policy_name}`} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx index c766042ac44..e810c3622d1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/_components/PromptTable.tsx @@ -73,6 +73,7 @@ const PromptTable: React.FC = ({ return ( prompt.prompt_id ? `${prompt.prompt_id}::${prompt.environment || "development"}` : String(index) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTable.tsx index 70fc6a376df..f60f4f3d1da 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolTable.tsx @@ -50,6 +50,7 @@ const SearchToolTable: React.FC = ({ return ( searchToolKey(tool) || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx index c581b0dfdeb..1b1ccb0932a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/skills/_components/PluginTable.tsx @@ -42,6 +42,7 @@ const PluginTable: React.FC = ({ pluginsList, isLoading, onDel return ( plugin.id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx index 488190a0fdf..076166ac827 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/tag-management/_components/TagTable.tsx @@ -39,6 +39,7 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag return ( tag.name || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx index 927fd48acb6..a0b0c02f99d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/IndexesTable.tsx @@ -46,6 +46,7 @@ const IndexesTable: React.FC = ({ return ( row.id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx index 2f8508dc7c6..32e7bc2324d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreTable.tsx @@ -41,6 +41,7 @@ const VectorStoreTable: React.FC = ({ data, onView, onEdi return ( vectorStore.vector_store_id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 475e3dcd70b..5f6f26bd16c 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -474,6 +474,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* Model Table */} model.model_group || String(index)} sortingMode="client" @@ -540,6 +541,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* Agent Table */} agent.agent_id || agent.name || String(index)} sortingMode="client" @@ -581,6 +583,7 @@ const ModelHubTable: React.FC = ({ accessToken, publicPage, {/* MCP Server Table */} server.server_id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx b/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx index 992ef49742d..9cede3b4497 100644 --- a/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/SkillHubDashboard.tsx @@ -162,6 +162,7 @@ const SkillHubDashboard: React.FC = ({
skill.id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.test.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.test.tsx index 6bf5d1caf61..952d8764463 100644 --- a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.test.tsx +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.test.tsx @@ -1,4 +1,5 @@ -import { screen } from "@testing-library/react"; +import { fireEvent, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { vi, it, expect, beforeEach, MockedFunction } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; import DeletedTeamsPage from "./DeletedTeamsPage"; @@ -31,7 +32,7 @@ beforeEach(() => { vi.clearAllMocks(); mockUseDeletedTeams.mockReturnValue({ - data: [mockDeletedTeam], + data: { teams: [mockDeletedTeam], total: 1 }, isLoading: false, } as unknown as ReturnType); }); @@ -42,6 +43,49 @@ it("should render DeletedTeamsPage component", () => { expect(screen.getByText("Test Team")).toBeInTheDocument(); }); +it("requests the first page of 25 deleted teams and shows the server total in the footer", () => { + mockUseDeletedTeams.mockReturnValue({ + data: { teams: [mockDeletedTeam], total: 137 }, + isLoading: false, + } as unknown as ReturnType); + + renderWithProviders(); + + expect(mockUseDeletedTeams).toHaveBeenLastCalledWith(1, 25); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 137"); + expect(screen.getByTestId("pagination-next")).toBeEnabled(); +}); + +it("requests the next page from the server when Next is clicked", () => { + mockUseDeletedTeams.mockReturnValue({ + data: { teams: [mockDeletedTeam], total: 137 }, + isLoading: false, + } as unknown as ReturnType); + + renderWithProviders(); + fireEvent.click(screen.getByTestId("pagination-next")); + + expect(mockUseDeletedTeams).toHaveBeenLastCalledWith(2, 25); +}); + +it("offers the shared page sizes and refetches with the selected one", async () => { + const user = userEvent.setup(); + mockUseDeletedTeams.mockReturnValue({ + data: { teams: [mockDeletedTeam], total: 137 }, + isLoading: false, + } as unknown as ReturnType); + + renderWithProviders(); + await user.click(screen.getByTestId("pagination-page-size")); + + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual(["25", "50", "100"]); + + await user.click(screen.getByRole("option", { name: "100" })); + + expect(mockUseDeletedTeams).toHaveBeenLastCalledWith(1, 100); +}); + it("should show the enterprise notice for a non-premium user", () => { renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx index eab150d6ab5..8c3aac2cac7 100644 --- a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsPage.tsx @@ -1,13 +1,20 @@ "use client"; +import { PaginationState } from "@tanstack/react-table"; import { Info } from "lucide-react"; +import { useState } from "react"; import { Alert, AlertDescription, AlertTitle } from "@/components/shared/Alert"; +import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable"; import { useDeletedTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { DeletedTeamsTable } from "./DeletedTeamsTable/DeletedTeamsTable"; export default function DeletedTeamsPage() { const { premiumUser } = useAuthorized(); - const { data: teamsData, isLoading } = useDeletedTeams(1, 100); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE_OPTIONS[0], + }); + const { data: teamsData, isLoading } = useDeletedTeams(pagination.pageIndex + 1, pagination.pageSize); return (
@@ -20,7 +27,13 @@ export default function DeletedTeamsPage() { )} - +
); } diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx index c0cc5a342a8..e166f6b0d1b 100644 --- a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.test.tsx @@ -22,12 +22,19 @@ const makeDeletedTeam = (overrides: Partial = {}): DeletedTeam => ( ...overrides, }); +const paginationProps = { + pagination: { pageIndex: 0, pageSize: 25 }, + onPaginationChange: vi.fn(), +}; + beforeEach(() => { vi.clearAllMocks(); }); it("should display team information", () => { - renderWithProviders(); + renderWithProviders( + , + ); expect(screen.getByText("Test Team")).toBeInTheDocument(); expect(screen.getByText("team-1")).toBeInTheDocument(); @@ -39,7 +46,7 @@ it("should sort teams by deleted_at descending by default", () => { makeDeletedTeam({ team_id: "team-old", team_alias: "older-team", deleted_at: "2024-01-01T10:00:00Z" }), makeDeletedTeam({ team_id: "team-new", team_alias: "newer-team", deleted_at: "2024-06-01T10:00:00Z" }), ]; - renderWithProviders(); + renderWithProviders(); const rows = screen.getAllByRole("row").slice(1); expect(within(rows[0]).getByText("newer-team")).toBeInTheDocument(); @@ -47,13 +54,30 @@ it("should sort teams by deleted_at descending by default", () => { }); it("should show skeleton rows when loading", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getAllByTestId("skeleton-row").length).toBeGreaterThan(0); }); it("should show the empty state when there are no deleted teams", () => { - renderWithProviders(); + renderWithProviders(); expect(screen.getByText("No deleted teams found")).toBeInTheDocument(); }); + +it("renders the shared pagination footer with the server row count", () => { + renderWithProviders( + , + ); + + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 101-137 of 137"); + expect(screen.getByTestId("pagination-page-size")).toHaveTextContent("50"); + expect(screen.getByTestId("pagination-prev")).toBeEnabled(); + expect(screen.getByTestId("pagination-next")).toBeDisabled(); +}); diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx index 9578a52453f..c7e759754b8 100644 --- a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx @@ -1,6 +1,6 @@ "use client"; -import { SortingState } from "@tanstack/react-table"; +import { OnChangeFn, PaginationState, SortingState } from "@tanstack/react-table"; import { Inbox } from "lucide-react"; import { useMemo, useState } from "react"; @@ -12,6 +12,9 @@ import { getDeletedTeamsTableColumns } from "./DeletedTeamsTableColumns"; interface DeletedTeamsTableProps { teams: DeletedTeam[]; isLoading: boolean; + pagination: PaginationState; + onPaginationChange: OnChangeFn; + rowCount: number; } const DEFAULT_SORTING: SortingState = [{ id: "deleted_at", desc: true }]; @@ -28,7 +31,13 @@ function EmptyState() { ); } -export function DeletedTeamsTable({ teams, isLoading }: DeletedTeamsTableProps) { +export function DeletedTeamsTable({ + teams, + isLoading, + pagination, + onPaginationChange, + rowCount, +}: DeletedTeamsTableProps) { const [sorting, setSorting] = useState(DEFAULT_SORTING); const columns = useMemo(() => getDeletedTeamsTableColumns(), []); @@ -41,6 +50,10 @@ export function DeletedTeamsTable({ teams, isLoading }: DeletedTeamsTableProps) sortingMode="client" sorting={sorting} onSortingChange={setSorting} + paginationMode="server" + pagination={pagination} + onPaginationChange={onPaginationChange} + rowCount={rowCount} isLoading={isLoading} loadingMessage="Loading deleted teams…" noDataMessage={} diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx index 754e7ff68dd..35f0bd4bc62 100644 --- a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTable.tsx @@ -41,6 +41,7 @@ export function PassThroughEndpointsTable({ return ( endpoint.id || endpoint.path || String(index)} isLoading={isLoading} diff --git a/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx index 33d63e87a5b..835cd57ae92 100644 --- a/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx +++ b/ui/litellm-dashboard/src/components/model_add/CredentialsTable.tsx @@ -48,6 +48,7 @@ const CredentialsTable: React.FC = ({ return ( credential.credential_name || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 1384679a88a..8787b8111c6 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1692,6 +1692,8 @@ export const modelInfoCall = async ( sortOrder?: string, excludeAutoRouters?: boolean, modelName?: string, + accessGroup?: string, + wildcardOnly?: boolean, ) => { /** * Get all models on proxy @@ -1723,6 +1725,12 @@ export const modelInfoCall = async ( if (excludeAutoRouters) { params.append("exclude_auto_routers", "true"); } + if (accessGroup && accessGroup.trim()) { + params.append("access_group", accessGroup.trim()); + } + if (wildcardOnly) { + params.append("wildcard_only", "true"); + } if (params.toString()) { url += `?${params.toString()}`; } diff --git a/ui/litellm-dashboard/src/components/per_user_usage.test.tsx b/ui/litellm-dashboard/src/components/per_user_usage.test.tsx index 9cd199d786c..5cd0591bb15 100644 --- a/ui/litellm-dashboard/src/components/per_user_usage.test.tsx +++ b/ui/litellm-dashboard/src/components/per_user_usage.test.tsx @@ -78,6 +78,25 @@ describe("PerUserUsage", () => { }); }); + it("shows every fetched row with a footer that matches the server total and page size", async () => { + const results = Array.from({ length: 25 }, (_, index) => userRow(`user-${index}`, "curl/8.0", index)); + mockPerUserAnalyticsCall.mockResolvedValue({ ...mockResponse, results, total_count: 60, total_pages: 3 }); + render(); + + await waitFor(() => { + expect(screen.getByText("user-24")).toBeInTheDocument(); + }); + + expect(mockPerUserAnalyticsCall).toHaveBeenLastCalledWith("test-token", 1, 25, undefined); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 60"); + + fireEvent.click(screen.getByTestId("pagination-next")); + + await waitFor(() => { + expect(mockPerUserAnalyticsCall).toHaveBeenLastCalledWith("test-token", 2, 25, undefined); + }); + }); + it("keeps both tab panels mounted so switching tabs does not reset their state", async () => { render(); diff --git a/ui/litellm-dashboard/src/components/per_user_usage.tsx b/ui/litellm-dashboard/src/components/per_user_usage.tsx index 6f29077de84..5bdf02e61ca 100644 --- a/ui/litellm-dashboard/src/components/per_user_usage.tsx +++ b/ui/litellm-dashboard/src/components/per_user_usage.tsx @@ -1,8 +1,7 @@ import React, { useState, useEffect } from "react"; -import type { ColumnDef } from "@tanstack/react-table"; +import type { ColumnDef, PaginationState } from "@tanstack/react-table"; import { BarChart } from "@/components/shared/charts"; -import { DataTable } from "@/components/shared/DataTable"; -import { Button } from "@/components/ui/button"; +import { DataTable, DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { perUserAnalyticsCall } from "./networking"; @@ -42,7 +41,10 @@ const PerUserUsage: React.FC = ({ accessToken, selectedTags, total_pages: 0, }); - const [currentPage, setCurrentPage] = useState(1); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: DEFAULT_PAGE_SIZE_OPTIONS[0], + }); const fetchPerUserData = async () => { if (!accessToken) return; @@ -50,8 +52,8 @@ const PerUserUsage: React.FC = ({ accessToken, selectedTags, try { const response = await perUserAnalyticsCall( accessToken, - currentPage, - 50, + pagination.pageIndex + 1, + pagination.pageSize, selectedTags.length > 0 ? selectedTags : undefined, ); setPerUserData(response); @@ -62,19 +64,7 @@ const PerUserUsage: React.FC = ({ accessToken, selectedTags, useEffect(() => { fetchPerUserData(); - }, [accessToken, selectedTags, currentPage]); - - const handleNextPage = () => { - if (currentPage < perUserData.total_pages) { - setCurrentPage(currentPage + 1); - } - }; - - const handlePrevPage = () => { - if (currentPage > 1) { - setCurrentPage(currentPage - 1); - } - }; + }, [accessToken, selectedTags, pagination.pageIndex, pagination.pageSize]); const columns: ColumnDef[] = [ { @@ -137,30 +127,15 @@ const PerUserUsage: React.FC = ({ accessToken, selectedTags, row.user_id} + paginationMode="server" + pagination={pagination} + onPaginationChange={setPagination} + rowCount={perUserData.total_count} noDataMessage="No per-user usage data" size="compact" /> - - {perUserData.results.length > 10 && ( -
-

Showing 10 of {perUserData.total_count} results

-
- - -
-
- )}
{/* Tab 2: Usage Distribution Histogram */} diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx index f6364b5d9d1..546c3b4e018 100644 --- a/ui/litellm-dashboard/src/components/public_model_hub.tsx +++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx @@ -587,6 +587,7 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded model.model_group || String(index)} sortingMode="client" @@ -656,6 +657,7 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded agent.name || String(index)} sortingMode="client" @@ -722,6 +724,7 @@ const PublicModelHub: React.FC = ({ accessToken, isEmbedded server.server_id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.tsx b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.tsx index fce887fc63b..1d2ef75361f 100644 --- a/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.tsx +++ b/ui/litellm-dashboard/src/components/routing_groups/RoutingGroupsTable.tsx @@ -64,6 +64,7 @@ const RoutingGroupsTable: React.FC = ({ return ( group.group_name} sortingMode="client" diff --git a/ui/litellm-dashboard/src/components/team/AvailableTeamsTable.tsx b/ui/litellm-dashboard/src/components/team/AvailableTeamsTable.tsx index 6719cc09780..11c430d05d8 100644 --- a/ui/litellm-dashboard/src/components/team/AvailableTeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/team/AvailableTeamsTable.tsx @@ -46,6 +46,7 @@ const AvailableTeamsTable: React.FC = ({ teams, isLoad return ( team.team_id || String(index)} sortingMode="client" diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx index be0d0049c13..b10b2584548 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx @@ -148,13 +148,60 @@ describe("RequestLogsPanel", () => { }); describe("server-grouped session pagination (#38060)", () => { - it("requests session-grouped pages of 10 rows by default without a cursor", async () => { + it("requests session-grouped pages of 25 rows by default without a cursor", async () => { renderPanel(); await waitFor(() => expect(uiSpendLogsCall).toHaveBeenCalled()); expect(lastCall()?.params?.group_by_session).toBe(true); expect(lastCall()?.params?.session_cursor).toBeUndefined(); - expect(lastCall()?.page_size).toBe(10); + expect(lastCall()?.page_size).toBe(25); + }); + + it("offers the same page sizes as the other tables", async () => { + const user = userEvent.setup(); + respondWith([logEntry({ request_id: "req-a" })]); + renderPanel(); + + await waitFor(() => expect(row("req-a")).not.toBeNull()); + await user.click(screen.getByTestId("pagination-page-size")); + + const options = await screen.findAllByRole("option"); + expect(options.map((option) => option.textContent)).toEqual(["25", "50", "100"]); + }); + + it("counts the rendered rows in the footer instead of the server's session total", async () => { + vi.mocked(uiSpendLogsCall).mockResolvedValue({ + data: [logEntry({ request_id: "req-a" }), logEntry({ request_id: "req-b" }), logEntry({ request_id: "req-c" })], + total: 40, + page: 1, + page_size: 25, + total_pages: 2, + next_session_cursor: null, + has_more: false, + }); + renderPanel(); + + await waitFor(() => expect(row("req-a")).not.toBeNull()); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-3 of 3"); + expect(screen.getByTestId("pagination-next")).toBeDisabled(); + }); + + it("keeps Next enabled from the server total while more session pages remain", async () => { + const firstPage = Array.from({ length: 25 }, (_, index) => logEntry({ request_id: `req-${index}` })); + vi.mocked(uiSpendLogsCall).mockResolvedValue({ + data: firstPage, + total: 80, + page: 1, + page_size: 25, + total_pages: 4, + next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1", + has_more: true, + }); + renderPanel(); + + await waitFor(() => expect(row("req-0")).not.toBeNull()); + expect(screen.getByTestId("pagination-range")).toHaveTextContent("Showing 1-25 of 80"); + expect(screen.getByTestId("pagination-next")).toBeEnabled(); }); it("renders every row the server returns without client-side collapsing", async () => { diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 9b99c6af923..6e984297bf2 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -6,13 +6,13 @@ import type { ColumnFiltersState, OnChangeFn, PaginationState, SortingState } fr import moment from "moment"; import { useCallback, useEffect, useMemo, useState } from "react"; +import { DEFAULT_PAGE_SIZE_OPTIONS } from "@/components/shared/DataTable"; import { AutoRouterModelGroupsProvider } from "@/components/shared/table_cells"; import { DEBOUNCE_WAIT_MS } from "@/utils/debounceConstants"; import type { KeyResponse } from "../key_team_helpers/key_list"; import { keyInfoV1Call, uiSpendLogsCall } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import type { LogEntry } from "./columns"; -import { LOGS_PAGE_SIZE_OPTIONS } from "./constants"; import { DEFAULT_LOGS_SORTING, formatLogsWindow, @@ -26,7 +26,7 @@ import { LogDetailsDrawer } from "./LogDetailsDrawer"; import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar"; import { RequestLogsTable } from "./RequestLogsTable"; -const PAGE_SIZE = LOGS_PAGE_SIZE_OPTIONS[0]; +const PAGE_SIZE = DEFAULT_PAGE_SIZE_OPTIONS[0]; const DEFAULT_INTERVAL = { value: 24, unit: "hours" }; interface RequestLogsPanelProps { @@ -166,6 +166,10 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, const isDrawerOpen = displayLog !== null || displaySessionId !== null; const rows: LogEntry[] = filteredLogs.data; + const rowsThroughThisPage = pagination.pageIndex * pagination.pageSize + rows.length; + const isLastPage = + filteredLogs.has_more === false || (filteredLogs.has_more === undefined && rows.length < pagination.pageSize); + const rowCount = isLastPage ? rowsThroughThisPage : Math.max(filteredLogs.total, rowsThroughThisPage); const handleSearchChange = useCallback((value: string) => { setColumnFilters((previous) => { @@ -290,7 +294,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, Date: Fri, 4 Sep 2026 00:11:00 +0000 Subject: [PATCH 086/410] test(ui): hoist mock responses to named variables to stay within lint budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/per_user_usage.test.tsx | 3 ++- .../components/view_logs/RequestLogsPanel.test.tsx | 13 +++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/components/per_user_usage.test.tsx b/ui/litellm-dashboard/src/components/per_user_usage.test.tsx index 5cd0591bb15..01494ef8bfa 100644 --- a/ui/litellm-dashboard/src/components/per_user_usage.test.tsx +++ b/ui/litellm-dashboard/src/components/per_user_usage.test.tsx @@ -80,7 +80,8 @@ describe("PerUserUsage", () => { it("shows every fetched row with a footer that matches the server total and page size", async () => { const results = Array.from({ length: 25 }, (_, index) => userRow(`user-${index}`, "curl/8.0", index)); - mockPerUserAnalyticsCall.mockResolvedValue({ ...mockResponse, results, total_count: 60, total_pages: 3 }); + const firstPage = { ...mockResponse, results, total_count: 60, total_pages: 3 }; + mockPerUserAnalyticsCall.mockResolvedValue(firstPage); render(); await waitFor(() => { diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx index b10b2584548..295446186b1 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.test.tsx @@ -170,7 +170,7 @@ describe("RequestLogsPanel", () => { }); it("counts the rendered rows in the footer instead of the server's session total", async () => { - vi.mocked(uiSpendLogsCall).mockResolvedValue({ + const lastPage = { data: [logEntry({ request_id: "req-a" }), logEntry({ request_id: "req-b" }), logEntry({ request_id: "req-c" })], total: 40, page: 1, @@ -178,7 +178,8 @@ describe("RequestLogsPanel", () => { total_pages: 2, next_session_cursor: null, has_more: false, - }); + }; + vi.mocked(uiSpendLogsCall).mockResolvedValue(lastPage); renderPanel(); await waitFor(() => expect(row("req-a")).not.toBeNull()); @@ -187,16 +188,16 @@ describe("RequestLogsPanel", () => { }); it("keeps Next enabled from the server total while more session pages remain", async () => { - const firstPage = Array.from({ length: 25 }, (_, index) => logEntry({ request_id: `req-${index}` })); - vi.mocked(uiSpendLogsCall).mockResolvedValue({ - data: firstPage, + const firstPage = { + data: Array.from({ length: 25 }, (_, index) => logEntry({ request_id: `req-${index}` })), total: 80, page: 1, page_size: 25, total_pages: 4, next_session_cursor: "2026-07-07 09:50:13|key-1|sess-1", has_more: true, - }); + }; + vi.mocked(uiSpendLogsCall).mockResolvedValue(firstPage); renderPanel(); await waitFor(() => expect(row("req-0")).not.toBeNull()); From c911740d8292b12c9c7ad4cca926b513127cbc86 Mon Sep 17 00:00:00 2001 From: ryan Date: Fri, 4 Sep 2026 00:19:13 +0000 Subject: [PATCH 087/410] test(ui): update useModelsInfo call assertions for the new filter arguments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/app/(dashboard)/hooks/models/useModels.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts index 7231c126a63..cfafe82ee30 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts @@ -119,6 +119,8 @@ describe("useModelsInfo", () => { // every other consumer of this hook keeps seeing auto-routers. false, undefined, + undefined, + false, ); expect(modelInfoCall).toHaveBeenCalledTimes(1); }); @@ -147,6 +149,8 @@ describe("useModelsInfo", () => { // every other consumer of this hook keeps seeing auto-routers. false, undefined, + undefined, + false, ); }); From 533a1c959c21f659a78fb130b17c700a3d6d1a06 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 17:42:46 -0700 Subject: [PATCH 088/410] fix(proxy): reserve budget for the Bedrock Converse prompt, not the context window Resolving the model from the /bedrock path sends passthrough calls through optimistic budget reservation, whose tokenizer cannot walk Converse content blocks and so fell back to the model's max_input_tokens. Count those messages as text and read inferenceConfig.maxTokens so a budgeted key reserves the request's cost. --- .../spend_tracking/budget_reservation.py | 37 ++++++++++++------- .../spend_tracking/test_budget_reservation.py | 31 +++++++++++++++- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py index 91d2ece7a51..5ed52a327d4 100644 --- a/litellm/proxy/spend_tracking/budget_reservation.py +++ b/litellm/proxy/spend_tracking/budget_reservation.py @@ -1362,12 +1362,15 @@ def _approximate_input_size(request_body: Mapping[str, object]) -> int: def _count_input_tokens(request_body: dict, model: str) -> int | None: try: if "messages" in request_body: - return litellm.token_counter( - model=model, - messages=request_body.get("messages") or [], - tools=request_body.get("tools"), - tool_choice=request_body.get("tool_choice"), - ) + try: + return litellm.token_counter( + model=model, + messages=request_body.get("messages") or (), + tools=request_body.get("tools"), + tool_choice=request_body.get("tool_choice"), + ) + except ValueError: + return _count_text_tokens(model=model, text=request_body.get("messages")) if "prompt" in request_body: return _count_text_tokens(model=model, text=request_body.get("prompt")) if "input" in request_body: @@ -1415,11 +1418,7 @@ def _estimate_output_tokens( if _is_input_only_route(route=route): return 0 - requested: int | None = None - for key in ("max_completion_tokens", "max_tokens", "max_output_tokens"): - requested = _to_int(request_body.get(key)) - if requested is not None: - break + requested: Final = _requested_output_tokens(request_body) # Clamp at min(requested-or-default, model_max-or-default). Two purposes: # (1) Without an explicit cap we still need a finite reservation so the @@ -1430,9 +1429,19 @@ def _estimate_output_tokens( # at the cap — the model can only physically emit max_output_tokens # anyway, so reserving more is both wasteful and a DoS surface. model_ceiling: Final = _to_int(model_info.get("max_output_tokens")) or DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK - if requested is None: - requested = DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK - return min(requested, model_ceiling) + return min(DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK if requested is None else requested, model_ceiling) + + +_OUTPUT_TOKEN_FIELDS: Final = ("max_completion_tokens", "max_tokens", "max_output_tokens") + + +def _requested_output_tokens(request_body: Mapping[str, object]) -> int | None: + inference_config: Final = request_body.get("inferenceConfig") + candidates: Final = ( + *(request_body.get(field) for field in _OUTPUT_TOKEN_FIELDS), + inference_config.get("maxTokens") if isinstance(inference_config, Mapping) else None, + ) + return next((tokens for tokens in map(_to_int, candidates) if tokens is not None), None) def _count_text_tokens(model: str, text: object) -> int: diff --git a/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py b/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py index f65f68812a2..c7de8c943ad 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py +++ b/tests/test_litellm/proxy/spend_tracking/test_budget_reservation.py @@ -5,7 +5,7 @@ import pytest from litellm.caching import DualCache from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache -from litellm.proxy.spend_tracking.budget_reservation import reserve_budget_for_request +from litellm.proxy.spend_tracking.budget_reservation import estimate_request_max_cost, reserve_budget_for_request from litellm.proxy.utils import ProxyLogging TOKEN_COUNTING_ROUTES: Final = ( @@ -46,3 +46,32 @@ async def test_non_exempt_llm_route_still_reserves_budget(): assert reservation is not None assert reservation["reserved_cost"] > 0 + + +BEDROCK_SONNET: Final = "us.anthropic.claude-sonnet-4-6" +CONVERSE_BODY: Final = { + "messages": [{"role": "user", "content": [{"text": "Reply with one word: pong"}]}], + "inferenceConfig": {"maxTokens": 5}, +} +INVOKE_BODY: Final = { + "anthropic_version": "bedrock-2023-05-31", + "max_tokens": 5, + "messages": [{"role": "user", "content": "Reply with one word: pong"}], +} + + +def test_bedrock_converse_body_reserves_the_prompt_not_the_context_window(): + converse_cost: Final = estimate_request_max_cost( + request_body=CONVERSE_BODY, + route=f"/bedrock/model/{BEDROCK_SONNET}/converse", + llm_router=None, + input_token_counts={}, + ) + invoke_cost: Final = estimate_request_max_cost( + request_body=INVOKE_BODY, + route=f"/bedrock/model/{BEDROCK_SONNET}/invoke", + llm_router=None, + input_token_counts={}, + ) + assert converse_cost is not None and invoke_cost is not None + assert invoke_cost < converse_cost < 2 * invoke_cost From 43f31b0a4bde40d640a1dfdcc7a5fc2d4a8059c6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:40:59 -0700 Subject: [PATCH 089/410] feat(pricing): add GovCloud Claude Opus 5 and us-gov. inference profile rows --- ...odel_prices_and_context_window_backup.json | 158 ++++++++++++++++++ model_prices_and_context_window.json | 158 ++++++++++++++++++ .../test_bedrock_usgov_pricing.py | 29 ++-- whitelisted_bedrock_models.txt | 2 + 4 files changed, 337 insertions(+), 10 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 44c4f10ec38..945653e09ef 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -42414,6 +42414,102 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "us-gov.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "cache_creation_input_token_cost": 7.5e-06, + "cache_creation_input_token_cost_above_1hr": 1.2e-05, + "cache_read_input_token_cost": 6e-07, + "input_cost_per_token": 6e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, + "us-gov.nvidia.nemotron-nano-3-30b": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262144, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.88e-07, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "us-gov.nvidia.nemotron-nano-12b-v2": { + "input_cost_per_token": 2.4e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.2e-07, + "supports_system_messages": true, + "supports_vision": true + }, + "us-gov.nvidia.nemotron-super-3-120b": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 256000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 7.8e-07, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "us-gov.openai.gpt-oss-20b-1:0": { + "input_cost_per_token": 8.4e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.6e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "us-gov.openai.gpt-oss-120b-1:0": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.2e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "au.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_creation_input_token_cost_above_1hr": 2.2e-06, @@ -58218,6 +58314,37 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "bedrock/us-gov-west-1/anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "cache_creation_input_token_cost": 7.5e-06, + "cache_creation_input_token_cost_above_1hr": 1.2e-05, + "cache_read_input_token_cost": 6e-07, + "input_cost_per_token": 6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "bedrock/us-gov-east-1/nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, "litellm_provider": "bedrock", @@ -58347,6 +58474,37 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "bedrock/us-gov-east-1/anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "cache_creation_input_token_cost": 7.5e-06, + "cache_creation_input_token_cost_above_1hr": 1.2e-05, + "cache_read_input_token_cost": 6e-07, + "input_cost_per_token": 6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "bedrock_mantle/us-gov-west-1/openai.gpt-5.6-terra": { "litellm_provider": "bedrock_mantle", "max_input_tokens": 1050000, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 44c4f10ec38..945653e09ef 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -42414,6 +42414,102 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "us-gov.anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "cache_creation_input_token_cost": 7.5e-06, + "cache_creation_input_token_cost_above_1hr": 1.2e-05, + "cache_read_input_token_cost": 6e-07, + "input_cost_per_token": 6e-06, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, + "us-gov.nvidia.nemotron-nano-3-30b": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 262144, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.88e-07, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_native_structured_output": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "us-gov.nvidia.nemotron-nano-12b-v2": { + "input_cost_per_token": 2.4e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 7.2e-07, + "supports_system_messages": true, + "supports_vision": true + }, + "us-gov.nvidia.nemotron-super-3-120b": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 256000, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "output_cost_per_token": 7.8e-07, + "source": "https://aws.amazon.com/bedrock/pricing/", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_system_messages": true, + "supports_tool_choice": true + }, + "us-gov.openai.gpt-oss-20b-1:0": { + "input_cost_per_token": 8.4e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3.6e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "us-gov.openai.gpt-oss-120b-1:0": { + "input_cost_per_token": 1.8e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 7.2e-07, + "supports_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "au.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_creation_input_token_cost_above_1hr": 2.2e-06, @@ -58218,6 +58314,37 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "bedrock/us-gov-west-1/anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "cache_creation_input_token_cost": 7.5e-06, + "cache_creation_input_token_cost_above_1hr": 1.2e-05, + "cache_read_input_token_cost": 6e-07, + "input_cost_per_token": 6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "bedrock/us-gov-east-1/nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, "litellm_provider": "bedrock", @@ -58347,6 +58474,37 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "bedrock/us-gov-east-1/anthropic.claude-opus-5": { + "bedrock_converse_supports_strict_tools": false, + "cache_creation_input_token_cost": 7.5e-06, + "cache_creation_input_token_cost_above_1hr": 1.2e-05, + "cache_read_input_token_cost": 6e-07, + "input_cost_per_token": 6e-06, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 3e-05, + "prompt_cache_min_tokens": 512, + "supports_adaptive_thinking": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_mid_conversation_system": true, + "supports_native_structured_output": false, + "supports_output_config": true, + "supports_parallel_tool_use_config": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true + }, "bedrock_mantle/us-gov-west-1/openai.gpt-5.6-terra": { "litellm_provider": "bedrock_mantle", "max_input_tokens": 1050000, diff --git a/tests/test_litellm/test_bedrock_usgov_pricing.py b/tests/test_litellm/test_bedrock_usgov_pricing.py index f7d95ecda01..45c867db70b 100644 --- a/tests/test_litellm/test_bedrock_usgov_pricing.py +++ b/tests/test_litellm/test_bedrock_usgov_pricing.py @@ -132,6 +132,13 @@ CLAUDE_GOV_EXPECTED = { "cache_creation_input_token_cost_above_1hr": 1.2e-05, "cache_read_input_token_cost": 6e-07, }, + "anthropic.claude-opus-5": { + "input_cost_per_token": 6e-06, + "output_cost_per_token": 3e-05, + "cache_creation_input_token_cost": 7.5e-06, + "cache_creation_input_token_cost_above_1hr": 1.2e-05, + "cache_read_input_token_cost": 6e-07, + }, } @@ -144,15 +151,16 @@ USGOV_CLAUDE_KEY_TEMPLATES = { @pytest.mark.parametrize("base_key", CLAUDE_GOV_EXPECTED) @pytest.mark.parametrize("key_template,expected_provider", USGOV_CLAUDE_KEY_TEMPLATES.items()) -def test_usgov_claude_sonnet5_opus48_pricing(model_data, key_template, expected_provider, base_key): - """Sonnet 5 and Opus 4.8 gov entries, both in-region keys and the us-gov. - geo inference profile the model cards list for GovCloud, must match the - rates AWS publishes on the Bedrock pricing page (1.2x global). +def test_usgov_claude_pricing(model_data, key_template, expected_provider, base_key): + """Sonnet 5, Opus 4.8, and Opus 5 gov entries, both in-region keys and the + us-gov. geo inference profile the model cards list for GovCloud, must match + the rates AWS publishes in the GovCloud offer file (1.2x global). """ gov_key = key_template.format(base_key=base_key) assert gov_key in model_data, f"Missing model entry: {gov_key}" info = model_data[gov_key] assert info["litellm_provider"] == expected_provider + assert "search_context_cost_per_query" not in info for field, expected in CLAUDE_GOV_EXPECTED[base_key].items(): assert info[field] == expected, f"{gov_key}: {field} should be {expected} (got {info[field]})" ratio = info[field] / model_data[base_key][field] @@ -169,18 +177,19 @@ CONVERSE_GOV_EXPECTED = { @pytest.mark.parametrize("base_key", CONVERSE_GOV_EXPECTED) -@pytest.mark.parametrize("region", ["us-gov-east-1", "us-gov-west-1"]) -def test_usgov_converse_model_pricing(model_data, region, base_key): - """Nemotron and gpt-oss gov entries must match the AWS Bedrock offer file, - which prices both GovCloud regions identically at 1.2x commercial. +@pytest.mark.parametrize("key_template,expected_provider", USGOV_CLAUDE_KEY_TEMPLATES.items()) +def test_usgov_converse_model_pricing(model_data, key_template, expected_provider, base_key): + """Nemotron and gpt-oss gov entries, in-region and the us-gov. geo inference + profile both GovCloud regions list as ACTIVE, must match the AWS Bedrock + offer file, which prices both regions identically at 1.2x commercial. """ - gov_key = f"bedrock/{region}/{base_key}" + gov_key = key_template.format(base_key=base_key) assert gov_key in model_data, f"Missing model entry: {gov_key}" info = model_data[gov_key] expected_input, expected_output = CONVERSE_GOV_EXPECTED[base_key] assert info["input_cost_per_token"] == expected_input assert info["output_cost_per_token"] == expected_output - assert info["litellm_provider"] == "bedrock" + assert info["litellm_provider"] == expected_provider base = model_data[base_key] assert abs(info["input_cost_per_token"] / base["input_cost_per_token"] - 1.2) < 1e-9 assert abs(info["output_cost_per_token"] / base["output_cost_per_token"] - 1.2) < 1e-9 diff --git a/whitelisted_bedrock_models.txt b/whitelisted_bedrock_models.txt index 8753d7c3c77..1dc817ffe48 100644 --- a/whitelisted_bedrock_models.txt +++ b/whitelisted_bedrock_models.txt @@ -231,3 +231,5 @@ bedrock/us-gov-east-1/openai.gpt-oss-20b-1:0 bedrock/us-gov-east-1/openai.gpt-oss-120b-1:0 bedrock/us-gov-east-1/anthropic.claude-sonnet-5 bedrock/us-gov-east-1/anthropic.claude-opus-4-8 +bedrock/us-gov-west-1/anthropic.claude-opus-5 +bedrock/us-gov-east-1/anthropic.claude-opus-5 From 8b37de14b1e9921b60535108b07fa7b0166a6bc7 Mon Sep 17 00:00:00 2001 From: mateo Date: Fri, 4 Sep 2026 08:05:04 +0000 Subject: [PATCH 090/410] refactor: drop fresh Any annotations and suppressions from admission control, spend summary, and dual cache Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- basedpyright-code-budget.json | 6 ++--- litellm/caching/dual_cache.py | 2 +- litellm/litellm_core_utils/litellm_logging.py | 2 +- .../admission_control_middleware.py | 10 ++------ .../spend_management_endpoints.py | 25 ++++++++++--------- ruff-strict-budget.json | 2 +- type-discipline-budget.json | 4 +-- 7 files changed, 23 insertions(+), 28 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 0a29e7eae7e..91e32bc1789 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,6 +1,6 @@ { "reportAny": { - "limit": 14074 + "limit": 14070 }, "reportArgumentType": { "limit": 2206 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 4123 + "limit": 4117 }, "reportFunctionMemberAccess": { "limit": 7 @@ -108,7 +108,7 @@ "limit": 38311 }, "reportUnknownParameterType": { - "limit": 19624 + "limit": 19623 }, "reportUnknownVariableType": { "limit": 29847 diff --git a/litellm/caching/dual_cache.py b/litellm/caching/dual_cache.py index df67ba08416..ec17cc1d809 100644 --- a/litellm/caching/dual_cache.py +++ b/litellm/caching/dual_cache.py @@ -257,7 +257,7 @@ class DualCache(BaseCache): self, current_time: float, keys: list[str], - result: Sequence[Any], + result: Sequence[object], ) -> tuple[list[str], dict[str, float | None]]: """ Atomically choose keys to fetch from Redis and reserve their access time. diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 17a19f05fa3..925c7416b19 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -3815,7 +3815,7 @@ class Logging(LiteLLMLoggingBaseClass): def record_streamed_anthropic_message_id(self, message_id: str) -> None: self.streamed_anthropic_message_id = message_id - def _anthropic_messages_logged_response(self, result: Any) -> ModelResponse: + def _anthropic_messages_logged_response(self, result: object) -> ModelResponse: """ The ModelResponse a /v1/messages spend_logs row is built from. diff --git a/litellm/proxy/middleware/admission_control_middleware.py b/litellm/proxy/middleware/admission_control_middleware.py index aa62ef9e3bf..e347428be83 100644 --- a/litellm/proxy/middleware/admission_control_middleware.py +++ b/litellm/proxy/middleware/admission_control_middleware.py @@ -32,9 +32,6 @@ class AdmissionControlSettings: queue_timeout_seconds: float -AdmissionControlSettingsGetter: TypeAlias = Callable[[], AdmissionControlSettings | None] # mutable-ok: Callable params - - @dataclass(frozen=True, slots=True) class AdmissionControlStats: admitted: int @@ -66,13 +63,10 @@ class AdmissionControlMetrics: rejected_counter: _Counter -AdmissionControlMetricsFactory: TypeAlias = Callable[[], AdmissionControlMetrics | None] # mutable-ok: Callable params - - class AdmissionControlState: """Per-process admission counters and the in-flight semaphore shared by one worker's requests.""" - def __init__(self, metrics_factory: AdmissionControlMetricsFactory) -> None: + def __init__(self, metrics_factory: Callable[[], AdmissionControlMetrics | None]) -> None: self._metrics_factory = metrics_factory self._metrics: AdmissionControlMetrics | None = None self._metrics_init_attempted = False @@ -140,7 +134,7 @@ class AdmissionControlMiddleware: def __init__( self, app: ASGIApp, - get_settings: AdmissionControlSettingsGetter, + get_settings: Callable[[], AdmissionControlSettings | None], state: AdmissionControlState, ) -> None: self.app = app diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index dff100bdea7..f8831ca4152 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -3365,23 +3365,24 @@ async def view_spend_logs( ) sql_query, params = summary_sql_and_params rows: Final[Sequence[_SpendDailySummaryRow]] = await _query_raw(prisma_client, sql_query, *params) - if len(rows) == 0: - return [] # pyright: ignore[reportUnknownVariableType] # empty summary has no element type - summary_items: Final = tuple( _daily_summary_item(date.fromisoformat(day), tuple(day_rows)) for day, day_rows in groupby(rows, key=lambda row: row["day"]) ) - final_date: Final = date.fromisoformat(rows[-1]["day"]) + final_date: Final = date.fromisoformat(rows[-1]["day"]) if len(rows) > 0 else None end_date_date: Final = end_date_obj.date() - padding: Final[tuple[Mapping[str, object], ...]] = tuple( - { - "startTime": final_date + timedelta(days=offset), - "spend": 0, - "users": {}, - "models": {}, - } - for offset in range(1, (end_date_date - final_date).days + 1) + padding: Final[tuple[Mapping[str, object], ...]] = ( + () + if final_date is None + else tuple( + { + "startTime": final_date + timedelta(days=offset), + "spend": 0, + "users": {}, + "models": {}, + } + for offset in range(1, (end_date_date - final_date).days + 1) + ) ) return [*summary_items, *padding] diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 8763318b4eb..f54f31d182d 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -78,7 +78,7 @@ "limit": 1 }, "C901": { - "limit": 311 + "limit": 309 }, "D419": { "limit": 6 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index ab1a793e09d..3b56aa11d02 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22328 + "limit": 22326 }, "LIT002": { - "limit": 26750 + "limit": 26746 }, "LIT003": { "limit": 261 From 200e2901d661d9bcea83d6e69c3839ea71b07e2c Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Fri, 4 Sep 2026 14:33:10 +0530 Subject: [PATCH 091/410] fix(anthropic_responses): preserve Responses refusal blocks in Anthropic translation When OpenAI Responses returns a refusal content block, Anthropic /v1/messages erased the refusal text into an empty content array and emitted stop_reason 'end_turn'. Translate refusal blocks to Anthropic text blocks, map stop_reason to 'refusal', and add 'refusal' to AnthropicFinishReason. Fixes #39721 --- .../responses_adapters/streaming_iterator.py | 22 +++++- .../responses_adapters/transformation.py | 35 +++++++-- litellm/types/llms/anthropic.py | 2 +- ...t_responses_adapters_streaming_iterator.py | 34 +++++++++ .../test_responses_adapters_transformation.py | 76 +++++++++++++------ 5 files changed, 138 insertions(+), 31 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py index a97ce18d179..5f6c5bad190 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py @@ -111,7 +111,7 @@ class AnthropicResponsesStreamWrapper: item_type: Final = getattr(item, "type", None) or (item.get("type") if isinstance(item, dict) else None) item_id = getattr(item, "id", None) or (item.get("id") if isinstance(item, dict) else None) - if item_type == "message": + if item_type in ("message", "refusal"): self._open_block(item_id, {"type": "text", "text": ""}) elif item_type == "function_call": call_id: Final = ( @@ -132,7 +132,7 @@ class AnthropicResponsesStreamWrapper: return # ---- text delta ---- - if event_type == "response.output_text.delta": + if event_type in ("response.output_text.delta", "response.refusal.delta"): item_id = getattr(event, "item_id", None) or (event.get("item_id") if isinstance(event, dict) else None) delta = getattr(event, "delta", "") or (event.get("delta", "") if isinstance(event, dict) else "") block_idx = self._item_id_to_block_index.get(item_id, -1) if item_id else self._current_block_index @@ -238,6 +238,24 @@ class AnthropicResponsesStreamWrapper: if out_type == "function_call": stop_reason = "tool_use" break + elif out_type == "refusal": + stop_reason = "refusal" + break + elif out_type == "message": + content_parts = getattr(out_item, "content", ()) or ( + out_item.get("content") or () if isinstance(out_item, dict) else () + ) + for part in content_parts: + part_type = getattr(part, "type", None) or ( + part.get("type") if isinstance(part, dict) else None + ) + if ( + part_type == "refusal" + or hasattr(part, "refusal") + or (isinstance(part, dict) and "refusal" in part) + ): + stop_reason = "refusal" + break self._chunk_queue.append( { diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 0eb0e38a46e..6cabd35117d 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -631,10 +631,18 @@ class LiteLLMAnthropicToResponsesAPIAdapter: elif isinstance(item, ResponseOutputMessage): for part in item.content: - if getattr(part, "type", None) == "output_text": + part_type = getattr(part, "type", None) + if part_type == "output_text": content.append( AnthropicResponseContentBlockText(type="text", text=getattr(part, "text", "")).model_dump() ) + elif part_type == "refusal" or hasattr(part, "refusal"): + content.append( + AnthropicResponseContentBlockText( + type="text", text=getattr(part, "refusal", "") or "" + ).model_dump() + ) + stop_reason = "refusal" elif isinstance(item, ResponseFunctionToolCall): try: @@ -654,11 +662,22 @@ class LiteLLMAnthropicToResponsesAPIAdapter: elif isinstance(item, dict): item_type = item.get("type") if item_type == "message": - for part in item.get("content", []): - if isinstance(part, dict) and part.get("type") == "output_text": - content.append( - AnthropicResponseContentBlockText(type="text", text=part.get("text", "")).model_dump() - ) + for part in item.get("content", ()): + if isinstance(part, dict): + part_type = part.get("type") + if part_type == "output_text": + content.append( + AnthropicResponseContentBlockText( + type="text", text=part.get("text", "") + ).model_dump() + ) + elif part_type == "refusal" or "refusal" in part: + content.append( + AnthropicResponseContentBlockText( + type="text", text=part.get("refusal", "") or "" + ).model_dump() + ) + stop_reason = "refusal" elif item_type == "reasoning": content.extend( self._thinking_blocks_from_reasoning_item( @@ -679,6 +698,10 @@ class LiteLLMAnthropicToResponsesAPIAdapter: ).model_dump() ) stop_reason = "tool_use" + elif item_type == "refusal": + refusal_text = item.get("refusal") or item.get("text", "") or "" + content.append(AnthropicResponseContentBlockText(type="text", text=refusal_text).model_dump()) + stop_reason = "refusal" # status -> stop_reason override if response.status == "incomplete": diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index b3462203c4b..f1107c87c73 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -658,7 +658,7 @@ class AnthropicOutputTokensDetails(BaseModel): thinking_tokens: int | None = None -AnthropicFinishReason = Literal["end_turn", "max_tokens", "stop_sequence", "tool_use"] +AnthropicFinishReason = Literal["end_turn", "max_tokens", "stop_sequence", "tool_use", "refusal"] class AnthropicResponse(BaseModel): diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py index aebbed88c70..77ecfb73ff6 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py @@ -308,3 +308,37 @@ class TestResponseCompletedUsage: "cache_creation_input_tokens": 10, "cache_read_input_tokens": 4004, } + + +class TestRefusalStreamEvents: + def test_refusal_delta_emits_text_delta(self): + chunks = _process_all( + [ + {"type": "response.created"}, + {"type": "response.refusal.delta", "item_id": "ref_1", "delta": "I cannot fulfill this."}, + ] + ) + assert any( + c.get("type") == "content_block_delta" and c.get("delta", {}).get("text") == "I cannot fulfill this." + for c in chunks + ) + + def test_response_completed_with_refusal_sets_stop_reason_refusal(self): + response = SimpleNamespace( + status="completed", + output=[{"type": "message", "content": [{"type": "refusal", "refusal": "Policy violation"}]}], + usage=None, + ) + chunks = _process_all([{"type": "response.completed", "response": response}]) + message_delta = next(c for c in chunks if c["type"] == "message_delta") + assert message_delta["delta"]["stop_reason"] == "refusal" + + def test_response_completed_with_standalone_refusal_item_sets_stop_reason_refusal(self): + response = SimpleNamespace( + status="completed", + output=[{"type": "refusal", "refusal": "Standalone refusal"}], + usage=None, + ) + chunks = _process_all([{"type": "response.completed", "response": response}]) + message_delta = next(c for c in chunks if c["type"] == "message_delta") + assert message_delta["delta"]["stop_reason"] == "refusal" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py index 5ecf604f096..f48c31ea5ed 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -147,9 +147,7 @@ class TestOutputConfigStructuredOutput: def test_output_config_format_explicit_strict_true_is_preserved(self): """Nested output_config.format with explicit strict=True is preserved.""" - req = _make_request( - output_config={"format": {"type": "json_schema", "schema": self._SCHEMA, "strict": True}} - ) + req = _make_request(output_config={"format": {"type": "json_schema", "schema": self._SCHEMA, "strict": True}}) kwargs = _ADAPTER.translate_request(req) assert kwargs["text"]["format"]["strict"] is True @@ -1207,6 +1205,18 @@ def _make_output_message(texts: List[str]) -> MagicMock: return msg +def _make_refusal_message(refusal_text: str) -> MagicMock: + from openai.types.responses import ResponseOutputMessage + + part = MagicMock() + part.type = "refusal" + part.refusal = refusal_text + + msg = MagicMock(spec=ResponseOutputMessage) + msg.content = [part] + return msg + + def _make_function_call_item(call_id: str, name: str, arguments: str) -> MagicMock: """Build a mock ResponseFunctionToolCall.""" from openai.types.responses import ResponseFunctionToolCall # type: ignore[import] @@ -1279,6 +1289,38 @@ class TestTranslateResponse: result: Any = _ADAPTER.translate_response(response) assert result["stop_reason"] == "end_turn" + def test_refusal_part_becomes_text_block_and_sets_stop_reason_refusal(self): + response = _make_mock_response(output=[_make_refusal_message("I cannot fulfill this request.")]) + result: Any = _ADAPTER.translate_response(response) + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "text" + assert result["content"][0]["text"] == "I cannot fulfill this request." + assert result["stop_reason"] == "refusal" + + def test_dict_refusal_part_in_message_becomes_text_block(self): + output_item = { + "type": "message", + "content": [{"type": "refusal", "refusal": "Refused by policy"}], + } + response = _make_mock_response(output=[output_item]) + result: Any = _ADAPTER.translate_response(response) + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "text" + assert result["content"][0]["text"] == "Refused by policy" + assert result["stop_reason"] == "refusal" + + def test_dict_refusal_item_becomes_text_block(self): + output_item = { + "type": "refusal", + "refusal": "Standalone refusal", + } + response = _make_mock_response(output=[output_item]) + result: Any = _ADAPTER.translate_response(response) + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "text" + assert result["content"][0]["text"] == "Standalone refusal" + assert result["stop_reason"] == "refusal" + def test_incomplete_status_sets_max_tokens(self): """status='incomplete' overrides stop_reason to 'max_tokens'.""" response = _make_mock_response( @@ -1337,9 +1379,7 @@ class TestTranslateResponse: ] ) result: Any = _ADAPTER.translate_response(response) - assert result["content"] == [ - {"type": "thinking", "thinking": "Weighing the options.", "signature": None} - ] + assert result["content"] == [{"type": "thinking", "thinking": "Weighing the options.", "signature": None}] def test_thinking_blocks_are_dropped_when_replayed_to_anthropic(self): """Replaying this turn to an Anthropic model must not send a signature it cannot verify.""" @@ -1481,9 +1521,7 @@ class TestToolResultImages: }, { "role": "user", - "content": [ - {"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content} - ], + "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}], }, ] @@ -1630,9 +1668,7 @@ class TestToolResultDocuments: }, { "role": "user", - "content": [ - {"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content} - ], + "content": [{"type": "tool_result", "tool_use_id": "toolu_01", "content": tool_result_content}], }, ] @@ -1665,9 +1701,7 @@ class TestToolResultDocuments: def test_document_title_becomes_filename(self): output = self._tool_output(self._translate([self._base64_document(title="quarterly-report.pdf")])) - assert output == [ - {"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI} - ] + assert output == [{"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI}] def test_url_document_becomes_file_url_part(self): output = self._tool_output( @@ -1776,9 +1810,7 @@ class TestUserContentDocuments: def test_document_title_becomes_filename(self): content = self._user_content(self._translate([self._base64_document(title="quarterly-report.pdf")])) - assert content == [ - {"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI} - ] + assert content == [{"type": "input_file", "filename": "quarterly-report.pdf", "file_data": self.PDF_DATA_URI}] def test_url_document_becomes_file_url_part(self): content = self._user_content( @@ -1808,9 +1840,7 @@ class TestUserContentDocuments: assert content == [{"type": "input_text", "text": "still here"}] def test_document_breakpoint_rides_on_the_file_part(self): - content = self._user_content( - self._translate([self._base64_document(prompt_cache_breakpoint=self.EXPLICIT)]) - ) + content = self._user_content(self._translate([self._base64_document(prompt_cache_breakpoint=self.EXPLICIT)])) assert content == [ { "type": "input_file", @@ -1857,7 +1887,9 @@ class TestPromptCacheBreakpointToResponses: ] def test_system_without_breakpoint_still_becomes_instructions(self): - request = _make_request(system=[{"type": "text", "text": "Be concise."}, {"type": "text", "text": "Be helpful."}]) + request = _make_request( + system=[{"type": "text", "text": "Be concise."}, {"type": "text", "text": "Be helpful."}] + ) kwargs = _ADAPTER.translate_request(request) assert kwargs["instructions"] == "Be concise.\nBe helpful." assert kwargs["input"] == [ From 03a82823fc46e19fde89021acbd9095edb94fa79 Mon Sep 17 00:00:00 2001 From: mateo Date: Fri, 4 Sep 2026 10:07:34 +0000 Subject: [PATCH 092/410] test: deflake redis loop-stall burst test and pre-commit interrupt cleanup The redis breaker test raced the event loop: the fake call had to still be pending when a real time.sleep stall began, which needs the loop to get from scheduling to the stall in under 1ms. The fake now holds its answer behind an asyncio.Event so the whole burst times out deterministically. The pre-commit interrupt test found a real leak: lint_dashboard creates its eslint report with mktemp and only removed it on the happy path, so an interrupt landing during the whole-folder eslint run left the file behind. The subshell now removes it from an EXIT trap, and the test drives the interrupt while that eslint run is in flight. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- scripts/pre_commit_lint.sh | 3 ++- tests/test_litellm/caching/test_redis_cache.py | 17 +++++++---------- tests/test_litellm/test_pre_commit_lint.py | 9 ++++++++- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/scripts/pre_commit_lint.sh b/scripts/pre_commit_lint.sh index ff553be6461..d38a0eee3de 100755 --- a/scripts/pre_commit_lint.sh +++ b/scripts/pre_commit_lint.sh @@ -142,6 +142,8 @@ fi lint_dashboard() { ( + trap 'exit 143' TERM + trap 'rm -f "${report:-}"' EXIT rc=0 prettier_rel=() eslint_rel=() @@ -168,7 +170,6 @@ EOF report=$(mktemp) npx eslint . -f json -o "$report" || true node scripts/check-lint-budgets.mjs "$report" eslint-budgets.json || rc=1 - rm -f "$report" exit $rc ) } diff --git a/tests/test_litellm/caching/test_redis_cache.py b/tests/test_litellm/caching/test_redis_cache.py index e4724ff8705..dd87bf0cf0f 100644 --- a/tests/test_litellm/caching/test_redis_cache.py +++ b/tests/test_litellm/caching/test_redis_cache.py @@ -822,30 +822,27 @@ async def test_event_loop_stall_timeout_burst_keeps_breaker_closed(): Every operation already waiting on the loop times out together when the loop resumes, so a purely consecutive threshold is satisfied instantly even though the Redis on the - other end (here an in-process fake that answers immediately) is healthy. + other end is healthy. The stall is modelled by holding the fake Redis's answers back + until the whole burst has hit its client timeout, then releasing them. """ - import time as time_mod - from litellm.caching.redis_cache import RedisCircuitBreaker, _run_under_circuit_breaker breaker = RedisCircuitBreaker(failure_threshold=3, recovery_timeout=60, timeout_min_duration=5.0) + loop_resumed = asyncio.Event() async def healthy_redis_call_with_client_timeout(): - return await asyncio.wait_for(asyncio.sleep(0.001, result="ok"), timeout=0.05) - - async def stall_the_loop(): - await asyncio.sleep(0) - time_mod.sleep(0.2) + await asyncio.wait_for(loop_resumed.wait(), timeout=0.05) + return "ok" results = await asyncio.gather( *(_run_under_circuit_breaker(breaker, "op", healthy_redis_call_with_client_timeout) for _ in range(8)), - stall_the_loop(), return_exceptions=True, ) timeouts = [r for r in results if isinstance(r, asyncio.TimeoutError)] - assert len(timeouts) >= breaker.failure_threshold, "the stall must time out a full burst" + assert len(timeouts) == 8, "the stall must time out the whole burst" assert breaker.is_open() is False, "a healthy Redis behind one loop stall must stay in the pool" + loop_resumed.set() assert await _run_under_circuit_breaker(breaker, "op", healthy_redis_call_with_client_timeout) == "ok" diff --git a/tests/test_litellm/test_pre_commit_lint.py b/tests/test_litellm/test_pre_commit_lint.py index 5ea0e79a196..aa2260e89ea 100644 --- a/tests/test_litellm/test_pre_commit_lint.py +++ b/tests/test_litellm/test_pre_commit_lint.py @@ -53,6 +53,12 @@ case "$*" in "eslint --no-warn-ignored"*) [ "${STUB_FAIL:-}" = "eslint" ] && exit 1 ;; + "eslint . -f json"*) + if [ -n "${STUB_HANG_DIR:-}" ]; then + touch "$STUB_HANG_DIR/eslint_report.started" + sleep 60 + fi + ;; esac exit 0 """ @@ -340,11 +346,12 @@ def test_interrupt_kills_background_jobs_and_removes_logs(tmp_path: Path) -> Non ) try: assert _wait_until((hang_dir / "make.started").exists, 10) + assert _wait_until((hang_dir / "eslint_report.started").exists, 10) os.killpg(proc.pid, signal.SIGINT) assert proc.wait(timeout=10) != 0 make_pid = int((hang_dir / "make.pid").read_text()) assert _wait_until(lambda: _pid_gone(make_pid), 5) - assert list(tmp_dir.iterdir()) == [] + assert _wait_until(lambda: not any(tmp_dir.iterdir()), 5), list(tmp_dir.iterdir()) finally: with suppress(ProcessLookupError, PermissionError): os.killpg(proc.pid, signal.SIGTERM) From daced81f20a6b98c01beecf66fa997310d90bfb4 Mon Sep 17 00:00:00 2001 From: amasen02 Date: Fri, 4 Sep 2026 15:37:53 +0530 Subject: [PATCH 093/410] fix(proxy): invalidate end-user spend counter and cache on budget reset (#39726) Signed-off-by: amasen02 --- .../proxy/common_utils/reset_budget_job.py | 24 ++++++++++++++++- .../common_utils/test_reset_budget_job.py | 26 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 47f69732e95..12ba75aea24 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -38,6 +38,7 @@ from litellm.proxy.common_utils.timezone_utils import ( get_budget_reset_settings, ) from litellm.proxy.common_utils.user_api_key_cache import ( + end_user_cache_key, model_access_group_cache_key, model_access_group_spend_counter_key, tag_cache_key, @@ -177,6 +178,21 @@ def _model_access_group_cache_keys(row: _ModelAccessGroupRow) -> tuple[str, ...] return (model_access_group_cache_key(row.access_group_name),) +def _enduser_counter_key(row: _EndUserRow) -> str: + return f"spend:end_user:{row.user_id}" + + +def _enduser_cache_keys(row: _EndUserRow) -> tuple[str, ...]: + return (end_user_cache_key(row.user_id),) + + +def _enduser_carried_spend(row: _EndUserRow, caps: Mapping[str, float]) -> float: + if not caps: + return 0.0 + effective_budget_id = row.budget_id or litellm.max_end_user_budget_id + return _carried_spend(row.spend, caps.get(effective_budget_id) if effective_budget_id is not None else None) + + def _budget_link_where( budget_ids: Sequence[str], extra: Mapping[str, object] = MappingProxyType({}), @@ -650,6 +666,7 @@ class ResetBudgetJob: if _rollover_enabled() else {} # mutable-ok: empty sentinel immediately frozen by MappingProxyType ) + endusers: Final[tuple[_EndUserRow, ...]] = await self._collect_endusers_to_reset(budget_ids) return _BudgetCascade( budgets=tuple(budgets_to_reset), budget_ids=budget_ids, @@ -661,7 +678,7 @@ class ResetBudgetJob: for b in budgets_to_reset if b.budget_id is not None and b.budget_duration is not None ), - endusers=await self._collect_endusers_to_reset(budget_ids), + endusers=endusers, counter_resets=( *( (_team_membership_counter_key(row), _row_carried_spend(row, rollover_caps)) @@ -674,6 +691,10 @@ class ResetBudgetJob: (_model_access_group_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in model_access_groups ), + *( + (_enduser_counter_key(row), _enduser_carried_spend(row, rollover_caps)) + for row in endusers + ), ), rollover_caps=rollover_caps, cache_keys=( @@ -682,6 +703,7 @@ class ResetBudgetJob: *(key for row in orgs for key in _org_cache_keys(row)), *(key for row in tags for key in _tag_cache_keys(row)), *(key for row in model_access_groups for key in _model_access_group_cache_keys(row)), + *(key for row in endusers for key in _enduser_cache_keys(row)), ), ) 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 03b05bd9d87..e6bbfd8c7d4 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 @@ -1495,6 +1495,32 @@ def test_budget_table_reset_invalidates_every_tag_not_just_the_first(reset_budge assert deleted == {"tag:tenant-a", "tag:tenant-b", "tag:tenant-c"} +def test_budget_table_reset_invalidates_enduser_counter_and_cache(reset_budget_job, mock_prisma_client, monkeypatch): + """When an end user's budget resets, its Redis spend counter is zeroed and its management cache is evicted.""" + counter_cache = _make_counter_invalidation_job(monkeypatch) + budget = _budget_row(budget_id="budget-1") + mock_prisma_client.data["budget"] = [budget] + test_enduser = type( + "LiteLLM_EndUserTable", + (), + { + "spend": 20.0, + "litellm_budget_table": budget, + "budget_id": "budget-1", + "user_id": "customer-42", + }, + ) + mock_prisma_client.data["enduser"] = [test_enduser] + + asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table()) + + counter_cache.in_memory_cache.set_cache.assert_any_call(key="spend:end_user:customer-42", value=0.0, ttl=60) + counter_cache.redis_cache.async_set_cache.assert_any_await(key="spend:end_user:customer-42", value=0.0, ttl=60) + deleted = {call.kwargs.get("key") for call in counter_cache.user_api_key_cache.async_delete_cache.await_args_list} + assert "end_user_id:customer-42" in deleted + + + def test_budget_table_reset_commits_even_when_cache_eviction_fails(reset_budget_job, mock_prisma_client, monkeypatch): """Eviction runs after the commit, so a broken cache cannot undo the write.""" counter_cache = _make_counter_invalidation_job(monkeypatch) From 3623aecc6419ed5442bea4efd435cdca3246101a Mon Sep 17 00:00:00 2001 From: amasen02 Date: Fri, 4 Sep 2026 16:33:57 +0530 Subject: [PATCH 094/410] style(proxy): add Final type annotations to enduser budget reset variables --- litellm/proxy/common_utils/reset_budget_job.py | 2 +- .../proxy/common_utils/test_reset_budget_job.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 12ba75aea24..4fb544cbb15 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -189,7 +189,7 @@ def _enduser_cache_keys(row: _EndUserRow) -> tuple[str, ...]: def _enduser_carried_spend(row: _EndUserRow, caps: Mapping[str, float]) -> float: if not caps: return 0.0 - effective_budget_id = row.budget_id or litellm.max_end_user_budget_id + effective_budget_id: Final[str | None] = row.budget_id or litellm.max_end_user_budget_id return _carried_spend(row.spend, caps.get(effective_budget_id) if effective_budget_id is not None else None) 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 e6bbfd8c7d4..bc9926a314f 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 @@ -4,7 +4,7 @@ import sys import types from datetime import datetime, timedelta, timezone from datetime import time as dt_time -from typing import Any, Dict, List +from typing import Any, Dict, Final, List from unittest.mock import AsyncMock, MagicMock import httpx @@ -1497,10 +1497,10 @@ def test_budget_table_reset_invalidates_every_tag_not_just_the_first(reset_budge def test_budget_table_reset_invalidates_enduser_counter_and_cache(reset_budget_job, mock_prisma_client, monkeypatch): """When an end user's budget resets, its Redis spend counter is zeroed and its management cache is evicted.""" - counter_cache = _make_counter_invalidation_job(monkeypatch) - budget = _budget_row(budget_id="budget-1") + counter_cache: Final = _make_counter_invalidation_job(monkeypatch) + budget: Final = _budget_row(budget_id="budget-1") mock_prisma_client.data["budget"] = [budget] - test_enduser = type( + test_enduser: Final = type( "LiteLLM_EndUserTable", (), { @@ -1516,7 +1516,7 @@ def test_budget_table_reset_invalidates_enduser_counter_and_cache(reset_budget_j counter_cache.in_memory_cache.set_cache.assert_any_call(key="spend:end_user:customer-42", value=0.0, ttl=60) counter_cache.redis_cache.async_set_cache.assert_any_await(key="spend:end_user:customer-42", value=0.0, ttl=60) - deleted = {call.kwargs.get("key") for call in counter_cache.user_api_key_cache.async_delete_cache.await_args_list} + deleted: Final = {call.kwargs.get("key") for call in counter_cache.user_api_key_cache.async_delete_cache.await_args_list} assert "end_user_id:customer-42" in deleted From 9c8594c7b8c4c948abb85507e7611762b0b0f70f Mon Sep 17 00:00:00 2001 From: amasen02 Date: Fri, 4 Sep 2026 16:37:58 +0530 Subject: [PATCH 095/410] style(proxy): format reset_budget_job with ruff --- litellm/proxy/common_utils/reset_budget_job.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 4fb544cbb15..f2648c8466e 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -691,10 +691,7 @@ class ResetBudgetJob: (_model_access_group_counter_key(row), _row_carried_spend(row, rollover_caps)) for row in model_access_groups ), - *( - (_enduser_counter_key(row), _enduser_carried_spend(row, rollover_caps)) - for row in endusers - ), + *((_enduser_counter_key(row), _enduser_carried_spend(row, rollover_caps)) for row in endusers), ), rollover_caps=rollover_caps, cache_keys=( From 0b34abe8fe3904453ff4796cfdb8b8981dfaf7e8 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:02:07 +0530 Subject: [PATCH 096/410] fix(anthropic): harden refusal translation --- .../adapters/streaming_iterator.py | 39 +++++- .../adapters/transformation.py | 37 +++++- .../responses_adapters/streaming_iterator.py | 114 +++++++++++------- .../responses_adapters/transformation.py | 58 +++++++-- litellm/types/llms/anthropic.py | 7 ++ .../anthropic_messages/anthropic_response.py | 11 +- ...al_pass_through_adapters_transformation.py | 45 +++++++ .../test_streaming_iterator_first_delta.py | 87 +++++++++++++ ...t_responses_adapters_streaming_iterator.py | 60 +++++++-- .../test_responses_adapters_transformation.py | 41 ++++--- 10 files changed, 397 insertions(+), 102 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 78ff83cafbf..41db4f12143 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -4,13 +4,14 @@ import copy import json import traceback from collections import deque -from collections.abc import AsyncIterator, Iterator, Sequence +from collections.abc import AsyncIterator, Iterator, Mapping, Sequence from typing import ( TYPE_CHECKING, Any, Final, Literal, Protocol, + cast, get_args, ) @@ -305,6 +306,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # Synthesized compaction block from compact_20260112 polyfill (streaming). self.compaction_block = compaction_block self.iterations_usage = iterations_usage + self._refusal_text_parts: list[str] = [] self.sent_compaction_block: bool = False # Per-phase flags so the compaction block's start/delta/stop events # are emitted (and the public state machine is advanced) in @@ -572,6 +574,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): current_content_block_index=self.current_content_block_index, applied_edits=(self.applied_edits if is_final_chunk and not will_merge_into_held else None), ) + processed_chunk = self._with_refusal_stop_details(processed_chunk) # Check if this is a usage chunk and we have a held stop_reason chunk if will_merge_into_held: @@ -806,6 +809,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): current_content_block_index=self.current_content_block_index, applied_edits=(self.applied_edits if is_final_chunk and not will_merge_into_held else None), ) + processed_chunk = self._with_refusal_stop_details(processed_chunk) # Check if this is a usage chunk and we have a held stop_reason chunk if will_merge_into_held: @@ -993,6 +997,31 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): def _increment_content_block_index(self): self.current_content_block_index += 1 + def _with_refusal_stop_details( + self, + processed_chunk: ContentBlockDelta | MessageBlockDelta, + ) -> ContentBlockDelta | MessageBlockDelta: + if processed_chunk.get("type") != "message_delta" or not self._refusal_text_parts: + return processed_chunk + delta: Final = cast(Mapping[str, object], processed_chunk["delta"]) + if delta.get("stop_reason") == "max_tokens": + return processed_chunk + return cast( + ContentBlockDelta | MessageBlockDelta, + { + **processed_chunk, + "delta": { + **delta, + "stop_reason": "refusal", + "stop_details": { + "type": "refusal", + "category": None, + "explanation": "".join(self._refusal_text_parts), + }, + }, + }, + ) + @staticmethod def _delta_has_content(processed_chunk: dict[str, Any]) -> bool: """Return True if a translated chunk carries a non-empty @@ -1044,6 +1073,8 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): return False if getattr(delta, "content", None): return False + if getattr(delta, "refusal", None): + return False if getattr(delta, "reasoning_content", None): return False # thinking_blocks whose entries are all empty AND unsigned must not @@ -1069,8 +1100,10 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): """ from .transformation import LiteLLMAnthropicMessagesAdapter - # Example logic - customize based on your needs: - # If chunk indicates a tool call + refusal_text: Final = LiteLLMAnthropicMessagesAdapter._refusal_text(chunk.choices[0].delta) + if refusal_text is not None: + self._refusal_text_parts.append(refusal_text) + if chunk.choices[0].finish_reason is not None: return False diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 573a461e89e..5655f59a4cc 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -307,6 +307,17 @@ class LiteLLMAnthropicMessagesAdapter: def __init__(self): pass + @staticmethod + def _refusal_text(message_or_delta: object) -> str | None: + refusal: Final = getattr(message_or_delta, "refusal", None) + if isinstance(refusal, str): + return refusal + provider_specific_fields: Final = getattr(message_or_delta, "provider_specific_fields", None) + if isinstance(provider_specific_fields, Mapping): + provider_refusal: Final = provider_specific_fields.get("refusal") + return provider_refusal if isinstance(provider_refusal, str) else None + return None + ### FOR [BETA] `/v1/messages` endpoint support def _extract_signature_from_tool_call(self, tool_call: object) -> str | None: @@ -1324,6 +1335,8 @@ class LiteLLMAnthropicMessagesAdapter: new_content.append( AnthropicResponseContentBlockText(type="text", text=choice.message.content).model_dump() ) + if (refusal_text := self._refusal_text(choice.message)) is not None: + new_content.append(AnthropicResponseContentBlockText(type="text", text=refusal_text).model_dump()) # Handle tool calls (in parallel to text content) if choice.message.tool_calls is not None and len(choice.message.tool_calls) > 0: for tool_call in choice.message.tool_calls: @@ -1482,14 +1495,23 @@ class LiteLLMAnthropicMessagesAdapter: choices=response.choices, tool_name_mapping=tool_name_mapping, ) + refusal_text: Final = next( + (text for choice in response.choices if (text := self._refusal_text(choice.message)) is not None), + None, + ) if polyfill_result is not None and polyfill_result.compaction_block is not None: anthropic_content.insert(0, polyfill_result.compaction_block) ## extract finish reason - anthropic_finish_reason: Final = self._translate_openai_finish_reason_to_anthropic( + translated_finish_reason: Final = self._translate_openai_finish_reason_to_anthropic( openai_finish_reason=response.choices[0].finish_reason ) + anthropic_finish_reason: Final = ( + "refusal" + if refusal_text is not None and translated_finish_reason != "max_tokens" + else translated_finish_reason + ) # extract usage usage: Final[Usage] = getattr(response, "usage") anthropic_usage: Final = self._translate_openai_usage_to_anthropic_usage(usage) @@ -1511,6 +1533,15 @@ class LiteLLMAnthropicMessagesAdapter: usage=anthropic_usage, content=anthropic_content, stop_reason=anthropic_finish_reason, + stop_details=( + { + "type": "refusal", + "category": None, + "explanation": refusal_text, + } + if anthropic_finish_reason == "refusal" + else None + ), ) applied_edits: Final = polyfill_result.applied_edits_for_response() if polyfill_result else None @@ -1551,7 +1582,9 @@ class LiteLLMAnthropicMessagesAdapter: "signature": thought_sig, } return "tool_use", cast("ContentBlockContentBlockDict", tool_block) - elif choice.delta.content is not None and len(choice.delta.content) > 0: + elif (choice.delta.content is not None and len(choice.delta.content) > 0) or self._refusal_text( + choice.delta + ) is not None: return "text", TextBlock(type="text", text="") elif isinstance(choice, StreamingChoices) and hasattr(choice.delta, "thinking_blocks"): thinking_blocks = choice.delta.thinking_blocks or [] diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py index 5f6c5bad190..1b0ab5923d8 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py @@ -1,5 +1,6 @@ # What is this? ## Translates OpenAI call to Anthropic `/v1/messages` format +import asyncio import json import traceback from collections import deque @@ -49,6 +50,8 @@ class AnthropicResponsesStreamWrapper: self._sent_message_start = False self._sent_message_stop = False self._chunk_queue: deque = deque() + self._refusal_text_parts: list[str] = [] + self._sync_responses_iterator: Any = None def _make_message_start(self) -> dict[str, Any]: return { @@ -111,7 +114,7 @@ class AnthropicResponsesStreamWrapper: item_type: Final = getattr(item, "type", None) or (item.get("type") if isinstance(item, dict) else None) item_id = getattr(item, "id", None) or (item.get("id") if isinstance(item, dict) else None) - if item_type in ("message", "refusal"): + if item_type == "message": self._open_block(item_id, {"type": "text", "text": ""}) elif item_type == "function_call": call_id: Final = ( @@ -131,8 +134,14 @@ class AnthropicResponsesStreamWrapper: ) return + if event_type == "response.refusal.delta": + delta = getattr(event, "delta", "") or (event.get("delta", "") if isinstance(event, dict) else "") + if isinstance(delta, str): + self._refusal_text_parts.append(delta) + return + # ---- text delta ---- - if event_type in ("response.output_text.delta", "response.refusal.delta"): + if event_type == "response.output_text.delta": item_id = getattr(event, "item_id", None) or (event.get("item_id") if isinstance(event, dict) else None) delta = getattr(event, "delta", "") or (event.get("delta", "") if isinstance(event, dict) else "") block_idx = self._item_id_to_block_index.get(item_id, -1) if item_id else self._current_block_index @@ -215,52 +224,53 @@ class AnthropicResponsesStreamWrapper: response_obj: Final = getattr(event, "response", None) or ( event.get("response") if isinstance(event, dict) else None ) - stop_reason = "end_turn" - anthropic_usage: AnthropicUsage = AnthropicUsage(input_tokens=0, output_tokens=0) - - if response_obj is not None: - status: Final = getattr(response_obj, "status", None) - if status == "incomplete": - stop_reason = "max_tokens" - anthropic_usage = ( - LiteLLMAnthropicToResponsesAPIAdapter.translate_responses_api_usage_to_anthropic_usage( - getattr(response_obj, "usage", None) - ) + output: Final = (getattr(response_obj, "output", None) or ()) if response_obj is not None else () + refusal_text: Final = LiteLLMAnthropicToResponsesAPIAdapter._refusal_text_from_output(output) or ( + "".join(self._refusal_text_parts) or None + ) + status: Final = getattr(response_obj, "status", None) if response_obj is not None else None + has_tool_call: Final = any( + getattr(item, "type", None) == "function_call" + or (isinstance(item, dict) and item.get("type") == "function_call") + for item in output + ) + stop_reason: Final = ( + "max_tokens" + if status == "incomplete" + else "refusal" + if refusal_text is not None + else "tool_use" + if has_tool_call + else "end_turn" + ) + anthropic_usage: Final[AnthropicUsage] = ( + LiteLLMAnthropicToResponsesAPIAdapter.translate_responses_api_usage_to_anthropic_usage( + getattr(response_obj, "usage", None) ) + if response_obj is not None + else AnthropicUsage(input_tokens=0, output_tokens=0) + ) - # Check if tool_use was in the output to override stop_reason - if response_obj is not None: - output: Final = getattr(response_obj, "output", []) or [] - for out_item in output: - out_type = getattr(out_item, "type", None) or ( - out_item.get("type") if isinstance(out_item, dict) else None - ) - if out_type == "function_call": - stop_reason = "tool_use" - break - elif out_type == "refusal": - stop_reason = "refusal" - break - elif out_type == "message": - content_parts = getattr(out_item, "content", ()) or ( - out_item.get("content") or () if isinstance(out_item, dict) else () - ) - for part in content_parts: - part_type = getattr(part, "type", None) or ( - part.get("type") if isinstance(part, dict) else None - ) - if ( - part_type == "refusal" - or hasattr(part, "refusal") - or (isinstance(part, dict) and "refusal" in part) - ): - stop_reason = "refusal" - break + message_delta_payload: Final = { + "stop_reason": stop_reason, + "stop_sequence": None, + **( + { + "stop_details": { + "type": "refusal", + "category": None, + "explanation": refusal_text, + } + } + if stop_reason == "refusal" + else {} + ), + } self._chunk_queue.append( { "type": "message_delta", - "delta": {"stop_reason": stop_reason, "stop_sequence": None}, + "delta": message_delta_payload, "usage": dict(anthropic_usage), } ) @@ -284,10 +294,22 @@ class AnthropicResponsesStreamWrapper: # Consume the upstream stream try: - async for event in self.responses_stream: - self._process_event(event) - if self._chunk_queue: - return self._chunk_queue.popleft() + if hasattr(self.responses_stream, "__aiter__"): + async for event in self.responses_stream: + self._process_event(event) + if self._chunk_queue: + return self._chunk_queue.popleft() + else: + if self._sync_responses_iterator is None: + self._sync_responses_iterator = iter(self.responses_stream) + missing: Final = object() + while True: + event = await asyncio.to_thread(next, self._sync_responses_iterator, missing) + if event is missing: + break + self._process_event(event) + if self._chunk_queue: + return self._chunk_queue.popleft() except StopAsyncIteration: pass except Exception as e: diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 6cabd35117d..92f3e08f24d 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -6,7 +6,7 @@ path used for OpenAI and Azure models. """ import json -from collections.abc import Iterable, Mapping +from collections.abc import Iterable, Mapping, Sequence from itertools import groupby from typing import Any, Final, cast @@ -69,6 +69,38 @@ class LiteLLMAnthropicToResponsesAPIAdapter: chat_usage = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(raw_usage) return LiteLLMAnthropicMessagesAdapter._translate_openai_usage_to_anthropic_usage(chat_usage) + @staticmethod + def _refusal_text_from_output(output: Iterable[object]) -> str | None: + from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal + + def refusal_text_from_item(item: object) -> str | None: + if isinstance(item, ResponseOutputMessage): + return next( + (part.refusal for part in item.content if isinstance(part, ResponseOutputRefusal)), + None, + ) + if not isinstance(item, Mapping): + return None + item_mapping: Final = cast(Mapping[str, object], item) + raw_parts: Final = item_mapping.get("content") + if item_mapping.get("type") != "message" or not isinstance(raw_parts, Sequence): + return None + return next( + ( + refusal + for part in cast(Sequence[object], raw_parts) + if isinstance(part, Mapping) + and cast(Mapping[str, object], part).get("type") == "refusal" + and isinstance((refusal := cast(Mapping[str, object], part).get("refusal")), str) + ), + None, + ) + + return next( + (text for item in output if (text := refusal_text_from_item(item)) is not None), + None, + ) + # ------------------------------------------------------------------ # # Request translation: Anthropic -> Responses API # # ------------------------------------------------------------------ # @@ -624,6 +656,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: content: Final[list[dict[str, object]]] = [] stop_reason: AnthropicFinishReason = "end_turn" + refusal_text: Final = self._refusal_text_from_output(cast(Iterable[object], response.output)) for item in response.output: if isinstance(item, ResponseReasoningItem): @@ -636,13 +669,12 @@ class LiteLLMAnthropicToResponsesAPIAdapter: content.append( AnthropicResponseContentBlockText(type="text", text=getattr(part, "text", "")).model_dump() ) - elif part_type == "refusal" or hasattr(part, "refusal"): + elif part_type == "refusal": content.append( AnthropicResponseContentBlockText( type="text", text=getattr(part, "refusal", "") or "" ).model_dump() ) - stop_reason = "refusal" elif isinstance(item, ResponseFunctionToolCall): try: @@ -671,13 +703,12 @@ class LiteLLMAnthropicToResponsesAPIAdapter: type="text", text=part.get("text", "") ).model_dump() ) - elif part_type == "refusal" or "refusal" in part: + elif part_type == "refusal": content.append( AnthropicResponseContentBlockText( type="text", text=part.get("refusal", "") or "" ).model_dump() ) - stop_reason = "refusal" elif item_type == "reasoning": content.extend( self._thinking_blocks_from_reasoning_item( @@ -698,14 +729,10 @@ class LiteLLMAnthropicToResponsesAPIAdapter: ).model_dump() ) stop_reason = "tool_use" - elif item_type == "refusal": - refusal_text = item.get("refusal") or item.get("text", "") or "" - content.append(AnthropicResponseContentBlockText(type="text", text=refusal_text).model_dump()) - stop_reason = "refusal" - - # status -> stop_reason override if response.status == "incomplete": stop_reason = "max_tokens" + elif refusal_text is not None: + stop_reason = "refusal" anthropic_usage: Final = self.translate_responses_api_usage_to_anthropic_usage(response.usage) @@ -718,4 +745,13 @@ class LiteLLMAnthropicToResponsesAPIAdapter: usage=anthropic_usage, content=content, stop_reason=stop_reason, + stop_details=( + { + "type": "refusal", + "category": None, + "explanation": refusal_text, + } + if stop_reason == "refusal" + else None + ), ) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index f1107c87c73..cdfc5227e06 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -520,8 +520,15 @@ ContentBlockContentBlockDict = ToolUseBlock | TextBlock | ChatCompletionThinking ContentBlockStart = ContentBlockStartToolUse | ContentBlockStartText +class AnthropicStopDetails(TypedDict, total=False): + type: ReadOnly[Literal["refusal"]] + category: ReadOnly[str | None] + explanation: ReadOnly[str | None] + + class MessageDelta(TypedDict, total=False): stop_reason: str | None + stop_details: AnthropicStopDetails class ServerToolUsage(TypedDict, total=False): diff --git a/litellm/types/llms/anthropic_messages/anthropic_response.py b/litellm/types/llms/anthropic_messages/anthropic_response.py index 4fe1dafc73b..038a23a3ca2 100644 --- a/litellm/types/llms/anthropic_messages/anthropic_response.py +++ b/litellm/types/llms/anthropic_messages/anthropic_response.py @@ -5,6 +5,7 @@ from typing_extensions import NotRequired, ReadOnly, TypedDict from litellm.types.llms.anthropic import ( AnthropicResponseContentBlockText, AnthropicResponseContentBlockToolUse, + AnthropicStopDetails, ContextManagementResponse, ServerToolUsage, ) @@ -78,16 +79,6 @@ class AnthropicUsage(TypedDict, total=False): server_tool_use: NotRequired[ReadOnly[ServerToolUsage]] -class AnthropicStopDetails(TypedDict, total=False): - """ - Safeguard verdict accompanying a `stop_reason: "refusal"` response: - https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback - """ - - category: ReadOnly[str | None] - explanation: ReadOnly[str | None] - - class AnthropicMessagesResponse(TypedDict, total=False): """ Anthropic Messages API Response: https://docs.anthropic.com/en/api/messages diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 2d74c00071b..fba5532d1e5 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -40,6 +40,51 @@ from litellm.types.utils import ( ) +def test_translate_chat_refusal_to_anthropic_response(): + response = ModelResponse( + id="chatcmpl-refusal", + model="openai-model", + choices=[ + Choices( + index=0, + finish_reason="stop", + message=Message(content=None, role="assistant", refusal="I cannot fulfill this request."), + ) + ], + usage=Usage(prompt_tokens=1, completion_tokens=1, total_tokens=2), + ) + + result = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(response) + + assert result["content"] == [{"type": "text", "text": "I cannot fulfill this request."}] + assert result["stop_reason"] == "refusal" + assert result.get("stop_details") == { + "type": "refusal", + "category": None, + "explanation": "I cannot fulfill this request.", + } + + +def test_translate_chat_length_takes_precedence_over_refusal(): + response = ModelResponse( + id="chatcmpl-partial-refusal", + model="openai-model", + choices=[ + Choices( + index=0, + finish_reason="length", + message=Message(content=None, role="assistant", refusal="Partial refusal"), + ) + ], + usage=Usage(prompt_tokens=1, completion_tokens=1, total_tokens=2), + ) + + result = LiteLLMAnthropicMessagesAdapter().translate_openai_response_to_anthropic(response) + + assert result["stop_reason"] == "max_tokens" + assert result.get("stop_details") is None + + def test_translate_streaming_openai_chunk_to_anthropic_content_block(): choices = [ StreamingChoices( diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py index 17d42f55ae0..e7381986ec2 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py @@ -108,6 +108,93 @@ def _text_deltas(events: List[dict]) -> List[str]: ] +def test_streaming_chat_refusal_emits_only_refusal_stop_details(): + chunks = [ + _make_chunk(Delta(content=None, refusal="I cannot fulfill this request.")), + _make_chunk(Delta(content=None), finish_reason="stop"), + ] + wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="openai-model") + + events = _drain_sync(wrapper) + + assert _text_deltas(events) == [] + message_delta = next(event for event in events if event["type"] == "message_delta") + assert message_delta["delta"] == { + "stop_reason": "refusal", + "stop_details": { + "type": "refusal", + "category": None, + "explanation": "I cannot fulfill this request.", + }, + } + + +@pytest.mark.asyncio +async def test_streaming_chat_refusal_emits_only_refusal_stop_details_async(): + chunks = [ + _make_chunk(Delta(content=None, refusal="I cannot fulfill this request.")), + _make_chunk(Delta(content=None), finish_reason="stop"), + ] + wrapper = AnthropicStreamWrapper(completion_stream=_AsyncStream(chunks), model="openai-model") + + events = await _drain_async(wrapper) + + assert _text_deltas(events) == [] + message_delta = next(event for event in events if event["type"] == "message_delta") + assert message_delta["delta"]["stop_reason"] == "refusal" + assert message_delta["delta"]["stop_details"]["explanation"] == "I cannot fulfill this request." + + +def test_streaming_chat_combined_refusal_and_finish_reason_is_preserved(): + chunks = [ + _make_chunk( + Delta(content=None, refusal="I cannot fulfill this request."), + finish_reason="stop", + ) + ] + wrapper = AnthropicStreamWrapper(completion_stream=iter(chunks), model="openai-model") + + events = _drain_sync(wrapper) + + message_delta = next(event for event in events if event["type"] == "message_delta") + assert message_delta["delta"]["stop_reason"] == "refusal" + assert message_delta["delta"]["stop_details"]["explanation"] == "I cannot fulfill this request." + + +@pytest.mark.asyncio +async def test_streaming_chat_combined_refusal_and_finish_reason_is_preserved_async(): + chunks = [ + _make_chunk( + Delta(content=None, refusal="I cannot fulfill this request."), + finish_reason="stop", + ) + ] + wrapper = AnthropicStreamWrapper(completion_stream=_AsyncStream(chunks), model="openai-model") + + events = await _drain_async(wrapper) + + message_delta = next(event for event in events if event["type"] == "message_delta") + assert message_delta["delta"]["stop_reason"] == "refusal" + assert message_delta["delta"]["stop_details"]["explanation"] == "I cannot fulfill this request." + + +@pytest.mark.parametrize("async_mode", [False, True]) +@pytest.mark.asyncio +async def test_streaming_chat_length_takes_precedence_over_refusal(async_mode: bool): + chunks = [ + _make_chunk(Delta(content=None, refusal="Partial refusal")), + _make_chunk(Delta(content=None), finish_reason="length"), + ] + stream = _AsyncStream(chunks) if async_mode else iter(chunks) + wrapper = AnthropicStreamWrapper(completion_stream=stream, model="openai-model") + + events = await _drain_async(wrapper) if async_mode else _drain_sync(wrapper) + + message_delta = next(event for event in events if event["type"] == "message_delta") + assert message_delta["delta"]["stop_reason"] == "max_tokens" + assert "stop_details" not in message_delta["delta"] + + def _input_json_deltas(events: List[dict]) -> List[str]: return [ e["delta"]["partial_json"] diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py index 77ecfb73ff6..d8f16dde3e7 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_streaming_iterator.py @@ -34,6 +34,14 @@ def _drain_async(events: list) -> list: return asyncio.run(_run()) +def _drain_sync_upstream(events: list) -> list: + async def _run() -> list: + wrapper = AnthropicResponsesStreamWrapper(responses_stream=iter(events), model="m") + return [chunk async for chunk in wrapper] + + return asyncio.run(_run()) + + class TestMessageStartEmittedExactlyOnce: """The ``__anext__`` fallback emits ``message_start`` before consuming the stream, so ``_process_event`` must not emit a second one when @@ -55,6 +63,15 @@ class TestMessageStartEmittedExactlyOnce: chunks = _drain_async([{"type": "response.created"}]) assert chunks[0]["type"] == "message_start" + def test_sync_upstream_iterator_is_consumed(self): + chunks = _drain_sync_upstream( + [ + {"type": "response.created"}, + {"type": "response.output_text.delta", "item_id": "m1", "delta": "hi"}, + ] + ) + assert any(chunk.get("delta", {}).get("text") == "hi" for chunk in chunks) + class TestProcessEventResponseCreatedGuard: """``_process_event`` must emit ``message_start`` exactly once even if @@ -311,17 +328,37 @@ class TestResponseCompletedUsage: class TestRefusalStreamEvents: - def test_refusal_delta_emits_text_delta(self): + def test_refusal_event_sequence_emits_only_stop_details(self): + response = SimpleNamespace( + status="completed", + output=[{"type": "message", "content": [{"type": "refusal", "refusal": "I cannot fulfill this."}]}], + usage=None, + ) chunks = _process_all( [ {"type": "response.created"}, - {"type": "response.refusal.delta", "item_id": "ref_1", "delta": "I cannot fulfill this."}, + {"type": "response.output_item.added", "item": {"type": "message", "id": "msg_1"}}, + {"type": "response.refusal.delta", "item_id": "msg_1", "delta": "I cannot fulfill this."}, + {"type": "response.output_item.done", "item": {"type": "message", "id": "msg_1"}}, + {"type": "response.completed", "response": response}, ] ) - assert any( - c.get("type") == "content_block_delta" and c.get("delta", {}).get("text") == "I cannot fulfill this." - for c in chunks - ) + assert [chunk["type"] for chunk in chunks] == [ + "message_start", + "content_block_start", + "content_block_stop", + "message_delta", + "message_stop", + ] + assert chunks[3]["delta"] == { + "stop_reason": "refusal", + "stop_sequence": None, + "stop_details": { + "type": "refusal", + "category": None, + "explanation": "I cannot fulfill this.", + }, + } def test_response_completed_with_refusal_sets_stop_reason_refusal(self): response = SimpleNamespace( @@ -333,12 +370,13 @@ class TestRefusalStreamEvents: message_delta = next(c for c in chunks if c["type"] == "message_delta") assert message_delta["delta"]["stop_reason"] == "refusal" - def test_response_completed_with_standalone_refusal_item_sets_stop_reason_refusal(self): + def test_incomplete_status_takes_precedence_over_refusal(self): response = SimpleNamespace( - status="completed", - output=[{"type": "refusal", "refusal": "Standalone refusal"}], + status="incomplete", + output=[{"type": "message", "content": [{"type": "refusal", "refusal": "Partial refusal"}]}], usage=None, ) - chunks = _process_all([{"type": "response.completed", "response": response}]) + chunks = _process_all([{"type": "response.incomplete", "response": response}]) message_delta = next(c for c in chunks if c["type"] == "message_delta") - assert message_delta["delta"]["stop_reason"] == "refusal" + assert message_delta["delta"]["stop_reason"] == "max_tokens" + assert "stop_details" not in message_delta["delta"] diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py index f48c31ea5ed..8205e993d26 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/responses_adapters/test_responses_adapters_transformation.py @@ -1205,16 +1205,16 @@ def _make_output_message(texts: List[str]) -> MagicMock: return msg -def _make_refusal_message(refusal_text: str) -> MagicMock: - from openai.types.responses import ResponseOutputMessage +def _make_refusal_message(refusal_text: str): + from openai.types.responses import ResponseOutputMessage, ResponseOutputRefusal - part = MagicMock() - part.type = "refusal" - part.refusal = refusal_text - - msg = MagicMock(spec=ResponseOutputMessage) - msg.content = [part] - return msg + return ResponseOutputMessage( + id="msg_refusal", + content=[ResponseOutputRefusal(type="refusal", refusal=refusal_text)], + role="assistant", + status="completed", + type="message", + ) def _make_function_call_item(call_id: str, name: str, arguments: str) -> MagicMock: @@ -1296,6 +1296,11 @@ class TestTranslateResponse: assert result["content"][0]["type"] == "text" assert result["content"][0]["text"] == "I cannot fulfill this request." assert result["stop_reason"] == "refusal" + assert result.get("stop_details") == { + "type": "refusal", + "category": None, + "explanation": "I cannot fulfill this request.", + } def test_dict_refusal_part_in_message_becomes_text_block(self): output_item = { @@ -1308,18 +1313,16 @@ class TestTranslateResponse: assert result["content"][0]["type"] == "text" assert result["content"][0]["text"] == "Refused by policy" assert result["stop_reason"] == "refusal" + assert result.get("stop_details", {}).get("explanation") == "Refused by policy" - def test_dict_refusal_item_becomes_text_block(self): - output_item = { - "type": "refusal", - "refusal": "Standalone refusal", - } - response = _make_mock_response(output=[output_item]) + def test_incomplete_status_takes_precedence_over_refusal(self): + response = _make_mock_response( + output=[_make_refusal_message("Partial refusal")], + status="incomplete", + ) result: Any = _ADAPTER.translate_response(response) - assert len(result["content"]) == 1 - assert result["content"][0]["type"] == "text" - assert result["content"][0]["text"] == "Standalone refusal" - assert result["stop_reason"] == "refusal" + assert result["stop_reason"] == "max_tokens" + assert result.get("stop_details") is None def test_incomplete_status_sets_max_tokens(self): """status='incomplete' overrides stop_reason to 'max_tokens'.""" From 7399b3844dfc45ab0c9ddbb116b26235ce4f7df0 Mon Sep 17 00:00:00 2001 From: Atharva-Kanherkar <142440039+Atharva-Kanherkar@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:33:06 +0530 Subject: [PATCH 097/410] fix(anthropic): satisfy lint budget gates for refusal translation --- .../adapters/streaming_iterator.py | 14 +++++----- .../adapters/transformation.py | 2 +- .../responses_adapters/streaming_iterator.py | 10 +++---- .../responses_adapters/transformation.py | 28 ++++++++++--------- litellm/types/llms/anthropic.py | 2 +- 5 files changed, 29 insertions(+), 27 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py index 41db4f12143..66fcad474d5 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py @@ -11,7 +11,7 @@ from typing import ( Final, Literal, Protocol, - cast, + cast, # noqa: TID251 # rebuilt message_delta dict spans the ContentBlockDelta/MessageBlockDelta union get_args, ) @@ -306,7 +306,7 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): # Synthesized compaction block from compact_20260112 polyfill (streaming). self.compaction_block = compaction_block self.iterations_usage = iterations_usage - self._refusal_text_parts: list[str] = [] + self._refusal_text_parts: list[str] = [] # mutable-ok: accumulates streamed refusal delta text across chunks self.sent_compaction_block: bool = False # Per-phase flags so the compaction block's start/delta/stop events # are emitted (and the public state machine is advanced) in @@ -1003,17 +1003,17 @@ class AnthropicStreamWrapper(AdapterCompletionStreamWrapper): ) -> ContentBlockDelta | MessageBlockDelta: if processed_chunk.get("type") != "message_delta" or not self._refusal_text_parts: return processed_chunk - delta: Final = cast(Mapping[str, object], processed_chunk["delta"]) + delta: Final = cast(Mapping[str, object], processed_chunk["delta"]) # cast-ok: keys checked before use if delta.get("stop_reason") == "max_tokens": return processed_chunk - return cast( + return cast( # cast-ok: rebuilt dict matches the message_delta TypedDict shape for this branch ContentBlockDelta | MessageBlockDelta, - { + { # mutable-ok: fresh translation payload; never mutated after construction **processed_chunk, - "delta": { + "delta": { # mutable-ok: fresh message_delta payload; never mutated after construction **delta, "stop_reason": "refusal", - "stop_details": { + "stop_details": { # mutable-ok: fresh stop_details payload; never mutated after construction "type": "refusal", "category": None, "explanation": "".join(self._refusal_text_parts), diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 5655f59a4cc..2d950f007a5 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -1534,7 +1534,7 @@ class LiteLLMAnthropicMessagesAdapter: content=anthropic_content, stop_reason=anthropic_finish_reason, stop_details=( - { + { # mutable-ok: fresh refusal stop_details payload built per response "type": "refusal", "category": None, "explanation": refusal_text, diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py index 1b0ab5923d8..8e5ce77e48f 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/streaming_iterator.py @@ -50,7 +50,7 @@ class AnthropicResponsesStreamWrapper: self._sent_message_start = False self._sent_message_stop = False self._chunk_queue: deque = deque() - self._refusal_text_parts: list[str] = [] + self._refusal_text_parts: list[str] = [] # mutable-ok: accumulates streamed refusal delta text across chunks self._sync_responses_iterator: Any = None def _make_message_start(self) -> dict[str, Any]: @@ -251,19 +251,19 @@ class AnthropicResponsesStreamWrapper: else AnthropicUsage(input_tokens=0, output_tokens=0) ) - message_delta_payload: Final = { + message_delta_payload: Final = { # mutable-ok: fresh message_delta payload built per chunk "stop_reason": stop_reason, "stop_sequence": None, **( - { - "stop_details": { + { # mutable-ok: fresh refusal stop_details payload built per chunk + "stop_details": { # mutable-ok: fresh refusal stop_details payload built per chunk "type": "refusal", "category": None, "explanation": refusal_text, } } if stop_reason == "refusal" - else {} + else {} # mutable-ok: empty spread placeholder for non-refusal stop ), } diff --git a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py index 92f3e08f24d..318065148b8 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -81,20 +81,20 @@ class LiteLLMAnthropicToResponsesAPIAdapter: ) if not isinstance(item, Mapping): return None - item_mapping: Final = cast(Mapping[str, object], item) + item_mapping: Final = cast(Mapping[str, object], item) # cast-ok: keys re-checked before use raw_parts: Final = item_mapping.get("content") if item_mapping.get("type") != "message" or not isinstance(raw_parts, Sequence): return None - return next( - ( - refusal - for part in cast(Sequence[object], raw_parts) - if isinstance(part, Mapping) - and cast(Mapping[str, object], part).get("type") == "refusal" - and isinstance((refusal := cast(Mapping[str, object], part).get("refusal")), str) - ), - None, - ) + for part in cast(Sequence[object], raw_parts): # cast-ok: members re-validated below + if not isinstance(part, Mapping): + continue + part_mapping = cast(Mapping[str, object], part) # cast-ok: keys re-checked before use + if part_mapping.get("type") != "refusal": + continue + refusal = part_mapping.get("refusal") + if isinstance(refusal, str): + return refusal + return None return next( (text for item in output if (text := refusal_text_from_item(item)) is not None), @@ -656,7 +656,9 @@ class LiteLLMAnthropicToResponsesAPIAdapter: content: Final[list[dict[str, object]]] = [] stop_reason: AnthropicFinishReason = "end_turn" - refusal_text: Final = self._refusal_text_from_output(cast(Iterable[object], response.output)) + refusal_text: Final = self._refusal_text_from_output( + cast(Iterable[object], response.output) # cast-ok: output items re-validated per item + ) for item in response.output: if isinstance(item, ResponseReasoningItem): @@ -746,7 +748,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: content=content, stop_reason=stop_reason, stop_details=( - { + { # mutable-ok: fresh refusal stop_details payload built per response "type": "refusal", "category": None, "explanation": refusal_text, diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index cdfc5227e06..365d59a179b 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -528,7 +528,7 @@ class AnthropicStopDetails(TypedDict, total=False): class MessageDelta(TypedDict, total=False): stop_reason: str | None - stop_details: AnthropicStopDetails + stop_details: ReadOnly[AnthropicStopDetails] class ServerToolUsage(TypedDict, total=False): From 788efea7b3f137de91848feb67a2cad473a874fb Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 4 Sep 2026 16:25:15 +0000 Subject: [PATCH 098/410] fix(fireworks_ai): resolve tool_choice/reasoning support for short model names Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fireworks_ai/chat/transformation.py | 5 ++--- .../chat/test_fireworks_ai_chat_transformation.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index b6a5ee40672..b9c81a93730 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -32,7 +32,6 @@ from litellm.utils import ( get_model_cost_mutation_generation, supports_function_calling, supports_reasoning, - supports_tool_choice, ) from ...openai.chat.gpt_transformation import ( @@ -272,11 +271,11 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): ) # Only add tool_choice for models that explicitly support it - if supports_tool_choice(model=model, custom_llm_provider="fireworks_ai"): + if self._get_model_cost_capability_exact(model=model, capability="supports_tool_choice"): supported_params.append("tool_choice") # Only add reasoning params for models that support it - if supports_reasoning(model=model, custom_llm_provider="fireworks_ai"): + if self._get_model_cost_capability_exact(model=model, capability="supports_reasoning"): supported_params.append("reasoning_effort") supported_params.append("reasoning_history") supported_params.append("thinking") diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py index ec8725db5f7..672d47a2c03 100644 --- a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py @@ -363,6 +363,17 @@ def test_get_supported_openai_params_parallel_tool_calls(): assert "parallel_tool_calls" not in unsupported_params +def test_get_supported_openai_params_short_model_name_resolves_account_prefixed_entry(): + config = FireworksAIConfig() + + supported_params = config.get_supported_openai_params( + "fireworks_ai/deepseek-v4-pro-0813" + ) + + assert "tool_choice" in supported_params + assert "reasoning_effort" in supported_params + + def test_get_supported_openai_params_parallel_tool_calls_without_tool_choice( monkeypatch, ): From e7c29351e8b2e912ad42140938d7d4a77cc6e4d5 Mon Sep 17 00:00:00 2001 From: mateo Date: Fri, 4 Sep 2026 16:35:26 +0000 Subject: [PATCH 099/410] chore: ratchet lint budgets after merging litellm_internal_staging Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- basedpyright-code-budget.json | 6 +++--- ruff-strict-budget.json | 2 +- type-discipline-budget.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 1b0650c70d8..ca288a2a644 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,6 +1,6 @@ { "reportAny": { - "limit": 14074 + "limit": 14072 }, "reportArgumentType": { "limit": 2206 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 4124 + "limit": 4121 }, "reportFunctionMemberAccess": { "limit": 7 @@ -108,7 +108,7 @@ "limit": 38309 }, "reportUnknownParameterType": { - "limit": 19622 + "limit": 19621 }, "reportUnknownVariableType": { "limit": 29846 diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 0252e85efa6..7a1e709bb22 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -78,7 +78,7 @@ "limit": 1 }, "C901": { - "limit": 309 + "limit": 308 }, "D419": { "limit": 6 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 589d5249b2d..78405a9a9db 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22326 + "limit": 22325 }, "LIT002": { - "limit": 26746 + "limit": 26744 }, "LIT003": { "limit": 261 From bef3585d82ac688fe50576fedcf5dd088c72165a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 10:03:43 -0700 Subject: [PATCH 100/410] feat(pricing): add GovCloud rows for every live but unpriced Bedrock model Every model bedrock list-foundation-models and list-inference-profiles report as live in us-gov-west-1 or us-gov-east-1 now has a priced row: Claude Fable 5.1 (profile plus in-region), Nemotron Nano 9B (profile plus in-region), Grok 4.6 (profile plus Mantle in both regions), the us-gov. Claude 3 Haiku profile in the east, Nova Lite, Micro and the Nova 2 multimodal embeddings in the west, and the Gemma 4 and gpt-oss Mantle SKUs the GovCloud offer files price. Offer-file rates are used where AWS publishes them; Claude rows carry the 1.2x GovCloud premium. --- ...odel_prices_and_context_window_backup.json | 374 ++++++++++++++++++ model_prices_and_context_window.json | 374 ++++++++++++++++++ .../test_bedrock_usgov_pricing.py | 157 +++++++- whitelisted_bedrock_models.txt | 8 +- 4 files changed, 909 insertions(+), 4 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 945653e09ef..4ed5e798679 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -11957,6 +11957,48 @@ "output_cost_per_token": 2.65e-06, "supports_pdf_input": true }, + "bedrock/us-gov-west-1/amazon.nova-2-multimodal-embeddings-v1:0": { + "litellm_provider": "bedrock", + "max_input_tokens": 8172, + "max_tokens": 8172, + "mode": "embedding", + "input_cost_per_token": 1.62e-07, + "input_cost_per_image": 7.2e-05, + "input_cost_per_video_per_second": 0.00084, + "input_cost_per_audio_per_second": 0.000168, + "output_cost_per_token": 0.0, + "output_vector_size": 3072, + "supports_embedding_image_input": true, + "supports_image_input": true, + "supports_video_input": true, + "supports_audio_input": true + }, + "bedrock/us-gov-west-1/amazon.nova-lite-v1:0": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "bedrock", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 2.88e-07, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/amazon.nova-micro-v1:0": { + "input_cost_per_token": 4.2e-08, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 1.68e-07, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true + }, "bedrock/us-gov-west-1/amazon.nova-pro-v1:0": { "input_cost_per_token": 9.6e-07, "litellm_provider": "bedrock", @@ -42320,6 +42362,23 @@ "input_cost_per_token_batches": 1.65e-06, "output_cost_per_token_batches": 8.25e-06 }, + "us-gov.anthropic.claude-3-haiku-20240307-v1:0": { + "deprecation_date": "2026-09-10", + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07 + }, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, @@ -42445,6 +42504,39 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "us-gov.anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.5e-05, + "cache_creation_input_token_cost_above_1hr": 2.4e-05, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 1.2e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, "litellm_provider": "bedrock_converse", @@ -42470,6 +42562,16 @@ "supports_system_messages": true, "supports_vision": true }, + "us-gov.nvidia.nemotron-nano-9b-v2": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.76e-07, + "supports_system_messages": true + }, "us-gov.nvidia.nemotron-super-3-120b": { "input_cost_per_token": 1.8e-07, "litellm_provider": "bedrock_converse", @@ -42510,6 +42612,21 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "us-gov.xai.grok-4.6": { + "input_cost_per_token": 2.64e-06, + "output_cost_per_token": 7.92e-06, + "cache_read_input_token_cost": 6.6e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "au.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_creation_input_token_cost_above_1hr": 2.2e-06, @@ -58210,6 +58327,16 @@ "supports_system_messages": true, "supports_vision": true }, + "bedrock/us-gov-west-1/nvidia.nemotron-nano-9b-v2": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.76e-07, + "supports_system_messages": true + }, "bedrock/us-gov-west-1/nvidia.nemotron-super-3-120b": { "input_cost_per_token": 1.8e-07, "litellm_provider": "bedrock", @@ -58345,6 +58472,39 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "bedrock/us-gov-west-1/anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.5e-05, + "cache_creation_input_token_cost_above_1hr": 2.4e-05, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 1.2e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "bedrock/us-gov-east-1/nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, "litellm_provider": "bedrock", @@ -58370,6 +58530,16 @@ "supports_system_messages": true, "supports_vision": true }, + "bedrock/us-gov-east-1/nvidia.nemotron-nano-9b-v2": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.76e-07, + "supports_system_messages": true + }, "bedrock/us-gov-east-1/nvidia.nemotron-super-3-120b": { "input_cost_per_token": 1.8e-07, "litellm_provider": "bedrock", @@ -58505,6 +58675,39 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "bedrock/us-gov-east-1/anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.5e-05, + "cache_creation_input_token_cost_above_1hr": 2.4e-05, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 1.2e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "bedrock_mantle/us-gov-west-1/openai.gpt-5.6-terra": { "litellm_provider": "bedrock_mantle", "max_input_tokens": 1050000, @@ -58619,6 +58822,120 @@ "output_cost_per_token": 3e-06, "cache_read_input_token_cost": 2.4e-07 }, + "bedrock_mantle/us-gov-west-1/xai.grok-4.6": { + "use_openai_responses_path": true, + "input_cost_per_token": 2.64e-06, + "output_cost_per_token": 7.92e-06, + "cache_read_input_token_cost": 6.6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/google.gemma-4-e2b": { + "input_cost_per_token": 4.8e-08, + "output_cost_per_token": 9.6e-08, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/google.gemma-4-26b-a4b": { + "input_cost_per_token": 1.56e-07, + "output_cost_per_token": 4.8e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/google.gemma-4-31b": { + "input_cost_per_token": 1.68e-07, + "output_cost_per_token": 4.8e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/openai.gpt-oss-20b": { + "input_cost_per_token": 8.4e-08, + "output_cost_per_token": 3.6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/us-gov-west-1/openai.gpt-oss-120b": { + "input_cost_per_token": 1.8e-07, + "output_cost_per_token": 7.2e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "bedrock_mantle/us-gov-east-1/openai.gpt-5.4": { "litellm_provider": "bedrock_mantle", "max_input_tokens": 1050000, @@ -58646,6 +58963,63 @@ "cache_read_input_token_cost": 3.3e-07, "output_cost_per_token": 1.98e-05 }, + "bedrock_mantle/us-gov-east-1/xai.grok-4.6": { + "use_openai_responses_path": true, + "input_cost_per_token": 2.64e-06, + "output_cost_per_token": 7.92e-06, + "cache_read_input_token_cost": 6.6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-east-1/openai.gpt-oss-20b": { + "input_cost_per_token": 8.4e-08, + "output_cost_per_token": 3.6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/us-gov-east-1/openai.gpt-oss-120b": { + "input_cost_per_token": 1.8e-07, + "output_cost_per_token": 7.2e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "azure/us-gov/gpt-5.1": { "cache_read_input_token_cost": 1.71875e-07, "default_reasoning_effort": "none", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 945653e09ef..4ed5e798679 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -11957,6 +11957,48 @@ "output_cost_per_token": 2.65e-06, "supports_pdf_input": true }, + "bedrock/us-gov-west-1/amazon.nova-2-multimodal-embeddings-v1:0": { + "litellm_provider": "bedrock", + "max_input_tokens": 8172, + "max_tokens": 8172, + "mode": "embedding", + "input_cost_per_token": 1.62e-07, + "input_cost_per_image": 7.2e-05, + "input_cost_per_video_per_second": 0.00084, + "input_cost_per_audio_per_second": 0.000168, + "output_cost_per_token": 0.0, + "output_vector_size": 3072, + "supports_embedding_image_input": true, + "supports_image_input": true, + "supports_video_input": true, + "supports_audio_input": true + }, + "bedrock/us-gov-west-1/amazon.nova-lite-v1:0": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "bedrock", + "max_input_tokens": 300000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 2.88e-07, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_response_schema": true, + "supports_vision": true + }, + "bedrock/us-gov-west-1/amazon.nova-micro-v1:0": { + "input_cost_per_token": 4.2e-08, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 10000, + "max_tokens": 10000, + "mode": "chat", + "output_cost_per_token": 1.68e-07, + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_response_schema": true + }, "bedrock/us-gov-west-1/amazon.nova-pro-v1:0": { "input_cost_per_token": 9.6e-07, "litellm_provider": "bedrock", @@ -42320,6 +42362,23 @@ "input_cost_per_token_batches": 1.65e-06, "output_cost_per_token_batches": 8.25e-06 }, + "us-gov.anthropic.claude-3-haiku-20240307-v1:0": { + "deprecation_date": "2026-09-10", + "input_cost_per_token": 3e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 200000, + "max_output_tokens": 4096, + "max_tokens": 4096, + "mode": "chat", + "output_cost_per_token": 1.5e-06, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "cache_read_input_token_cost": 3e-08, + "cache_creation_input_token_cost": 3.75e-07 + }, "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0": { "cache_creation_input_token_cost": 4.5e-06, "cache_creation_input_token_cost_above_1hr": 7.2e-06, @@ -42445,6 +42504,39 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "us-gov.anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.5e-05, + "cache_creation_input_token_cost_above_1hr": 2.4e-05, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 1.2e-05, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "us-gov.nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, "litellm_provider": "bedrock_converse", @@ -42470,6 +42562,16 @@ "supports_system_messages": true, "supports_vision": true }, + "us-gov.nvidia.nemotron-nano-9b-v2": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.76e-07, + "supports_system_messages": true + }, "us-gov.nvidia.nemotron-super-3-120b": { "input_cost_per_token": 1.8e-07, "litellm_provider": "bedrock_converse", @@ -42510,6 +42612,21 @@ "supports_response_schema": true, "supports_tool_choice": true }, + "us-gov.xai.grok-4.6": { + "input_cost_per_token": 2.64e-06, + "output_cost_per_token": 7.92e-06, + "cache_read_input_token_cost": 6.6e-07, + "litellm_provider": "bedrock_converse", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supports_function_calling": true, + "supports_prompt_caching": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "au.anthropic.claude-haiku-4-5-20251001-v1:0": { "cache_creation_input_token_cost": 1.375e-06, "cache_creation_input_token_cost_above_1hr": 2.2e-06, @@ -58210,6 +58327,16 @@ "supports_system_messages": true, "supports_vision": true }, + "bedrock/us-gov-west-1/nvidia.nemotron-nano-9b-v2": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.76e-07, + "supports_system_messages": true + }, "bedrock/us-gov-west-1/nvidia.nemotron-super-3-120b": { "input_cost_per_token": 1.8e-07, "litellm_provider": "bedrock", @@ -58345,6 +58472,39 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "bedrock/us-gov-west-1/anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.5e-05, + "cache_creation_input_token_cost_above_1hr": 2.4e-05, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 1.2e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "bedrock/us-gov-east-1/nvidia.nemotron-nano-3-30b": { "input_cost_per_token": 7.2e-08, "litellm_provider": "bedrock", @@ -58370,6 +58530,16 @@ "supports_system_messages": true, "supports_vision": true }, + "bedrock/us-gov-east-1/nvidia.nemotron-nano-9b-v2": { + "input_cost_per_token": 7.2e-08, + "litellm_provider": "bedrock", + "max_input_tokens": 128000, + "max_output_tokens": 8192, + "max_tokens": 8192, + "mode": "chat", + "output_cost_per_token": 2.76e-07, + "supports_system_messages": true + }, "bedrock/us-gov-east-1/nvidia.nemotron-super-3-120b": { "input_cost_per_token": 1.8e-07, "litellm_provider": "bedrock", @@ -58505,6 +58675,39 @@ "supports_vision": true, "supports_xhigh_reasoning_effort": true }, + "bedrock/us-gov-east-1/anthropic.claude-fable-5-1": { + "cache_creation_input_token_cost": 1.5e-05, + "cache_creation_input_token_cost_above_1hr": 2.4e-05, + "cache_read_input_token_cost": 3e-07, + "input_cost_per_token": 1.2e-05, + "litellm_provider": "bedrock", + "max_input_tokens": 1000000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 6e-05, + "supports_adaptive_thinking": true, + "thinking_always_on": true, + "supports_mid_conversation_system": true, + "supports_assistant_prefill": false, + "supports_computer_use": true, + "supports_forced_tool_use": false, + "supports_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_sampling_params": false, + "supports_tool_choice": true, + "supports_vision": true, + "supports_xhigh_reasoning_effort": true, + "supports_native_structured_output": false, + "supports_max_reasoning_effort": true, + "supports_output_config": true, + "bedrock_output_config_effort_ceiling": "xhigh", + "supports_parallel_tool_use_config": true, + "prompt_cache_min_tokens": 512 + }, "bedrock_mantle/us-gov-west-1/openai.gpt-5.6-terra": { "litellm_provider": "bedrock_mantle", "max_input_tokens": 1050000, @@ -58619,6 +58822,120 @@ "output_cost_per_token": 3e-06, "cache_read_input_token_cost": 2.4e-07 }, + "bedrock_mantle/us-gov-west-1/xai.grok-4.6": { + "use_openai_responses_path": true, + "input_cost_per_token": 2.64e-06, + "output_cost_per_token": 7.92e-06, + "cache_read_input_token_cost": 6.6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/google.gemma-4-e2b": { + "input_cost_per_token": 4.8e-08, + "output_cost_per_token": 9.6e-08, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 128000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/google.gemma-4-26b-a4b": { + "input_cost_per_token": 1.56e-07, + "output_cost_per_token": 4.8e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/google.gemma-4-31b": { + "input_cost_per_token": 1.68e-07, + "output_cost_per_token": 4.8e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 256000, + "max_output_tokens": 256000, + "max_tokens": 256000, + "mode": "chat", + "use_openai_responses_path": true, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": false, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-west-1/openai.gpt-oss-20b": { + "input_cost_per_token": 8.4e-08, + "output_cost_per_token": 3.6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/us-gov-west-1/openai.gpt-oss-120b": { + "input_cost_per_token": 1.8e-07, + "output_cost_per_token": 7.2e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "bedrock_mantle/us-gov-east-1/openai.gpt-5.4": { "litellm_provider": "bedrock_mantle", "max_input_tokens": 1050000, @@ -58646,6 +58963,63 @@ "cache_read_input_token_cost": 3.3e-07, "output_cost_per_token": 1.98e-05 }, + "bedrock_mantle/us-gov-east-1/xai.grok-4.6": { + "use_openai_responses_path": true, + "input_cost_per_token": 2.64e-06, + "output_cost_per_token": 7.92e-06, + "cache_read_input_token_cost": 6.6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "bedrock_mantle/us-gov-east-1/openai.gpt-oss-20b": { + "input_cost_per_token": 8.4e-08, + "output_cost_per_token": 3.6e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, + "bedrock_mantle/us-gov-east-1/openai.gpt-oss-120b": { + "input_cost_per_token": 1.8e-07, + "output_cost_per_token": 7.2e-07, + "litellm_provider": "bedrock_mantle", + "max_input_tokens": 131072, + "max_output_tokens": 32768, + "max_tokens": 32768, + "mode": "chat", + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supports_function_calling": true, + "supports_parallel_function_calling": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true + }, "azure/us-gov/gpt-5.1": { "cache_read_input_token_cost": 1.71875e-07, "default_reasoning_effort": "none", diff --git a/tests/test_litellm/test_bedrock_usgov_pricing.py b/tests/test_litellm/test_bedrock_usgov_pricing.py index 45c867db70b..3576834dd27 100644 --- a/tests/test_litellm/test_bedrock_usgov_pricing.py +++ b/tests/test_litellm/test_bedrock_usgov_pricing.py @@ -139,6 +139,13 @@ CLAUDE_GOV_EXPECTED = { "cache_creation_input_token_cost_above_1hr": 1.2e-05, "cache_read_input_token_cost": 6e-07, }, + "anthropic.claude-fable-5-1": { + "input_cost_per_token": 1.2e-05, + "output_cost_per_token": 6e-05, + "cache_creation_input_token_cost": 1.5e-05, + "cache_creation_input_token_cost_above_1hr": 2.4e-05, + "cache_read_input_token_cost": 3e-07, + }, } @@ -152,9 +159,11 @@ USGOV_CLAUDE_KEY_TEMPLATES = { @pytest.mark.parametrize("base_key", CLAUDE_GOV_EXPECTED) @pytest.mark.parametrize("key_template,expected_provider", USGOV_CLAUDE_KEY_TEMPLATES.items()) def test_usgov_claude_pricing(model_data, key_template, expected_provider, base_key): - """Sonnet 5, Opus 4.8, and Opus 5 gov entries, both in-region keys and the - us-gov. geo inference profile the model cards list for GovCloud, must match - the rates AWS publishes in the GovCloud offer file (1.2x global). + """Sonnet 5, Opus 4.8, Opus 5, and Fable 5.1 gov entries, both in-region keys + and the us-gov. geo inference profile the model cards list for GovCloud, must + carry the 1.2x GovCloud premium over the global anthropic.* rates. No public + AWS source (offer files, pricing page) lists Claude GovCloud rows; the premium + is the one AWS quotes for Opus 4.8 in GovCloud ($6/$30 per million). """ gov_key = key_template.format(base_key=base_key) assert gov_key in model_data, f"Missing model entry: {gov_key}" @@ -169,6 +178,7 @@ def test_usgov_claude_pricing(model_data, key_template, expected_provider, base_ CONVERSE_GOV_EXPECTED = { "nvidia.nemotron-nano-3-30b": (7.2e-08, 2.88e-07), + "nvidia.nemotron-nano-9b-v2": (7.2e-08, 2.76e-07), "nvidia.nemotron-nano-12b-v2": (2.4e-07, 7.2e-07), "nvidia.nemotron-super-3-120b": (1.8e-07, 7.8e-07), "openai.gpt-oss-20b-1:0": (8.4e-08, 3.6e-07), @@ -268,6 +278,147 @@ def test_usgov_mantle_grok_4_3_west_only(model_data): assert "bedrock_mantle/us-gov-east-1/xai.grok-4.3" not in model_data +def test_usgov_east_haiku_profile_mirrors_in_region_row(model_data): + """us-gov-east-1 serves claude-3-haiku through the us-gov. inference profile + only, so the profile row must bill exactly like the in-region gov row. + """ + profile = model_data["us-gov.anthropic.claude-3-haiku-20240307-v1:0"] + in_region = model_data["bedrock/us-gov-east-1/anthropic.claude-3-haiku-20240307-v1:0"] + assert profile["litellm_provider"] == "bedrock_converse" + assert {k: v for k, v in profile.items() if k != "litellm_provider"} == { + k: v for k, v in in_region.items() if k != "litellm_provider" + } + + +GROK_4_6_GOV_KEYS = { + "us-gov.xai.grok-4.6": ("us.xai.grok-4.6", "bedrock_converse"), + "bedrock_mantle/us-gov-west-1/xai.grok-4.6": ("bedrock_mantle/xai.grok-4.6", "bedrock_mantle"), + "bedrock_mantle/us-gov-east-1/xai.grok-4.6": ("bedrock_mantle/xai.grok-4.6", "bedrock_mantle"), +} + + +@pytest.mark.parametrize("gov_key", GROK_4_6_GOV_KEYS) +def test_usgov_grok_4_6_pricing(model_data, gov_key): + """Both GovCloud regions serve grok-4.6 through the us-gov. profile only, and + both offer files price its standard SKU at 1.2x the commercial US rate. + """ + base_key, expected_provider = GROK_4_6_GOV_KEYS[gov_key] + assert gov_key in model_data, f"Missing model entry: {gov_key}" + info = model_data[gov_key] + assert info["litellm_provider"] == expected_provider + assert info["input_cost_per_token"] == 2.64e-06 + assert info["output_cost_per_token"] == 7.92e-06 + assert info["cache_read_input_token_cost"] == 6.6e-07 + for field in ("input_cost_per_token", "output_cost_per_token", "cache_read_input_token_cost"): + assert abs(info[field] / model_data[base_key][field] - 1.2) < 1e-9 + + +NOVA_GOV_WEST_EXPECTED = { + "amazon.nova-lite-v1:0": (7.2e-08, 2.88e-07), + "amazon.nova-micro-v1:0": (4.2e-08, 1.68e-07), +} + + +@pytest.mark.parametrize("base_key", NOVA_GOV_WEST_EXPECTED) +def test_usgov_west_nova_lite_micro_pricing(model_data, base_key): + """Nova Lite and Micro are on-demand in us-gov-west-1 only; the offer file + prices them at 1.2x commercial, like the Nova Pro row that was already there. + """ + gov_key = f"bedrock/us-gov-west-1/{base_key}" + assert gov_key in model_data, f"Missing model entry: {gov_key}" + info = model_data[gov_key] + expected_input, expected_output = NOVA_GOV_WEST_EXPECTED[base_key] + assert info["litellm_provider"] == "bedrock" + assert info["input_cost_per_token"] == expected_input + assert info["output_cost_per_token"] == expected_output + assert abs(info["input_cost_per_token"] / model_data[base_key]["input_cost_per_token"] - 1.2) < 1e-9 + assert abs(info["output_cost_per_token"] / model_data[base_key]["output_cost_per_token"] - 1.2) < 1e-9 + assert f"bedrock/us-gov-east-1/{base_key}" not in model_data + + +def test_usgov_west_nova_2_multimodal_embeddings_pricing(model_data): + """Every meter of the multimodal embedding model (tokens, images, audio and + video seconds) carries the 1.2x uplift the us-gov-west-1 offer file lists. + """ + gov_key = "bedrock/us-gov-west-1/amazon.nova-2-multimodal-embeddings-v1:0" + assert gov_key in model_data, f"Missing model entry: {gov_key}" + info = model_data[gov_key] + assert info["litellm_provider"] == "bedrock" + assert info["mode"] == "embedding" + assert info["input_cost_per_token"] == 1.62e-07 + assert info["input_cost_per_image"] == 7.2e-05 + assert info["input_cost_per_audio_per_second"] == 0.000168 + assert info["input_cost_per_video_per_second"] == 0.00084 + assert "bedrock/us-gov-east-1/amazon.nova-2-multimodal-embeddings-v1:0" not in model_data + + +MANTLE_GOV_FLAT_EXPECTED = { + "google.gemma-4-e2b": (4.8e-08, 9.6e-08, ("us-gov-west-1",)), + "google.gemma-4-26b-a4b": (1.56e-07, 4.8e-07, ("us-gov-west-1",)), + "google.gemma-4-31b": (1.68e-07, 4.8e-07, ("us-gov-west-1",)), + "openai.gpt-oss-20b": (8.4e-08, 3.6e-07, ("us-gov-west-1", "us-gov-east-1")), + "openai.gpt-oss-120b": (1.8e-07, 7.2e-07, ("us-gov-west-1", "us-gov-east-1")), +} + + +@pytest.mark.parametrize("model", MANTLE_GOV_FLAT_EXPECTED) +def test_usgov_mantle_gemma_and_gpt_oss_pricing(model_data, model): + """Gemma 4 is priced in the us-gov-west-1 offer file only and gpt-oss in both; + each Mantle gov row carries the offer file's standard SKU, and no row exists + for a region whose offer file has no SKU. + """ + expected_input, expected_output, regions = MANTLE_GOV_FLAT_EXPECTED[model] + for region in ("us-gov-west-1", "us-gov-east-1"): + gov_key = f"bedrock_mantle/{region}/{model}" + if region not in regions: + assert gov_key not in model_data + continue + assert gov_key in model_data, f"Missing model entry: {gov_key}" + info = model_data[gov_key] + assert info["litellm_provider"] == "bedrock_mantle" + assert info["input_cost_per_token"] == expected_input + assert info["output_cost_per_token"] == expected_output + + +GOV_ROW_SOURCES = { + "us-gov.anthropic.claude-fable-5-1": "anthropic.claude-fable-5-1", + "bedrock/us-gov-west-1/anthropic.claude-fable-5-1": "anthropic.claude-fable-5-1", + "bedrock/us-gov-east-1/anthropic.claude-fable-5-1": "anthropic.claude-fable-5-1", + "us-gov.nvidia.nemotron-nano-9b-v2": "nvidia.nemotron-nano-9b-v2", + "bedrock/us-gov-west-1/nvidia.nemotron-nano-9b-v2": "nvidia.nemotron-nano-9b-v2", + "bedrock/us-gov-east-1/nvidia.nemotron-nano-9b-v2": "nvidia.nemotron-nano-9b-v2", + "us-gov.xai.grok-4.6": "us.xai.grok-4.6", + "bedrock_mantle/us-gov-west-1/xai.grok-4.6": "bedrock_mantle/xai.grok-4.6", + "bedrock_mantle/us-gov-east-1/xai.grok-4.6": "bedrock_mantle/xai.grok-4.6", + "bedrock/us-gov-west-1/amazon.nova-2-multimodal-embeddings-v1:0": "amazon.nova-2-multimodal-embeddings-v1:0", + "bedrock/us-gov-west-1/amazon.nova-lite-v1:0": "amazon.nova-lite-v1:0", + "bedrock/us-gov-west-1/amazon.nova-micro-v1:0": "amazon.nova-micro-v1:0", + "bedrock_mantle/us-gov-west-1/google.gemma-4-e2b": "bedrock_mantle/google.gemma-4-e2b", + "bedrock_mantle/us-gov-west-1/google.gemma-4-26b-a4b": "bedrock_mantle/google.gemma-4-26b-a4b", + "bedrock_mantle/us-gov-west-1/google.gemma-4-31b": "bedrock_mantle/google.gemma-4-31b", + "bedrock_mantle/us-gov-west-1/openai.gpt-oss-20b": "bedrock_mantle/openai.gpt-oss-20b", + "bedrock_mantle/us-gov-east-1/openai.gpt-oss-20b": "bedrock_mantle/openai.gpt-oss-20b", + "bedrock_mantle/us-gov-west-1/openai.gpt-oss-120b": "bedrock_mantle/openai.gpt-oss-120b", + "bedrock_mantle/us-gov-east-1/openai.gpt-oss-120b": "bedrock_mantle/openai.gpt-oss-120b", +} + + +def _non_pricing_fields(info): + return {k: v for k, v in info.items() if "cost" not in k and k not in ("litellm_provider", "source")} + + +@pytest.mark.parametrize("gov_key", GOV_ROW_SOURCES) +def test_usgov_rows_keep_commercial_limits_and_capabilities(model_data, gov_key): + """A gov row differs from the commercial row it mirrors only in price and + provider: context limits, mode, and capability flags stay identical, so a + hand-copied row cannot silently drop tool calling or shrink the context window. + """ + gov = model_data[gov_key] + assert _non_pricing_fields(gov) == _non_pricing_fields(model_data[GOV_ROW_SOURCES[gov_key]]) + assert "search_context_cost_per_query" not in gov + assert "source" not in gov + + AZURE_GOV_EXPECTED = { "azure/us-gov/gpt-5.1": { "input_cost_per_token": 1.71875e-06, diff --git a/whitelisted_bedrock_models.txt b/whitelisted_bedrock_models.txt index 1dc817ffe48..578edddec8d 100644 --- a/whitelisted_bedrock_models.txt +++ b/whitelisted_bedrock_models.txt @@ -138,6 +138,8 @@ bedrock/us-gov-east-1/anthropic.claude-sonnet-4-5-20250929-v1:0 bedrock/us-gov-east-1/claude-sonnet-4-5-20250929-v1:0 bedrock/us-gov-east-1/meta.llama3-70b-instruct-v1:0 bedrock/us-gov-east-1/meta.llama3-8b-instruct-v1:0 +bedrock/us-gov-west-1/amazon.nova-lite-v1:0 +bedrock/us-gov-west-1/amazon.nova-micro-v1:0 bedrock/us-gov-west-1/amazon.nova-pro-v1:0 bedrock/us-gov-west-1/amazon.titan-text-express-v1 bedrock/us-gov-west-1/amazon.titan-text-lite-v1 @@ -219,17 +221,21 @@ bedrock/us-gov-east-1/anthropic.claude-haiku-4-5-20251001-v1:0 bedrock/us-gov-west-1/anthropic.claude-haiku-4-5-20251001-v1:0 bedrock/us-gov-west-1/nvidia.nemotron-nano-3-30b bedrock/us-gov-west-1/nvidia.nemotron-nano-12b-v2 +bedrock/us-gov-west-1/nvidia.nemotron-nano-9b-v2 bedrock/us-gov-west-1/nvidia.nemotron-super-3-120b bedrock/us-gov-west-1/openai.gpt-oss-20b-1:0 bedrock/us-gov-west-1/openai.gpt-oss-120b-1:0 bedrock/us-gov-west-1/anthropic.claude-sonnet-5 bedrock/us-gov-west-1/anthropic.claude-opus-4-8 +bedrock/us-gov-west-1/anthropic.claude-opus-5 +bedrock/us-gov-west-1/anthropic.claude-fable-5-1 bedrock/us-gov-east-1/nvidia.nemotron-nano-3-30b bedrock/us-gov-east-1/nvidia.nemotron-nano-12b-v2 +bedrock/us-gov-east-1/nvidia.nemotron-nano-9b-v2 bedrock/us-gov-east-1/nvidia.nemotron-super-3-120b bedrock/us-gov-east-1/openai.gpt-oss-20b-1:0 bedrock/us-gov-east-1/openai.gpt-oss-120b-1:0 bedrock/us-gov-east-1/anthropic.claude-sonnet-5 bedrock/us-gov-east-1/anthropic.claude-opus-4-8 -bedrock/us-gov-west-1/anthropic.claude-opus-5 bedrock/us-gov-east-1/anthropic.claude-opus-5 +bedrock/us-gov-east-1/anthropic.claude-fable-5-1 From 2f1da035ae7fa578b4ed76933fc43ec0248f1a0b Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 4 Sep 2026 17:06:01 +0000 Subject: [PATCH 101/410] fix(fireworks_ai): keep generic capability fallback for reasoning and tool_choice Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fireworks_ai/chat/transformation.py | 9 +++++++-- .../chat/test_fireworks_ai_chat_transformation.py | 14 +++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index b9c81a93730..6aa6a3600f0 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -32,6 +32,7 @@ from litellm.utils import ( get_model_cost_mutation_generation, supports_function_calling, supports_reasoning, + supports_tool_choice, ) from ...openai.chat.gpt_transformation import ( @@ -271,11 +272,15 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): ) # Only add tool_choice for models that explicitly support it - if self._get_model_cost_capability_exact(model=model, capability="supports_tool_choice"): + if self._get_model_cost_capability_exact( + model=model, capability="supports_tool_choice" + ) or supports_tool_choice(model=model, custom_llm_provider="fireworks_ai"): supported_params.append("tool_choice") # Only add reasoning params for models that support it - if self._get_model_cost_capability_exact(model=model, capability="supports_reasoning"): + if self._get_model_cost_capability_exact( + model=model, capability="supports_reasoning" + ) or supports_reasoning(model=model, custom_llm_provider="fireworks_ai"): supported_params.append("reasoning_effort") supported_params.append("reasoning_history") supported_params.append("thinking") diff --git a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py index 672d47a2c03..e6fe01be4ba 100644 --- a/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/chat/test_fireworks_ai_chat_transformation.py @@ -4,11 +4,9 @@ from unittest.mock import MagicMock, patch import pytest import litellm - - from litellm import get_model_info, supports_reasoning, supports_vision -from litellm.llms.fireworks_ai.chat.transformation import FireworksAIConfig from litellm.constants import SESSION_ID_GENERATED_METADATA_KEY +from litellm.llms.fireworks_ai.chat.transformation import FireworksAIConfig from litellm.llms.fireworks_ai.common_utils import get_fireworks_session_id from litellm.types.utils import ( ChatCompletionMessageToolCall, @@ -374,6 +372,16 @@ def test_get_supported_openai_params_short_model_name_resolves_account_prefixed_ assert "reasoning_effort" in supported_params +def test_get_supported_openai_params_preserves_generic_reasoning_fallback(): + config = FireworksAIConfig() + + supported_params = config.get_supported_openai_params( + "fireworks_ai/accounts/fireworks/models/glm-5p3-flash" + ) + + assert "reasoning_effort" in supported_params + + def test_get_supported_openai_params_parallel_tool_calls_without_tool_choice( monkeypatch, ): From dad1b132258edf500597b47dcfb0ddecf9b76fa7 Mon Sep 17 00:00:00 2001 From: Krrish Dholakia Date: Fri, 4 Sep 2026 17:07:59 +0000 Subject: [PATCH 102/410] style(fireworks_ai): format capability fallback Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/fireworks_ai/chat/transformation.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/litellm/llms/fireworks_ai/chat/transformation.py b/litellm/llms/fireworks_ai/chat/transformation.py index 6aa6a3600f0..26ad9a02a79 100644 --- a/litellm/llms/fireworks_ai/chat/transformation.py +++ b/litellm/llms/fireworks_ai/chat/transformation.py @@ -278,9 +278,9 @@ class FireworksAIConfig(FireworksAIMixin, OpenAIGPTConfig): supported_params.append("tool_choice") # Only add reasoning params for models that support it - if self._get_model_cost_capability_exact( - model=model, capability="supports_reasoning" - ) or supports_reasoning(model=model, custom_llm_provider="fireworks_ai"): + if self._get_model_cost_capability_exact(model=model, capability="supports_reasoning") or supports_reasoning( + model=model, custom_llm_provider="fireworks_ai" + ): supported_params.append("reasoning_effort") supported_params.append("reasoning_history") supported_params.append("thinking") From 2042364fc2976ea735ab3d8c77dd4f4b27df3b84 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 4 Sep 2026 11:50:28 -0700 Subject: [PATCH 103/410] fix(proxy): strip every TypedDict qualifier before numeric form-field detection _numeric_form_type only peeled a single ReadOnly layer, so a field still wrapped in Required/NotRequired was read as non-numeric and dropped from the mapping. Which qualifiers survive get_type_hints varies by interpreter version and by include_extras, so on Python 3.10 NotRequired[ReadOnly[int]] reached the check intact and the field was silently skipped, which is what turns the mapped test red on the 3.10 leg only. Peel Required/NotRequired/ReadOnly/Annotated in any order and nesting instead. The one production caller feeds a schema with no qualifiers, so the resulting mapping is unchanged on every interpreter in the matrix, but a field written the house-convention way stops being dropped. --- .../proxy/common_utils/http_parsing_utils.py | 16 +++++++++++++--- .../common_utils/test_http_parsing_utils.py | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/common_utils/http_parsing_utils.py b/litellm/proxy/common_utils/http_parsing_utils.py index 552d1ea434f..a396e543e94 100644 --- a/litellm/proxy/common_utils/http_parsing_utils.py +++ b/litellm/proxy/common_utils/http_parsing_utils.py @@ -2,11 +2,11 @@ import json import re from collections.abc import Collection, Mapping from types import MappingProxyType, UnionType -from typing import Any, Final, Union, get_args, get_origin +from typing import Annotated, Any, Final, Union, get_args, get_origin import orjson from fastapi import Request, UploadFile, status -from typing_extensions import ReadOnly +from typing_extensions import NotRequired, ReadOnly, Required from litellm._logging import verbose_proxy_logger from litellm.constants import MAX_REQUEST_BODY_SIZE_TO_REPAIR_MB @@ -18,6 +18,8 @@ from litellm.types.router import Deployment _FORM_CONTENT_TYPES: Final[frozenset[str]] = frozenset({"application/x-www-form-urlencoded", "multipart/form-data"}) +_ANNOTATION_QUALIFIERS: Final[frozenset[object]] = frozenset({Annotated, NotRequired, ReadOnly, Required}) + def _normalize_media_type(content_type: str) -> str: """Return the bare media type per RFC 7231: strip params, trim, lowercase.""" @@ -42,9 +44,17 @@ def _is_json_content_type(content_type: str) -> bool: return _normalize_media_type(content_type) == "application/json" +def _unqualified(annotation: object) -> object: + """Which qualifiers ``get_type_hints`` already stripped varies by interpreter version, so peel them all.""" + if get_origin(annotation) not in _ANNOTATION_QUALIFIERS: + return annotation + qualified: Final[tuple[object, ...]] = get_args(annotation) + return _unqualified(qualified[0]) + + def _numeric_form_type(annotation: object) -> type[int] | type[float] | None: """The scalar to parse an ``int``/``float``-typed field as, else ``None``.""" - unwrapped: Final = get_args(annotation)[0] if get_origin(annotation) is ReadOnly else annotation + unwrapped: Final = _unqualified(annotation) candidates: Final = ( tuple(arg for arg in get_args(unwrapped) if arg is not type(None)) if get_origin(unwrapped) in (Union, UnionType) diff --git a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py index fcfb9342176..011571a37e0 100644 --- a/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_http_parsing_utils.py @@ -1053,6 +1053,8 @@ class TestNumericFormFields: read_only: ReadOnly[int | None] not_required: NotRequired[ReadOnly[int]] required: Required[ReadOnly[Annotated[float, "meta"]]] + read_only_not_required: ReadOnly[NotRequired[int]] + read_only_required: ReadOnly[Required[float]] assert dict(numeric_form_fields(get_type_hints(Schema))) == { "plain": int, @@ -1061,6 +1063,22 @@ class TestNumericFormFields: "read_only": int, "not_required": int, "required": float, + "read_only_not_required": int, + "read_only_required": float, + } + + def test_qualifiers_are_unwrapped_when_get_type_hints_keeps_extras(self): + from typing_extensions import Annotated, NotRequired, ReadOnly, Required, TypedDict + + class Schema(TypedDict, total=False): + annotated: ReadOnly[Annotated[int, "meta"]] + not_required: NotRequired[ReadOnly[int]] + required: Required[ReadOnly[Annotated[float, "meta"]]] + + assert dict(numeric_form_fields(get_type_hints(Schema, include_extras=True))) == { + "annotated": int, + "not_required": int, + "required": float, } def test_non_scalar_and_bool_fields_are_skipped(self): From f9a5f676a866012c24427fb8afdfa240031f70d9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 13:16:39 -0700 Subject: [PATCH 104/410] docs(claude): have runs embed their own QA screenshots on visual changes --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d9e9e8f1586..9c525ffe420 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,7 +37,7 @@ Same applies for filing bug reports and feature requests, with .github/ISSUE_TEM If you're resolving a linear ticket, in the "## Linear ticket" section of the PR, say "Resolves LIT-1234", replacing "LIT-1234" with the actual ticket id that you're resolving. If you don't have the ticket id, don't make one up or search for it. Just leave the section blank -Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, just tell me which URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, what fields to fill out, etc. along with the other commands to run in an ordered list, and I'll do it myself and post the screenshots after you make the PR +Never use `pytest` commands or the like as "Screenshots / Proof of Fix". We prefer curl'ing a live proxy instance running on localhost:4000 (I like to run it with `python litellm/proxy/proxy_cli.py --config litellm/proxy/dev_config.yaml --detailed_debug --reload --use_v2_migration_resolver 2>&1 | tee litellm.log`; the Admin UI dev server is `npm run dev` in `ui/litellm-dashboard`, served on port 3000) and showing both the command run and the output. Also, it should hit real LLM provider APIs, not mocks, and cost real $$$ because that is the most realistic test. The proof of fix should be exactly what the end user / customer would see / do. The run logs in PR #27703 is a prime example of how to do it (not a huge fan of using a python test script that future me and the team will have no visibility into; I prefer just curl commands or a short list of bash commands (e.g., using `for`)). If it's a UI thing, or the main use case runs through a headful agentic coding tool like Claude Code or Codex, drive that surface yourself and embed your own before and after screenshots of it in the PR (the Admin UI page, or what the coding tool shows), next to an ordered list of the URLs to go to (e.g., http://localhost:4000/ui/?page=logs), where to click, and what fields to fill out so a reviewer can reproduce it If you ever write any human-facing text (pull requests, issues, commit messages, discussion posts, github comments, release notes, docs, etc.), always follow these guidelines to sound less AI-y: - don't use emojis From 50fb35e17eecef15260eb4c1cd3610afef8e08cc Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 4 Sep 2026 13:39:54 -0700 Subject: [PATCH 105/410] fix(vector_stores): make MongoDB errors actionable on self-managed deployments mongod serves $vectorSearch identically whether mongot runs under Atlas or beside a self-managed deployment, so the provider already worked against on-prem. The guidance did not: a refused connection told the operator to check their project's IP access list and whether the cluster was paused, neither of which exists outside Atlas, and the index errors claimed an "Atlas Vector Search index" they do not have. Every message now names a remedy for both, keeping the Atlas-specific hint labelled as such. Also diagnoses unescaped credentials, which self-managed deployments hit more often because the password is usually generated. pymongo reports those three different ways and none of them mentions the password: '@', ':' and '%' raise an RFC 3986 complaint, '/' is read as the database separator and surfaces as Bad database name, and an unescaped ':' looks like a bad port and comes back as a plain ValueError. All three now point at the credentials. The ValueError branch's comment claimed it fired on an unescaped '/', which pymongo actually reports as InvalidURI; corrected to the port parse it really catches. Verified against a self-managed mongod 8.0 with mongot, reached over plain mongodb:// with no SRV and no TLS: 13 cases with live OpenAI embeddings, and 4 credential cases against an auth-enabled instance whose password holds % @ / and :. list_search_indexes returns the same queryable and status fields there as on Atlas, so the index-readiness check needed no change. --- litellm/llms/mongodb/common_utils.py | 46 +++-- .../mongodb/vector_stores/transformation.py | 22 +-- .../test_mongodb_transformation.py | 168 +++++++++++++++++- 3 files changed, 205 insertions(+), 31 deletions(-) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index bf3bf953772..0978368e874 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -1,10 +1,10 @@ -"""Shared helpers for MongoDB Atlas integrations. +"""Shared helpers for MongoDB integrations. pymongo ships in the optional ``mongodb`` extra, so every import of it is deferred to call time and raises an actionable error when it is absent. Clients are cached per connection because building one costs an SRV lookup, a -TLS handshake and topology discovery: measured at ~890ms against Atlas versus +TLS handshake and topology discovery: measured at ~890ms against a remote deployment versus ~80ms on a warm client, so a client per search would dominate query latency. """ @@ -141,15 +141,16 @@ def reset_client_cache() -> None: _AUTHENTICATION_FAILED_CODE: Final = 18 _UNAUTHORIZED_CODE: Final = 13 -# Atlas reports a rejected user as code 8000 "AtlasError", not 18, so only the message is reliable +# Atlas reports a rejected user as code 8000 "AtlasError" where a self-managed mongod reports 18 _AUTHENTICATION_MESSAGE_MARKERS: Final = ("bad auth", "authentication failed", "not authorized") _RESOLUTION_TIMEOUT_MARKERS: Final = ("resolution lifetime expired", "dns operation timed out") _UNKNOWN_HOSTNAME_MARKERS: Final = ("dns query name does not exist", "name or service not known") +_CREDENTIAL_ESCAPING_MARKERS: Final = ("must be escaped according to rfc 3986", "bad database name") def _index_hint(index_name: str, database: str, collection: str) -> str: return ( - f"No queryable Atlas Vector Search index named '{index_name}' was found on " + f"No queryable MongoDB Vector Search index named '{index_name}' was found on " f"'{database}.{collection}'. Confirm the index exists on that exact collection, that its " "status is READY rather than still building, and that the vector store id matches the index name." ) @@ -168,7 +169,7 @@ def missing_index_error(index_name: str, database: str, collection: str) -> BadR def index_not_ready_error(index_name: str, database: str, collection: str, status: str) -> BadRequestError: return config_error( - f"The Atlas Vector Search index '{index_name}' on '{database}.{collection}' is not queryable " + f"The MongoDB Vector Search index '{index_name}' on '{database}.{collection}' is not queryable " f"yet; its status is {status}. Searches against it return no results until the build finishes." ) @@ -194,8 +195,9 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll if isinstance(error, ServerSelectionTimeoutError): return timeout_error( "Could not reach the MongoDB deployment before the timeout. On Atlas this is usually the " - "project's IP access list not containing this host, or a paused cluster; it can also be an " - f"unresolvable hostname. Driver detail: {error}" + "project's IP access list not containing this host, or a paused cluster. On a self-managed " + "deployment it is usually the host or port in the URI, or a firewall between this process " + f"and mongod. Either way it can also be an unresolvable hostname. Driver detail: {error}" ) # ExecutionTimeout subclasses OperationFailure, so it has to be matched before it if isinstance(error, (NetworkTimeout, ExecutionTimeout)): @@ -208,8 +210,9 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll if isinstance(error, ConnectionFailure): return config_error( f"The connection to '{database}.{collection}' was refused or dropped. On Atlas this is " - "usually a connection string with no username and password, or a TLS failure. Confirm " - f"the URI is the one Atlas shows under Connect, Drivers. Driver detail: {error}" + "usually a connection string with no username and password, or a TLS failure, so confirm " + "the URI is the one Atlas shows under Connect, Drivers. On a self-managed deployment, check " + f"that mongod is listening on the host and port in the URI. Driver detail: {error}" ) if isinstance(error, OperationFailure): code: Final = error.code @@ -223,13 +226,13 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll ) if "dimension" in detail: return config_error( - "The query embedding does not match the vector dimensions the Atlas index was built for. " + "The query embedding does not match the vector dimensions the index was built for. " "litellm_embedding_model must be the same model that produced the stored vectors. " f"Driver detail: {error}" ) if "is not indexed as vector" in detail: return config_error( - "mongodb_embedding_field names a field the Atlas Vector Search index does not cover. " + "mongodb_embedding_field names a field the MongoDB Vector Search index does not cover. " f"It must match the 'path' the index '{index_name}' was created on. Driver detail: {error}" ) if "index" in detail and ("not found" in detail or "does not exist" in detail or "unknown" in detail): @@ -248,19 +251,28 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll ) if any(marker in configuration_detail for marker in _UNKNOWN_HOSTNAME_MARKERS): return config_error( - "The cluster hostname in mongodb_connection_string does not exist in DNS. Check the " - f"cluster name against the URI Atlas shows under Connect, Drivers. Driver detail: {error}" + "The hostname in mongodb_connection_string does not exist in DNS. On Atlas, check the " + "cluster name against the URI shown under Connect, Drivers. On a self-managed deployment, " + f"check that the hostname resolves from this process. Driver detail: {error}" + ) + if any(marker in configuration_detail for marker in _CREDENTIAL_ESCAPING_MARKERS): + return config_error( + "mongodb_connection_string could not be parsed. A username or password containing " + "'@', '/', ':' or '%' has to be percent-encoded per RFC 3986, so 'p@ss/word' becomes " + "'p%40ss%2Fword'. If the credentials are already encoded, check the database name in " + f"the URI path instead. Driver detail: {error}" ) return config_error( f"mongodb_connection_string is not a usable MongoDB connection string. Driver detail: {error}" ) if isinstance(error, InvalidOperation): return config_error(f"The MongoDB client was already closed or is unusable. Driver detail: {error}") - # pymongo's URI parser raises a plain ValueError, not a PyMongoError, for a password holding an - # unescaped '/', which would otherwise reach the caller as a 500 + # pymongo raises a plain ValueError, not a PyMongoError, for an unusable port, which an unescaped + # ':' in a password also produces, and which would otherwise reach the caller as a 500 if isinstance(error, ValueError): return config_error( - "mongodb_connection_string could not be parsed. A username or password containing " - f"'@', '/', ':' or '%' has to be percent-encoded per RFC 3986. Driver detail: {error}" + "The host and port in mongodb_connection_string could not be parsed. If the port is a " + "number between 0 and 65535, the cause is usually an unescaped ':' in the password, which " + f"has to be percent-encoded per RFC 3986 as '%3A'. Driver detail: {error}" ) return error diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 5e59fd30f1b..571061d39a2 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -1,11 +1,12 @@ -"""MongoDB Atlas vector store provider. +"""MongoDB vector store provider, for Atlas and self-managed deployments alike. -Atlas Vector Search has no HTTP query API (the Data API and HTTPS Endpoints are +MongoDB Vector Search has no HTTP query API (the Data API and HTTPS Endpoints are end-of-life), so this config extends BaseDirectVectorStoreConfig and runs the ``$vectorSearch`` aggregation itself through pymongo instead of shaping an httpx -request. +request. mongod serves that stage identically whether mongot runs under Atlas or +beside a self-managed deployment, so one code path covers both. -``vector_store_id`` is the Atlas Search index name, matching the Valkey provider +``vector_store_id`` is the search index name, matching the Valkey provider where the id names the index; the database and collection it covers come from litellm_params. """ @@ -60,7 +61,7 @@ MAX_QUERY_CHARACTERS: Final = 32_000 _EMPTY_EMBEDDING_CONFIG: Final = MappingProxyType({}) _SEARCH_ONLY_MESSAGE: Final = ( - "MongoDB vector store is search-only. Create the collection and its Atlas Vector Search " + "MongoDB vector store is search-only. Create the collection and its MongoDB Vector Search " "index in MongoDB directly, then register it here by index name." ) @@ -101,7 +102,8 @@ class _MongoDBSearchParams(BaseModel): if not self.mongodb_connection_string: raise config_error( "mongodb_connection_string is required in litellm_params for the MongoDB vector store. " - "Example: mongodb+srv://:@.mongodb.net" + "Example: mongodb+srv://:@.mongodb.net for Atlas, or " + "mongodb://:@:27017 for a self-managed deployment" ) scheme: Final = self.mongodb_connection_string.split("://", 1)[0].lower() if scheme not in ("mongodb", "mongodb+srv"): @@ -234,12 +236,12 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): if vector_store_search_optional_params.get("filters") is not None: raise config_error( "MongoDB vector store does not support the filters parameter yet. " - "Restrict the collection or the Atlas Vector Search index definition instead." + "Restrict the collection or the MongoDB Vector Search index definition instead." ) if vector_store_search_optional_params.get("ranking_options") is not None: raise config_error( "MongoDB vector store does not support the ranking_options parameter yet. " - "Every result already carries the Atlas vectorSearchScore, so filter or re-rank " + "Every result already carries the vectorSearchScore, so filter or re-rank " "on that rather than having the threshold silently ignored." ) if vector_store_search_optional_params.get("rewrite_query") is not None: @@ -296,7 +298,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def _raise_for_missing_text_field( cls, documents: Sequence[Mapping[str, object]], text_field: str, database: str, collection: str ) -> None: - """Atlas happily matches vectors in documents that carry no text at all, so a mistyped + """$vectorSearch happily matches documents that carry no text at all, so a mistyped mongodb_text_field returns well-scored results whose content is empty and feeds an empty context to the model. Every matched document lacking the field is the misconfiguration.""" if documents and all(cls._field_value(document, text_field) is None for document in documents): @@ -322,7 +324,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def _raise_for_unusable_index( catalogue: Sequence[Mapping[str, object]], index_name: str, database: str, collection: str ) -> None: - """An empty result set is ambiguous: Atlas returns zero documents both for a query that + """An empty result set is ambiguous: mongod returns zero documents both for a query that genuinely matched nothing and for a missing database, collection or index. Only the second is a misconfiguration, so the index catalogue decides which one happened.""" if not catalogue: diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index d60504c31e5..4e57755076f 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -788,8 +788,8 @@ class TestErrorTranslation: assert "refused or dropped" not in str(translated) def test_an_unescaped_password_character_is_a_400_not_a_500(self): - """pymongo's URI parser raises a plain ValueError, not a PyMongoError, when a password - holds an unescaped '/'. That is a routine mistake and it must not be a 500.""" + """pymongo's URI parser raises a plain ValueError, not a PyMongoError, for an unusable port, + which is also what an unescaped ':' in a password produces. It must not be a 500.""" translated = self._translate(ValueError("Port contains non-digit characters")) assert isinstance(translated, BadRequestError) @@ -895,7 +895,7 @@ class TestEmptyResultsAreDisambiguated: def test_a_missing_index_becomes_an_error_rather_than_an_empty_page(self): config, _, collection = _config(documents=[], search_indexes=[]) - with pytest.raises(BadRequestError, match="No queryable Atlas Vector Search index"): + with pytest.raises(BadRequestError, match="No queryable MongoDB Vector Search index"): _search(config) assert collection.listed_indexes == [INDEX] @@ -934,7 +934,7 @@ class TestEmptyResultsAreDisambiguated: async def test_async_missing_index_becomes_an_error_rather_than_an_empty_page(self): config, _, collection = _async_config(documents=[], search_indexes=[]) - with pytest.raises(BadRequestError, match="No queryable Atlas Vector Search index"): + with pytest.raises(BadRequestError, match="No queryable MongoDB Vector Search index"): await _asearch(config) assert collection.listed_indexes == [INDEX] @@ -1202,3 +1202,163 @@ class TestClientConstructionFailures: with pytest.raises(BadRequestError, match="not a usable MongoDB connection string"): await _asearch(config) + + +class TestSelfManagedDeploymentsAreFirstClass: + """mongod serves $vectorSearch identically whether mongot runs under Atlas or beside a + self-managed deployment, so an operator without an Atlas account has to be able to act on + every message. Guidance that only names Atlas remedies sends them looking for an IP access + list and a paused cluster that do not exist in their deployment.""" + + def _config_that_fails_to_connect(self, error): + def factory(_key): + raise error + + return MongoDBVectorStoreConfig( + embedding_fn=FakeEmbeddingFn([0.1, 0.2, 0.3]), sync_client_factory=factory + ) + + def test_a_plain_mongodb_uri_without_srv_or_credentials_is_accepted(self): + params = _MongoDBSearchParams.model_validate( + {**BASE_PARAMS, "mongodb_connection_string": "mongodb://mongod.internal:27017"} + ) + + assert params.require_connection_string() == "mongodb://mongod.internal:27017" + + def test_an_unreachable_deployment_names_a_self_managed_remedy(self): + from pymongo.errors import ServerSelectionTimeoutError + + config = self._config_that_fails_to_connect(ServerSelectionTimeoutError("connection refused")) + + with pytest.raises(Timeout) as excinfo: + _search(config) + + assert "self-managed" in str(excinfo.value) + assert "host or port" in str(excinfo.value) + + def test_a_refused_connection_names_a_self_managed_remedy(self): + from pymongo.errors import ConnectionFailure + + config = self._config_that_fails_to_connect(ConnectionFailure("connection closed")) + + with pytest.raises(BadRequestError) as excinfo: + _search(config) + + assert "self-managed" in str(excinfo.value) + assert "mongod is listening" in str(excinfo.value) + + def test_an_unresolvable_hostname_names_a_self_managed_remedy(self): + from pymongo.errors import ConfigurationError + + config = self._config_that_fails_to_connect(ConfigurationError("The DNS query name does not exist")) + + with pytest.raises(BadRequestError) as excinfo: + _search(config) + + assert "self-managed" in str(excinfo.value) + + def test_the_missing_index_message_does_not_claim_atlas(self): + message = str(missing_index_error(INDEX, "sample_mflix", "embedded_movies")) + + assert "MongoDB Vector Search index" in message + assert "Atlas" not in message + + def test_the_not_ready_message_does_not_claim_atlas(self): + message = str(index_not_ready_error(INDEX, "sample_mflix", "embedded_movies", "PENDING")) + + assert "MongoDB Vector Search index" in message + assert "Atlas" not in message + + def test_the_search_only_refusal_does_not_claim_atlas(self): + config = MongoDBVectorStoreConfig() + + with pytest.raises(BadRequestError) as excinfo: + config.transform_create_vector_store_request({}, api_base="") + + assert "Atlas" not in str(excinfo.value) + + def test_a_dimension_mismatch_does_not_claim_atlas(self): + from pymongo.errors import OperationFailure + + error = OperationFailure("vector field is indexed with 128 dimensions but queried with 256") + translated = translate_mongo_error(error, index_name=INDEX, database="db", collection="c") + + assert "Atlas" not in str(translated) + assert "dimensions the index was built for" in str(translated) + + def test_an_uncovered_embedding_field_does_not_claim_atlas(self): + from pymongo.errors import OperationFailure + + error = OperationFailure("embedding is not indexed as vector") + translated = translate_mongo_error(error, index_name=INDEX, database="db", collection="c") + + assert "MongoDB Vector Search index does not cover" in str(translated) + assert "Atlas" not in str(translated) + + def test_a_self_managed_auth_failure_is_still_recognised_by_code_18(self): + from pymongo.errors import OperationFailure + + error = OperationFailure("Authentication failed.", code=18, details={"code": 18}) + translated = translate_mongo_error(error, index_name=INDEX, database="db", collection="c") + + assert isinstance(translated, BadRequestError) + assert "rejected the credentials" in str(translated) + + +class TestUnescapedCredentialsAreDiagnosed: + """Self-managed deployments usually carry a generated password, so '@', '/', ':' and '%' in one + are routine. pymongo reports those as a port, a database name or an RFC 3986 complaint, none of + which points the operator at their password, so each has to be named for what it is. The errors + here come from pymongo's real parser rather than a synthetic stand-in.""" + + @staticmethod + def _real_parse_error(uri): + from pymongo import MongoClient + + try: + MongoClient(uri, serverSelectionTimeoutMS=1) + except Exception as e: + return e + raise AssertionError(f"expected {uri!r} to fail parsing") + + def _translated(self, uri): + return translate_mongo_error( + self._real_parse_error(uri), index_name=INDEX, database="db", collection="c" + ) + + @pytest.mark.parametrize( + "uri", + [ + "mongodb://user:pa@ss@host:27017/", + "mongodb://user:pa:ss@host:27017/", + "mongodb://user:pa%ss@host:27017/", + "mongodb://user@x:pw@host:27017/", + ], + ) + def test_rfc_3986_complaints_tell_the_operator_to_encode_the_password(self, uri): + translated = self._translated(uri) + + assert isinstance(translated, BadRequestError) + assert "percent-encoded per RFC 3986" in str(translated) + + @pytest.mark.parametrize( + "uri", + ["mongodb://user:pa/ss@host:27017/", "mongodb://user/x:pw@host:27017/"], + ) + def test_a_slash_in_the_credentials_is_not_reported_as_a_database_name(self, uri): + translated = self._translated(uri) + + assert isinstance(translated, BadRequestError) + assert "percent-encoded per RFC 3986" in str(translated) + + def test_an_unusable_port_names_the_host_and_port_not_the_database(self): + translated = self._translated("mongodb://host:99999/") + + assert isinstance(translated, BadRequestError) + assert "host and port" in str(translated) + + def test_a_genuinely_bad_database_name_still_mentions_the_uri_path(self): + translated = self._translated("mongodb://host:27017/has space") + + assert isinstance(translated, BadRequestError) + assert "database name in the URI path" in str(translated) From 9e0659212a02dccfdaf74711bffb75bef2a4fda0 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 4 Sep 2026 13:48:42 -0700 Subject: [PATCH 106/410] test(e2e): repair two suites broken by intentional behaviour changes Both of these are e2e assumptions that PRs #31731 and #39532 invalidated, not product regressions. They have been red in litellm-e2e builds 119-123. Wildcard readiness probe (6 errors in test_model_access_group_e2e.py) #31731 made _get_wildcard_models drop a wildcard route from /v1/models unconditionally; before it, a wildcard with a matching router deployment stayed in the list and only the no-router / no-deployment fallbacks removed it. The shared readiness helper polls /v1/models for an exact id match, so registering openai/gpt-5.4* now times out at model_servable_timeout every run and every test in the class errors in setup. return_wildcard_routes=True still re-adds the route, so the poll asks for it. The flag is a no-op for a concrete model name -- it only ever adds wildcard entries -- so it is set unconditionally rather than sniffing the name. Semantic auto-router spend assertion #39532 bills the routing embedding to the caller's key on purpose, so the key's spend logs now legitimately carry an openai/text-embedding-3-small row and _assert_served_only_by rejects it. Widening the allowlist would have weakened the assertion this test exists for -- that the request reached the target deployment. Instead the embedding row is split off and asserted separately, which turns the break into coverage for #39532. The poll gains a predicate so it waits for the embedding row rather than racing whichever row is written first. --- tests/e2e/models.py | 9 +++++++++ tests/e2e/proxy_client.py | 3 ++- .../router/test_auto_router_regressions_e2e.py | 15 +++++++++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 79d9e011f7e..b5229744d6f 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -851,6 +851,15 @@ class ModelListEntry(BaseModel): id: str +class ModelsListParams(BaseModel): + """Query for GET /v1/models. A wildcard route such as ``openai/gpt-5.4*`` is + listed only under ``return_wildcard_routes``; without it the route is dropped + and only its expansions remain, so a readiness poll for the pattern itself + never resolves.""" + + return_wildcard_routes: bool = True + + class ModelsListResponse(BaseModel): """GET /v1/models on the data plane: the deployments the gateway can actually serve right now. Used to confirm a freshly created model has propagated from diff --git a/tests/e2e/proxy_client.py b/tests/e2e/proxy_client.py index 2d382a610e1..cdc20e5299a 100644 --- a/tests/e2e/proxy_client.py +++ b/tests/e2e/proxy_client.py @@ -55,6 +55,7 @@ from models import ( ModelMode, ModelNewBody, ModelNewResponse, + ModelsListParams, ModelsListResponse, ModelUpdateBody, OcrBody, @@ -336,7 +337,7 @@ class ProxyClient: lambda poll_timeout: self.transport.get( "/v1/models", headers=headers, - params=NoBody(), + params=ModelsListParams(), response_type=ModelsListResponse, timeout=poll_timeout, ), diff --git a/tests/e2e/router/test_auto_router_regressions_e2e.py b/tests/e2e/router/test_auto_router_regressions_e2e.py index 35ba2c8d3d1..188db2a8eb5 100644 --- a/tests/e2e/router/test_auto_router_regressions_e2e.py +++ b/tests/e2e/router/test_auto_router_regressions_e2e.py @@ -597,9 +597,20 @@ class TestSemanticAutoRouterResponses: ) ) assert answer.id, "/v1/responses through the semantic auto-router returned no response id" - rows: Final = proxy.poll_logs_for_key(key, min_rows=1) + rows: Final = proxy.poll_logs_for_key( + key, + min_rows=2, + predicate=lambda logged: any(row.model == EMBEDDING_MODEL for row in logged), + ) + embedding_rows: Final = tuple(row for row in rows if row.model == EMBEDDING_MODEL) + assert embedding_rows, ( + "the routing embedding was not billed to the caller's key; " + f"spend logs show {tuple(row.model for row in rows)}" + ) _assert_served_only_by( - rows, CHEAP_SERVED | {semantic_auto_router.target}, "semantic auto-router /v1/responses string input" + [row for row in rows if row.model != EMBEDDING_MODEL], + CHEAP_SERVED | {semantic_auto_router.target}, + "semantic auto-router /v1/responses string input", ) From 323f51269d3d781e19a68aa658b9158fd4d9edcb Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 4 Sep 2026 13:59:07 -0700 Subject: [PATCH 107/410] fix(vector_stores): return 400 when a MongoDB TLS file cannot be read tlsCAFile and tlsCertificateKeyFile are how a self-managed deployment presents a private CA, so they are the options on-prem operators actually set. pymongo opens those files itself during TLS setup and lets OSError out, which is neither a PyMongoError nor a ValueError, so it missed every branch of the translator and litellm.exception_type turned it into a 500 with a traceback in the body. A mistyped path, or one that exists on the host but not inside the container, is a routine mistake and has to read as a 400 naming the file. Matched on the exception carrying a filename so a socket-level OSError still falls through to the branches that handle it. Verified against a self-managed mongod with a missing CA file, a CA path that is a directory, and a missing client certificate. --- litellm/llms/mongodb/common_utils.py | 8 ++++ .../test_mongodb_transformation.py | 43 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 0978368e874..4e37e21948b 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -267,6 +267,14 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll ) if isinstance(error, InvalidOperation): return config_error(f"The MongoDB client was already closed or is unusable. Driver detail: {error}") + # A tlsCAFile or tlsCertificateKeyFile the process cannot open raises OSError from the TLS setup + # rather than a PyMongoError, and those options are how self-managed deployments present a private CA + if isinstance(error, OSError) and error.filename: + return config_error( + f"'{error.filename}', named by a TLS option in mongodb_connection_string, could not be read. " + "Check that tlsCAFile and tlsCertificateKeyFile point at files this process can open; inside " + f"a container that is the path in the container, not on the host. Driver detail: {error}" + ) # pymongo raises a plain ValueError, not a PyMongoError, for an unusable port, which an unescaped # ':' in a password also produces, and which would otherwise reach the caller as a 500 if isinstance(error, ValueError): diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 4e57755076f..7d71ff5c213 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -1362,3 +1362,46 @@ class TestUnescapedCredentialsAreDiagnosed: assert isinstance(translated, BadRequestError) assert "database name in the URI path" in str(translated) + + +class TestUnreadableTlsFilesAreDiagnosed: + """A private CA is how self-managed deployments present TLS, so tlsCAFile and + tlsCertificateKeyFile are on-prem options in practice. pymongo opens those files itself and + lets OSError out, which is not a PyMongoError, so before this they reached the caller as a 500 + with a traceback. The errors here come from pymongo's real TLS setup.""" + + @staticmethod + def _real_tls_error(uri): + from pymongo import MongoClient + + try: + MongoClient(uri, serverSelectionTimeoutMS=1500).admin.command("ping") + except Exception as e: + return e + raise AssertionError(f"expected {uri!r} to fail") + + def _translated(self, uri): + return translate_mongo_error(self._real_tls_error(uri), index_name=INDEX, database="db", collection="c") + + @pytest.mark.parametrize( + "path", + ["/nonexistent-directory-for-tests/ca.pem", "/tmp"], + ) + def test_an_unreadable_ca_file_is_a_400_naming_the_path(self, path): + translated = self._translated(f"mongodb://localhost:27717/?tls=true&tlsCAFile={path}") + + assert isinstance(translated, BadRequestError) + assert path in str(translated) + assert "tlsCAFile" in str(translated) + + def test_an_unreadable_client_certificate_is_a_400_naming_the_path(self): + path = "/nonexistent-directory-for-tests/client.pem" + translated = self._translated(f"mongodb://localhost:27717/?tls=true&tlsCertificateKeyFile={path}") + + assert isinstance(translated, BadRequestError) + assert path in str(translated) + + def test_an_oserror_carrying_no_filename_is_left_for_the_other_branches(self): + translated = translate_mongo_error(OSError("socket hung up"), index_name=INDEX, database="db", collection="c") + + assert not isinstance(translated, BadRequestError) From 7c6638e5c34c2deed2dda7268eeeafdedb341308 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 14:15:39 -0700 Subject: [PATCH 108/410] feat(router): auto-escalate stalled complexity-router tasks Adds stall_escalation_enabled to the complexity router: when the assistant's own recent tool calls look stuck (identical repeats, or repeated tool errors on a surface that reports one), the request is bumped one configured tier higher, the automatic counterpart to escalation_keywords. Detection is stateless: it rereads the last stall_escalation_window tool calls from that request's own message list on every classified turn, so the bump lasts only as long as the recent calls still look stuck and lifts on its own once they don't, and evidence survives a plain follow-up like "try again" instead of resetting on the newest human ask. Off by default. Rejected together with session_affinity and classification_mode='user_turn', which both replay a held routing decision instead of classifying most turns, and with tier_definitions, for the same reason escalation_keywords is: both rely on the built-in tier severity order a custom tier set does not define. Dashboard controls for this are not included; config.yaml and the management API accept it today through ComplexityRouterConfig. --- .../complexity_router/README.md | 46 ++++++ .../complexity_router/complexity_router.py | 12 ++ .../complexity_router/config.py | 56 +++++++ .../complexity_router/stall_detector.py | 126 +++++++++++++++ .../router_strategy/test_complexity_router.py | 151 +++++++++++++++--- .../router_strategy/test_stall_detector.py | 121 ++++++++++++++ 6 files changed, 492 insertions(+), 20 deletions(-) create mode 100644 litellm/router_strategy/complexity_router/stall_detector.py create mode 100644 tests/test_litellm/router_strategy/test_stall_detector.py diff --git a/litellm/router_strategy/complexity_router/README.md b/litellm/router_strategy/complexity_router/README.md index afa27719064..a7ed9e9dc21 100644 --- a/litellm/router_strategy/complexity_router/README.md +++ b/litellm/router_strategy/complexity_router/README.md @@ -247,6 +247,52 @@ unless `modality_routing` is also on. `session_affinity_ttl_seconds` is the idle window for both the model pin selected by session affinity and the deployment pin. Every request that reuses a pin refreshes its TTL, so a session actively sending requests stays pinned. After the window passes with no pin reuse, the next request classifies again and creates a fresh pin. Omit the setting to track the default of 3600 seconds. +### Mid-task stall escalation + +A weak model working an agentic task can get stuck: it keeps calling the same tool with the +same arguments, or the same call keeps erroring, when a stronger model would have broken the +loop. `stall_escalation_enabled: true` catches this and bumps the request one tier higher, the +automatic counterpart to a user typing an escalation keyword: + +```yaml +model_list: + - model_name: smart-router + litellm_params: + model: auto_router/complexity_router + complexity_router_config: + stall_escalation_enabled: true + stall_escalation_window: 6 + stall_escalation_repeat_threshold: 3 + tiers: + SIMPLE: gpt-4o-mini + MEDIUM: gpt-4o + COMPLEX: claude-sonnet-4 + REASONING: o1-preview +``` + +Detection looks at the assistant's own tool calls, not the human's messages: of the last +`stall_escalation_window` tool calls, if `stall_escalation_repeat_threshold` or more are +identical (same tool, same arguments) or came back as errors, the task counts as stalled and the +classified tier is bumped one step by the same `_escalate_tier` ladder `escalation_keywords` +uses, capped at the highest configured tier. It reads both tool-call shapes: Anthropic Messages +`tool_use`/`tool_result` blocks (including `is_error`) and chat-completions `tool_calls`/`tool` +messages (which carry no standard error flag, so those calls are judged on repetition alone). + +There is no state to expire or leak: detection reruns on every classified turn from that +request's own message list, so the bump lasts only as long as the recent tool calls still look +stuck and lifts on its own the moment they don't. This also means it reads the whole +conversation rather than only the turns since the newest human ask, so a plain follow-up like +"try again" does not discard evidence from before it. Escalation records `stall_escalation` in +`routing_decision.signals`; unlike `escalation_keywords`, it does not set the +`escalated`/`escalation_keyword` pair, which is reserved for the keyword mechanism specifically. + +`stall_escalation_enabled` cannot be combined with `session_affinity` or +`classification_mode: user_turn`: both replay a held routing decision on most turns instead of +classifying, so detection would never see the tool calls it needs to look at. It is also +rejected together with `tier_definitions`, for the same reason `escalation_keywords` is: both +rely on the built-in tier severity order, which a custom tier set does not define. Off by +default. + ### Heuristic-first chaining `classifier_type: heuristic_first` runs the local scorer on every request and only calls the LLM diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 17e3d1256d0..01b4665a7d9 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -70,6 +70,7 @@ from .config import ( ComplexityTier, TierDefinition, ) +from .stall_detector import detect_stalled_task if TYPE_CHECKING: from semantic_router.routers import SemanticRouter @@ -3135,6 +3136,17 @@ class ComplexityRouter(CustomLogger): escalated: Final = tier != classified_tier if escalated: signals = (*signals, "escalation") + # Recomputed from this request's own tool calls, not remembered from a prior turn: the + # bump lasts only as long as the recent tool calls still look stuck, and lifts itself + # the moment they don't, with nothing to expire or leak past the task that earned it. + stalled: Final = self.config.stall_escalation_enabled and detect_stalled_task( + resolved_messages, + window=self.config.stall_escalation_window, + repeat_threshold=self.config.stall_escalation_repeat_threshold, + ) + if stalled: + tier = self._escalate_tier(tier) + signals = (*signals, "stall_escalation") pre_floor_tier: Final = tier if plan_floor is not None: tier = self._apply_plan_mode_floor(tier) diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index 70c1b281e31..508c4ec8c91 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -809,6 +809,42 @@ class ComplexityRouterConfig(BaseModel): description="Rules that force a specific tier when their keywords match the prompt", ) + stall_escalation_enabled: bool = Field( + default=False, + description=( + "Escalate mid-task to the next-higher configured tier when the assistant's own recent " + "tool calls look stuck: stall_escalation_repeat_threshold or more of the last " + "stall_escalation_window tool calls are identical repeats (same tool, same arguments) " + "or came back as errors. One tier at most, on the same ladder escalation_keywords bumps " + "along, and never above the highest configured tier. Detection re-runs on every " + "classified turn from the tool calls visible in that request, so it needs no state and " + "nothing survives past the task: once the recent tool calls stop looking stuck, the " + "next classified turn routes normally again. Mutually exclusive with session_affinity " + "and classification_mode='user_turn', which both replay a held routing decision instead " + "of classifying most turns, so this would never see the tool calls to look at. Off by " + "default." + ), + ) + stall_escalation_window: int = Field( + default=6, + gt=0, + description=( + "How many of the assistant's most recent tool calls stall detection looks at, oldest " + "ones dropped as new calls happen. Counted across the whole visible conversation " + "rather than reset at the newest human ask, so evidence from before a plain follow-up " + "message like 'try again' is still visible on the turn after it." + ), + ) + stall_escalation_repeat_threshold: int = Field( + default=3, + ge=2, + description=( + "How many of the last stall_escalation_window tool calls must be identical repeats, or " + "error results, before the task counts as stalled. Must not exceed " + "stall_escalation_window, or the condition could never be reached." + ), + ) + plan_mode_min_tier: str | None = Field( default=None, description=( @@ -1246,6 +1282,7 @@ class ComplexityRouterConfig(BaseModel): ("adaptive", self.adaptive), ("session_affinity", self.session_affinity), ("escalation_keywords", bool(self.escalation_keywords)), + ("stall_escalation_enabled", self.stall_escalation_enabled), ("plugins", bool(self.plugins)), ) if enabled @@ -1422,6 +1459,25 @@ class ComplexityRouterConfig(BaseModel): ) return self + @model_validator(mode="after") + def _validate_stall_escalation(self) -> "ComplexityRouterConfig": + if not self.stall_escalation_enabled: + return self + if self.session_affinity or self.classification_mode == "user_turn": + raise ValueError( + "stall_escalation_enabled cannot be combined with session_affinity or " + "classification_mode='user_turn': both replay a held routing decision on most " + "turns instead of classifying, so stall detection would never see the tool calls " + "of the turns it needs to look at. Disable one or the other." + ) + if self.stall_escalation_repeat_threshold > self.stall_escalation_window: + raise ValueError( + "stall_escalation_repeat_threshold " + f"({self.stall_escalation_repeat_threshold}) cannot exceed stall_escalation_window " + f"({self.stall_escalation_window}); the condition could never be reached." + ) + return self + @model_validator(mode="after") def _validate_tier_param_placement(self) -> "ComplexityRouterConfig": """Reject a router setting written into a tier entry's request params. diff --git a/litellm/router_strategy/complexity_router/stall_detector.py b/litellm/router_strategy/complexity_router/stall_detector.py new file mode 100644 index 00000000000..450f8b6a653 --- /dev/null +++ b/litellm/router_strategy/complexity_router/stall_detector.py @@ -0,0 +1,126 @@ +""" +Mid-task stall detection for the Complexity Router. + +Looks at the assistant's own recent tool calls -- visible on every request an agentic +client resends, since each turn carries the whole conversation so far -- for a tight loop +of identical calls or repeated tool errors. No LLM call, no state: the same fixed-size +window is rescanned on every classified turn, so a stall reads the same way whether it +started one turn ago or ten, and stops reading as a stall the moment the recent calls +change. + +Assistant tool calls appear in two shapes depending on the API surface, and this module +reads both without translating one into the other: +- Anthropic Messages: assistant `content` blocks of type "tool_use" (id, name, input), + answered by a later user-turn `content` block of type "tool_result" (tool_use_id, + is_error). +- Chat completions: assistant `tool_calls` entries (id, function.name, function.arguments + as a JSON string), answered by a later `role: "tool"` message. Chat completions has no + standard error flag on that message, so those calls are judged on repetition alone. +""" + +from __future__ import annotations + +import json +from collections import Counter +from collections.abc import Iterator, Mapping, Sequence +from itertools import islice +from typing import Final, NamedTuple + +_ARGUMENTS_PARSE_FAILED: Final = object() + + +class _ToolCallEvent(NamedTuple): + signature: tuple[str, str] + is_error: bool | None + """None when the surface carries no structured error signal for this call. Never + treated as an error: a call this module cannot judge must not count toward the tally.""" + + +def _json_arguments(raw: str) -> object: + try: + return json.loads(raw) + except (TypeError, ValueError): + return _ARGUMENTS_PARSE_FAILED + + +def _tool_call_signature(name: str, raw_arguments: object) -> tuple[str, str]: + """A (name, canonical-arguments) pair that compares equal across both surfaces' + argument shapes: a dict (Anthropic `input`) and a JSON-encoded string (chat + completions `function.arguments`) representing the same call must match.""" + parsed: Final = _json_arguments(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments + arguments: Final = raw_arguments if parsed is _ARGUMENTS_PARSE_FAILED else parsed + try: + return name, json.dumps(arguments, sort_keys=True, default=str) + except (TypeError, ValueError): + return name, str(arguments) + + +def _iter_tool_result_error_pairs(messages: Sequence[Mapping[str, object]]) -> Iterator[tuple[str, bool]]: + """(call id, whether that call's result was an error), read only where the surface + reports one: an Anthropic Messages `tool_result` content block's `is_error`.""" + for msg in messages: + content = msg.get("content") + if msg.get("role") != "user" or not isinstance(content, list): + continue + for part in content: + if isinstance(part, Mapping) and part.get("type") == "tool_result": + call_id = part.get("tool_use_id") + if isinstance(call_id, str): + yield call_id, bool(part.get("is_error", False)) + + +def _iter_tool_call_events_newest_first(messages: Sequence[Mapping[str, object]]) -> Iterator[_ToolCallEvent]: + """Every tool call the assistant made, newest first, paired with its result's error + status where the surface reports one.""" + error_by_call_id: Final = dict(_iter_tool_result_error_pairs(messages)) + for msg in reversed(messages): + if msg.get("role") != "assistant": + continue + content = msg.get("content") + if isinstance(content, list): + for part in reversed(content): + if not (isinstance(part, Mapping) and part.get("type") == "tool_use"): + continue + name = part.get("name") + if isinstance(name, str): + call_id = part.get("id") + yield _ToolCallEvent( + signature=_tool_call_signature(name, part.get("input")), + is_error=error_by_call_id.get(call_id) if isinstance(call_id, str) else None, + ) + tool_calls = msg.get("tool_calls") + if not isinstance(tool_calls, list): + continue + for call in reversed(tool_calls): + function = call.get("function") if isinstance(call, Mapping) else None + name = function.get("name") if isinstance(function, Mapping) else None + if isinstance(name, str): + yield _ToolCallEvent( + signature=_tool_call_signature(name, function.get("arguments") if function else None), + is_error=None, + ) + + +def detect_stalled_task( + messages: Sequence[Mapping[str, object]] | None, + *, + window: int, + repeat_threshold: int, +) -> bool: + """Whether the assistant's recent tool-call activity looks stuck: repeat_threshold or + more of the last `window` tool calls share an identical signature, or resolved to an + error on a surface that reports one. + + Reads the whole message list rather than only the turns since the newest human ask, + so a follow-up like "try again" does not discard the evidence that came before it. + """ + if not messages or repeat_threshold <= 0: + return False + recent: Final = tuple(islice(_iter_tool_call_events_newest_first(messages), window)) + if len(recent) < repeat_threshold: + return False + _, most_common_count = Counter(event.signature for event in recent).most_common(1)[0] + if most_common_count >= repeat_threshold: + return True + error_count: Final = sum(1 for event in recent if event.is_error) + return error_count >= repeat_threshold diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index c74360875f7..3ae3165bf62 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -2690,9 +2690,7 @@ class TestRouterPreRoutingAliasOverrides: import time monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path)) - (tmp_path / "api-key.json").write_text( - json.dumps({"token": "tid=test", "expires_at": int(time.time()) + 3600}) - ) + (tmp_path / "api-key.json").write_text(json.dumps({"token": "tid=test", "expires_at": int(time.time()) + 3600})) router = Router( model_list=[ { @@ -2717,7 +2715,9 @@ class TestRouterPreRoutingAliasOverrides: copilot_resolutions: List = [] def _guarded(*args, **kwargs): - target = str(kwargs.get("model") or (args[0] if args else "")) + str(kwargs.get("custom_llm_provider") or "") + target = str(kwargs.get("model") or (args[0] if args else "")) + str( + kwargs.get("custom_llm_provider") or "" + ) if "github_copilot" in target: copilot_resolutions.append(target) raise RuntimeError("routing must not resolve an authenticating provider") @@ -5887,6 +5887,123 @@ class TestEscalationKeywords: assert result.model == "o1-b" # unchanged: no random hop to o1-a / o1-c +def _stalled_tool_history(repeats: int = 3) -> List[Dict]: + """`repeats` identical bash tool calls in a row, the automatic counterpart to a user + typing an escalation keyword: the assistant, not the human, is the one stuck.""" + return [ + turn + for i in range(repeats) + for turn in ( + { + "role": "assistant", + "content": [{"type": "tool_use", "id": f"call-{i}", "name": "bash", "input": {"cmd": "pytest"}}], + }, + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": f"call-{i}", "is_error": True, "content": "fail"}], + }, + ) + ] + + +class TestStallEscalation: + """Mid-task auto-escalation when the assistant's own recent tool calls look stuck: the + automatic counterpart to escalation_keywords, gated by stall_escalation_enabled and off + by default.""" + + @pytest.mark.asyncio + async def test_repeated_tool_calls_escalate_the_classified_tier(self, mock_router_instance, basic_config): + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={**basic_config, "stall_escalation_enabled": True}, + ) + messages = [*_stalled_tool_history(), {"role": "user", "content": "Hello there!"}] + result = await router.async_pre_routing_hook(model="test-model", request_kwargs={}, messages=messages) + assert result.model == "gpt-4o" # SIMPLE bumped to MEDIUM + + @pytest.mark.asyncio + async def test_varied_tool_calls_do_not_escalate(self, mock_router_instance, basic_config): + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={**basic_config, "stall_escalation_enabled": True}, + ) + messages = [ + { + "role": "assistant", + "content": [{"type": "tool_use", "id": "c1", "name": "bash", "input": {"cmd": "ls"}}], + }, + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "c1", "is_error": False, "content": "ok"}], + }, + {"role": "user", "content": "Hello there!"}, + ] + result = await router.async_pre_routing_hook(model="test-model", request_kwargs={}, messages=messages) + assert result.model == "gpt-4o-mini" # not escalated + + @pytest.mark.asyncio + async def test_disabled_by_default_ignores_repeated_tool_calls(self, mock_router_instance, basic_config): + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config=basic_config, + ) + messages = [*_stalled_tool_history(), {"role": "user", "content": "Hello there!"}] + result = await router.async_pre_routing_hook(model="test-model", request_kwargs={}, messages=messages) + assert result.model == "gpt-4o-mini" # stall_escalation_enabled defaults False + + @pytest.mark.asyncio + async def test_signals_record_stall_escalation(self, mock_router_instance, basic_config): + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={**basic_config, "stall_escalation_enabled": True}, + ) + messages = [*_stalled_tool_history(), {"role": "user", "content": "Hello there!"}] + result = await router.async_pre_routing_hook(model="test-model", request_kwargs={}, messages=messages) + assert "stall_escalation" in result.routing_decision["signals"] + + @pytest.mark.asyncio + async def test_stall_escalation_caps_at_highest_tier(self, mock_router_instance, basic_config): + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={**basic_config, "stall_escalation_enabled": True}, + ) + messages = [ + *_stalled_tool_history(), + {"role": "user", "content": "Let's think step by step and reason through this carefully."}, + ] + result = await router.async_pre_routing_hook(model="test-model", request_kwargs={}, messages=messages) + assert result.model == "o1-preview" # already REASONING, stays there + + @pytest.mark.asyncio + async def test_stall_escalation_stacks_with_keyword_escalation(self, mock_router_instance, basic_config): + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={**basic_config, "stall_escalation_enabled": True}, + ) + messages = [*_stalled_tool_history(), {"role": "user", "content": "LITELLM ESCALATE Hello there!"}] + result = await router.async_pre_routing_hook(model="test-model", request_kwargs={}, messages=messages) + assert result.model == "claude-sonnet-4-20250514" # SIMPLE -> MEDIUM (keyword) -> COMPLEX (stall) + + @pytest.mark.asyncio + async def test_evidence_survives_a_new_human_ask(self, mock_router_instance, basic_config): + """A plain follow-up like 'try again' must not erase the stall evidence that came + before it: escalation still fires on the turn carrying that follow-up.""" + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={**basic_config, "stall_escalation_enabled": True}, + ) + messages = [*_stalled_tool_history(), {"role": "user", "content": "try again"}] + result = await router.async_pre_routing_hook(model="test-model", request_kwargs={}, messages=messages) + assert result.model == "gpt-4o" # SIMPLE ("try again" carries no signal) bumped to MEDIUM + + class TestRoutingDecisionContents: """Every routing path must return a PreRoutingHookResponse carrying a routing_decision that names the mechanism that actually decided, with the facts of that path only.""" @@ -7781,7 +7898,6 @@ class TestClientHousekeepingCalls: assert result is not None assert result.model == "claude-sonnet-4-20250514" - @pytest.mark.asyncio async def test_a_classifier_plugin_still_decides_its_own_routers(self, mock_router_instance): """A plugin is where an operator encodes policy the tier ladder cannot express. @@ -7816,9 +7932,7 @@ class TestClientHousekeepingCalls: assert result.model == "o1-preview" assert result.routing_decision["cause"] == "classifier_plugin" - def _adaptive_router( - self, tier_distance_penalty: float, plan_mode_min_tier: str | None = None - ) -> ComplexityRouter: + def _adaptive_router(self, tier_distance_penalty: float, plan_mode_min_tier: str | None = None) -> ComplexityRouter: adaptive_instance = MagicMock() adaptive_instance.model_list = [ { @@ -7855,9 +7969,7 @@ class TestClientHousekeepingCalls: return router @pytest.mark.asyncio - async def test_the_bandit_cannot_route_a_housekeeping_call_above_the_cheapest_tier( - self, mock_router_instance - ): + async def test_the_bandit_cannot_route_a_housekeeping_call_above_the_cheapest_tier(self, mock_router_instance): """The tier here is what the request IS, not how hard it is, so the bandit has nothing to win. Without a ceiling the tier distance penalty is the only thing holding the tier, so a @@ -7890,7 +8002,6 @@ class TestClientHousekeepingCalls: assert result is not None assert result.model == "premium" - @pytest.mark.asyncio async def test_a_housekeeping_call_never_becomes_the_session_pin(self, mock_router_instance): """Pinning this is the most expensive mistake of the transient causes. @@ -7932,9 +8043,7 @@ class TestClientHousekeepingCalls: assert work_turn.routing_decision["cause"] == "llm_classifier" @pytest.mark.asyncio - async def test_the_decision_records_which_sentinel_matched( - self, mock_router_instance, llm_classifier_config - ): + async def test_the_decision_records_which_sentinel_matched(self, mock_router_instance, llm_classifier_config): """The cause's contract says the sentinel rides in matched_keyword, so it has to be there. Without it an operator reading the logs can see that a call was treated as housekeeping but @@ -7955,7 +8064,6 @@ class TestClientHousekeepingCalls: "Write the title in the predominant language of the session" ) - @pytest.mark.asyncio async def test_the_plan_mode_floor_raises_a_housekeeping_call_under_adaptive(self, mock_router_instance): """Floor and ceiling must not contradict each other on the same request. @@ -9054,6 +9162,7 @@ class TestTierDefinitions: ({"adaptive": True}, "severity order"), ({"session_affinity": True}, "severity order"), ({"escalation_keywords": ["GO UP"]}, "severity order"), + ({"stall_escalation_enabled": True}, "severity order"), ( {"classifier_llm_config": {"model": "haiku-classifier", "system_prompt": "grade it"}}, "system_prompt", @@ -10340,9 +10449,7 @@ class TestHeuristicFirst: # Scores 0.175 with one signal, so it sits 0.025 from simple_medium: the pair of tiers either side of # that boundary are different model pools, and a hair's difference in score picks the other one. -NEAR_BOUNDARY_PROMPT = ( - "design a distributed cache with consistent hashing, then explain the failure modes step by step" -) +NEAR_BOUNDARY_PROMPT = "design a distributed cache with consistent hashing, then explain the failure modes step by step" # Scores 0.075 with signals, the far side of any margin under 0.075: the scorer is decided here. CLEAR_OF_BOUNDARY_PROMPT = "explain step by step how consistent hashing rebalances keys" @@ -10784,6 +10891,7 @@ class TestContextWindowEscalation: litellm_router_instance=_windowed_router(_SMALL, _BIG), complexity_router_config=_tier_config(session_affinity=True), ) + def session_kwargs() -> dict[str, object]: return {"metadata": {"session_id": "s-1", "user_api_key_hash": "k-1"}} @@ -10808,6 +10916,7 @@ class TestContextWindowEscalation: litellm_router_instance=_windowed_router(_SMALL, _BIG), complexity_router_config=_tier_config(session_affinity=True), ) + def session_kwargs() -> dict[str, object]: return {"metadata": {"session_id": "s-2", "user_api_key_hash": "k-2"}} @@ -10885,7 +10994,9 @@ class TestContextWindowEscalation: copilot_resolutions: List = [] def _guarded(*args, **kwargs): - target = str(kwargs.get("model") or (args[0] if args else "")) + str(kwargs.get("custom_llm_provider") or "") + target = str(kwargs.get("model") or (args[0] if args else "")) + str( + kwargs.get("custom_llm_provider") or "" + ) if "github_copilot" in target: copilot_resolutions.append(target) raise RuntimeError("the gate must not resolve an authenticating provider") diff --git a/tests/test_litellm/router_strategy/test_stall_detector.py b/tests/test_litellm/router_strategy/test_stall_detector.py new file mode 100644 index 00000000000..8f39969a8ec --- /dev/null +++ b/tests/test_litellm/router_strategy/test_stall_detector.py @@ -0,0 +1,121 @@ +""" +Tests for mid-task stall detection: repeated identical tool calls or repeated tool +errors, read from both Anthropic Messages and chat-completions tool-call shapes. +""" + +from litellm.router_strategy.complexity_router.stall_detector import detect_stalled_task + + +def _anthropic_call(call_id: str, name: str, arguments: dict, *, is_error: bool) -> list[dict]: + return [ + {"role": "assistant", "content": [{"type": "tool_use", "id": call_id, "name": name, "input": arguments}]}, + { + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": call_id, "is_error": is_error, "content": "result"}], + }, + ] + + +def _chat_completions_call(call_id: str, name: str, arguments_json: str) -> list[dict]: + return [ + { + "role": "assistant", + "tool_calls": [ + {"id": call_id, "type": "function", "function": {"name": name, "arguments": arguments_json}} + ], + }, + {"role": "tool", "tool_call_id": call_id, "content": "result"}, + ] + + +class TestDetectStalledTask: + def test_repeated_identical_anthropic_calls_are_stalled(self): + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "pytest"}, is_error=False), + *_anthropic_call("t2", "bash", {"cmd": "pytest"}, is_error=False), + *_anthropic_call("t3", "bash", {"cmd": "pytest"}, is_error=False), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is True + + def test_repeated_errors_are_stalled_even_with_varied_arguments(self): + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "pytest tests/a.py"}, is_error=True), + *_anthropic_call("t2", "bash", {"cmd": "pytest tests/b.py"}, is_error=True), + *_anthropic_call("t3", "bash", {"cmd": "pytest tests/c.py"}, is_error=True), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is True + + def test_varied_successful_calls_are_not_stalled(self): + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "ls"}, is_error=False), + *_anthropic_call("t2", "bash", {"cmd": "pytest"}, is_error=False), + *_anthropic_call("t3", "grep", {"pattern": "x"}, is_error=False), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is False + + def test_chat_completions_repeats_are_stalled(self): + messages = [ + *_chat_completions_call("c1", "bash", '{"cmd": "pytest"}'), + *_chat_completions_call("c2", "bash", '{"cmd": "pytest"}'), + *_chat_completions_call("c3", "bash", '{"cmd": "pytest"}'), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is True + + def test_chat_completions_has_no_structured_error_signal(self): + """A chat-completions tool message carries no standard error flag, so varied calls + whose content happens to read like failures still aren't flagged on error alone.""" + messages = [ + *_chat_completions_call("c1", "bash", '{"cmd": "a"}'), + *_chat_completions_call("c2", "bash", '{"cmd": "b"}'), + *_chat_completions_call("c3", "bash", '{"cmd": "c"}'), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is False + + def test_dict_and_json_string_arguments_compare_equal_across_surfaces(self): + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "pytest"}, is_error=False), + *_chat_completions_call("c2", "bash", '{"cmd": "pytest"}'), + *_anthropic_call("t3", "bash", {"cmd": "pytest"}, is_error=False), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is True + + def test_below_repeat_threshold_is_not_stalled(self): + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "pytest"}, is_error=False), + *_anthropic_call("t2", "bash", {"cmd": "pytest"}, is_error=False), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is False + + def test_evidence_older_than_the_window_does_not_count(self): + """Only the most recent `window` tool calls are considered, so a stall the model + already recovered from does not keep re-triggering forever.""" + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "pytest"}, is_error=False), + *_anthropic_call("t2", "bash", {"cmd": "pytest"}, is_error=False), + *_anthropic_call("t3", "bash", {"cmd": "pytest"}, is_error=False), + *_anthropic_call("t4", "grep", {"pattern": "a"}, is_error=False), + *_anthropic_call("t5", "grep", {"pattern": "b"}, is_error=False), + ] + assert detect_stalled_task(messages, window=2, repeat_threshold=2) is False + + def test_evidence_survives_a_new_human_ask(self): + """A follow-up like 'try again' must not erase evidence from before it: detection + reads the whole message list, not just the turns since the newest human ask.""" + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "pytest"}, is_error=False), + *_anthropic_call("t2", "bash", {"cmd": "pytest"}, is_error=False), + *_anthropic_call("t3", "bash", {"cmd": "pytest"}, is_error=False), + {"role": "user", "content": [{"type": "text", "text": "try again"}]}, + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is True + + def test_no_messages_is_not_stalled(self): + assert detect_stalled_task(None, window=6, repeat_threshold=3) is False + assert detect_stalled_task([], window=6, repeat_threshold=3) is False + + def test_zero_threshold_never_flags_stalled(self): + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "pytest"}, is_error=True), + *_anthropic_call("t2", "bash", {"cmd": "pytest"}, is_error=True), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=0) is False From 4774a426c5b4dd9bb4e5122941661bf36c0c9fbb Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 4 Sep 2026 14:20:28 -0700 Subject: [PATCH 109/410] refactor(vector_stores): route MongoDB query embeddings through the shared executor The base vector store interface grew an embedding_executor argument, and litellm.vector_stores.search now always passes one. MongoDB still carried its own embedding_fn/aembedding_fn constructor seam, so every search through the public entry point failed with an unexpected keyword argument. Drop the local seam in favour of the shared executor: one path instead of two, and the unit tests now drive the same seam production uses. --- .../mongodb/vector_stores/transformation.py | 37 ++++----- .../test_mongodb_transformation.py | 78 ++++++++++++++----- 2 files changed, 76 insertions(+), 39 deletions(-) diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 571061d39a2..2e69e35edcf 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -11,15 +11,18 @@ where the id names the index; the database and collection it covers come from litellm_params. """ -from collections.abc import Awaitable, Callable, Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from types import MappingProxyType from typing import TYPE_CHECKING, Final, NoReturn import httpx from pydantic import BaseModel, ConfigDict -import litellm -from litellm.llms.base_llm.vector_store.transformation import BaseDirectVectorStoreConfig +from litellm.llms.base_llm.vector_store.transformation import ( + BaseDirectVectorStoreConfig, + LiteLLMVectorStoreEmbeddingExecutor, + VectorStoreEmbeddingExecutor, +) from litellm.llms.mongodb.common_utils import ( DEFAULT_CONNECT_TIMEOUT_MS, DEFAULT_SERVER_SELECTION_TIMEOUT_MS, @@ -139,17 +142,13 @@ _KNOWN_MONGODB_PARAMS: Final = frozenset( class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def __init__( self, - embedding_fn: Callable[..., EmbeddingResponse] | None = None, - aembedding_fn: Callable[..., Awaitable[EmbeddingResponse]] | None = None, + embedding_executor: VectorStoreEmbeddingExecutor | None = None, sync_client_factory: Callable[[MongoClientKey], object] | None = None, async_client_factory: Callable[[MongoClientKey], object] | None = None, ) -> None: super().__init__() - self.embedding_fn: Final[Callable[..., EmbeddingResponse]] = ( - embedding_fn if embedding_fn is not None else litellm.embedding - ) - self.aembedding_fn: Final[Callable[..., Awaitable[EmbeddingResponse]]] = ( - aembedding_fn if aembedding_fn is not None else litellm.aembedding + self.embedding_executor: Final[VectorStoreEmbeddingExecutor] = ( + embedding_executor if embedding_executor is not None else LiteLLMVectorStoreEmbeddingExecutor() ) self.sync_client_factory: Final[Callable[[MongoClientKey], object]] = ( sync_client_factory if sync_client_factory is not None else get_sync_client @@ -350,6 +349,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, litellm_logging_obj: "LiteLLMLoggingObj", litellm_params: Mapping[str, object], + embedding_executor: VectorStoreEmbeddingExecutor | None = None, timeout: float | httpx.Timeout | None = None, ) -> VectorStoreSearchResponse: self._reject_unknown_params(litellm_params) @@ -359,10 +359,10 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): database: Final = params.require_database() collection: Final = params.require_collection() - embedding_response: Final = self.embedding_fn( - model=params.require_embedding_model(), - input=[query_text], # mutable-ok: litellm.embedding's input contract is a list - **(params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG), + embedding_response: Final = (embedding_executor or self.embedding_executor).embed( + params.require_embedding_model(), + query_text, + params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG, ) pipeline: Final = self._pipeline( vector_store_id, self._embedding_vector(embedding_response), params, vector_store_search_optional_params @@ -392,6 +392,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): vector_store_search_optional_params: VectorStoreSearchOptionalRequestParams, litellm_logging_obj: "LiteLLMLoggingObj", litellm_params: Mapping[str, object], + embedding_executor: VectorStoreEmbeddingExecutor | None = None, timeout: float | httpx.Timeout | None = None, ) -> VectorStoreSearchResponse: self._reject_unknown_params(litellm_params) @@ -401,10 +402,10 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): database: Final = params.require_database() collection: Final = params.require_collection() - embedding_response: Final = await self.aembedding_fn( - model=params.require_embedding_model(), - input=[query_text], # mutable-ok: litellm.embedding's input contract is a list - **(params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG), + embedding_response: Final = await (embedding_executor or self.embedding_executor).aembed( + params.require_embedding_model(), + query_text, + params.litellm_embedding_config or _EMPTY_EMBEDDING_CONFIG, ) pipeline: Final = self._pipeline( vector_store_id, self._embedding_vector(embedding_response), params, vector_store_search_optional_params diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 7d71ff5c213..7a2df28cc04 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -111,27 +111,27 @@ class FakeClient: return self.database -class FakeEmbeddingFn: +class FakeEmbeddingExecutor: def __init__(self, embedding): self.embedding = embedding - self.captured_kwargs = None + self.captured = None - def __call__(self, **kwargs): - self.captured_kwargs = kwargs + def _respond(self, model, query, configuration): + self.captured = SimpleNamespace(model=model, query=query, configuration=configuration) return SimpleNamespace(data=[{"embedding": self.embedding}] if self.embedding is not None else []) + def embed(self, model, query, configuration): + return self._respond(model, query, configuration) -class FakeAsyncEmbeddingFn(FakeEmbeddingFn): - async def __call__(self, **kwargs): - self.captured_kwargs = kwargs - return SimpleNamespace(data=[{"embedding": self.embedding}] if self.embedding is not None else []) + async def aembed(self, model, query, configuration): + return self._respond(model, query, configuration) def _config(documents=(), embedding=(0.1, 0.2, 0.3), error=None, search_indexes=None): collection = FakeCollection(list(documents), error, search_indexes) client = FakeClient(collection) config = MongoDBVectorStoreConfig( - embedding_fn=FakeEmbeddingFn(list(embedding) if embedding is not None else None), + embedding_executor=FakeEmbeddingExecutor(list(embedding) if embedding is not None else None), sync_client_factory=lambda key: client, ) return config, client, collection @@ -141,7 +141,7 @@ def _async_config(documents=(), embedding=(0.1, 0.2, 0.3), error=None, search_in collection = FakeAsyncCollection(list(documents), error, search_indexes) client = FakeClient(collection) config = MongoDBVectorStoreConfig( - aembedding_fn=FakeAsyncEmbeddingFn(list(embedding) if embedding is not None else None), + embedding_executor=FakeEmbeddingExecutor(list(embedding) if embedding is not None else None), async_client_factory=lambda key: client, ) return config, client, collection @@ -252,22 +252,20 @@ def test_num_candidates_below_the_limit_or_above_the_ceiling_is_rejected(configu def test_list_query_is_joined_into_one_embedding_input(): config, _, _ = _config() - embedding_fn = config.embedding_fn _search(config, query=["deep", "space", "rescue"]) - assert embedding_fn.captured_kwargs["input"] == ["deep space rescue"] + assert config.embedding_executor.captured.query == "deep space rescue" def test_embedding_config_is_expanded_into_the_embedding_call(): config, _, _ = _config() - embedding_fn = config.embedding_fn _search(config, litellm_params={"litellm_embedding_config": {"api_base": "https://example.test", "timeout": 7}}) - assert embedding_fn.captured_kwargs["api_base"] == "https://example.test" - assert embedding_fn.captured_kwargs["timeout"] == 7 - assert embedding_fn.captured_kwargs["model"] == "openai/text-embedding-ada-002" + captured = config.embedding_executor.captured + assert captured.configuration == {"api_base": "https://example.test", "timeout": 7} + assert captured.model == "openai/text-embedding-ada-002" def test_response_maps_documents_to_openai_shaped_results(): @@ -530,7 +528,7 @@ def test_search_fails_when_the_embedding_model_returns_nothing(): def test_validation_runs_before_any_connection_is_opened(): opened = [] config = MongoDBVectorStoreConfig( - embedding_fn=FakeEmbeddingFn([0.1]), + embedding_executor=FakeEmbeddingExecutor([0.1]), sync_client_factory=lambda key: opened.append(key) or FakeClient(FakeCollection([])), ) @@ -973,7 +971,7 @@ class TestEmptyResultsAreDisambiguated: collection = ExplodingCollection([], None, []) config = MongoDBVectorStoreConfig( - embedding_fn=FakeEmbeddingFn([0.1]), + embedding_executor=FakeEmbeddingExecutor([0.1]), sync_client_factory=lambda key: FakeClient(collection), ) @@ -1157,7 +1155,7 @@ class TestClientConstructionFailures: raise error return MongoDBVectorStoreConfig( - embedding_fn=FakeEmbeddingFn([0.1, 0.2, 0.3]), sync_client_factory=factory + embedding_executor=FakeEmbeddingExecutor([0.1, 0.2, 0.3]), sync_client_factory=factory ) def _async_config_that_fails_to_connect(self, error): @@ -1165,7 +1163,7 @@ class TestClientConstructionFailures: raise error return MongoDBVectorStoreConfig( - aembedding_fn=FakeAsyncEmbeddingFn([0.1, 0.2, 0.3]), async_client_factory=factory + embedding_executor=FakeEmbeddingExecutor([0.1, 0.2, 0.3]), async_client_factory=factory ) def test_a_malformed_uri_is_a_bad_request_not_a_500(self): @@ -1215,7 +1213,7 @@ class TestSelfManagedDeploymentsAreFirstClass: raise error return MongoDBVectorStoreConfig( - embedding_fn=FakeEmbeddingFn([0.1, 0.2, 0.3]), sync_client_factory=factory + embedding_executor=FakeEmbeddingExecutor([0.1, 0.2, 0.3]), sync_client_factory=factory ) def test_a_plain_mongodb_uri_without_srv_or_credentials_is_accepted(self): @@ -1405,3 +1403,41 @@ class TestUnreadableTlsFilesAreDiagnosed: translated = translate_mongo_error(OSError("socket hung up"), index_name=INDEX, database="db", collection="c") assert not isinstance(translated, BadRequestError) + + +class TestTheCallerSuppliedEmbeddingExecutorIsUsed: + """litellm.vector_stores.search always hands a direct provider an embedding_executor, so the + provider has to accept it and route the query through it rather than its own default.""" + + def test_the_supplied_executor_produces_the_query_vector(self): + config, _, collection = _config(embedding=(0.9, 0.9, 0.9), search_indexes=READY_INDEX) + caller = FakeEmbeddingExecutor([0.4, 0.5, 0.6]) + + config.execute_search_vector_store_request( + vector_store_id=INDEX, + query="a lone astronaut", + vector_store_search_optional_params={}, + litellm_logging_obj=MagicMock(), + litellm_params=BASE_PARAMS, + embedding_executor=caller, + ) + + assert caller.captured.query == "a lone astronaut" + assert _stage(collection, "$vectorSearch")["queryVector"] == (0.4, 0.5, 0.6) + + @pytest.mark.asyncio + async def test_the_supplied_executor_produces_the_query_vector_on_the_async_path(self): + config, _, collection = _async_config(embedding=(0.9, 0.9, 0.9), search_indexes=READY_INDEX) + caller = FakeEmbeddingExecutor([0.4, 0.5, 0.6]) + + await config.aexecute_search_vector_store_request( + vector_store_id=INDEX, + query="a lone astronaut", + vector_store_search_optional_params={}, + litellm_logging_obj=MagicMock(), + litellm_params=BASE_PARAMS, + embedding_executor=caller, + ) + + assert caller.captured.query == "a lone astronaut" + assert _stage(collection, "$vectorSearch")["queryVector"] == (0.4, 0.5, 0.6) From da58c0c6d5ecd34ff2af2398271034e14eb3fe06 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 4 Sep 2026 15:08:37 -0700 Subject: [PATCH 110/410] fix(vector_stores): keep a lost MongoDB connection retryable and bound the client cache by use litellm only retries 408, 409, 429 and 5xx, so classifying a dropped connection as a 400 turned one replica set failover into a permanently failed search. It is a 503 now, with the message still naming the misconfigurations that also close a connection. The client cache skipped insertion once it held 32 entries, so any store added after that rebuilt its client on every search, paying an SRV lookup, a TLS handshake and topology discovery each time. It evicts the least recently used entry instead, which only drops the cache's own reference. Also trims the explanatory comments to the one-line form the repo asks for. --- litellm/llms/mongodb/common_utils.py | 89 ++++++++++--------- .../mongodb/vector_stores/transformation.py | 32 ++----- .../test_mongodb_transformation.py | 87 +++++++++++++++--- 3 files changed, 133 insertions(+), 75 deletions(-) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 4e37e21948b..27a2a96bd1f 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -1,22 +1,16 @@ -"""Shared helpers for MongoDB integrations. - -pymongo ships in the optional ``mongodb`` extra, so every import of it is -deferred to call time and raises an actionable error when it is absent. - -Clients are cached per connection because building one costs an SRV lookup, a -TLS handshake and topology discovery: measured at ~890ms against a remote deployment versus -~80ms on a warm client, so a client per search would dominate query latency. -""" +"""Shared helpers for the MongoDB integrations. pymongo lives in the optional ``mongodb`` extra, +so every import of it is deferred to call time.""" import asyncio import weakref from asyncio import AbstractEventLoop +from collections import OrderedDict from collections.abc import Callable, Mapping from dataclasses import dataclass from types import MappingProxyType -from typing import TYPE_CHECKING, Final, TypeAlias +from typing import TYPE_CHECKING, Final, TypeAlias, TypeVar -from litellm.exceptions import BadRequestError, Timeout +from litellm.exceptions import BadRequestError, ServiceUnavailableError, Timeout if TYPE_CHECKING: from pymongo import AsyncMongoClient, MongoClient @@ -30,8 +24,7 @@ MONGODB_PROVIDER: Final = "mongodb" def config_error(message: str) -> BadRequestError: - """Misconfiguration is the caller's to fix, so it maps to 400 rather than the 500 - a bare ValueError would become once litellm.exception_type wraps it.""" + """400 rather than the 500 a bare ValueError becomes once litellm.exception_type wraps it.""" return BadRequestError(message=message, model=None, llm_provider=MONGODB_PROVIDER) @@ -39,6 +32,11 @@ def timeout_error(message: str) -> Timeout: return Timeout(message=message, model=None, llm_provider=MONGODB_PROVIDER) +def unavailable_error(message: str) -> ServiceUnavailableError: + """litellm only retries 408, 409, 429 and 5xx, so a 400 here would make a failover permanent.""" + return ServiceUnavailableError(message=message, model=None, llm_provider=MONGODB_PROVIDER) + + DEFAULT_CONNECT_TIMEOUT_MS: Final = 10_000 DEFAULT_SOCKET_TIMEOUT_MS: Final = 30_000 DEFAULT_SERVER_SELECTION_TIMEOUT_MS: Final = 10_000 @@ -59,12 +57,26 @@ class MongoClientKey: SyncClientFactory: TypeAlias = Callable[..., "MongoClient"] AsyncClientFactory: TypeAlias = Callable[..., "AsyncMongoClient"] +_K = TypeVar("_K") +_V = TypeVar("_V") + _AsyncClientCacheKey: TypeAlias = tuple[MongoClientKey, int] # CPython recycles id() aggressively, so the id alone would hand a new loop a closed loop's client _AsyncClientEntry: TypeAlias = tuple["weakref.ref[AbstractEventLoop]", "AsyncMongoClient"] -_sync_clients: Final[dict[MongoClientKey, "MongoClient"]] = {} # mutable-ok: process-level client cache -_async_clients: Final[dict[_AsyncClientCacheKey, _AsyncClientEntry]] = {} # mutable-ok: same cache, per loop +_SyncClientCache: TypeAlias = "OrderedDict[MongoClientKey, MongoClient]" +_AsyncClientCache: TypeAlias = "OrderedDict[_AsyncClientCacheKey, _AsyncClientEntry]" + +_sync_clients: Final[_SyncClientCache] = OrderedDict() # mutable-ok: process-level client cache +_async_clients: Final[_AsyncClientCache] = OrderedDict() # mutable-ok: same cache, per loop + + +def _store_bounded(cache: "OrderedDict[_K, _V]", cache_key: "_K", value: "_V") -> None: + """Eviction only drops this cache's reference; an in-flight search keeps its client alive.""" + cache[cache_key] = value # mutable-ok: an LRU cache is mutable state by definition + cache.move_to_end(cache_key) + while len(cache) > _MAX_CACHED_CLIENTS: + cache.popitem(last=False) def import_sync_mongo_client() -> "type[MongoClient]": @@ -95,22 +107,19 @@ def _client_kwargs(key: MongoClientKey) -> Mapping[str, object]: def get_sync_client(key: MongoClientKey, client_class: SyncClientFactory | None = None) -> "MongoClient": - """``client_class`` is the injection seam the tests build fake clients through; left unset the - real pymongo class is imported at call time, keeping pymongo out of import-time dependencies.""" cached: Final = _sync_clients.get(key) if cached is not None: + _sync_clients.move_to_end(key) return cached build: Final = client_class if client_class is not None else import_sync_mongo_client() client: Final = build(key.connection_string, **_client_kwargs(key)) - if len(_sync_clients) < _MAX_CACHED_CLIENTS: - _sync_clients[key] = client + _store_bounded(_sync_clients, key, client) return client def _purge_dead_loops() -> None: - """The cached client holds its loop object alive, so a closed loop's entry would otherwise pin - that client and its sockets for the life of the process. Callers that run one loop per search - (``asyncio.run`` in a script) reach the cap this way and never release what is behind it.""" + """A cached client holds its loop alive, so a closed loop's entry would pin that client and its + sockets for the life of the process.""" for stale in tuple( cache_key for cache_key, (loop_ref, _) in _async_clients.items() @@ -125,12 +134,12 @@ def get_async_client(key: MongoClientKey, client_class: AsyncClientFactory | Non loop_key: Final = (key, id(loop)) cached: Final = _async_clients.get(loop_key) if cached is not None and cached[0]() is loop: + _async_clients.move_to_end(loop_key) return cached[1] _purge_dead_loops() build: Final = client_class if client_class is not None else import_async_mongo_client() client: Final = build(key.connection_string, **_client_kwargs(key)) - if len(_async_clients) < _MAX_CACHED_CLIENTS or loop_key in _async_clients: - _async_clients[loop_key] = (weakref.ref(loop), client) + _store_bounded(_async_clients, loop_key, (weakref.ref(loop), client)) return client @@ -157,9 +166,8 @@ def _index_hint(index_name: str, database: str, collection: str) -> str: def missing_index_error(index_name: str, database: str, collection: str) -> BadRequestError: - """$vectorSearch against a missing index, database or collection returns zero documents - instead of failing, so an empty result set is checked against the index catalogue and - turned into this rather than being reported as 'no matches'.""" + """$vectorSearch against a missing index, database or collection returns zero documents rather + than failing, so an empty result set is checked against the catalogue and reported as this.""" return config_error( f"{_index_hint(index_name, database, collection)} A vector search against a database, " "collection or index that does not exist returns no results rather than an error, so this " @@ -175,10 +183,7 @@ def index_not_ready_error(index_name: str, database: str, collection: str, statu def translate_mongo_error(error: Exception, index_name: str, database: str, collection: str) -> Exception: - """Turn a driver failure into a message that names the misconfiguration, never a silent empty result. - - Returns the exception to raise so callers keep the original as ``__cause__``. - """ + """Returns the exception to raise, so callers keep the driver error as ``__cause__``.""" try: from pymongo.errors import ( ConfigurationError, @@ -205,14 +210,16 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll f"The MongoDB vector search against '{database}.{collection}' timed out before returning. " f"Driver detail: {error}" ) - # ServerSelectionTimeoutError and NetworkTimeout both sit under ConnectionFailure, so this - # only sees what those two branches left: a dropped or refused connection + # ServerSelectionTimeoutError and NetworkTimeout also subclass ConnectionFailure, so this only + # sees what those branches left if isinstance(error, ConnectionFailure): - return config_error( - f"The connection to '{database}.{collection}' was refused or dropped. On Atlas this is " - "usually a connection string with no username and password, or a TLS failure, so confirm " - "the URI is the one Atlas shows under Connect, Drivers. On a self-managed deployment, check " - f"that mongod is listening on the host and port in the URI. Driver detail: {error}" + return unavailable_error( + f"The connection to '{database}.{collection}' was dropped or refused. That is usually a " + "replica set failover or a restarted node, so the search is worth retrying. If it keeps " + "happening: on Atlas the usual cause is a connection string with no username and password, " + "or a TLS failure, so confirm the URI is the one Atlas shows under Connect, Drivers; on a " + "self-managed deployment, check that mongod is listening on the host and port in the URI. " + f"Driver detail: {error}" ) if isinstance(error, OperationFailure): code: Final = error.code @@ -267,16 +274,14 @@ def translate_mongo_error(error: Exception, index_name: str, database: str, coll ) if isinstance(error, InvalidOperation): return config_error(f"The MongoDB client was already closed or is unusable. Driver detail: {error}") - # A tlsCAFile or tlsCertificateKeyFile the process cannot open raises OSError from the TLS setup - # rather than a PyMongoError, and those options are how self-managed deployments present a private CA + # An unreadable tlsCAFile or tlsCertificateKeyFile raises OSError, not a PyMongoError if isinstance(error, OSError) and error.filename: return config_error( f"'{error.filename}', named by a TLS option in mongodb_connection_string, could not be read. " "Check that tlsCAFile and tlsCertificateKeyFile point at files this process can open; inside " f"a container that is the path in the container, not on the host. Driver detail: {error}" ) - # pymongo raises a plain ValueError, not a PyMongoError, for an unusable port, which an unescaped - # ':' in a password also produces, and which would otherwise reach the caller as a 500 + # pymongo raises a plain ValueError, not a PyMongoError, for an unusable port if isinstance(error, ValueError): return config_error( "The host and port in mongodb_connection_string could not be parsed. If the port is a " diff --git a/litellm/llms/mongodb/vector_stores/transformation.py b/litellm/llms/mongodb/vector_stores/transformation.py index 2e69e35edcf..3382c931c96 100644 --- a/litellm/llms/mongodb/vector_stores/transformation.py +++ b/litellm/llms/mongodb/vector_stores/transformation.py @@ -1,15 +1,5 @@ -"""MongoDB vector store provider, for Atlas and self-managed deployments alike. - -MongoDB Vector Search has no HTTP query API (the Data API and HTTPS Endpoints are -end-of-life), so this config extends BaseDirectVectorStoreConfig and runs the -``$vectorSearch`` aggregation itself through pymongo instead of shaping an httpx -request. mongod serves that stage identically whether mongot runs under Atlas or -beside a self-managed deployment, so one code path covers both. - -``vector_store_id`` is the search index name, matching the Valkey provider -where the id names the index; the database and collection it covers come from -litellm_params. -""" +"""MongoDB Vector Search has no HTTP query API, so this is a direct provider that runs the +``$vectorSearch`` aggregation through pymongo. ``vector_store_id`` is the search index name.""" from collections.abc import Callable, Mapping, Sequence from types import MappingProxyType @@ -159,9 +149,8 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): @staticmethod def _reject_unknown_params(litellm_params: Mapping[str, object]) -> None: - """The params model ignores unrelated keys because litellm_params carries plenty of them, - which would otherwise turn a mistyped mongodb_collection into 'mongodb_collection is - required' pointing at a key the reader can see they have set.""" + """Without this a mistyped mongodb_collection reads as 'mongodb_collection is required', + naming a key the reader can see they have set.""" unknown: Final = sorted( key for key in litellm_params if key.startswith(_MONGODB_PARAM_PREFIX) and key not in _KNOWN_MONGODB_PARAMS ) @@ -268,8 +257,7 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): @classmethod def _field_value(cls, document: Mapping[str, object], dotted_path: str) -> str | None: - """None means the path is absent from the document, which is what separates a - mistyped mongodb_text_field from a document whose text is genuinely empty.""" + """None means absent, which is what separates a mistyped field from genuinely empty text.""" head, _, rest = dotted_path.partition(".") if head not in document: return None @@ -297,9 +285,8 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def _raise_for_missing_text_field( cls, documents: Sequence[Mapping[str, object]], text_field: str, database: str, collection: str ) -> None: - """$vectorSearch happily matches documents that carry no text at all, so a mistyped - mongodb_text_field returns well-scored results whose content is empty and feeds an empty - context to the model. Every matched document lacking the field is the misconfiguration.""" + """$vectorSearch matches documents carrying no text, so a mistyped mongodb_text_field + returns well-scored results with empty content instead of failing.""" if documents and all(cls._field_value(document, text_field) is None for document in documents): raise config_error( f"None of the {len(documents)} matched documents in '{database}.{collection}' has a " @@ -323,9 +310,8 @@ class MongoDBVectorStoreConfig(BaseDirectVectorStoreConfig): def _raise_for_unusable_index( catalogue: Sequence[Mapping[str, object]], index_name: str, database: str, collection: str ) -> None: - """An empty result set is ambiguous: mongod returns zero documents both for a query that - genuinely matched nothing and for a missing database, collection or index. Only the second - is a misconfiguration, so the index catalogue decides which one happened.""" + """mongod returns zero documents both for a query that matched nothing and for a missing + database, collection or index, so the catalogue decides which one happened.""" if not catalogue: raise missing_index_error(index_name, database, collection) entry: Final = catalogue[0] diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index 7a2df28cc04..faf20f87ae5 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -8,10 +8,12 @@ from unittest.mock import MagicMock, patch import httpx import pytest -from litellm.exceptions import BadRequestError, Timeout +import litellm +from litellm.exceptions import BadRequestError, ServiceUnavailableError, Timeout from litellm.llms.mongodb.common_utils import ( _MAX_CACHED_CLIENTS, _async_clients, + _sync_clients, MongoClientKey, index_not_ready_error, missing_index_error, @@ -643,6 +645,37 @@ class TestClientCache: assert first.connection_string == CONNECTION_STRING + def _fill_cache(self): + for slot in range(_MAX_CACHED_CLIENTS): + get_sync_client(self._key(f"mongodb://cold-{slot}:27017"), RecordingClient) + + def test_a_store_added_after_the_cache_filled_is_still_cached(self): + """Rebuilding a client costs an SRV lookup, a TLS handshake and topology discovery, so a + store that misses the cache on every single search pays that on every search.""" + self._fill_cache() + latecomer = self._key("mongodb://latecomer:27017") + + first = get_sync_client(latecomer, RecordingClient) + + assert get_sync_client(latecomer, RecordingClient) is first + + def test_the_cache_evicts_the_least_recently_used_client(self): + self._fill_cache() + oldest = self._key("mongodb://cold-0:27017") + newest = self._key(f"mongodb://cold-{_MAX_CACHED_CLIENTS - 1}:27017") + kept = get_sync_client(newest, RecordingClient) + + get_sync_client(self._key("mongodb://latecomer:27017"), RecordingClient) + + assert get_sync_client(newest, RecordingClient) is kept + assert oldest not in _sync_clients + + def test_the_cache_never_grows_past_its_cap(self): + for slot in range(_MAX_CACHED_CLIENTS * 3): + get_sync_client(self._key(f"mongodb://host-{slot}:27017"), RecordingClient) + + assert len(_sync_clients) == _MAX_CACHED_CLIENTS + def test_a_new_loop_never_inherits_a_closed_loop_client(self): """CPython recycles id() so aggressively that a fresh event loop almost always lands on the id of one already collected: measured at 37 of 40 rounds. Keying the cache on the id @@ -757,17 +790,51 @@ class TestErrorTranslation: assert "rejected the credentials" in str(translated) - def test_a_dropped_connection_is_a_400_not_an_unhandled_driver_error(self): - """AutoReconnect sits under ConnectionFailure alongside the two timeout classes, and Atlas - answers a URI with no credentials by closing the connection rather than failing auth. Left - untranslated it is not a litellm exception type, so it reaches the caller as a 500.""" + def test_a_dropped_connection_stays_retryable(self): + """A replica set failover reaches the driver as AutoReconnect. litellm only retries 408, + 409, 429 and 5xx, so classifying it as a client error would turn one failover into a + permanently failed search.""" + from pymongo.errors import AutoReconnect + + translated = self._translate(AutoReconnect("connection closed")) + + assert litellm._should_retry(translated.status_code) + assert "dropped or refused" in str(translated) + + def test_a_dropped_connection_still_names_the_misconfigurations_behind_it(self): + """Atlas answers a URI with no credentials by closing the connection rather than failing + auth, so the retryable message still has to name that.""" from pymongo.errors import AutoReconnect translated = self._translate(AutoReconnect("connection closed")) - assert isinstance(translated, BadRequestError) - assert "refused or dropped" in str(translated) assert "no username and password" in str(translated) + assert "mongod is listening" in str(translated) + + def test_the_retryable_classification_survives_the_public_sdk_error_wrapper(self): + """litellm.exception_type only passes its own exception types through; anything else becomes + an APIConnectionError and a 500, which would drop the retryable classification.""" + from pymongo.errors import AutoReconnect + + translated = self._translate(AutoReconnect("connection closed")) + + wrapped = litellm.exception_type( + model=None, + original_exception=translated, + custom_llm_provider="mongodb", + completion_kwargs={}, + extra_kwargs={}, + ) + + assert isinstance(wrapped, ServiceUnavailableError) + assert litellm._should_retry(wrapped.status_code) + + def test_a_pool_wait_queue_timeout_stays_retryable(self): + from pymongo.errors import WaitQueueTimeoutError + + translated = self._translate(WaitQueueTimeoutError("timed out waiting for a connection")) + + assert litellm._should_retry(translated.status_code) def test_server_selection_timeout_still_wins_over_the_connection_branch(self): from pymongo.errors import ServerSelectionTimeoutError @@ -775,7 +842,7 @@ class TestErrorTranslation: translated = self._translate(ServerSelectionTimeoutError("no servers")) assert isinstance(translated, Timeout) - assert "refused or dropped" not in str(translated) + assert "dropped or refused" not in str(translated) def test_network_timeout_still_wins_over_the_connection_branch(self): from pymongo.errors import NetworkTimeout @@ -783,7 +850,7 @@ class TestErrorTranslation: translated = self._translate(NetworkTimeout("socket timed out")) assert isinstance(translated, Timeout) - assert "refused or dropped" not in str(translated) + assert "dropped or refused" not in str(translated) def test_an_unescaped_password_character_is_a_400_not_a_500(self): """pymongo's URI parser raises a plain ValueError, not a PyMongoError, for an unusable port, @@ -1239,7 +1306,7 @@ class TestSelfManagedDeploymentsAreFirstClass: config = self._config_that_fails_to_connect(ConnectionFailure("connection closed")) - with pytest.raises(BadRequestError) as excinfo: + with pytest.raises(ServiceUnavailableError) as excinfo: _search(config) assert "self-managed" in str(excinfo.value) From 98a0cf306f213f511744502b22ed3f3a2a00d5bc Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 15:17:00 -0700 Subject: [PATCH 111/410] fix(shadow_eval): size the judge output cap for a judge that reasons The cap covers reasoning tokens as well as the verdict, and the models people pick as judges reason before answering whether the call asks them to or not: Anthropic's 5 family thinks adaptively and cannot be told not to, so the reasoning bills against max_tokens with nothing in the request to opt out. At 1500 the reasoning consumed the budget and the reply arrived empty or cut off mid-object, which the attempt recorded as an unparseable judge verdict rather than a result. Headroom costs nothing: max_tokens is a ceiling and only generated tokens bill, so the only movement is that judge calls which used to bill their full budget and return nothing now return a verdict. Deliberately not passing reasoning_effort to bound the reasoning instead: is_thinking_enabled treats any reasoning_effort as thinking-enabled, which drops the forced tool_choice that json_mode relies on and turns thinking on with a 1024-token floor for judges that were not reasoning at all. --- litellm/integrations/shadow_eval_logger.py | 10 ++-- .../integrations/test_shadow_eval_logger.py | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index 27da785331a..a1716c0954d 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -60,9 +60,13 @@ _MAX_CONCURRENT_SHADOW_TASKS: Final = 16 _MAX_JUDGE_RESPONSE_CHARS: Final = 8_000 _MAX_JUDGE_PROMPT_CHARS: Final = 24_000 -# The judge answers with a small JSON object; a tighter budget truncates the JSON -# mid-object and the attempt is lost to an error row. -JUDGE_MAX_OUTPUT_TOKENS: Final = 1500 +# The judge answers with a small JSON object, but the cap covers reasoning tokens too, +# and the models people pick as judges reason before answering whether or not the call +# asks them to (Anthropic's 5 family thinks adaptively and cannot be told not to). A +# budget sized for the JSON alone is spent on invisible reasoning instead, and the reply +# arrives empty or truncated mid-object, which the attempt records as an unparseable +# verdict. Headroom is free: max_tokens is a ceiling, and only generated tokens bill. +JUDGE_MAX_OUTPUT_TOKENS: Final = 4096 _MAX_ERROR_CHARS: Final = 500 diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 5628d69de26..877677505d6 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -120,6 +120,27 @@ def _router( return router +def _reasoning_judge_router(reasoning_tokens, verdict='{"preference": "A", "confidence": 0.9}'): + """A router whose judge arm reasons before it answers, the way Anthropic's 5 family + does whether or not the call asks it to. Reasoning is billed against the caller's own + max_tokens and the reply is cut off at that cap, so a cap that does not clear the + reasoning budget yields a truncated verdict or no verdict at all. One character stands + in for one token, which is what makes the cap the thing under test.""" + router = MagicMock() + router.model_group_alias = {} + router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) + + async def acompletion(**kwargs): + if kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == SHADOW_EVAL_ROUTER_CALL_ORIGIN: + kwargs["metadata"]["routing_decision"] = {"tier_label": "SIMPLE", "routed_model": "cheap-model"} + return {"choices": [{"message": {"content": "shadow answer"}}]} + budget_for_the_answer = kwargs["max_tokens"] - reasoning_tokens + return {"choices": [{"message": {"content": verdict[: max(0, budget_for_the_answer)]}}]} + + router.acompletion = MagicMock(side_effect=acompletion) + return router + + def _spend_counter(store=None): """In-memory stand-in for the proxy's cross-pod spend counter: reads take the max of the counter and the caller's fallback, exactly like get_current_spend does for a key @@ -1134,6 +1155,34 @@ class TestShadowPipeline: assert row["shadow_cost"] == 0.007 assert logger._test_counter["spend:shadow_eval:job-1"] == 0.007 + async def test_the_judge_output_cap_leaves_room_for_a_reasoning_judge(self): + """The output cap covers reasoning tokens as well as the answer, and the models + people pick as judges reason before answering whether or not the call asks them to. + A cap sized for the verdict JSON alone is spent on reasoning instead and the reply + arrives empty, which the attempt records as an unparseable verdict rather than a + result. The judge here burns a reasoning budget typical of a thinking model on a + comparison task, so the cap has to clear it for the verdict to survive.""" + reasoning_tokens = 2000 + logger = _logger(router=_reasoning_judge_router(reasoning_tokens), prisma=(prisma := _prisma())) + + await logger._run_shadow_eval( + job=_job(), + request_id="req-1", + messages=({"role": "user", "content": "hi"},), + real_text="real answer", + real_model="claude-opus", + real_cost=0.0, + real_classifier_cost=0.0, + real_cache_hit=False, + control_tier=None, + shadow_params={}, + parent_metadata={}, + ) + + row = prisma.db.litellm_shadowevalattempt.create.call_args.kwargs["data"] + assert row["outcome"] in ("real", "shadow", "tie"), row["error"] + assert row["error"] is None + async def test_a_pipeline_error_after_the_shadow_call_keeps_its_billed_cost(self, monkeypatch: pytest.MonkeyPatch): """An unexpected error between the billed shadow call and the attempt write must still record the shadow cost, or the per-key dollar gate undercounts forever.""" From 2a11c2747f58f24a1c9f1babc30027afa9ec2a8a Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 4 Sep 2026 15:25:38 -0700 Subject: [PATCH 112/410] fix(vector_stores): serialize the MongoDB client cache so concurrent searches cannot trip over an eviction Async searches reach the sync client through executor threads, so the LRU cache is shared state. A key could be evicted between the lookup and the reordering that followed it, and the reordering then raised KeyError and became a 500. Reproduced at 15 failures per run with 16 threads over 34 keys and a 1ns switch interval; the regression test is that workload. --- litellm/llms/mongodb/common_utils.py | 40 ++++++++++++------- .../test_mongodb_transformation.py | 27 +++++++++++++ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/litellm/llms/mongodb/common_utils.py b/litellm/llms/mongodb/common_utils.py index 27a2a96bd1f..02c0b359407 100644 --- a/litellm/llms/mongodb/common_utils.py +++ b/litellm/llms/mongodb/common_utils.py @@ -2,6 +2,7 @@ so every import of it is deferred to call time.""" import asyncio +import threading import weakref from asyncio import AbstractEventLoop from collections import OrderedDict @@ -69,14 +70,23 @@ _AsyncClientCache: TypeAlias = "OrderedDict[_AsyncClientCacheKey, _AsyncClientEn _sync_clients: Final[_SyncClientCache] = OrderedDict() # mutable-ok: process-level client cache _async_clients: Final[_AsyncClientCache] = OrderedDict() # mutable-ok: same cache, per loop +# async searches reach the sync client through executor threads, so both caches are shared state +_cache_lock: Final = threading.Lock() def _store_bounded(cache: "OrderedDict[_K, _V]", cache_key: "_K", value: "_V") -> None: """Eviction only drops this cache's reference; an in-flight search keeps its client alive.""" - cache[cache_key] = value # mutable-ok: an LRU cache is mutable state by definition - cache.move_to_end(cache_key) - while len(cache) > _MAX_CACHED_CLIENTS: - cache.popitem(last=False) + with _cache_lock: + cache[cache_key] = value # mutable-ok: an LRU cache is mutable state by definition + cache.move_to_end(cache_key) + while len(cache) > _MAX_CACHED_CLIENTS: + cache.popitem(last=False) + + +def _mark_used(cache: "OrderedDict[_K, _V]", cache_key: "_K") -> None: + with _cache_lock: + if cache_key in cache: + cache.move_to_end(cache_key) def import_sync_mongo_client() -> "type[MongoClient]": @@ -109,7 +119,7 @@ def _client_kwargs(key: MongoClientKey) -> Mapping[str, object]: def get_sync_client(key: MongoClientKey, client_class: SyncClientFactory | None = None) -> "MongoClient": cached: Final = _sync_clients.get(key) if cached is not None: - _sync_clients.move_to_end(key) + _mark_used(_sync_clients, key) return cached build: Final = client_class if client_class is not None else import_sync_mongo_client() client: Final = build(key.connection_string, **_client_kwargs(key)) @@ -120,12 +130,13 @@ def get_sync_client(key: MongoClientKey, client_class: SyncClientFactory | None def _purge_dead_loops() -> None: """A cached client holds its loop alive, so a closed loop's entry would pin that client and its sockets for the life of the process.""" - for stale in tuple( - cache_key - for cache_key, (loop_ref, _) in _async_clients.items() - if (cached_loop := loop_ref()) is None or cached_loop.is_closed() - ): - del _async_clients[stale] + with _cache_lock: + for stale in tuple( + cache_key + for cache_key, (loop_ref, _) in _async_clients.items() + if (cached_loop := loop_ref()) is None or cached_loop.is_closed() + ): + del _async_clients[stale] def get_async_client(key: MongoClientKey, client_class: AsyncClientFactory | None = None) -> "AsyncMongoClient": @@ -134,7 +145,7 @@ def get_async_client(key: MongoClientKey, client_class: AsyncClientFactory | Non loop_key: Final = (key, id(loop)) cached: Final = _async_clients.get(loop_key) if cached is not None and cached[0]() is loop: - _async_clients.move_to_end(loop_key) + _mark_used(_async_clients, loop_key) return cached[1] _purge_dead_loops() build: Final = client_class if client_class is not None else import_async_mongo_client() @@ -144,8 +155,9 @@ def get_async_client(key: MongoClientKey, client_class: AsyncClientFactory | Non def reset_client_cache() -> None: - _sync_clients.clear() - _async_clients.clear() + with _cache_lock: + _sync_clients.clear() + _async_clients.clear() _AUTHENTICATION_FAILED_CODE: Final = 18 diff --git a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py index faf20f87ae5..f5d31c0da54 100644 --- a/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py +++ b/tests/test_litellm/llms/mongodb/vector_stores/test_mongodb_transformation.py @@ -1,6 +1,7 @@ import asyncio import gc import sys +import threading import weakref from types import SimpleNamespace from unittest.mock import MagicMock, patch @@ -670,6 +671,32 @@ class TestClientCache: assert get_sync_client(newest, RecordingClient) is kept assert oldest not in _sync_clients + def test_concurrent_searches_never_trip_over_an_eviction(self): + """Async searches run the sync client through executor threads, so a key can be evicted + between the lookup and the reordering that follows it.""" + errors = [] + churn = _MAX_CACHED_CLIENTS + 2 + + def hammer(offset): + try: + for step in range(3_000): + get_sync_client(self._key(f"mongodb://h-{(step + offset) % churn}:27017"), RecordingClient) + except Exception as e: + errors.append(repr(e)) + + previous = sys.getswitchinterval() + sys.setswitchinterval(1e-9) + try: + threads = [threading.Thread(target=hammer, args=(offset,)) for offset in range(16)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + finally: + sys.setswitchinterval(previous) + + assert errors == [] + def test_the_cache_never_grows_past_its_cap(self): for slot in range(_MAX_CACHED_CLIENTS * 3): get_sync_client(self._key(f"mongodb://host-{slot}:27017"), RecordingClient) From 01b55daee971d3ea3865d223ea28daf70c0bb3ec Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 15:31:54 -0700 Subject: [PATCH 113/410] feat(ui): add stalled task escalation controls to the auto-router form Adds an "Advanced: Stalled Task Escalation" section to the complexity router config: a toggle plus the repeat threshold and the window of recent tool calls to examine. Both knobs are seeded on enable and cleared on disable, so an off router sends none of the three keys, which is what the backend requires next to session pinning and a custom tier set. The toggle locks out with an explanation when "How often to classify" is set to once-per-session or new-user-message, since both replay a held routing decision instead of classifying and a stall would never reach the classifier. The keys join the custom-tier restriction registry, which both strips them from a custom-tier save and marks the section restricted. ResponseFormatControls moves into its own file to keep ComplexityRouterConfig.tsx under the 800-line lint ceiling, matching the one-file-per-control layout its siblings already use. --- .../add_model/ComplexityRouterConfig.tsx | 38 +++--- .../add_model/ResponseFormatControls.tsx | 24 ++++ .../add_model/StallEscalationConfig.test.tsx | 108 ++++++++++++++++ .../add_model/StallEscalationConfig.tsx | 116 ++++++++++++++++++ .../add_model/add_auto_router_tab.tsx | 3 + .../build_complexity_router_config.test.ts | 31 +++++ .../build_complexity_router_config.ts | 18 +++ .../src/components/add_model/tier_rows.ts | 4 + ...d_updated_complexity_router_config.test.ts | 38 ++++++ .../edit_auto_router_modal.tsx | 16 +++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 18 +++ 11 files changed, 395 insertions(+), 19 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/ResponseFormatControls.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 06363830d64..a6d5932dea0 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -33,6 +33,8 @@ import { ModelGroup } from "@/components/llm_calls/fetch_models"; import AdaptiveRoutingConfig from "./AdaptiveRoutingConfig"; import ClassificationMethodConfig from "./ClassificationMethodConfig"; import ContextWindowEscalationConfig from "./ContextWindowEscalationConfig"; +import ResponseFormatControls from "./ResponseFormatControls"; +import StallEscalationConfig from "./StallEscalationConfig"; import { Restricted, restrictedBy } from "./TierRestrictions"; import { type TierSetAction, applyTierSetAction, setFallbackTier } from "./tier_set_actions"; import { @@ -418,6 +420,14 @@ export interface ComplexityRouterConfigValue { deployment_affinity?: boolean; /** Plan-mode floor as a tier ROW ID, unset meaning off. The wire carries the row's name. */ plan_mode_min_tier?: string; + /** + * Mid-task stall escalation. Undefined means off, which keeps all three keys out of the payload: + * the backend rejects them alongside session pinning, user-turn classification and a custom tier + * set, so an off router must stay silent about them rather than send an explicit false. + */ + stall_escalation_enabled?: boolean; + stall_escalation_window?: number; + stall_escalation_repeat_threshold?: number; adaptive?: boolean; adaptive_weights?: AdaptiveRouterWeights; tier_distance_penalty?: number; @@ -571,25 +581,6 @@ const PlanModeOverrideControls: React.FC<{ ); -const ResponseFormatControls: React.FC<{ - value: ComplexityRouterConfigValue; - onChange: (value: ComplexityRouterConfigValue) => void; -}> = ({ value, onChange }) => ( - <> -
- onChange({ ...value, return_raw_model_name: returnRawModelName })} - aria-label="Return raw model name" - /> - Return raw model name -
- - Return the resolved underlying model name in responses instead of the autorouter alias. - - -); - const ComplexityRouterConfig: React.FC = ({ modelInfo, value, @@ -855,6 +846,15 @@ const ComplexityRouterConfig: React.FC = ({ label: Advanced: Context Window Escalation, children: , }, + { + key: "stall-escalation", + label: Advanced: Stalled Task Escalation, + children: ( + + + + ), + }, { key: "response", label: Advanced: Response Format, diff --git a/ui/litellm-dashboard/src/components/add_model/ResponseFormatControls.tsx b/ui/litellm-dashboard/src/components/add_model/ResponseFormatControls.tsx new file mode 100644 index 00000000000..68dd880a684 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ResponseFormatControls.tsx @@ -0,0 +1,24 @@ +import { Switch } from "@/components/ui/switch"; +import React from "react"; +import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; + +const ResponseFormatControls: React.FC<{ + value: ComplexityRouterConfigValue; + onChange: (value: ComplexityRouterConfigValue) => void; +}> = ({ value, onChange }) => ( + <> +
+ onChange({ ...value, return_raw_model_name: returnRawModelName })} + aria-label="Return raw model name" + /> + Return raw model name +
+ + Return the resolved underlying model name in responses instead of the autorouter alias. + + +); + +export default ResponseFormatControls; diff --git a/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx new file mode 100644 index 00000000000..2c346306307 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx @@ -0,0 +1,108 @@ +import { fireEvent, renderWithProviders, screen } from "../../../tests/test-utils"; +import { vi } from "vitest"; +import type { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; +import StallEscalationConfig, { stallEscalationBlockedReason } from "./StallEscalationConfig"; + +const tiers = { SIMPLE: "gpt-4o-mini", MEDIUM: "gpt-4o", COMPLEX: "claude-sonnet-4", REASONING: "o1-preview" }; + +const baseValue: ComplexityRouterConfigValue = { + tiers, + classifier_type: "heuristic", +}; + +const renderConfig = (value: Partial = {}) => { + const onChange = vi.fn(); + renderWithProviders(); + return onChange; +}; + +const toggle = () => screen.getByRole("switch", { name: "Escalate a stalled task to a stronger model" }); + +describe("stallEscalationBlockedReason", () => { + it("blocks on session pinning, which replays a model instead of classifying", () => { + expect(stallEscalationBlockedReason({ ...baseValue, session_affinity: true })).toContain("Classification Method"); + }); + + it("blocks on user-turn classification, which skips the agent-loop turns a stall shows up in", () => { + expect(stallEscalationBlockedReason({ ...baseValue, classification_mode: "user_turn" })).toContain("every request"); + }); + + it("allows the default every-request router", () => { + expect(stallEscalationBlockedReason(baseValue)).toBeNull(); + }); +}); + +describe("StallEscalationConfig", () => { + it("hides the knobs until the feature is turned on", () => { + renderConfig(); + expect(toggle()).not.toBeChecked(); + expect(screen.queryByLabelText("Repeats before escalating")).not.toBeInTheDocument(); + }); + + it("turning it on seeds both knobs so the saved config is explicit rather than half-set", () => { + const onChange = renderConfig(); + fireEvent.click(toggle()); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + stall_escalation_enabled: true, + stall_escalation_window: 6, + stall_escalation_repeat_threshold: 3, + }), + ); + }); + + it("turning it off clears all three keys, since the backend rejects them next to session pinning", () => { + const onChange = renderConfig({ + stall_escalation_enabled: true, + stall_escalation_window: 6, + stall_escalation_repeat_threshold: 3, + }); + fireEvent.click(toggle()); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ + stall_escalation_enabled: undefined, + stall_escalation_window: undefined, + stall_escalation_repeat_threshold: undefined, + }), + ); + }); + + it("raises the window to match a larger threshold, which could otherwise never be reached", () => { + const onChange = renderConfig({ + stall_escalation_enabled: true, + stall_escalation_window: 4, + stall_escalation_repeat_threshold: 3, + }); + fireEvent.change(screen.getByLabelText("Repeats before escalating"), { target: { value: "9" } }); + expect(onChange).toHaveBeenCalledWith( + expect.objectContaining({ stall_escalation_repeat_threshold: 9, stall_escalation_window: 9 }), + ); + }); + + it("holds the window at the threshold when someone types a smaller one", () => { + const onChange = renderConfig({ + stall_escalation_enabled: true, + stall_escalation_window: 6, + stall_escalation_repeat_threshold: 3, + }); + fireEvent.change(screen.getByLabelText("Recent calls examined"), { target: { value: "1" } }); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ stall_escalation_window: 3 })); + }); + + it("floors the threshold at 2, below which a single ordinary retry would escalate", () => { + const onChange = renderConfig({ stall_escalation_enabled: true }); + fireEvent.change(screen.getByLabelText("Repeats before escalating"), { target: { value: "1" } }); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ stall_escalation_repeat_threshold: 2 })); + }); + + it("disables the toggle and says why when session pinning is on", () => { + renderConfig({ session_affinity: true }); + expect(toggle()).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByText(/How often to classify/)).toBeInTheDocument(); + }); + + it("hides the knobs when a blocker is switched on under an already-enabled router", () => { + renderConfig({ stall_escalation_enabled: true, session_affinity: true }); + expect(screen.queryByLabelText("Repeats before escalating")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx new file mode 100644 index 00000000000..fdb8c30f3b8 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx @@ -0,0 +1,116 @@ +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import React from "react"; +import { type ComplexityRouterConfigValue, classificationFrequency } from "./ComplexityRouterConfig"; + +export const DEFAULT_STALL_ESCALATION_WINDOW = 6; +export const DEFAULT_STALL_ESCALATION_REPEAT_THRESHOLD = 3; + +/** + * Why the toggle is unavailable, or null when it can be turned on. Both blockers replay a held + * routing decision instead of classifying most turns, so detection would never see the tool + * calls it reads. + */ +export const stallEscalationBlockedReason = (value: ComplexityRouterConfigValue): string | null => { + const frequency = classificationFrequency(value); + if (frequency === "session") + return 'Set "How often to classify" to every request under Advanced: Classification Method to use this. Scoring once per session replays that model instead of classifying, so a stall never reaches the classifier.'; + if (frequency === "user_turn") + return 'Set "How often to classify" to every request under Advanced: Classification Method to use this. Scoring only new user messages skips the tool-call turns a stall shows up in.'; + return null; +}; + +const clampedInt = (raw: string, min: number, fallback: number): number => { + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.trunc(parsed)); +}; + +const StallEscalationConfig: React.FC<{ + value: ComplexityRouterConfigValue; + onChange: (value: ComplexityRouterConfigValue) => void; +}> = ({ value, onChange }) => { + const enabled = value.stall_escalation_enabled ?? false; + const blockedReason = stallEscalationBlockedReason(value); + const window = value.stall_escalation_window ?? DEFAULT_STALL_ESCALATION_WINDOW; + const threshold = value.stall_escalation_repeat_threshold ?? DEFAULT_STALL_ESCALATION_REPEAT_THRESHOLD; + // A threshold above the window can never be reached, and the backend rejects the pair, so the + // window rises with the threshold rather than letting the form save something inert. + const commitThreshold = (raw: string) => { + const nextThreshold = clampedInt(raw, 2, DEFAULT_STALL_ESCALATION_REPEAT_THRESHOLD); + onChange({ + ...value, + stall_escalation_repeat_threshold: nextThreshold, + stall_escalation_window: Math.max(window, nextThreshold), + }); + }; + const commitWindow = (raw: string) => { + const nextWindow = clampedInt(raw, 1, DEFAULT_STALL_ESCALATION_WINDOW); + onChange({ + ...value, + stall_escalation_window: Math.max(nextWindow, threshold), + }); + }; + const toggle = (next: boolean) => { + const enabledValue: ComplexityRouterConfigValue = { + ...value, + stall_escalation_enabled: next || undefined, + stall_escalation_window: next ? window : undefined, + stall_escalation_repeat_threshold: next ? threshold : undefined, + }; + onChange(enabledValue); + }; + return ( + <> +
+ + Escalate a stalled task to a stronger model +
+ + When the model keeps repeating the same tool call, or the same call keeps erroring, bump the request one tier + higher for as long as it looks stuck. The automatic counterpart to an escalation keyword: nobody has to notice + the loop and ask. Off means a stuck task keeps the model it was classified onto. + {blockedReason !== null && ` ${blockedReason}`} + + {enabled && blockedReason === null && ( +
+
+ + commitThreshold(event.target.value)} + /> + + How many identical or failing calls count as stuck. At least 2; lower reacts sooner and misfires more. + +
+
+ + commitWindow(event.target.value)} + /> + + How far back to look, in tool calls. Never below the repeat count, since that could never be reached. + +
+
+ )} + + ); +}; + +export default StallEscalationConfig; diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index a548c2c6533..ac6e18614bb 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -394,6 +394,9 @@ const AddAutoRouterTab: React.FC = ({ embeddingModel, matchThreshold, escalationKeywords, + stallEscalationEnabled: complexityRouterConfig.stall_escalation_enabled, + stallEscalationWindow: complexityRouterConfig.stall_escalation_window, + stallEscalationRepeatThreshold: complexityRouterConfig.stall_escalation_repeat_threshold, adaptive: complexityRouterConfig.adaptive ?? false, adaptiveWeights: complexityRouterConfig.adaptive_weights ?? DEFAULT_ADAPTIVE_WEIGHTS, tierDistancePenalty: complexityRouterConfig.tier_distance_penalty ?? DEFAULT_TIER_DISTANCE_PENALTY, diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts index 9ee555f5dd2..b4f0affd3df 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts @@ -1020,6 +1020,9 @@ describe("buildComplexityRouterConfig with an edited tier set", () => { heuristicFirstMaxTier: "SIMPLE", hybridBoundaryMargin: 0.03, customTechnicalKeywords: ["kubernetes"], + stallEscalationEnabled: true, + stallEscalationWindow: 6, + stallEscalationRepeatThreshold: 3, }; const emittingType = key === "heuristic_first_max_tier" ? "heuristic_first" : "llm"; const typeForKey = key === "hybrid_boundary_margin" ? "hybrid" : emittingType; @@ -1114,6 +1117,34 @@ describe("hydrateCustomTierSet", () => { }); }); +describe("buildComplexityRouterConfig stall escalation", () => { + it("omits all three keys when the toggle is off, since the backend rejects them next to session pinning", () => { + const config = buildComplexityRouterConfig({ ...baseParams, stallEscalationEnabled: false }); + expect(config).not.toHaveProperty("stall_escalation_enabled"); + expect(config).not.toHaveProperty("stall_escalation_window"); + expect(config).not.toHaveProperty("stall_escalation_repeat_threshold"); + }); + + it("emits the toggle and both knobs when it is on", () => { + const config = buildComplexityRouterConfig({ + ...baseParams, + stallEscalationEnabled: true, + stallEscalationWindow: 8, + stallEscalationRepeatThreshold: 4, + }); + expect(config.stall_escalation_enabled).toBe(true); + expect(config.stall_escalation_window).toBe(8); + expect(config.stall_escalation_repeat_threshold).toBe(4); + }); + + it("emits the toggle alone when neither knob was touched, so both track the backend defaults", () => { + const config = buildComplexityRouterConfig({ ...baseParams, stallEscalationEnabled: true }); + expect(config.stall_escalation_enabled).toBe(true); + expect(config).not.toHaveProperty("stall_escalation_window"); + expect(config).not.toHaveProperty("stall_escalation_repeat_threshold"); + }); +}); + describe("dryRunRejection", () => { it("blocks the save on a rejection whose message is missing, which the write would return as a raw 400", () => { expect(dryRunRejection({ valid: false })).toBe("The proxy rejected this auto-router configuration"); diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts index 956e593a234..3d9cf1c747e 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts @@ -126,6 +126,9 @@ export interface BuildComplexityRouterConfigParams { embeddingModel: string | undefined; matchThreshold: number; escalationKeywords: string[]; + stallEscalationEnabled?: boolean; + stallEscalationWindow?: number; + stallEscalationRepeatThreshold?: number; adaptive: boolean; adaptiveWeights: AdaptiveRouterWeights; tierDistancePenalty: number; @@ -186,6 +189,9 @@ export interface ComplexityRouterConfigPayload { embedding_model?: string; match_threshold?: number; escalation_keywords?: string[]; + stall_escalation_enabled?: boolean; + stall_escalation_window?: number; + stall_escalation_repeat_threshold?: number; adaptive?: boolean; adaptive_weights?: AdaptiveRouterWeights; tier_distance_penalty?: number; @@ -446,6 +452,9 @@ export const buildComplexityRouterConfig = ({ embeddingModel, matchThreshold, escalationKeywords, + stallEscalationEnabled, + stallEscalationWindow, + stallEscalationRepeatThreshold, adaptive, adaptiveWeights, tierDistancePenalty, @@ -507,6 +516,15 @@ export const buildComplexityRouterConfig = ({ ...(customTechnicalKeywords.length > 0 && { custom_technical_keywords: customTechnicalKeywords }), ...(cleanedKeywordTierRules.length > 0 && { keyword_tier_rules: cleanedKeywordTierRules }), escalation_keywords: cleanedEscalationKeywords, + // Only written when on: the backend rejects it alongside session_affinity, user_turn mode and + // a custom tier set, so an off router must not carry the key into any of those saves. + ...(stallEscalationEnabled && { + stall_escalation_enabled: true, + ...(stallEscalationWindow !== undefined && { stall_escalation_window: stallEscalationWindow }), + ...(stallEscalationRepeatThreshold !== undefined && { + stall_escalation_repeat_threshold: stallEscalationRepeatThreshold, + }), + }), ...(semanticMatchingEnabled && { semantic_keyword_matching: true, embedding_model: embeddingModel, diff --git a/ui/litellm-dashboard/src/components/add_model/tier_rows.ts b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts index 3c5b149f4da..5e2a32addee 100644 --- a/ui/litellm-dashboard/src/components/add_model/tier_rows.ts +++ b/ui/litellm-dashboard/src/components/add_model/tier_rows.ts @@ -113,6 +113,10 @@ export const CUSTOM_TIER_RESTRICTIONS = { omit: ["escalation_keywords"], reason: "Escalation bumps a request along the built-in tier ladder, which your tier set replaces", }, + stallEscalation: { + omit: ["stall_escalation_enabled", "stall_escalation_window", "stall_escalation_repeat_threshold"], + reason: "Stall escalation bumps a request along the built-in tier ladder, which your tier set replaces", + }, adaptive: { omit: ["adaptive", "adaptive_weights", "tier_distance_penalty", "adaptive_eligible"], reason: "Adaptive routing scores models along the built-in tier ladder, which your tier set replaces", diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts index 877b35199a4..44152b08ef9 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/edit_auto_router/build_updated_complexity_router_config.test.ts @@ -593,16 +593,54 @@ describe("managed keys survive an untouched open-and-save", () => { "hybrid_boundary_margin", ]); + // The stall keys are rejected beside the session pinning and user-turn classification this + // fixture sets, so they get their own round trip below rather than widening this one. + const KEYS_ANOTHER_CLASSIFICATION_FREQUENCY_OWNS = new Set([ + "stall_escalation_enabled", + "stall_escalation_window", + "stall_escalation_repeat_threshold", + ]); + it("carries every managed key a built-in router can hold through hydrate then save", () => { const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined); const saved = buildUpdatedComplexityRouterConfig(STORED_ALL_MANAGED, hydrated); const dropped = [...MANAGED_COMPLEXITY_ROUTER_KEYS] .filter((key) => !KEYS_ANOTHER_CLASSIFIER_TYPE_OWNS.has(key)) + .filter((key) => !KEYS_ANOTHER_CLASSIFICATION_FREQUENCY_OWNS.has(key)) .filter((key) => saved[key] === undefined); expect(dropped).toEqual([]); }); + it("carries the stall-escalation keys through their own round trip", () => { + const stored: Record = { + ...STORED_ALL_MANAGED, + session_affinity: false, + classification_mode: "every_request", + stall_escalation_enabled: true, + stall_escalation_window: 8, + stall_escalation_repeat_threshold: 4, + }; + const hydrated = hydrateComplexityRouterConfig(stored, undefined); + const saved = buildUpdatedComplexityRouterConfig(stored, hydrated); + + expect(saved.stall_escalation_enabled).toBe(true); + expect(saved.stall_escalation_window).toBe(8); + expect(saved.stall_escalation_repeat_threshold).toBe(4); + }); + + it("leaves the stall keys out of a saved config that never had them on", () => { + const stored: Record = { + ...STORED_ALL_MANAGED, + session_affinity: false, + classification_mode: "every_request", + }; + const hydrated = hydrateComplexityRouterConfig(stored, undefined); + const saved = buildUpdatedComplexityRouterConfig(stored, hydrated); + + expect(saved).not.toHaveProperty("stall_escalation_enabled"); + }); + it("drops a stored local-scorer threshold when the operator converts the router to custom tiers", () => { const hydrated = hydrateComplexityRouterConfig(STORED_ALL_MANAGED, undefined); const converted = { diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index ea1e5cba6a3..1b65c1c35a4 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -115,6 +115,9 @@ export interface StoredComplexityRouterConfig { return_raw_model_name?: boolean; enable_context_window_escalation?: unknown; context_window_escalation_buffer?: unknown; + stall_escalation_enabled?: unknown; + stall_escalation_window?: unknown; + stall_escalation_repeat_threshold?: unknown; } /** @@ -208,6 +211,13 @@ export const hydrateComplexityRouterConfig = ( typeof parsedConfig.context_window_escalation_buffer === "number" ? parsedConfig.context_window_escalation_buffer : undefined, + stall_escalation_enabled: parsedConfig.stall_escalation_enabled === true || undefined, + stall_escalation_window: + typeof parsedConfig.stall_escalation_window === "number" ? parsedConfig.stall_escalation_window : undefined, + stall_escalation_repeat_threshold: + typeof parsedConfig.stall_escalation_repeat_threshold === "number" + ? parsedConfig.stall_escalation_repeat_threshold + : undefined, }; }; @@ -245,6 +255,9 @@ export const MANAGED_COMPLEXITY_ROUTER_KEYS = new Set([ "reasoning_override_min_score", "enable_context_window_escalation", "context_window_escalation_buffer", + "stall_escalation_enabled", + "stall_escalation_window", + "stall_escalation_repeat_threshold", ]); // Managed only when the caller passes the corresponding state. A caller that does not render @@ -351,6 +364,9 @@ export const buildUpdatedComplexityRouterConfig = ( tierModelParams: value.tier_model_params, enableContextWindowEscalation: value.enable_context_window_escalation, contextWindowEscalationBuffer: value.context_window_escalation_buffer, + stallEscalationEnabled: value.stall_escalation_enabled, + stallEscalationWindow: value.stall_escalation_window, + stallEscalationRepeatThreshold: value.stall_escalation_repeat_threshold, }; const built = buildComplexityRouterConfig(builderParams); diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index f4cb88bbae1..09dfbd841c8 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -34896,6 +34896,24 @@ export interface components { * @description Keywords indicating simple/basic queries */ simple_keywords?: string[] | null; + /** + * Stall Escalation Enabled + * @description Escalate mid-task to the next-higher configured tier when the assistant's own recent tool calls look stuck: stall_escalation_repeat_threshold or more of the last stall_escalation_window tool calls are identical repeats (same tool, same arguments) or came back as errors. One tier at most, on the same ladder escalation_keywords bumps along, and never above the highest configured tier. Detection re-runs on every classified turn from the tool calls visible in that request, so it needs no state and nothing survives past the task: once the recent tool calls stop looking stuck, the next classified turn routes normally again. Mutually exclusive with session_affinity and classification_mode='user_turn', which both replay a held routing decision instead of classifying most turns, so this would never see the tool calls to look at. Off by default. + * @default false + */ + stall_escalation_enabled: boolean; + /** + * Stall Escalation Repeat Threshold + * @description How many of the last stall_escalation_window tool calls must be identical repeats, or error results, before the task counts as stalled. Must not exceed stall_escalation_window, or the condition could never be reached. + * @default 3 + */ + stall_escalation_repeat_threshold: number; + /** + * Stall Escalation Window + * @description How many of the assistant's most recent tool calls stall detection looks at, oldest ones dropped as new calls happen. Counted across the whole visible conversation rather than reset at the newest human ask, so evidence from before a plain follow-up message like 'try again' is still visible on the turn after it. + * @default 6 + */ + stall_escalation_window: number; /** * Technical Keywords * @description Keywords indicating technical content From 966ab10fd659d3d7febc5515757c021a816dccae Mon Sep 17 00:00:00 2001 From: mateo Date: Fri, 4 Sep 2026 22:33:15 +0000 Subject: [PATCH 114/410] chore: ratchet lint budgets after merging litellm_internal_staging Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- basedpyright-code-budget.json | 6 +++--- ruff-strict-budget.json | 2 +- type-discipline-budget.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 4cea4a4804a..9c32cc84ee9 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,6 +1,6 @@ { "reportAny": { - "limit": 14072 + "limit": 14070 }, "reportArgumentType": { "limit": 2206 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 4121 + "limit": 4118 }, "reportFunctionMemberAccess": { "limit": 7 @@ -108,7 +108,7 @@ "limit": 38309 }, "reportUnknownParameterType": { - "limit": 19621 + "limit": 19620 }, "reportUnknownVariableType": { "limit": 29846 diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 7a1e709bb22..fe5dad5731b 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -78,7 +78,7 @@ "limit": 1 }, "C901": { - "limit": 308 + "limit": 307 }, "D419": { "limit": 6 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 8589a9451cf..71571317d9b 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22328 + "limit": 22327 }, "LIT002": { - "limit": 26748 + "limit": 26746 }, "LIT003": { "limit": 261 From a2f926eb8f7fac36a193a851b035eabb92b27373 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 15:55:19 -0700 Subject: [PATCH 115/410] fix(shadow_eval): correct the judge output cap's causal claim The prior commit claimed claude-sonnet-5 reasons invisibly by default and eats the judge's budget regardless of what the call asks for. Verified against a live proxy: with no thinking param (what _call_judge sends today), forced tool-choice json_mode, native structured output, and even an explicit thinking=adaptive, the model returned 0 reasoning tokens and a clean compact verdict every time, on prompts up to several thousand characters. The real mechanism only shows up with an elevated reasoning_effort or output_config.effort on the request, which happens when the judge_model deployment is configured with one, e.g. an admin pointing the judge at their best reasoning model. Reproduced directly: reasoning_effort=max, 300-token cap, real Anthropic reply came back finish_reason=length, content=None, 299 of 300 tokens spent on reasoning. Same request at 4096 returned a valid verdict. This is a narrower, verified claim than the one it replaces. --- litellm/integrations/shadow_eval_logger.py | 12 +++++----- .../integrations/test_shadow_eval_logger.py | 22 +++++++++---------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index a1716c0954d..b554c4bc668 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -60,12 +60,12 @@ _MAX_CONCURRENT_SHADOW_TASKS: Final = 16 _MAX_JUDGE_RESPONSE_CHARS: Final = 8_000 _MAX_JUDGE_PROMPT_CHARS: Final = 24_000 -# The judge answers with a small JSON object, but the cap covers reasoning tokens too, -# and the models people pick as judges reason before answering whether or not the call -# asks them to (Anthropic's 5 family thinks adaptively and cannot be told not to). A -# budget sized for the JSON alone is spent on invisible reasoning instead, and the reply -# arrives empty or truncated mid-object, which the attempt records as an unparseable -# verdict. Headroom is free: max_tokens is a ceiling, and only generated tokens bill. +# The judge answers with a small JSON object, but the cap covers reasoning tokens too. A +# judge_model deployment configured with an elevated reasoning_effort or thinking budget +# (a realistic pick: an admin's best reasoning model doubling as the judge) spends most or +# all of a tight cap on that reasoning, invisibly to this call, and the reply arrives empty +# or truncated mid-object, which the attempt records as an unparseable verdict. Headroom is +# free: max_tokens is a ceiling, and only generated tokens bill. JUDGE_MAX_OUTPUT_TOKENS: Final = 4096 _MAX_ERROR_CHARS: Final = 500 diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 877677505d6..367ad758772 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -121,11 +121,11 @@ def _router( def _reasoning_judge_router(reasoning_tokens, verdict='{"preference": "A", "confidence": 0.9}'): - """A router whose judge arm reasons before it answers, the way Anthropic's 5 family - does whether or not the call asks it to. Reasoning is billed against the caller's own - max_tokens and the reply is cut off at that cap, so a cap that does not clear the - reasoning budget yields a truncated verdict or no verdict at all. One character stands - in for one token, which is what makes the cap the thing under test.""" + """A router whose judge arm reasons before it answers, the way a deployment carrying an + elevated reasoning_effort does. Reasoning is billed against the caller's own max_tokens + and the reply is cut off at that cap, so a cap that does not clear the reasoning budget + yields a truncated verdict or no verdict at all. One character stands in for one token, + which is what makes the cap the thing under test.""" router = MagicMock() router.model_group_alias = {} router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) @@ -1156,12 +1156,12 @@ class TestShadowPipeline: assert logger._test_counter["spend:shadow_eval:job-1"] == 0.007 async def test_the_judge_output_cap_leaves_room_for_a_reasoning_judge(self): - """The output cap covers reasoning tokens as well as the answer, and the models - people pick as judges reason before answering whether or not the call asks them to. - A cap sized for the verdict JSON alone is spent on reasoning instead and the reply - arrives empty, which the attempt records as an unparseable verdict rather than a - result. The judge here burns a reasoning budget typical of a thinking model on a - comparison task, so the cap has to clear it for the verdict to survive.""" + """The output cap covers reasoning tokens as well as the answer, and a judge_model + deployment carrying an elevated reasoning_effort spends that budget before it writes + anything. A cap sized for the verdict JSON alone goes entirely to reasoning and the + reply arrives empty, which the attempt records as an unparseable verdict rather than + a result. The judge here burns a reasoning budget a live claude-sonnet-5 call was + measured at, so the cap has to clear it for the verdict to survive.""" reasoning_tokens = 2000 logger = _logger(router=_reasoning_judge_router(reasoning_tokens), prisma=(prisma := _prisma())) From 939039f4927b219b92efbacc83b94a5a51839cd6 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:03:20 -0700 Subject: [PATCH 116/410] fix(router): anchor stall detection on the newest tool call Counting whichever pattern was most common across the window escalated a task that had already recovered: three identical failures stay in the window for a few turns after the model breaks out of them, and on their own they met the threshold. Both tests now anchor on the newest call. The repeat test counts calls matching the newest one, and the error test only runs while the newest call is itself an error, so a window whose recent calls are healthy no longer escalates. The matches still do not have to be adjacent, so a retry loop broken up by an unrelated lookup keeps counting. Found by Greptile on #39809. --- .../complexity_router/README.md | 16 +++-- .../complexity_router/config.py | 27 ++++----- .../complexity_router/stall_detector.py | 58 ++++++++----------- .../router_strategy/test_stall_detector.py | 33 +++++++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 +- 5 files changed, 85 insertions(+), 53 deletions(-) diff --git a/litellm/router_strategy/complexity_router/README.md b/litellm/router_strategy/complexity_router/README.md index a7ed9e9dc21..ad84d4499d2 100644 --- a/litellm/router_strategy/complexity_router/README.md +++ b/litellm/router_strategy/complexity_router/README.md @@ -270,14 +270,20 @@ model_list: REASONING: o1-preview ``` -Detection looks at the assistant's own tool calls, not the human's messages: of the last -`stall_escalation_window` tool calls, if `stall_escalation_repeat_threshold` or more are -identical (same tool, same arguments) or came back as errors, the task counts as stalled and the -classified tier is bumped one step by the same `_escalate_tier` ladder `escalation_keywords` -uses, capped at the highest configured tier. It reads both tool-call shapes: Anthropic Messages +Detection looks at the assistant's own tool calls, not the human's messages. The task counts as +stalled when the NEWEST tool call is still part of a stuck pattern: it repeats, or it errored, at +least `stall_escalation_repeat_threshold` times across the last `stall_escalation_window` calls. +The tier is then bumped one step by the same `_escalate_tier` ladder `escalation_keywords` uses, +capped at the highest configured tier. It reads both tool-call shapes: Anthropic Messages `tool_use`/`tool_result` blocks (including `is_error`) and chat-completions `tool_calls`/`tool` messages (which carry no standard error flag, so those calls are judged on repetition alone). +Anchoring on the newest call is what keeps a recovered task from being escalated on stale +evidence. A model that tried the same command three times and then moved on still has those +three calls sitting in the window for a few turns, and counting whichever pattern is most common +in the window would escalate a request that is already making progress again. Anchoring still +leaves room between the matches, so a retry loop broken up by an unrelated lookup counts. + There is no state to expire or leak: detection reruns on every classified turn from that request's own message list, so the bump lasts only as long as the recent tool calls still look stuck and lifts on its own the moment they don't. This also means it reads the whole diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index 508c4ec8c91..46b5dd4a0f0 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -813,16 +813,17 @@ class ComplexityRouterConfig(BaseModel): default=False, description=( "Escalate mid-task to the next-higher configured tier when the assistant's own recent " - "tool calls look stuck: stall_escalation_repeat_threshold or more of the last " - "stall_escalation_window tool calls are identical repeats (same tool, same arguments) " - "or came back as errors. One tier at most, on the same ladder escalation_keywords bumps " - "along, and never above the highest configured tier. Detection re-runs on every " - "classified turn from the tool calls visible in that request, so it needs no state and " - "nothing survives past the task: once the recent tool calls stop looking stuck, the " - "next classified turn routes normally again. Mutually exclusive with session_affinity " - "and classification_mode='user_turn', which both replay a held routing decision instead " - "of classifying most turns, so this would never see the tool calls to look at. Off by " - "default." + "tool calls look stuck: the newest tool call repeats, or errors, at least " + "stall_escalation_repeat_threshold times across the last stall_escalation_window " + "calls. Both tests are anchored on the newest call, so a task that tried the same " + "thing a few times and then moved on is not escalated on the strength of those older " + "calls alone, while a retry loop broken up by an unrelated lookup still counts. One " + "tier at most, on the same ladder escalation_keywords bumps along, and never above " + "the highest configured tier. Detection re-runs on every classified turn from the " + "tool calls visible in that request, so it needs no state and nothing survives past " + "the task. Mutually exclusive with session_affinity and classification_mode=" + "'user_turn', which both replay a held routing decision instead of classifying most " + "turns, so this would never see the tool calls to look at. Off by default." ), ) stall_escalation_window: int = Field( @@ -839,9 +840,9 @@ class ComplexityRouterConfig(BaseModel): default=3, ge=2, description=( - "How many of the last stall_escalation_window tool calls must be identical repeats, or " - "error results, before the task counts as stalled. Must not exceed " - "stall_escalation_window, or the condition could never be reached." + "How many of the last stall_escalation_window tool calls must repeat the newest call, " + "or must have errored alongside it, before the task counts as stalled. Must not " + "exceed stall_escalation_window, or the condition could never be reached." ), ) diff --git a/litellm/router_strategy/complexity_router/stall_detector.py b/litellm/router_strategy/complexity_router/stall_detector.py index 450f8b6a653..690603f05d5 100644 --- a/litellm/router_strategy/complexity_router/stall_detector.py +++ b/litellm/router_strategy/complexity_router/stall_detector.py @@ -1,27 +1,21 @@ """ Mid-task stall detection for the Complexity Router. -Looks at the assistant's own recent tool calls -- visible on every request an agentic -client resends, since each turn carries the whole conversation so far -- for a tight loop -of identical calls or repeated tool errors. No LLM call, no state: the same fixed-size -window is rescanned on every classified turn, so a stall reads the same way whether it -started one turn ago or ten, and stops reading as a stall the moment the recent calls -change. +Reads the assistant's own recent tool calls, which every agentic client resends on each +turn, and reports whether the task currently looks stuck. No LLM call and no stored state: +the same window is rescanned per classified turn, so the verdict follows the conversation +rather than latching. -Assistant tool calls appear in two shapes depending on the API surface, and this module -reads both without translating one into the other: -- Anthropic Messages: assistant `content` blocks of type "tool_use" (id, name, input), - answered by a later user-turn `content` block of type "tool_result" (tool_use_id, - is_error). -- Chat completions: assistant `tool_calls` entries (id, function.name, function.arguments - as a JSON string), answered by a later `role: "tool"` message. Chat completions has no - standard error flag on that message, so those calls are judged on repetition alone. +Tool calls arrive in two shapes and are read in place rather than translated: +- Anthropic Messages: assistant `tool_use` content blocks, answered by a user-turn + `tool_result` block carrying `is_error` +- Chat completions: assistant `tool_calls` entries, answered by a `role: "tool"` message, + which has no standard error flag, so those calls are judged on repetition alone """ from __future__ import annotations import json -from collections import Counter from collections.abc import Iterator, Mapping, Sequence from itertools import islice from typing import Final, NamedTuple @@ -32,8 +26,7 @@ _ARGUMENTS_PARSE_FAILED: Final = object() class _ToolCallEvent(NamedTuple): signature: tuple[str, str] is_error: bool | None - """None when the surface carries no structured error signal for this call. Never - treated as an error: a call this module cannot judge must not count toward the tally.""" + """None where the surface reports no error status, and never counted as an error.""" def _json_arguments(raw: str) -> object: @@ -44,9 +37,8 @@ def _json_arguments(raw: str) -> object: def _tool_call_signature(name: str, raw_arguments: object) -> tuple[str, str]: - """A (name, canonical-arguments) pair that compares equal across both surfaces' - argument shapes: a dict (Anthropic `input`) and a JSON-encoded string (chat - completions `function.arguments`) representing the same call must match.""" + """Canonicalized so the same call compares equal across both surfaces, which carry + arguments as a dict and as a JSON string respectively.""" parsed: Final = _json_arguments(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments arguments: Final = raw_arguments if parsed is _ARGUMENTS_PARSE_FAILED else parsed try: @@ -56,8 +48,6 @@ def _tool_call_signature(name: str, raw_arguments: object) -> tuple[str, str]: def _iter_tool_result_error_pairs(messages: Sequence[Mapping[str, object]]) -> Iterator[tuple[str, bool]]: - """(call id, whether that call's result was an error), read only where the surface - reports one: an Anthropic Messages `tool_result` content block's `is_error`.""" for msg in messages: content = msg.get("content") if msg.get("role") != "user" or not isinstance(content, list): @@ -70,8 +60,6 @@ def _iter_tool_result_error_pairs(messages: Sequence[Mapping[str, object]]) -> I def _iter_tool_call_events_newest_first(messages: Sequence[Mapping[str, object]]) -> Iterator[_ToolCallEvent]: - """Every tool call the assistant made, newest first, paired with its result's error - status where the surface reports one.""" error_by_call_id: Final = dict(_iter_tool_result_error_pairs(messages)) for msg in reversed(messages): if msg.get("role") != "assistant": @@ -107,20 +95,24 @@ def detect_stalled_task( window: int, repeat_threshold: int, ) -> bool: - """Whether the assistant's recent tool-call activity looks stuck: repeat_threshold or - more of the last `window` tool calls share an identical signature, or resolved to an - error on a surface that reports one. + """Whether the newest tool call is still part of a stuck pattern: it repeats, or it + errored, at least repeat_threshold times across the last `window` calls. - Reads the whole message list rather than only the turns since the newest human ask, - so a follow-up like "try again" does not discard the evidence that came before it. + Both tests are anchored on the newest call rather than counting whichever pattern is + most common in the window. A task that tried the same thing three times and then moved + on has those three calls in the window for a while yet, and counting them alone would + escalate a request that already recovered. Anchoring also leaves room between the + matches, so a retry loop broken up by an unrelated lookup still reads as stuck. """ if not messages or repeat_threshold <= 0: return False recent: Final = tuple(islice(_iter_tool_call_events_newest_first(messages), window)) if len(recent) < repeat_threshold: return False - _, most_common_count = Counter(event.signature for event in recent).most_common(1)[0] - if most_common_count >= repeat_threshold: + newest: Final = recent[0] + repeats: Final = sum(1 for event in recent if event.signature == newest.signature) + if repeats >= repeat_threshold: return True - error_count: Final = sum(1 for event in recent if event.is_error) - return error_count >= repeat_threshold + if not newest.is_error: + return False + return sum(1 for event in recent if event.is_error) >= repeat_threshold diff --git a/tests/test_litellm/router_strategy/test_stall_detector.py b/tests/test_litellm/router_strategy/test_stall_detector.py index 8f39969a8ec..34067cc3626 100644 --- a/tests/test_litellm/router_strategy/test_stall_detector.py +++ b/tests/test_litellm/router_strategy/test_stall_detector.py @@ -109,6 +109,39 @@ class TestDetectStalledTask: ] assert detect_stalled_task(messages, window=6, repeat_threshold=3) is True + def test_a_recovered_task_is_not_stalled_while_its_old_failures_sit_in_the_window(self): + """The three identical failures stay in the window for a few turns after the model + breaks out of them, and counting them on their own would escalate a request that is + already making progress again.""" + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "pytest"}, is_error=True), + *_anthropic_call("t2", "bash", {"cmd": "pytest"}, is_error=True), + *_anthropic_call("t3", "bash", {"cmd": "pytest"}, is_error=True), + *_anthropic_call("t4", "read_file", {"path": "conftest.py"}, is_error=False), + *_anthropic_call("t5", "edit_file", {"path": "conftest.py"}, is_error=False), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is False + + def test_a_retry_loop_broken_up_by_an_unrelated_call_still_counts(self): + """Anchoring on the newest call must not require the repeats to be adjacent: a model + re-running the same failing command around a lookup in between is still stuck.""" + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "pytest"}, is_error=True), + *_anthropic_call("t2", "read_file", {"path": "conftest.py"}, is_error=False), + *_anthropic_call("t3", "bash", {"cmd": "pytest"}, is_error=True), + *_anthropic_call("t4", "bash", {"cmd": "pytest"}, is_error=True), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is True + + def test_errors_only_count_while_the_newest_call_is_still_failing(self): + messages = [ + *_anthropic_call("t1", "bash", {"cmd": "pytest a"}, is_error=True), + *_anthropic_call("t2", "bash", {"cmd": "pytest b"}, is_error=True), + *_anthropic_call("t3", "bash", {"cmd": "pytest c"}, is_error=True), + *_anthropic_call("t4", "bash", {"cmd": "pytest d"}, is_error=False), + ] + assert detect_stalled_task(messages, window=6, repeat_threshold=3) is False + def test_no_messages_is_not_stalled(self): assert detect_stalled_task(None, window=6, repeat_threshold=3) is False assert detect_stalled_task([], window=6, repeat_threshold=3) is False diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 09dfbd841c8..ce19330a511 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -34898,13 +34898,13 @@ export interface components { simple_keywords?: string[] | null; /** * Stall Escalation Enabled - * @description Escalate mid-task to the next-higher configured tier when the assistant's own recent tool calls look stuck: stall_escalation_repeat_threshold or more of the last stall_escalation_window tool calls are identical repeats (same tool, same arguments) or came back as errors. One tier at most, on the same ladder escalation_keywords bumps along, and never above the highest configured tier. Detection re-runs on every classified turn from the tool calls visible in that request, so it needs no state and nothing survives past the task: once the recent tool calls stop looking stuck, the next classified turn routes normally again. Mutually exclusive with session_affinity and classification_mode='user_turn', which both replay a held routing decision instead of classifying most turns, so this would never see the tool calls to look at. Off by default. + * @description Escalate mid-task to the next-higher configured tier when the assistant's own recent tool calls look stuck: the newest tool call repeats, or errors, at least stall_escalation_repeat_threshold times across the last stall_escalation_window calls. Both tests are anchored on the newest call, so a task that tried the same thing a few times and then moved on is not escalated on the strength of those older calls alone, while a retry loop broken up by an unrelated lookup still counts. One tier at most, on the same ladder escalation_keywords bumps along, and never above the highest configured tier. Detection re-runs on every classified turn from the tool calls visible in that request, so it needs no state and nothing survives past the task. Mutually exclusive with session_affinity and classification_mode='user_turn', which both replay a held routing decision instead of classifying most turns, so this would never see the tool calls to look at. Off by default. * @default false */ stall_escalation_enabled: boolean; /** * Stall Escalation Repeat Threshold - * @description How many of the last stall_escalation_window tool calls must be identical repeats, or error results, before the task counts as stalled. Must not exceed stall_escalation_window, or the condition could never be reached. + * @description How many of the last stall_escalation_window tool calls must repeat the newest call, or must have errored alongside it, before the task counts as stalled. Must not exceed stall_escalation_window, or the condition could never be reached. * @default 3 */ stall_escalation_repeat_threshold: number; From c5c10bc91fe1b6e7aba457fae4a081059f588a5d Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 4 Sep 2026 16:08:50 -0700 Subject: [PATCH 117/410] feat(access-groups): resolve resource names on access group responses The access group detail page rendered MCP servers, agents, attached teams and keys as bare ids, so an admin had to look each one up elsewhere to audit a group Every access group response now also carries access_mcp_servers, access_agents, assigned_teams and assigned_keys as {id, name} pairs. Names come from the DB rows first and fall back to config-declared MCP servers and agents (including legacy agent ids), resolved with one query per table across all groups in a list call. The existing *_ids columns are unchanged The UI renders the name with the id in a tooltip, links teams and keys to their detail pages, and shows the raw id only when nothing resolves --- litellm/proxy/_lazy_openapi_snapshot.json | 58 +++ .../access_group_endpoints.py | 120 ++++-- .../resource_display_names.py | 61 +++ litellm/types/access_group.py | 11 + .../test_access_group_endpoints.py | 190 +++++++++- .../test_resource_display_names.py | 130 +++++++ .../AccessGroupsDetailsPage.test.tsx | 349 +++++++++++------- .../_components/AccessGroupsDetailsPage.tsx | 93 +++-- .../AccessGroupEditModal.integration.test.tsx | 4 + .../_components/AccessGroupsPage.test.tsx | 8 + .../accessGroups/useAccessGroups.test.ts | 4 + .../hooks/accessGroups/useAccessGroups.ts | 16 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 18 + 13 files changed, 850 insertions(+), 212 deletions(-) create mode 100644 litellm/proxy/management_helpers/resource_display_names.py create mode 100644 tests/test_litellm/proxy/management_helpers/test_resource_display_names.py diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index 316bfb8cf92..c24eea968f8 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -741,6 +741,32 @@ "title": "AccessGroupInfo", "type": "object" }, + "AccessGroupResource": { + "description": "A resource referenced by an access group. `name` is null when the id no longer resolves or has no alias.", + "properties": { + "id": { + "title": "Id", + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Name" + } + }, + "required": [ + "id", + "name" + ], + "title": "AccessGroupResource", + "type": "object" + }, "AccessGroupResponse": { "properties": { "access_agent_ids": { @@ -750,6 +776,13 @@ "title": "Access Agent Ids", "type": "array" }, + "access_agents": { + "items": { + "$ref": "#/components/schemas/AccessGroupResource" + }, + "title": "Access Agents", + "type": "array" + }, "access_group_id": { "title": "Access Group Id", "type": "string" @@ -765,6 +798,13 @@ "title": "Access Mcp Server Ids", "type": "array" }, + "access_mcp_servers": { + "items": { + "$ref": "#/components/schemas/AccessGroupResource" + }, + "title": "Access Mcp Servers", + "type": "array" + }, "access_model_names": { "items": { "type": "string" @@ -779,6 +819,13 @@ "title": "Assigned Key Ids", "type": "array" }, + "assigned_keys": { + "items": { + "$ref": "#/components/schemas/AccessGroupResource" + }, + "title": "Assigned Keys", + "type": "array" + }, "assigned_team_ids": { "items": { "type": "string" @@ -786,6 +833,13 @@ "title": "Assigned Team Ids", "type": "array" }, + "assigned_teams": { + "items": { + "$ref": "#/components/schemas/AccessGroupResource" + }, + "title": "Assigned Teams", + "type": "array" + }, "created_at": { "format": "date-time", "title": "Created At", @@ -838,6 +892,10 @@ "access_agent_ids", "assigned_team_ids", "assigned_key_ids", + "access_mcp_servers", + "access_agents", + "assigned_teams", + "assigned_keys", "created_at", "updated_at" ], diff --git a/litellm/proxy/management_endpoints/access_group_endpoints.py b/litellm/proxy/management_endpoints/access_group_endpoints.py index 1f91eeedf64..a6cc5140b15 100644 --- a/litellm/proxy/management_endpoints/access_group_endpoints.py +++ b/litellm/proxy/management_endpoints/access_group_endpoints.py @@ -1,16 +1,20 @@ -from collections.abc import Mapping, Sequence +import asyncio +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass from types import MappingProxyType from typing import Final, Protocol from fastapi import APIRouter, Depends, HTTPException, status from litellm._logging import verbose_proxy_logger +from litellm.proxy._experimental.mcp_server.mcp_server_manager import global_mcp_server_manager from litellm.proxy._types import ( CommonProxyErrors, LiteLLM_AccessGroupTable, LitellmUserRoles, UserAPIKeyAuth, ) +from litellm.proxy.agent_endpoints.agent_registry import global_agent_registry from litellm.proxy.auth.auth_checks import ( _cache_access_object, _cache_key_object, @@ -20,10 +24,16 @@ from litellm.proxy.auth.auth_checks import ( from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.db.exception_handler import PrismaDBExceptionHandler from litellm.proxy.management_helpers.access_group_team_sync import invalidate_access_group_cache -from litellm.proxy.utils import get_prisma_client_or_throw +from litellm.proxy.management_helpers.resource_display_names import ( + agent_display_names, + key_display_names, + mcp_server_display_names, +) +from litellm.proxy.utils import PrismaClient, get_prisma_client_or_throw from litellm.repositories.table_repositories import AccessGroupRepository, TeamRepository from litellm.types.access_group import ( AccessGroupCreateRequest, + AccessGroupResource, AccessGroupResponse, AccessGroupUpdateRequest, ) @@ -37,6 +47,12 @@ class _AccessGroupRecord(Protocol): @property def access_group_id(self) -> str: ... + @property + def access_mcp_server_ids(self) -> Sequence[str] | None: ... + + @property + def access_agent_ids(self) -> Sequence[str] | None: ... + @property def assigned_team_ids(self) -> Sequence[str] | None: ... @@ -50,6 +66,9 @@ class _TeamRecord(Protocol): @property def team_id(self) -> str: ... + @property + def team_alias(self) -> str | None: ... + @property def access_group_ids(self) -> Sequence[str] | None: ... @@ -120,16 +139,75 @@ def _require_admin_view(user_api_key_dict: UserAPIKeyAuth) -> None: ) +@dataclass(frozen=True, slots=True) +class _ResourceNames: + mcp_servers: Mapping[str, str] + agents: Mapping[str, str] + teams: Mapping[str, str | None] + keys: Mapping[str, str] + + +def _label(ids: Sequence[str], names: Mapping[str, str | None]) -> tuple[AccessGroupResource, ...]: + return tuple(AccessGroupResource(id=resource_id, name=names.get(resource_id)) for resource_id in ids) + + def _record_to_response( - record: _AccessGroupRecord, *, assigned_team_ids: Sequence[str] | None = None + record: _AccessGroupRecord, *, assigned_team_ids: Sequence[str], names: _ResourceNames ) -> AccessGroupResponse: - stored: Final = record.dict() - payload: Final = ( - stored if assigned_team_ids is None else MappingProxyType({**stored, "assigned_team_ids": assigned_team_ids}) + payload: Final = MappingProxyType( + { + **record.dict(), + "assigned_team_ids": assigned_team_ids, + "access_mcp_servers": _label(record.access_mcp_server_ids or (), names.mcp_servers), + "access_agents": _label(record.access_agent_ids or (), names.agents), + "assigned_teams": _label(assigned_team_ids, names.teams), + "assigned_keys": _label(record.assigned_key_ids or (), names.keys), + } ) return AccessGroupResponse.model_validate(payload) +def _ids_across( + records: Sequence[_AccessGroupRecord], pick: Callable[[_AccessGroupRecord], Sequence[str] | None] +) -> tuple[str, ...]: + return tuple(dict.fromkeys(resource_id for record in records for resource_id in (pick(record) or ()))) + + +async def _responses_for( + prisma_client: PrismaClient, records: Sequence[_AccessGroupRecord] +) -> tuple[AccessGroupResponse, ...]: + if not records: + return () + teams: Final = await _teams_touching(TeamRepository(prisma_client).table, records) + mcp_servers, agents, keys = await asyncio.gather( + mcp_server_display_names( + prisma_client, + _ids_across(records, lambda record: record.access_mcp_server_ids), + global_mcp_server_manager.config_mcp_servers, + ), + agent_display_names( + prisma_client, _ids_across(records, lambda record: record.access_agent_ids), global_agent_registry + ), + key_display_names(prisma_client, _ids_across(records, lambda record: record.assigned_key_ids)), + ) + names: Final = _ResourceNames( + mcp_servers=mcp_servers, + agents=agents, + teams=MappingProxyType({team.team_id: team.team_alias for team in teams}), + keys=keys, + ) + attached: Final = _attached_team_ids_by_group(records, teams) + return tuple( + _record_to_response(record, assigned_team_ids=attached[record.access_group_id], names=names) + for record in records + ) + + +async def _response_for(prisma_client: PrismaClient, record: _AccessGroupRecord) -> AccessGroupResponse: + (response,) = await _responses_for(prisma_client, (record,)) + return response + + def _attached_team_ids_by_group( records: Sequence[_AccessGroupRecord], teams: Sequence[_TeamRecord] ) -> Mapping[str, tuple[str, ...]]: @@ -144,19 +222,21 @@ def _attached_team_ids_by_group( return MappingProxyType({record.access_group_id: attached(record) for record in records}) +async def _teams_touching(team_table: _TeamTable, records: Sequence[_AccessGroupRecord]) -> Sequence[_TeamRecord]: + """Team rows listed on any of the groups or carrying any of them in access_group_ids.""" + group_ids: Final = tuple(record.access_group_id for record in records) + stored_team_ids: Final = _ids_across(records, lambda record: record.assigned_team_ids) + carrying: Final = {"access_group_ids": {"hasSome": group_ids}} # mutable-ok: prisma where is a dict + listed: Final = {"team_id": {"in": stored_team_ids}} # mutable-ok: prisma where is a dict + return await team_table.find_many(where={"OR": (carrying, listed)}) # mutable-ok: prisma where is a dict + + async def _attached_team_ids_for( team_table: _TeamTable, records: Sequence[_AccessGroupRecord] ) -> Mapping[str, tuple[str, ...]]: if not records: return MappingProxyType({}) - group_ids: Final = tuple(record.access_group_id for record in records) - stored_team_ids: Final = tuple( - dict.fromkeys(team_id for record in records for team_id in (record.assigned_team_ids or ())) - ) - carrying: Final = {"access_group_ids": {"hasSome": group_ids}} # mutable-ok: prisma where is a dict - listed: Final = {"team_id": {"in": stored_team_ids}} # mutable-ok: prisma where is a dict - teams: Final = await team_table.find_many(where={"OR": (carrying, listed)}) # mutable-ok: prisma where is a dict - return _attached_team_ids_by_group(records, teams) + return _attached_team_ids_by_group(records, await _teams_touching(team_table, records)) async def _require_teams_exist(tx: _AccessGroupTx, team_ids: Sequence[str]) -> None: @@ -425,7 +505,7 @@ async def create_access_group( proxy_logging_obj, ) - return _record_to_response(record) + return await _response_for(prisma_client, record) @router.get( @@ -434,14 +514,13 @@ async def create_access_group( ) async def list_access_groups( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), -) -> list[AccessGroupResponse]: +) -> Sequence[AccessGroupResponse]: _require_admin_view(user_api_key_dict) prisma_client: Final = get_prisma_client_or_throw(CommonProxyErrors.db_not_connected_error.value) table: Final = AccessGroupRepository(prisma_client).table records: Final = await table.find_many(order={"created_at": "desc"}) - attached: Final = await _attached_team_ids_for(TeamRepository(prisma_client).table, records) - return [_record_to_response(r, assigned_team_ids=attached[r.access_group_id]) for r in records] + return await _responses_for(prisma_client, records) @router.get( @@ -462,8 +541,7 @@ async def get_access_group( status_code=status.HTTP_404_NOT_FOUND, detail=f"Access group '{access_group_id}' not found", ) - attached: Final = await _attached_team_ids_for(TeamRepository(prisma_client).table, (record,)) - return _record_to_response(record, assigned_team_ids=attached[record.access_group_id]) + return await _response_for(prisma_client, record) @router.put( @@ -560,7 +638,7 @@ async def update_access_group( await _patch_key_caches_add_access_group(keys_to_add, access_group_id, user_api_key_cache, proxy_logging_obj) await _patch_key_caches_remove_access_group(keys_to_remove, access_group_id, user_api_key_cache, proxy_logging_obj) - return _record_to_response(record) + return await _response_for(prisma_client, record) @router.delete( diff --git a/litellm/proxy/management_helpers/resource_display_names.py b/litellm/proxy/management_helpers/resource_display_names.py new file mode 100644 index 00000000000..31b7b68d233 --- /dev/null +++ b/litellm/proxy/management_helpers/resource_display_names.py @@ -0,0 +1,61 @@ +"""Display names for ids stored on management objects. DB rows win; config-declared servers and agents fill the gaps.""" + +from collections.abc import Mapping, Sequence +from types import MappingProxyType +from typing import Final + +from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry +from litellm.proxy.utils import PrismaClient +from litellm.repositories.table_repositories import AgentsRepository, MCPServerRepository +from litellm.repositories.verification_token_repository import VerificationTokenRepository +from litellm.types.mcp_server.mcp_server_manager import MCPServer + + +async def mcp_server_display_names( + prisma_client: PrismaClient, + server_ids: Sequence[str], + config_servers: Mapping[str, MCPServer], +) -> Mapping[str, str]: + """server_id -> alias, falling back to server_name; config-only servers also fall back to their registry name.""" + if not server_ids: + return MappingProxyType({}) + wanted: Final = frozenset(server_ids) + where: Final = {"server_id": {"in": tuple(wanted)}} # mutable-ok: prisma where is a dict + rows: Final = await MCPServerRepository(prisma_client).table.find_many(where=where) + from_config: Final = { + server_id: server.alias or server.server_name or server.name + for server_id, server in config_servers.items() + if server_id in wanted + } + from_db: Final = {row.server_id: name for row in rows if (name := row.alias or row.server_name)} + return MappingProxyType({**from_config, **from_db}) + + +async def agent_display_names( + prisma_client: PrismaClient, + agent_ids: Sequence[str], + registry: AgentRegistry, +) -> Mapping[str, str]: + """agent_id -> agent_name. The registry covers config-declared agents and their legacy ids.""" + if not agent_ids: + return MappingProxyType({}) + wanted: Final = frozenset(agent_ids) + where: Final = {"agent_id": {"in": tuple(wanted)}} # mutable-ok: prisma where is a dict + rows: Final = await AgentsRepository(prisma_client).table.find_many(where=where) + from_registry: Final = { + alias_id: agent.agent_name + for agent in registry.get_agent_list() + for alias_id in registry.ids_for_agent(agent.agent_id) + if alias_id in wanted + } + from_db: Final = {row.agent_id: row.agent_name for row in rows} + return MappingProxyType({**from_registry, **from_db}) + + +async def key_display_names(prisma_client: PrismaClient, tokens: Sequence[str]) -> Mapping[str, str]: + """token hash -> key_alias for the keys that have one.""" + if not tokens: + return MappingProxyType({}) + where: Final = {"token": {"in": tuple(frozenset(tokens))}} # mutable-ok: prisma where is a dict + rows: Final = await VerificationTokenRepository(prisma_client).table.find_many(where=where) + return MappingProxyType({row.token: row.key_alias for row in rows if row.key_alias}) diff --git a/litellm/types/access_group.py b/litellm/types/access_group.py index b477ce309b7..951e5a414b4 100644 --- a/litellm/types/access_group.py +++ b/litellm/types/access_group.py @@ -23,6 +23,13 @@ class AccessGroupUpdateRequest(BaseModel): assigned_key_ids: list[str] | None = None +class AccessGroupResource(BaseModel): + """A resource referenced by an access group. `name` is null when the id no longer resolves or has no alias.""" + + id: str + name: str | None + + class AccessGroupResponse(BaseModel): access_group_id: str access_group_name: str @@ -32,6 +39,10 @@ class AccessGroupResponse(BaseModel): access_agent_ids: list[str] assigned_team_ids: list[str] assigned_key_ids: list[str] + access_mcp_servers: tuple[AccessGroupResource, ...] + access_agents: tuple[AccessGroupResource, ...] + assigned_teams: tuple[AccessGroupResource, ...] + assigned_keys: tuple[AccessGroupResource, ...] created_at: datetime created_by: str | None = None updated_at: datetime diff --git a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py index 81816e21c10..d687f8d1c8d 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_access_group_endpoints.py @@ -57,8 +57,20 @@ def _make_access_group_record( return record -def _make_team_record(team_id: str, access_group_ids: list[str] | None = None): - return types.SimpleNamespace(team_id=team_id, access_group_ids=access_group_ids or []) +def _make_team_record(team_id: str, access_group_ids: list[str] | None = None, team_alias: str | None = None): + return types.SimpleNamespace(team_id=team_id, access_group_ids=access_group_ids or [], team_alias=team_alias) + + +def _make_mcp_server_record(server_id: str, alias: str | None = None, server_name: str | None = None): + return types.SimpleNamespace(server_id=server_id, alias=alias, server_name=server_name) + + +def _make_agent_record(agent_id: str, agent_name: str): + return types.SimpleNamespace(agent_id=agent_id, agent_name=agent_name) + + +def _make_key_record(token: str, key_alias: str | None = None): + return types.SimpleNamespace(token=token, key_alias=key_alias) @pytest.fixture @@ -109,6 +121,12 @@ def client_and_mocks(monkeypatch): mock_key_table.find_unique = AsyncMock(return_value=None) mock_key_table.update = AsyncMock(return_value=None) + mock_mcp_server_table = MagicMock() + mock_mcp_server_table.find_many = AsyncMock(return_value=[]) + + mock_agents_table = MagicMock() + mock_agents_table.find_many = AsyncMock(return_value=[]) + @asynccontextmanager async def mock_tx(): tx = types.SimpleNamespace( @@ -122,6 +140,8 @@ def client_and_mocks(monkeypatch): litellm_accessgrouptable=mock_access_group_table, litellm_teamtable=mock_team_table, litellm_verificationtoken=mock_key_table, + litellm_mcpservertable=mock_mcp_server_table, + litellm_agentstable=mock_agents_table, tx=mock_tx, ) mock_prisma.db = mock_db @@ -1447,3 +1467,169 @@ def test_update_access_group_null_assigned_ids_treated_as_empty(client_and_mocks update_call_kwargs = mock_table.update.call_args.kwargs assert update_call_kwargs["data"]["assigned_team_ids"] == [] assert update_call_kwargs["data"]["assigned_key_ids"] == [] + + +# --------------------------------------------------------------------------- +# Resolved resource names (LIT-6594) +# --------------------------------------------------------------------------- + + +def _mock_resource_tables(mock_prisma, *, mcp_servers=(), agents=(), teams=(), keys=()): + mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=list(mcp_servers)) + mock_prisma.db.litellm_agentstable.find_many = AsyncMock(return_value=list(agents)) + mock_prisma.db.litellm_teamtable.find_many = AsyncMock(return_value=list(teams)) + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=list(keys)) + + +@pytest.mark.parametrize("base_path", ACCESS_GROUP_PATHS) +def test_get_access_group_resolves_resource_names(client_and_mocks, base_path): + """Every id list gets a sibling list of {id, name}; name is null when the id has no alias or no longer resolves.""" + client, mock_prisma, mock_table, *_ = client_and_mocks + mock_table.find_unique = AsyncMock( + return_value=_make_access_group_record( + access_group_id="ag-123", + access_mcp_server_ids=["mcp-a", "mcp-b", "mcp-ghost"], + access_agent_ids=["agent-a", "agent-ghost"], + assigned_team_ids=["team-a", "team-b"], + assigned_key_ids=["key-a", "key-b"], + ) + ) + _mock_resource_tables( + mock_prisma, + mcp_servers=[ + _make_mcp_server_record("mcp-a", alias="GitHub"), + _make_mcp_server_record("mcp-b", server_name="jira_tools"), + ], + agents=[_make_agent_record("agent-a", "support-bot")], + teams=[ + _make_team_record("team-a", ["ag-123"], team_alias="Platform"), + _make_team_record("team-b", ["ag-123"]), + ], + keys=[_make_key_record("key-a", key_alias="ci-key"), _make_key_record("key-b")], + ) + + resp = client.get(f"{base_path}/ag-123") + assert resp.status_code == 200 + body = resp.json() + assert body["access_mcp_servers"] == [ + {"id": "mcp-a", "name": "GitHub"}, + {"id": "mcp-b", "name": "jira_tools"}, + {"id": "mcp-ghost", "name": None}, + ] + assert body["access_agents"] == [{"id": "agent-a", "name": "support-bot"}, {"id": "agent-ghost", "name": None}] + assert body["assigned_teams"] == [{"id": "team-a", "name": "Platform"}, {"id": "team-b", "name": None}] + assert body["assigned_keys"] == [{"id": "key-a", "name": "ci-key"}, {"id": "key-b", "name": None}] + assert body["access_mcp_server_ids"] == ["mcp-a", "mcp-b", "mcp-ghost"] + assert body["assigned_team_ids"] == ["team-a", "team-b"] + + mcp_where = mock_prisma.db.litellm_mcpservertable.find_many.call_args.kwargs["where"] + assert sorted(mcp_where["server_id"]["in"]) == ["mcp-a", "mcp-b", "mcp-ghost"] + agent_where = mock_prisma.db.litellm_agentstable.find_many.call_args.kwargs["where"] + assert sorted(agent_where["agent_id"]["in"]) == ["agent-a", "agent-ghost"] + key_where = mock_prisma.db.litellm_verificationtoken.find_many.call_args.kwargs["where"] + assert sorted(key_where["token"]["in"]) == ["key-a", "key-b"] + + +def test_list_access_groups_resolves_names_with_one_query_per_table(client_and_mocks): + """List batches every group's ids into one lookup per table and attributes names back to the right group.""" + client, mock_prisma, mock_table, *_ = client_and_mocks + mock_table.find_many = AsyncMock( + return_value=[ + _make_access_group_record( + access_group_id="ag-1", access_mcp_server_ids=["mcp-a"], access_agent_ids=["agent-a"], assigned_key_ids=["key-a"] + ), + _make_access_group_record( + access_group_id="ag-2", access_mcp_server_ids=["mcp-b"], access_agent_ids=["agent-b"], assigned_key_ids=["key-b"] + ), + ] + ) + _mock_resource_tables( + mock_prisma, + mcp_servers=[_make_mcp_server_record("mcp-a", alias="A"), _make_mcp_server_record("mcp-b", alias="B")], + agents=[_make_agent_record("agent-a", "Agent A"), _make_agent_record("agent-b", "Agent B")], + keys=[_make_key_record("key-a", key_alias="Key A"), _make_key_record("key-b", key_alias="Key B")], + ) + + resp = client.get("/v1/access_group") + assert resp.status_code == 200 + first, second = resp.json() + assert first["access_mcp_servers"] == [{"id": "mcp-a", "name": "A"}] + assert first["access_agents"] == [{"id": "agent-a", "name": "Agent A"}] + assert first["assigned_keys"] == [{"id": "key-a", "name": "Key A"}] + assert second["access_mcp_servers"] == [{"id": "mcp-b", "name": "B"}] + assert second["access_agents"] == [{"id": "agent-b", "name": "Agent B"}] + assert second["assigned_keys"] == [{"id": "key-b", "name": "Key B"}] + + for table, column in ( + (mock_prisma.db.litellm_mcpservertable, "server_id"), + (mock_prisma.db.litellm_agentstable, "agent_id"), + (mock_prisma.db.litellm_verificationtoken, "token"), + ): + table.find_many.assert_awaited_once() + assert len(table.find_many.call_args.kwargs["where"][column]["in"]) == 2 + + +def test_list_access_groups_skips_lookups_when_nothing_to_resolve(client_and_mocks): + """Groups with no MCP servers, agents or keys must not trigger an empty IN () query per table.""" + client, mock_prisma, mock_table, *_ = client_and_mocks + mock_table.find_many = AsyncMock( + return_value=[_make_access_group_record(access_group_id="ag-1"), _make_access_group_record(access_group_id="ag-2")] + ) + + resp = client.get("/v1/access_group") + assert resp.status_code == 200 + assert all(group["access_mcp_servers"] == [] and group["assigned_keys"] == [] for group in resp.json()) + + mock_prisma.db.litellm_mcpservertable.find_many.assert_not_awaited() + mock_prisma.db.litellm_agentstable.find_many.assert_not_awaited() + mock_prisma.db.litellm_verificationtoken.find_many.assert_not_awaited() + + +def test_create_access_group_response_carries_resolved_names(client_and_mocks): + """The create response already shows names so the UI never has to refetch to label what it just saved.""" + client, mock_prisma, *_ = client_and_mocks + team_record = _make_team_record("team-1", team_alias="Platform") + mock_prisma.db.litellm_teamtable.find_unique = AsyncMock(return_value=team_record) + _mock_resource_tables( + mock_prisma, + mcp_servers=[_make_mcp_server_record("mcp-a", alias="GitHub")], + agents=[_make_agent_record("agent-a", "support-bot")], + teams=[team_record], + ) + + resp = client.post( + "/v1/access_group", + json={ + "access_group_name": "new-group", + "access_mcp_server_ids": ["mcp-a"], + "access_agent_ids": ["agent-a"], + "assigned_team_ids": ["team-1"], + }, + ) + assert resp.status_code == 201 + body = resp.json() + assert body["access_mcp_servers"] == [{"id": "mcp-a", "name": "GitHub"}] + assert body["access_agents"] == [{"id": "agent-a", "name": "support-bot"}] + assert body["assigned_teams"] == [{"id": "team-1", "name": "Platform"}] + + +def test_update_access_group_response_carries_resolved_names(client_and_mocks): + """The update response reflects the new ids with their names, not the pre-update state.""" + client, mock_prisma, mock_table, *_ = client_and_mocks + mock_table.find_unique = AsyncMock( + return_value=_make_access_group_record(access_group_id="ag-update", access_mcp_server_ids=["mcp-old"]) + ) + _mock_resource_tables( + mock_prisma, + mcp_servers=[_make_mcp_server_record("mcp-new", alias="Linear")], + agents=[_make_agent_record("agent-a", "support-bot")], + ) + + resp = client.put( + "/v1/access_group/ag-update", json={"access_mcp_server_ids": ["mcp-new"], "access_agent_ids": ["agent-a"]} + ) + assert resp.status_code == 200 + body = resp.json() + assert body["access_mcp_servers"] == [{"id": "mcp-new", "name": "Linear"}] + assert body["access_agents"] == [{"id": "agent-a", "name": "support-bot"}] + assert body["access_mcp_server_ids"] == ["mcp-new"] diff --git a/tests/test_litellm/proxy/management_helpers/test_resource_display_names.py b/tests/test_litellm/proxy/management_helpers/test_resource_display_names.py new file mode 100644 index 00000000000..b530bc15c25 --- /dev/null +++ b/tests/test_litellm/proxy/management_helpers/test_resource_display_names.py @@ -0,0 +1,130 @@ +import types +from types import MappingProxyType +from unittest.mock import AsyncMock + +import pytest + +from litellm.proxy.agent_endpoints.agent_registry import AgentRegistry +from litellm.proxy.management_helpers.resource_display_names import ( + agent_display_names, + key_display_names, + mcp_server_display_names, +) +from litellm.types.agents import AgentResponse +from litellm.types.mcp_server.mcp_server_manager import MCPServer + + +def _table(rows=()): + return types.SimpleNamespace(find_many=AsyncMock(return_value=list(rows))) + + +def _prisma(**tables): + return types.SimpleNamespace(db=types.SimpleNamespace(**tables)) + + +def _config_server(server_id: str, name: str, alias: str | None = None, server_name: str | None = None) -> MCPServer: + return MCPServer(server_id=server_id, name=name, alias=alias, server_name=server_name, transport="http") + + +def _registry_with(*agents: AgentResponse, legacy_ids: dict[str, str] | None = None) -> AgentRegistry: + registry = AgentRegistry() + for agent in agents: + registry.register_agent(agent) + registry.config_agent_legacy_ids = MappingProxyType(legacy_ids or {}) + return registry + + +def _agent(agent_id: str, agent_name: str) -> AgentResponse: + return AgentResponse(agent_id=agent_id, agent_name=agent_name, agent_card_params={}) + + +@pytest.mark.asyncio +async def test_mcp_db_row_beats_config_entry_for_the_same_server(): + """The DB is authoritative when both sources know a server; the registry may lag behind a rename on another pod.""" + prisma = _prisma( + litellm_mcpservertable=_table([types.SimpleNamespace(server_id="s1", alias="db-alias", server_name=None)]) + ) + names = await mcp_server_display_names(prisma, ("s1",), {"s1": _config_server("s1", "config-name")}) + assert dict(names) == {"s1": "db-alias"} + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("alias", "server_name", "expected"), + [("Alias", "server_name", "Alias"), (None, "server_name", "server_name"), (None, None, "config-name")], +) +async def test_mcp_config_only_server_falls_back_alias_then_server_name_then_name(alias, server_name, expected): + """Config-declared servers have no DB row, so their registry entry supplies the label.""" + prisma = _prisma(litellm_mcpservertable=_table()) + config = {"s1": _config_server("s1", "config-name", alias=alias, server_name=server_name)} + names = await mcp_server_display_names(prisma, ("s1",), config) + assert dict(names) == {"s1": expected} + + +@pytest.mark.asyncio +async def test_mcp_db_row_without_alias_or_server_name_yields_no_label(): + """A bare DB row must not produce an empty string label; the caller falls back to the id.""" + prisma = _prisma( + litellm_mcpservertable=_table([types.SimpleNamespace(server_id="s1", alias=None, server_name=None)]) + ) + assert dict(await mcp_server_display_names(prisma, ("s1",), {})) == {} + + +@pytest.mark.asyncio +async def test_mcp_only_requested_ids_are_returned_and_the_query_is_deduped(): + """Unrequested config servers stay out of the result and repeated ids collapse to one IN filter entry.""" + table = _table([types.SimpleNamespace(server_id="s1", alias="A", server_name=None)]) + prisma = _prisma(litellm_mcpservertable=table) + config = {"other": _config_server("other", "not-requested")} + names = await mcp_server_display_names(prisma, ("s1", "s1", "missing"), config) + assert dict(names) == {"s1": "A"} + assert sorted(table.find_many.call_args.kwargs["where"]["server_id"]["in"]) == ["missing", "s1"] + + +@pytest.mark.asyncio +async def test_mcp_empty_ids_skip_the_db(): + table = _table() + names = await mcp_server_display_names(_prisma(litellm_mcpservertable=table), (), {}) + assert dict(names) == {} + table.find_many.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_agent_db_name_beats_registry_name(): + prisma = _prisma(litellm_agentstable=_table([types.SimpleNamespace(agent_id="a1", agent_name="from-db")])) + registry = _registry_with(_agent("a1", "from-registry")) + assert dict(await agent_display_names(prisma, ("a1",), registry)) == {"a1": "from-db"} + + +@pytest.mark.asyncio +async def test_agent_legacy_config_id_resolves_to_the_stable_agent_name(): + """Access groups saved before agent ids were stabilised still carry the legacy hash; it must still get a name.""" + prisma = _prisma(litellm_agentstable=_table()) + registry = _registry_with(_agent("stable-id", "config-agent"), legacy_ids={"legacy-id": "stable-id"}) + names = await agent_display_names(prisma, ("legacy-id", "stable-id", "unknown"), registry) + assert dict(names) == {"legacy-id": "config-agent", "stable-id": "config-agent"} + + +@pytest.mark.asyncio +async def test_agent_empty_ids_skip_the_db(): + table = _table() + names = await agent_display_names(_prisma(litellm_agentstable=table), (), _registry_with()) + assert dict(names) == {} + table.find_many.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_key_alias_only_for_keys_that_have_one(): + table = _table( + [types.SimpleNamespace(token="k1", key_alias="ci-key"), types.SimpleNamespace(token="k2", key_alias=None)] + ) + names = await key_display_names(_prisma(litellm_verificationtoken=table), ("k1", "k2", "k1")) + assert dict(names) == {"k1": "ci-key"} + assert sorted(table.find_many.call_args.kwargs["where"]["token"]["in"]) == ["k1", "k2"] + + +@pytest.mark.asyncio +async def test_key_empty_ids_skip_the_db(): + table = _table() + assert dict(await key_display_names(_prisma(litellm_verificationtoken=table), ())) == {} + table.find_many.assert_not_awaited() diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx index cf41f623fd6..aad63e979ac 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.test.tsx @@ -7,6 +7,7 @@ import { renderWithProviders } from "../../../../../tests/test-utils"; import { AccessGroupDetail } from "./AccessGroupsDetailsPage"; vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails"); +vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); vi.mock("./AccessGroupsModal/AccessGroupEditModal", () => ({ AccessGroupEditModal: ({ visible, onCancel }: { visible: boolean; onCancel: () => void }) => visible ? ( @@ -44,6 +45,8 @@ const baseMockReturnValue = { refetch: vi.fn(), } as unknown as ReturnType; +const unnamed = (ids: readonly string[]) => ids.map((id) => ({ id, name: null })); + const createMockAccessGroup = (overrides: Partial = {}): AccessGroupResponse => ({ access_group_id: "ag-1", access_group_name: "Test Group", @@ -53,6 +56,13 @@ const createMockAccessGroup = (overrides: Partial = {}): Ac access_agent_ids: ["agent-1"], assigned_team_ids: ["team-1"], assigned_key_ids: ["key-1", "key-2"], + access_mcp_servers: [{ id: "mcp-1", name: "GitHub MCP" }], + access_agents: [{ id: "agent-1", name: "Support Agent" }], + assigned_teams: [{ id: "team-1", name: "Platform Team" }], + assigned_keys: [ + { id: "key-1", name: "ci-key" }, + { id: "key-2", name: null }, + ], created_at: "2025-01-01T00:00:00Z", created_by: null, updated_at: "2025-01-02T00:00:00Z", @@ -60,6 +70,14 @@ const createMockAccessGroup = (overrides: Partial = {}): Ac ...overrides, }); +const renderWith = (overrides: Partial = {}) => { + mockUseAccessGroupDetails.mockReturnValue({ + ...baseMockReturnValue, + data: createMockAccessGroup(overrides), + } as ReturnType); + return renderWithProviders(); +}; + describe("AccessGroupDetail", () => { const mockOnBack = vi.fn(); const accessGroupId = "ag-1"; @@ -106,9 +124,7 @@ describe("AccessGroupDetail", () => { const user = userEvent.setup(); renderWithProviders(); - const buttons = screen.getAllByRole("button"); - const backButton = buttons.find((btn) => !btn.textContent?.includes("Edit")); - await user.click(backButton!); + await user.click(screen.getByRole("button", { name: "Back" })); expect(mockOnBack).toHaveBeenCalledTimes(1); }); @@ -128,12 +144,7 @@ describe("AccessGroupDetail", () => { }); it("should display em dash when description is empty", () => { - mockUseAccessGroupDetails.mockReturnValue({ - ...baseMockReturnValue, - data: createMockAccessGroup({ description: null }), - } as ReturnType); - - renderWithProviders(); + renderWith({ description: null }); expect(screen.getByText("—")).toBeInTheDocument(); }); @@ -144,8 +155,7 @@ describe("AccessGroupDetail", () => { expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument(); - const editButton = screen.getByRole("button", { name: /Edit Access Group/i }); - await user.click(editButton); + await user.click(screen.getByRole("button", { name: /Edit Access Group/i })); expect(screen.getByRole("dialog", { name: "Edit Access Group" })).toBeInTheDocument(); }); @@ -161,88 +171,126 @@ describe("AccessGroupDetail", () => { expect(screen.queryByRole("dialog", { name: "Edit Access Group" })).not.toBeInTheDocument(); }); - it("should display attached keys", () => { - renderWithProviders(); + describe("attached keys", () => { + it("should show the key alias and hide the token when the key has an alias", () => { + renderWithProviders(); - expect(screen.getByText("Attached Keys")).toBeInTheDocument(); - expect(screen.getByText("key-1")).toBeInTheDocument(); - expect(screen.getByText("key-2")).toBeInTheDocument(); + expect(screen.getByText("Attached Keys")).toBeInTheDocument(); + expect(screen.getByText("ci-key")).toBeInTheDocument(); + expect(screen.queryByText("key-1")).not.toBeInTheDocument(); + }); + + it("should fall back to the token when the key has no alias", () => { + renderWithProviders(); + + expect(screen.getByText("key-2")).toBeInTheDocument(); + }); + + it("should link each key to its detail page", () => { + renderWithProviders(); + + expect(screen.getByRole("link", { name: "ci-key" })).toHaveAttribute( + "href", + expect.stringContaining("key=key-1"), + ); + expect(screen.getByRole("link", { name: "key-2" })).toHaveAttribute("href", expect.stringContaining("key=key-2")); + }); + + it("should reveal the token in a tooltip when hovering an aliased key", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.hover(screen.getByText("ci-key")); + + expect(await screen.findByText("key-1")).toBeInTheDocument(); + }); + + it("should show View All button for keys when more than 5", () => { + renderWith({ assigned_keys: unnamed(["k1", "k2", "k3", "k4", "k5", "k6"]) }); + + expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); + expect(screen.queryByText("k6")).not.toBeInTheDocument(); + }); + + it("should toggle between View All and Show Less for keys", async () => { + const user = userEvent.setup(); + renderWith({ assigned_keys: unnamed(["k1", "k2", "k3", "k4", "k5", "k6"]) }); + + await user.click(screen.getByRole("button", { name: "View All (6)" })); + expect(screen.getByRole("button", { name: "Show Less" })).toBeInTheDocument(); + expect(screen.getByText("k6")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Show Less" })); + expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); + }); + + it("should show empty state when no keys attached", () => { + renderWith({ assigned_keys: [] }); + + expect(screen.getByText("No keys attached")).toBeInTheDocument(); + }); + + it("should truncate long unaliased tokens with ellipsis", () => { + renderWith({ assigned_keys: unnamed(["a".repeat(25)]) }); + + expect(screen.getByText(/^a{10}\.\.\.a{6}$/)).toBeInTheDocument(); + }); + + it("should not truncate a long alias", () => { + const alias = "b".repeat(25); + renderWith({ assigned_keys: [{ id: "a".repeat(25), name: alias }] }); + + expect(screen.getByText(alias)).toBeInTheDocument(); + }); }); - it("should display attached teams", () => { - renderWithProviders(); + describe("attached teams", () => { + it("should show the team alias and hide the id when the team has an alias", () => { + renderWithProviders(); - expect(screen.getByText("Attached Teams")).toBeInTheDocument(); - expect(screen.getByText("team-1")).toBeInTheDocument(); + expect(screen.getByText("Attached Teams")).toBeInTheDocument(); + expect(screen.getByText("Platform Team")).toBeInTheDocument(); + expect(screen.queryByText("team-1")).not.toBeInTheDocument(); + }); + + it("should link each team to its detail page", () => { + renderWithProviders(); + + expect(screen.getByRole("link", { name: "Platform Team" })).toHaveAttribute( + "href", + expect.stringContaining("team=team-1"), + ); + }); + + it("should reveal the team id in a tooltip when hovering an aliased team", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.hover(screen.getByText("Platform Team")); + + expect(await screen.findByText("team-1")).toBeInTheDocument(); + }); + + it("should fall back to the team id when the team has no alias", () => { + renderWith({ assigned_teams: unnamed(["team-ghost"]) }); + + expect(screen.getByText("team-ghost")).toBeInTheDocument(); + }); + + it("should show View All button for teams when more than 5", () => { + renderWith({ assigned_teams: unnamed(["t1", "t2", "t3", "t4", "t5", "t6"]) }); + + expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); + }); + + it("should show empty state when no teams attached", () => { + renderWith({ assigned_teams: [] }); + + expect(screen.getByText("No teams attached")).toBeInTheDocument(); + }); }); - it("should show View All button for keys when more than 5", () => { - mockUseAccessGroupDetails.mockReturnValue({ - ...baseMockReturnValue, - data: createMockAccessGroup({ - assigned_key_ids: ["k1", "k2", "k3", "k4", "k5", "k6"], - }), - } as ReturnType); - - renderWithProviders(); - - expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); - }); - - it("should toggle between View All and Show Less for keys", async () => { - const user = userEvent.setup(); - mockUseAccessGroupDetails.mockReturnValue({ - ...baseMockReturnValue, - data: createMockAccessGroup({ - assigned_key_ids: ["k1", "k2", "k3", "k4", "k5", "k6"], - }), - } as ReturnType); - - renderWithProviders(); - - await user.click(screen.getByRole("button", { name: "View All (6)" })); - expect(screen.getByRole("button", { name: "Show Less" })).toBeInTheDocument(); - - await user.click(screen.getByRole("button", { name: "Show Less" })); - expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); - }); - - it("should show View All button for teams when more than 5", () => { - mockUseAccessGroupDetails.mockReturnValue({ - ...baseMockReturnValue, - data: createMockAccessGroup({ - assigned_team_ids: ["t1", "t2", "t3", "t4", "t5", "t6"], - }), - } as ReturnType); - - renderWithProviders(); - - expect(screen.getByRole("button", { name: "View All (6)" })).toBeInTheDocument(); - }); - - it("should show empty state when no keys attached", () => { - mockUseAccessGroupDetails.mockReturnValue({ - ...baseMockReturnValue, - data: createMockAccessGroup({ assigned_key_ids: [] }), - } as ReturnType); - - renderWithProviders(); - - expect(screen.getByText("No keys attached")).toBeInTheDocument(); - }); - - it("should show empty state when no teams attached", () => { - mockUseAccessGroupDetails.mockReturnValue({ - ...baseMockReturnValue, - data: createMockAccessGroup({ assigned_team_ids: [] }), - } as ReturnType); - - renderWithProviders(); - - expect(screen.getByText("No teams attached")).toBeInTheDocument(); - }); - - it("should display Models tab with model IDs", () => { + it("should display Models tab with model names", () => { renderWithProviders(); expect(screen.getByRole("tab", { name: /Models/i })).toBeInTheDocument(); @@ -250,73 +298,90 @@ describe("AccessGroupDetail", () => { expect(screen.getByText("model-2")).toBeInTheDocument(); }); - it("should display MCP Servers tab with server IDs", async () => { - const user = userEvent.setup(); - renderWithProviders(); + describe("MCP Servers tab", () => { + it("should show server names instead of ids", async () => { + const user = userEvent.setup(); + renderWithProviders(); - const mcpTab = screen.getByRole("tab", { name: /MCP Servers/i }); - expect(mcpTab).toBeInTheDocument(); - await user.click(mcpTab); - expect(screen.getByText("mcp-1")).toBeInTheDocument(); + await user.click(screen.getByRole("tab", { name: /MCP Servers/i })); + + expect(screen.getByText("GitHub MCP")).toBeInTheDocument(); + expect(screen.queryByText("mcp-1")).not.toBeInTheDocument(); + }); + + it("should reveal the server id in a tooltip when hovering the name", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("tab", { name: /MCP Servers/i })); + await user.hover(screen.getByText("GitHub MCP")); + + expect(await screen.findByText("mcp-1")).toBeInTheDocument(); + }); + + it("should fall back to the id when the server has no name", async () => { + const user = userEvent.setup(); + renderWith({ access_mcp_servers: unnamed(["mcp-deleted"]) }); + + await user.click(screen.getByRole("tab", { name: /MCP Servers/i })); + + expect(screen.getByText("mcp-deleted")).toBeInTheDocument(); + }); + + it("should show empty state when none assigned", async () => { + const user = userEvent.setup(); + renderWith({ access_mcp_servers: [] }); + + await user.click(screen.getByRole("tab", { name: /MCP Servers/i })); + + expect(screen.getByText("No MCP servers assigned to this group")).toBeInTheDocument(); + }); }); - it("should display Agents tab with agent IDs", async () => { - const user = userEvent.setup(); - renderWithProviders(); + describe("Agents tab", () => { + it("should show agent names instead of ids", async () => { + const user = userEvent.setup(); + renderWithProviders(); - const agentsTab = screen.getByRole("tab", { name: /Agents/i }); - expect(agentsTab).toBeInTheDocument(); - await user.click(agentsTab); - expect(screen.getByText("agent-1")).toBeInTheDocument(); + await user.click(screen.getByRole("tab", { name: /Agents/i })); + + expect(screen.getByText("Support Agent")).toBeInTheDocument(); + expect(screen.queryByText("agent-1")).not.toBeInTheDocument(); + }); + + it("should fall back to the id when the agent has no name", async () => { + const user = userEvent.setup(); + renderWith({ access_agents: unnamed(["agent-deleted"]) }); + + await user.click(screen.getByRole("tab", { name: /Agents/i })); + + expect(screen.getByText("agent-deleted")).toBeInTheDocument(); + }); + + it("should show empty state when none assigned", async () => { + const user = userEvent.setup(); + renderWith({ access_agents: [] }); + + await user.click(screen.getByRole("tab", { name: /Agents/i })); + + expect(screen.getByText("No agents assigned to this group")).toBeInTheDocument(); + }); }); it("should show empty state in Models tab when no models assigned", () => { - mockUseAccessGroupDetails.mockReturnValue({ - ...baseMockReturnValue, - data: createMockAccessGroup({ access_model_names: [] }), - } as ReturnType); - - renderWithProviders(); + renderWith({ access_model_names: [] }); expect(screen.getByText("No models assigned to this group")).toBeInTheDocument(); }); - it("should show empty state in MCP Servers tab when none assigned", async () => { - const user = userEvent.setup(); - mockUseAccessGroupDetails.mockReturnValue({ - ...baseMockReturnValue, - data: createMockAccessGroup({ access_mcp_server_ids: [] }), - } as ReturnType); + it("should count resources from the resolved lists in the tab badges", () => { + renderWith({ + access_mcp_servers: unnamed(["m1", "m2", "m3"]), + access_agents: unnamed(["a1", "a2"]), + }); - renderWithProviders(); - - await user.click(screen.getByRole("tab", { name: /MCP Servers/i })); - expect(screen.getByText("No MCP servers assigned to this group")).toBeInTheDocument(); - }); - - it("should show empty state in Agents tab when none assigned", async () => { - const user = userEvent.setup(); - mockUseAccessGroupDetails.mockReturnValue({ - ...baseMockReturnValue, - data: createMockAccessGroup({ access_agent_ids: [] }), - } as ReturnType); - - renderWithProviders(); - - await user.click(screen.getByRole("tab", { name: /Agents/i })); - expect(screen.getByText("No agents assigned to this group")).toBeInTheDocument(); - }); - - it("should truncate long key IDs with ellipsis", () => { - const longKeyId = "a".repeat(25); - mockUseAccessGroupDetails.mockReturnValue({ - ...baseMockReturnValue, - data: createMockAccessGroup({ assigned_key_ids: [longKeyId] }), - } as ReturnType); - - renderWithProviders(); - - expect(screen.getByText(/a{10}\.\.\.a{6}/)).toBeInTheDocument(); + expect(screen.getByRole("tab", { name: /MCP Servers/i })).toHaveTextContent("3"); + expect(screen.getByRole("tab", { name: /Agents/i })).toHaveTextContent("2"); }); it("should display created and last updated timestamps", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx index 9476a8d98af..1eeebe4ebba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsDetailsPage.tsx @@ -2,14 +2,20 @@ import { useAccessGroupDetails } from "@/app/(dashboard)/hooks/accessGroups/useA import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react"; import { useState } from "react"; import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; +import { BadgeLink } from "@/components/shared/BadgeLink"; import CopyButton from "@/components/shared/CopyButton"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardAction, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { SimpleTooltip } from "@/components/ui/tooltip"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; +import type { components } from "@/lib/http/schema"; +import { keyDetailHref, teamDetailHref } from "@/utils/entityLinks"; import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal"; +type AccessGroupResource = components["schemas"]["AccessGroupResource"]; + interface AccessGroupDetailProps { accessGroupId: string; onBack: () => void; @@ -17,16 +23,24 @@ interface AccessGroupDetailProps { const MAX_PREVIEW = 5; -function ResourceList({ ids, emptyMessage }: { ids: string[]; emptyMessage: string }) { - if (ids.length === 0) { +const shortId = (id: string) => (id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id); + +function ResourceList({ items, emptyMessage }: { items: readonly AccessGroupResource[]; emptyMessage: string }) { + if (items.length === 0) { return

{emptyMessage}

; } return (
- {ids.map((id) => ( + {items.map(({ id, name }) => ( - {id} + {name ? ( + + {name} + + ) : ( + {id} + )} ))} @@ -34,6 +48,23 @@ function ResourceList({ ids, emptyMessage }: { ids: string[]; emptyMessage: stri ); } +function ResourceBadge({ + resource: { id, name }, + href, + fallback, +}: { + resource: AccessGroupResource; + href: string; + fallback: (id: string) => string; +}) { + const badge = ( + + {name ?? fallback(id)} + + ); + return name ? {badge} : badge; +} + export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailProps) { const { data: accessGroup, isLoading } = useAccessGroupDetails(accessGroupId); const [isEditModalVisible, setIsEditModalVisible] = useState(false); @@ -61,14 +92,14 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr ); } - const modelIds = accessGroup.access_model_names ?? []; - const mcpServerIds = accessGroup.access_mcp_server_ids ?? []; - const agentIds = accessGroup.access_agent_ids ?? []; - const keyIds = accessGroup.assigned_key_ids ?? []; - const teamIds = accessGroup.assigned_team_ids ?? []; + const models = accessGroup.access_model_names.map((id) => ({ id, name: null })); + const mcpServers = accessGroup.access_mcp_servers; + const agents = accessGroup.access_agents; + const keys = accessGroup.assigned_keys; + const teams = accessGroup.assigned_teams; - const displayedKeys = showAllKeys ? keyIds : keyIds.slice(0, MAX_PREVIEW); - const displayedTeams = showAllTeams ? teamIds : teamIds.slice(0, MAX_PREVIEW); + const displayedKeys = showAllKeys ? keys : keys.slice(0, MAX_PREVIEW); + const displayedTeams = showAllTeams ? teams : teams.slice(0, MAX_PREVIEW); return (
@@ -129,23 +160,21 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr Attached Keys - {keyIds.length} + {keys.length} - {keyIds.length > MAX_PREVIEW && ( + {keys.length > MAX_PREVIEW && ( )} - {keyIds.length > 0 ? ( + {keys.length > 0 ? (
- {displayedKeys.map((id) => ( - - {id.length > 20 ? `${id.slice(0, 10)}...${id.slice(-6)}` : id} - + {displayedKeys.map((key) => ( + ))}
) : ( @@ -159,23 +188,21 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr Attached Teams - {teamIds.length} + {teams.length} - {teamIds.length > MAX_PREVIEW && ( + {teams.length > MAX_PREVIEW && ( )} - {teamIds.length > 0 ? ( + {teams.length > 0 ? (
- {displayedTeams.map((id) => ( - - {id} - + {displayedTeams.map((team) => ( + id} /> ))}
) : ( @@ -192,27 +219,27 @@ export function AccessGroupDetail({ accessGroupId, onBack }: AccessGroupDetailPr Models - {modelIds.length} + {models.length} MCP Servers - {mcpServerIds.length} + {mcpServers.length} Agents - {agentIds.length} + {agents.length} - + - + - +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx index 2e65be36796..bd77ad8e897 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsModal/AccessGroupEditModal.integration.test.tsx @@ -42,6 +42,10 @@ const accessGroup: AccessGroupResponse = { access_agent_ids: ["agent-1"], assigned_team_ids: [], assigned_key_ids: [], + access_mcp_servers: [{ id: "srv-1", name: "Server One" }], + access_agents: [{ id: "agent-1", name: "Agent One" }], + assigned_teams: [], + assigned_keys: [], created_at: "2024-01-01T00:00:00Z", created_by: "user-1", updated_at: "2024-01-02T00:00:00Z", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx index 63ff0f4100f..12d3d773c1f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/_components/AccessGroupsPage.test.tsx @@ -15,6 +15,10 @@ const mockAccessGroups: AccessGroupResponse[] = [ access_agent_ids: ["a1"], assigned_team_ids: [], assigned_key_ids: [], + access_mcp_servers: [{ id: "s1", name: "Server One" }], + access_agents: [{ id: "a1", name: "Agent One" }], + assigned_teams: [], + assigned_keys: [], created_at: "2024-01-15T10:00:00Z", created_by: "user-1", updated_at: "2024-01-20T12:00:00Z", @@ -29,6 +33,10 @@ const mockAccessGroups: AccessGroupResponse[] = [ access_agent_ids: [], assigned_team_ids: [], assigned_key_ids: [], + access_mcp_servers: [], + access_agents: [], + assigned_teams: [], + assigned_keys: [], created_at: "2024-01-10T09:00:00Z", created_by: null, updated_at: "2024-01-12T11:00:00Z", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts index b15ea4491e9..14cae5b1c1f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.test.ts @@ -46,6 +46,10 @@ const mockAccessGroups: AccessGroupResponse[] = [ access_agent_ids: [], assigned_team_ids: [], assigned_key_ids: [], + access_mcp_servers: [], + access_agents: [], + assigned_teams: [], + assigned_keys: [], created_at: "2025-01-01T00:00:00Z", created_by: "user-1", updated_at: "2025-01-01T00:00:00Z", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts index 9f306c21459..b251c019187 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/accessGroups/useAccessGroups.ts @@ -3,23 +3,11 @@ import { createQueryKeys } from "../common/queryKeysFactory"; import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking"; import { all_admin_roles } from "@/utils/roles"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import type { components } from "@/lib/http/schema"; // ── Types ──────────────────────────────────────────────────────────────────── -export interface AccessGroupResponse { - access_group_id: string; - access_group_name: string; - description: string | null; - access_model_names: string[]; - access_mcp_server_ids: string[]; - access_agent_ids: string[]; - assigned_team_ids: string[]; - assigned_key_ids: string[]; - created_at: string; - created_by: string | null; - updated_at: string; - updated_by: string | null; -} +export type AccessGroupResponse = components["schemas"]["AccessGroupResponse"]; // ── Query keys (shared across access-group hooks) ──────────────────────────── diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index d94b5425ba9..d6459c06b90 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -22764,22 +22764,40 @@ export interface components { /** Spend */ spend?: number | null; }; + /** + * AccessGroupResource + * @description A resource referenced by an access group. `name` is null when the id no longer resolves or has no alias. + */ + AccessGroupResource: { + /** Id */ + id: string; + /** Name */ + name: string | null; + }; /** AccessGroupResponse */ AccessGroupResponse: { /** Access Agent Ids */ access_agent_ids: string[]; + /** Access Agents */ + access_agents: components["schemas"]["AccessGroupResource"][]; /** Access Group Id */ access_group_id: string; /** Access Group Name */ access_group_name: string; /** Access Mcp Server Ids */ access_mcp_server_ids: string[]; + /** Access Mcp Servers */ + access_mcp_servers: components["schemas"]["AccessGroupResource"][]; /** Access Model Names */ access_model_names: string[]; /** Assigned Key Ids */ assigned_key_ids: string[]; + /** Assigned Keys */ + assigned_keys: components["schemas"]["AccessGroupResource"][]; /** Assigned Team Ids */ assigned_team_ids: string[]; + /** Assigned Teams */ + assigned_teams: components["schemas"]["AccessGroupResource"][]; /** * Created At * Format: date-time From b29f9a94bccd10406fb3a78610041fc397a141c1 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 4 Sep 2026 16:09:01 -0700 Subject: [PATCH 118/410] refactor(router): resolve retry policy by exception MRO and add DefaultRetries Replace the hand-ordered isinstance ladder in get_num_retries_from_retry_policy with a class-to-field mapping walked along the exception's MRO, most specific class first. A RetryPolicy field can no longer go silently dead the way InternalServerErrorRetries did, and subclasses such as ContentPolicyViolationError or MidStreamFallbackError pick up their parent's field when they have none of their own. Add a DefaultRetries catch-all so errors without a dedicated field (BadGatewayError, APIConnectionError, NotFoundError, ...) can be governed by the policy too. Specific fields still win over DefaultRetries. Wiring the previously dead InternalServerErrorRetries changes one test expectation: a policy of 2 now overrides a per-deployment num_retries of 5, so the amplification test sees 3 upstream requests instead of 6. Expose DefaultRetries as "All other errors" in the Admin UI retry settings tab and ratchet the lint budgets down by the violations this branch fixed. --- basedpyright-code-budget.json | 8 +- litellm/router_utils/get_retry_from_policy.py | 83 +++++---- litellm/types/router.py | 1 + ruff-strict-budget.json | 2 +- .../test_get_retry_from_policy.py | 169 +++++++++++------- tests/test_litellm/test_router.py | 31 ++-- .../test_router_per_deployment_num_retries.py | 7 +- type-discipline-budget.json | 4 +- .../components/ModelRetrySettingsTab.tsx | 1 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 10 files changed, 179 insertions(+), 129 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 9b59480a0dc..669107bb5b1 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -57,7 +57,7 @@ "limit": 5601 }, "reportMissingTypeArgument": { - "limit": 15285 + "limit": 15284 }, "reportMissingTypeStubs": { "limit": 40 @@ -99,7 +99,7 @@ "limit": 0 }, "reportUnknownArgumentType": { - "limit": 44360 + "limit": 44358 }, "reportUnknownLambdaType": { "limit": 109 @@ -108,10 +108,10 @@ "limit": 38309 }, "reportUnknownParameterType": { - "limit": 19622 + "limit": 19621 }, "reportUnknownVariableType": { - "limit": 29846 + "limit": 29844 }, "reportUnnecessaryCast": { "limit": 111 diff --git a/litellm/router_utils/get_retry_from_policy.py b/litellm/router_utils/get_retry_from_policy.py index 051cde127bf..ad4a6b0be99 100644 --- a/litellm/router_utils/get_retry_from_policy.py +++ b/litellm/router_utils/get_retry_from_policy.py @@ -1,8 +1,8 @@ -""" -Get num retries for an exception. +"""Resolve how many retries a RetryPolicy grants for a given exception.""" -- Account for retry policy by exception type. -""" +from collections.abc import Callable, Mapping +from types import MappingProxyType +from typing import Final from litellm.exceptions import ( AuthenticationError, @@ -15,49 +15,48 @@ from litellm.exceptions import ( ) from litellm.types.router import RetryPolicy +_RETRIES_BY_EXCEPTION_TYPE: Final[Mapping[type, Callable[[RetryPolicy], int | None]]] = MappingProxyType( + { + AuthenticationError: lambda policy: policy.AuthenticationErrorRetries, + Timeout: lambda policy: policy.TimeoutErrorRetries, + RateLimitError: lambda policy: policy.RateLimitErrorRetries, + ContentPolicyViolationError: lambda policy: policy.ContentPolicyViolationErrorRetries, + BadRequestError: lambda policy: policy.BadRequestErrorRetries, + ServiceUnavailableError: lambda policy: policy.ServiceUnavailableErrorRetries, + InternalServerError: lambda policy: policy.InternalServerErrorRetries, + } +) + + +def _resolve_policy( + retry_policy: RetryPolicy | Mapping[str, int | None] | None, + model_group: str | None, + model_group_retry_policy: Mapping[str, RetryPolicy | Mapping[str, int | None]] | None, +) -> RetryPolicy | None: + selected: Final = ( + model_group_retry_policy[model_group] + if model_group_retry_policy is not None and model_group is not None and model_group in model_group_retry_policy + else retry_policy + ) + if isinstance(selected, Mapping): + return RetryPolicy(**selected) + return selected + def get_num_retries_from_retry_policy( exception: Exception, - retry_policy: RetryPolicy | dict | None = None, + retry_policy: RetryPolicy | Mapping[str, int | None] | None = None, model_group: str | None = None, - model_group_retry_policy: dict[str, RetryPolicy] | None = None, -): - """ - BadRequestErrorRetries: Optional[int] = None - AuthenticationErrorRetries: Optional[int] = None - TimeoutErrorRetries: Optional[int] = None - RateLimitErrorRetries: Optional[int] = None - ContentPolicyViolationErrorRetries: Optional[int] = None - InternalServerErrorRetries: Optional[int] = None - ServiceUnavailableErrorRetries: Optional[int] = None - """ - # if we can find the exception then in the retry policy -> return the number of retries - - if model_group_retry_policy is not None and model_group is not None and model_group in model_group_retry_policy: - retry_policy = model_group_retry_policy.get(model_group, None) - - if retry_policy is None: + model_group_retry_policy: Mapping[str, RetryPolicy | Mapping[str, int | None]] | None = None, +) -> int | None: + """Walk the exception's MRO, most specific class first, and return the first configured retry count.""" + policy: Final = _resolve_policy(retry_policy, model_group, model_group_retry_policy) + if policy is None: return None - if isinstance(retry_policy, dict): - retry_policy = RetryPolicy(**retry_policy) - - if isinstance(exception, AuthenticationError) and retry_policy.AuthenticationErrorRetries is not None: - return retry_policy.AuthenticationErrorRetries - if isinstance(exception, Timeout) and retry_policy.TimeoutErrorRetries is not None: - return retry_policy.TimeoutErrorRetries - if isinstance(exception, RateLimitError) and retry_policy.RateLimitErrorRetries is not None: - return retry_policy.RateLimitErrorRetries - if ( - isinstance(exception, ContentPolicyViolationError) - and retry_policy.ContentPolicyViolationErrorRetries is not None - ): - return retry_policy.ContentPolicyViolationErrorRetries - if isinstance(exception, ServiceUnavailableError) and retry_policy.ServiceUnavailableErrorRetries is not None: - return retry_policy.ServiceUnavailableErrorRetries - if isinstance(exception, InternalServerError) and retry_policy.InternalServerErrorRetries is not None: - return retry_policy.InternalServerErrorRetries - if isinstance(exception, BadRequestError) and retry_policy.BadRequestErrorRetries is not None: - return retry_policy.BadRequestErrorRetries + configured: Final = ( + _RETRIES_BY_EXCEPTION_TYPE[cls](policy) for cls in type(exception).__mro__ if cls in _RETRIES_BY_EXCEPTION_TYPE + ) + return next((retries for retries in configured if retries is not None), policy.DefaultRetries) def reset_retry_policy() -> RetryPolicy: diff --git a/litellm/types/router.py b/litellm/types/router.py index 6ed9b3efd03..267e8853db1 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -105,6 +105,7 @@ class RetryPolicy(BaseModel): ContentPolicyViolationErrorRetries: int | None = None InternalServerErrorRetries: int | None = None ServiceUnavailableErrorRetries: int | None = None + DefaultRetries: int | None = None OptionalPreCallChecks = list[ diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 4aac1756af4..70408ea022b 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -9,7 +9,7 @@ "limit": 809 }, "ANN201": { - "limit": 1999 + "limit": 1998 }, "ANN202": { "limit": 835 diff --git a/tests/test_litellm/router_utils/test_get_retry_from_policy.py b/tests/test_litellm/router_utils/test_get_retry_from_policy.py index a5e239b8595..df157ea5ff7 100644 --- a/tests/test_litellm/router_utils/test_get_retry_from_policy.py +++ b/tests/test_litellm/router_utils/test_get_retry_from_policy.py @@ -1,102 +1,147 @@ +from types import MappingProxyType +from typing import Final + +import pytest + import litellm -from litellm.router_utils.get_retry_from_policy import ( - get_num_retries_from_retry_policy, -) +from litellm.router_utils.get_retry_from_policy import get_num_retries_from_retry_policy from litellm.types.router import RetryPolicy +_EXCEPTION_FOR_FIELD: Final = MappingProxyType( + { + "BadRequestErrorRetries": litellm.BadRequestError, + "AuthenticationErrorRetries": litellm.AuthenticationError, + "TimeoutErrorRetries": litellm.Timeout, + "RateLimitErrorRetries": litellm.RateLimitError, + "ContentPolicyViolationErrorRetries": litellm.ContentPolicyViolationError, + "InternalServerErrorRetries": litellm.InternalServerError, + "ServiceUnavailableErrorRetries": litellm.ServiceUnavailableError, + } +) -def _service_unavailable_error() -> litellm.ServiceUnavailableError: - return litellm.ServiceUnavailableError( - message="model is down", - llm_provider="openai", - model="gpt-5.6", +_SPECIFIC_FIELDS: Final = tuple(name for name in RetryPolicy.model_fields if name != "DefaultRetries") + + +def _error(exception_type: type[Exception]) -> Exception: + return exception_type(message="boom", llm_provider="openai", model="gpt-5.6") + + +@pytest.mark.parametrize("field", _SPECIFIC_FIELDS) +def test_every_specific_field_controls_retries_for_its_exception(field: str): + exception: Final = _error(_EXCEPTION_FOR_FIELD[field]) + + assert get_num_retries_from_retry_policy(exception=exception, retry_policy=RetryPolicy(**{field: 0})) == 0 + assert get_num_retries_from_retry_policy(exception=exception, retry_policy=RetryPolicy(**{field: 4})) == 4 + + +@pytest.mark.parametrize("field", _SPECIFIC_FIELDS) +def test_specific_field_does_not_apply_to_unrelated_exceptions(field: str): + policy: Final = RetryPolicy(**{field: 0}) + unrelated: Final = tuple( + exception_type + for name, exception_type in _EXCEPTION_FOR_FIELD.items() + if name != field and not issubclass(exception_type, _EXCEPTION_FOR_FIELD[field]) ) - -def _internal_server_error() -> litellm.InternalServerError: - return litellm.InternalServerError( - message="upstream 500", - llm_provider="openai", - model="gpt-5.6", - ) + for exception_type in unrelated: + assert get_num_retries_from_retry_policy(exception=_error(exception_type), retry_policy=policy) is None -def test_service_unavailable_error_retries_honored(): - policy = RetryPolicy(ServiceUnavailableErrorRetries=0) +def test_subclass_prefers_its_own_field_over_the_parent_field(): + policy: Final = RetryPolicy(BadRequestErrorRetries=5, ContentPolicyViolationErrorRetries=1) assert ( - get_num_retries_from_retry_policy( - exception=_service_unavailable_error(), - retry_policy=policy, - ) - == 0 + get_num_retries_from_retry_policy(exception=_error(litellm.ContentPolicyViolationError), retry_policy=policy) + == 1 + ) + assert get_num_retries_from_retry_policy(exception=_error(litellm.BadRequestError), retry_policy=policy) == 5 + + +def test_subclass_falls_back_to_the_parent_field(): + policy: Final = RetryPolicy(BadRequestErrorRetries=5) + + assert ( + get_num_retries_from_retry_policy(exception=_error(litellm.ContentPolicyViolationError), retry_policy=policy) + == 5 ) -def test_service_unavailable_error_retries_nonzero(): - policy = RetryPolicy(ServiceUnavailableErrorRetries=4) +@pytest.mark.parametrize("exception_type", (litellm.BadGatewayError, litellm.NotFoundError)) +def test_default_retries_covers_exceptions_without_a_specific_field(exception_type: type[Exception]): + exception: Final = _error(exception_type) + assert get_num_retries_from_retry_policy(exception=exception, retry_policy=RetryPolicy(DefaultRetries=0)) == 0 assert ( get_num_retries_from_retry_policy( - exception=_service_unavailable_error(), - retry_policy=policy, - ) - == 4 - ) - - -def test_internal_server_error_retries_honored(): - policy = RetryPolicy(InternalServerErrorRetries=0) - - assert ( - get_num_retries_from_retry_policy( - exception=_internal_server_error(), - retry_policy=policy, - ) - == 0 - ) - - -def test_service_unavailable_not_covered_by_internal_server_error_retries(): - policy = RetryPolicy(InternalServerErrorRetries=0) - - assert ( - get_num_retries_from_retry_policy( - exception=_service_unavailable_error(), - retry_policy=policy, + exception=exception, retry_policy=RetryPolicy(ServiceUnavailableErrorRetries=0) ) is None ) -def test_internal_server_error_not_covered_by_service_unavailable_retries(): - policy = RetryPolicy(ServiceUnavailableErrorRetries=0) +def test_specific_field_wins_over_default_retries(): + policy: Final = RetryPolicy(DefaultRetries=0, RateLimitErrorRetries=3) + + assert get_num_retries_from_retry_policy(exception=_error(litellm.RateLimitError), retry_policy=policy) == 3 + assert get_num_retries_from_retry_policy(exception=_error(litellm.BadGatewayError), retry_policy=policy) == 0 + + +def test_default_retries_applies_when_the_specific_field_is_unset(): + policy: Final = RetryPolicy(DefaultRetries=2) assert ( - get_num_retries_from_retry_policy( - exception=_internal_server_error(), - retry_policy=policy, - ) - is None + get_num_retries_from_retry_policy(exception=_error(litellm.ServiceUnavailableError), retry_policy=policy) == 2 ) -def test_service_unavailable_error_retries_from_dict_policy(): +def test_empty_policy_matches_nothing(): + assert ( + get_num_retries_from_retry_policy(exception=_error(litellm.ServiceUnavailableError), retry_policy=RetryPolicy()) + is None + ) + assert ( + get_num_retries_from_retry_policy(exception=_error(litellm.ServiceUnavailableError), retry_policy=None) is None + ) + + +def test_dict_policy_is_accepted(): assert ( get_num_retries_from_retry_policy( - exception=_service_unavailable_error(), + exception=_error(litellm.ServiceUnavailableError), retry_policy={"ServiceUnavailableErrorRetries": 0}, ) == 0 ) -def test_service_unavailable_error_retries_from_model_group_policy(): +def test_model_group_policy_replaces_the_global_policy(): + exception: Final = _error(litellm.ServiceUnavailableError) + global_policy: Final = RetryPolicy(ServiceUnavailableErrorRetries=5) + assert ( get_num_retries_from_retry_policy( - exception=_service_unavailable_error(), + exception=exception, + retry_policy=global_policy, model_group="gpt-5.6", - model_group_retry_policy={"gpt-5.6": RetryPolicy(ServiceUnavailableErrorRetries=1)}, + model_group_retry_policy={"gpt-5.6": {"ServiceUnavailableErrorRetries": 1}}, ) == 1 ) + assert ( + get_num_retries_from_retry_policy( + exception=exception, + retry_policy=global_policy, + model_group="gpt-5.6", + model_group_retry_policy={"gpt-5.6": RetryPolicy(RateLimitErrorRetries=1)}, + ) + is None + ) + assert ( + get_num_retries_from_retry_policy( + exception=exception, + retry_policy=global_policy, + model_group="other-group", + model_group_retry_policy={"gpt-5.6": RetryPolicy(ServiceUnavailableErrorRetries=1)}, + ) + == 5 + ) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index cb3baf042dd..5d83d0f8877 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -12896,10 +12896,17 @@ async def test_prompt_management_factory_marks_injection_for_every_deployment(mo @pytest.mark.asyncio -@pytest.mark.parametrize("policy_retries,expected_calls", [(0, 1), (1, 2)]) -async def test_router_retry_policy_service_unavailable_retries(policy_retries, expected_calls): - from litellm.types.router import RetryPolicy - +@pytest.mark.parametrize( + "retry_policy,error_type,expected_calls", + [ + ({"ServiceUnavailableErrorRetries": 0}, litellm.ServiceUnavailableError, 1), + ({"ServiceUnavailableErrorRetries": 1}, litellm.ServiceUnavailableError, 2), + ({"InternalServerErrorRetries": 0}, litellm.InternalServerError, 1), + ({"DefaultRetries": 0}, litellm.BadGatewayError, 1), + ({"DefaultRetries": 0, "ServiceUnavailableErrorRetries": 1}, litellm.ServiceUnavailableError, 2), + ], +) +async def test_router_retry_policy_controls_attempt_count(retry_policy, error_type, expected_calls): router = litellm.Router( model_list=[ { @@ -12907,20 +12914,14 @@ async def test_router_retry_policy_service_unavailable_retries(policy_retries, e "litellm_params": {"model": "openai/gpt-5.6", "api_key": "fake-key"}, } ], - retry_policy=RetryPolicy(ServiceUnavailableErrorRetries=policy_retries), + num_retries=2, + retry_policy=retry_policy, disable_cooldowns=True, ) + error = error_type(message="model is down", llm_provider="openai", model="gpt-5.6") - error = litellm.ServiceUnavailableError( - message="model is down", - llm_provider="openai", - model="gpt-5.6", - ) with patch.object(litellm, "acompletion", AsyncMock(side_effect=error)) as mock_acompletion: - with pytest.raises(litellm.ServiceUnavailableError): - await router.acompletion( - model="gpt-5.6", - messages=[{"role": "user", "content": "hi"}], - ) + with pytest.raises(error_type): + await router.acompletion(model="gpt-5.6", messages=[{"role": "user", "content": "hi"}]) assert mock_acompletion.call_count == expected_calls diff --git a/tests/test_litellm/test_router_per_deployment_num_retries.py b/tests/test_litellm/test_router_per_deployment_num_retries.py index d75e32a1821..99ad7c224f8 100644 --- a/tests/test_litellm/test_router_per_deployment_num_retries.py +++ b/tests/test_litellm/test_router_per_deployment_num_retries.py @@ -415,8 +415,9 @@ class TestNoProviderRetryAmplification: @pytest.mark.asyncio async def test_retry_policy_configured_does_not_reintroduce_amplification(self): """ - With a retry policy configured alongside a per-deployment ``num_retries=5``, the - provider SDK still must not retry: exactly ``6`` upstream requests, not 36. + ``InternalServerErrorRetries=2`` overrides the per-deployment ``num_retries=5`` for the + 500s this upstream returns, and the provider SDK still must not retry on top: exactly + ``3`` upstream requests, not 18. """ router = self._router( "https://policy.local/v1", @@ -424,7 +425,7 @@ class TestNoProviderRetryAmplification: num_retries=1, retry_policy=RetryPolicy(InternalServerErrorRetries=2), ) - assert await self._call_and_count(router) == 6 + assert await self._call_and_count(router) == 3 @pytest.mark.asyncio async def test_global_num_retries_not_amplified(self): diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 8589a9451cf..3d01c08e8eb 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,6 +1,6 @@ { "LIT001": { - "limit": 22328 + "limit": 22326 }, "LIT002": { "limit": 26748 @@ -30,7 +30,7 @@ "limit": 16468 }, "LIT011": { - "limit": 5514 + "limit": 5512 }, "LIT012": { "limit": 4487 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx index 9d6501c97ba..069a3f27beb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/ModelRetrySettingsTab.tsx @@ -35,6 +35,7 @@ const retryPolicyMap: Record = { "ContentPolicyViolationError (400)": "ContentPolicyViolationErrorRetries", "InternalServerError (500)": "InternalServerErrorRetries", "ServiceUnavailableError (503)": "ServiceUnavailableErrorRetries", + "All other errors": "DefaultRetries", }; const isValidRetryCount = (value: number) => Number.isFinite(value) && Number.isInteger(value) && value >= 0; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 549b9c0d01d..7d7fa8d7fe6 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -35014,6 +35014,8 @@ export interface components { BadRequestErrorRetries?: number | null; /** Contentpolicyviolationerrorretries */ ContentPolicyViolationErrorRetries?: number | null; + /** Defaultretries */ + DefaultRetries?: number | null; /** Internalservererrorretries */ InternalServerErrorRetries?: number | null; /** Ratelimiterrorretries */ From 544822b1a8afc96c3cc471a9de8e57035a951e65 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:14:28 -0700 Subject: [PATCH 119/410] fix: escalate a stalled keyword-forced tier, and let a blocked toggle clear Two issues Bugbot found on #39809. A keyword_tier_rule forces its tier and returns before any classification runs, so stall escalation never reached that path even though keyword escalation did. That left the one path that can pin a weak model to a whole conversation as the one path a stall could not lift. Stall detection now resolves before the override branch and both paths bump. The dashboard switch disabled itself whenever session pinning or user-turn classification was on, including for a router that already had stall escalation enabled. The conflicting keys stayed set, the backend rejected the save, and the disabled switch was the only way to clear them. It now disables only the off-to-on direction. --- .../complexity_router/complexity_router.py | 22 ++++++++------- .../router_strategy/test_complexity_router.py | 27 +++++++++++++++++++ .../add_model/StallEscalationConfig.test.tsx | 7 +++++ .../add_model/StallEscalationConfig.tsx | 5 +++- 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index 01b4665a7d9..5815f7577b9 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -3053,6 +3053,14 @@ class ComplexityRouter(CustomLogger): newest_ask: Final = _newest_turn_ask(resolved_messages, self._reminder_markers) escalation_keyword: Final = self._matched_escalation_keyword(newest_ask) if newest_ask is not None else None + # Resolved here rather than beside the classifier because the keyword-override path below + # returns before any classification runs, and a forced tier gets stuck for the same reason + # a classified one does. + stalled: Final = self.config.stall_escalation_enabled and detect_stalled_task( + resolved_messages, + window=self.config.stall_escalation_window, + repeat_threshold=self.config.stall_escalation_repeat_threshold, + ) plan_mode_sentinel: Final = self._matched_plan_mode_signal(request_kwargs, resolved_messages) plan_floor: Final = self._resolve_plan_mode_floor() if plan_mode_sentinel is not None else None @@ -3082,10 +3090,11 @@ class ComplexityRouter(CustomLogger): override: Final = await self._resolve_keyword_tier_override(user_message, request_kwargs) if override is not None: - escalated_tier: Final = ( + keyword_bumped_tier: Final = ( self._escalate_tier(override.tier) if escalation_keyword is not None else override.tier ) - keyword_escalated: Final = escalated_tier != override.tier + escalated_tier: Final = self._escalate_tier(keyword_bumped_tier) if stalled else keyword_bumped_tier + keyword_escalated: Final = keyword_bumped_tier != override.tier routed_tier: Final = ( self._apply_plan_mode_floor(escalated_tier) if plan_floor is not None else escalated_tier ) @@ -3113,6 +3122,7 @@ class ComplexityRouter(CustomLogger): conversation_continuing=conversation_continuing, cause=keyword_cause, tier=routed_tier, + signals=("stall_escalation",) if stalled else None, matched_keyword=plan_mode_sentinel if keyword_plan_floored else override.matched_keyword, escalation_keyword=escalation_keyword, escalated=keyword_escalated, @@ -3136,14 +3146,6 @@ class ComplexityRouter(CustomLogger): escalated: Final = tier != classified_tier if escalated: signals = (*signals, "escalation") - # Recomputed from this request's own tool calls, not remembered from a prior turn: the - # bump lasts only as long as the recent tool calls still look stuck, and lifts itself - # the moment they don't, with nothing to expire or leak past the task that earned it. - stalled: Final = self.config.stall_escalation_enabled and detect_stalled_task( - resolved_messages, - window=self.config.stall_escalation_window, - repeat_threshold=self.config.stall_escalation_repeat_threshold, - ) if stalled: tier = self._escalate_tier(tier) signals = (*signals, "stall_escalation") diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 3ae3165bf62..f2736492d30 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -5990,6 +5990,33 @@ class TestStallEscalation: result = await router.async_pre_routing_hook(model="test-model", request_kwargs={}, messages=messages) assert result.model == "claude-sonnet-4-20250514" # SIMPLE -> MEDIUM (keyword) -> COMPLEX (stall) + @pytest.mark.asyncio + async def test_a_keyword_forced_tier_still_escalates_when_stalled(self, mock_router_instance, basic_config): + """A keyword rule forces its tier and returns before any classification runs, so + without its own bump the one path that can pin a weak model to a whole conversation + would be the one path a stall could never lift.""" + router = ComplexityRouter( + model_name="test-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + **basic_config, + "stall_escalation_enabled": True, + "keyword_tier_rules": [{"keywords": ["billing"], "tier": "SIMPLE"}], + }, + ) + healthy = await router.async_pre_routing_hook( + model="test-model", request_kwargs={}, messages=[{"role": "user", "content": "a billing question"}] + ) + assert healthy.model == "gpt-4o-mini" # forced SIMPLE, nothing stuck + + stalled = await router.async_pre_routing_hook( + model="test-model", + request_kwargs={}, + messages=[*_stalled_tool_history(), {"role": "user", "content": "a billing question"}], + ) + assert stalled.model == "gpt-4o" # forced SIMPLE bumped to MEDIUM + assert "stall_escalation" in stalled.routing_decision["signals"] + @pytest.mark.asyncio async def test_evidence_survives_a_new_human_ask(self, mock_router_instance, basic_config): """A plain follow-up like 'try again' must not erase the stall evidence that came diff --git a/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx index 2c346306307..f215849c1a3 100644 --- a/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.test.tsx @@ -105,4 +105,11 @@ describe("StallEscalationConfig", () => { renderConfig({ stall_escalation_enabled: true, session_affinity: true }); expect(screen.queryByLabelText("Repeats before escalating")).not.toBeInTheDocument(); }); + + it("still lets an already-on router turn it off once a blocker appears, which the save needs", () => { + const onChange = renderConfig({ stall_escalation_enabled: true, session_affinity: true }); + expect(toggle()).not.toHaveAttribute("aria-disabled", "true"); + fireEvent.click(toggle()); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ stall_escalation_enabled: undefined })); + }); }); diff --git a/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx index fdb8c30f3b8..6f2a9cce365 100644 --- a/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/StallEscalationConfig.tsx @@ -65,7 +65,10 @@ const StallEscalationConfig: React.FC<{
From dd60b7e40f44d7687ad4577332d6f57fc21d7af3 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:16:38 -0700 Subject: [PATCH 120/410] feat(auto-router): decouple compression between the routing decision and the model call An auto router marker deployment can now set auto_router_routing_compression and auto_router_model_compression in its litellm_params, naming the compression guardrail each hop should use (or "none" for no compression on that hop). Neither key set means the request's own compression guardrails keep applying to both hops unchanged. Backend: Router.async_pre_routing_hook resolves the marker's policy and compresses a copy of the messages for the routing decision only when the policy differs from what the model call already got; when both hops share the same compression, it reuses what the ordinary pre-call guardrail pipeline already produced instead of compressing twice. The proxy layer suppresses every other compression guardrail once a policy is engaged and arms the model-side guardrail even when it is not default_on. UI: the auto router's Detailed Configuration gains an Advanced: Compression section with a routing-decision selector and a same/different toggle for the model call, matching the same/different address pattern. --- litellm/constants.py | 4 + litellm/integrations/custom_guardrail.py | 18 ++ litellm/proxy/common_request_processing.py | 7 + .../guardrails/auto_router_compression.py | 211 +++++++++++++ litellm/router.py | 53 +++- litellm/types/router.py | 4 + litellm/types/utils.py | 2 + .../integrations/test_custom_guardrail.py | 44 +++ .../test_auto_router_compression.py | 285 ++++++++++++++++++ .../proxy/test_common_request_processing.py | 54 ++++ tests/test_litellm/test_router.py | 151 ++++++++++ .../add_model/ComplexityRouterConfig.tsx | 34 +++ .../add_model/CompressionControls.tsx | 93 ++++++ .../add_model/add_auto_router_tab.test.tsx | 75 ++++- .../add_model/add_auto_router_tab.tsx | 11 + .../buildAutoRouterCompression.test.ts | 93 ++++++ .../add_model/buildAutoRouterCompression.ts | 52 ++++ .../handle_add_auto_router_submit.tsx | 5 +- .../edit_auto_router_modal.test.tsx | 81 +++++ .../edit_auto_router_modal.tsx | 18 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 8 + 21 files changed, 1297 insertions(+), 6 deletions(-) create mode 100644 litellm/proxy/guardrails/auto_router_compression.py create mode 100644 tests/test_litellm/proxy/guardrails/test_auto_router_compression.py create mode 100644 ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx create mode 100644 ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts create mode 100644 ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts diff --git a/litellm/constants.py b/litellm/constants.py index da731cb5eb2..25fdaec20de 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -215,6 +215,10 @@ MAX_CALLBACKS: Final = get_env_int("LITELLM_MAX_CALLBACKS", 100) # so the deployment-level hook does not re-run them for the same request PRE_CALL_EXECUTED_GUARDRAILS_KEY: Final = "_pre_call_executed_guardrails" +# Metadata key listing compression guardrails an auto router's own compression +# policy suppresses for this request. See litellm.proxy.guardrails.auto_router_compression. +AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: Final = "_auto_router_suppressed_compression_guardrails" + # Generic fallback for unknown models DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET: Final = int( os.getenv("DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET", 128) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 9bb613654e4..7f8effa2317 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -45,6 +45,7 @@ dc: Final = DualCache() from litellm.constants import ( + AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY, GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS, PRE_CALL_EXECUTED_GUARDRAILS_KEY, ) @@ -940,6 +941,20 @@ class CustomGuardrail(CustomLogger): """ return False + def _suppressed_by_auto_router_compression(self, data: dict) -> bool: + """True when an auto router's own compression policy suppresses this guardrail. + + Set only by litellm.proxy.guardrails.auto_router_compression.arm_pre_call, never + by the caller, so a request cannot suppress its own guardrails this way. + """ + for meta_key in ("metadata", "litellm_metadata"): + meta = data.get(meta_key) + if isinstance(meta, dict): + suppressed = meta.get(AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY) + if isinstance(suppressed, list) and self.guardrail_name in suppressed: + return True + return False + def should_run_guardrail( self, data, @@ -948,6 +963,9 @@ class CustomGuardrail(CustomLogger): """ Returns True if the guardrail should be run on the event_type """ + if self._suppressed_by_auto_router_compression(data): + return False + requested_guardrails: Final = self.get_guardrail_from_metadata(data) disable_global_guardrail: Final = self.get_disable_global_guardrail(data) opted_out_global_guardrails: Final = self.get_opted_out_global_guardrails_from_metadata(data) diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index f25fa46197e..534b2db3e61 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -61,6 +61,7 @@ from litellm.proxy.common_utils.sse_keepalive import ( wrap_sse_stream_with_keepalive_pings, ) from litellm.proxy.dd_span_tagger import DDSpanTagger +from litellm.proxy.guardrails.auto_router_compression import arm_pre_call as _arm_auto_router_compression from litellm.proxy.route_llm_request import route_request from litellm.proxy.utils import ProxyLogging, _check_and_merge_model_level_guardrails from litellm.router import Router @@ -2004,6 +2005,12 @@ class ProxyBaseLLMRequestProcessing: trust_client_model_info=False, ) + # An auto router with its own compression policy is authoritative for this + # request: suppress every other compression guardrail and arm whichever one + # the policy names for the model call, before those guardrails get a chance + # to run below. + self.data = await _arm_auto_router_compression(data=self.data, llm_router=llm_router) + self.data = await proxy_logging_obj.pre_call_hook( user_api_key_dict=user_api_key_dict, data=self.data, diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py new file mode 100644 index 00000000000..c3fba937d22 --- /dev/null +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -0,0 +1,211 @@ +""" +Decouples prompt compression between an auto router's routing decision and the +model it routes to. An auto router marker deployment may set +``auto_router_routing_compression`` and/or ``auto_router_model_compression`` in its +``litellm_params`` to name the compression guardrail that hop should use, or +``"none"`` to run no compression on that hop. Neither key set means the request's +own compression guardrails (key/team/model-level, or an "Always on" guardrail) +apply to both hops unchanged, exactly as before this feature existed. + +Once either key is set, this auto router is authoritative: every other compression +guardrail is suppressed for that request, and only these two settings decide what +each hop sees. +""" + +import copy +from collections.abc import Mapping +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final + +from litellm._logging import verbose_proxy_logger +from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY +from litellm.litellm_core_utils.core_helpers import ( + get_metadata_variable_name_from_kwargs, + get_or_create_metadata_bucket, +) +from litellm.router_utils.auto_router_model_naming import AUTO_ROUTER_MODEL_PREFIX +from litellm.types.utils import GenericGuardrailAPIInputs + +if TYPE_CHECKING: + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.router import Router +else: + CustomGuardrail = Any + Router = Any + +COMPRESSION_GUARDRAIL_PROVIDERS: Final = frozenset({"headroom", "compresr"}) +_NO_COMPRESSION: Final = "none" + +# Metadata key stashing the pre-compression messages so a routing decision that +# names a different compression than the model call still compresses the +# original text, not whatever the model-side guardrail already rewrote it to. +AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY: Final = "_auto_router_routing_messages_snapshot" + + +@dataclass(frozen=True, slots=True) +class AutoRouterCompressionPolicy: + """An auto router's compression choice for each hop. ``None`` means no compression.""" + + routing: str | None + model: str | None + + @property + def is_same(self) -> bool: + return self.routing == self.model + + +def _normalized_compression_choice(raw: object) -> str | None: + if not isinstance(raw, str) or not raw: + return None + return None if raw.strip().lower() == _NO_COMPRESSION else raw + + +def policy_from_litellm_params(litellm_params: Mapping[str, object]) -> AutoRouterCompressionPolicy | None: + raw_routing: Final = litellm_params.get("auto_router_routing_compression") + raw_model: Final = litellm_params.get("auto_router_model_compression") + if raw_routing is None and raw_model is None: + return None + return AutoRouterCompressionPolicy( + routing=_normalized_compression_choice(raw_routing), + model=_normalized_compression_choice(raw_model), + ) + + +def policy_for_model( + llm_router: "Router | None", model_alias: str, team_id: str | None +) -> AutoRouterCompressionPolicy | None: + """The compression policy declared by the auto router marker deployment `model_alias` resolves to. + + Mirrors the alias lookup in ``_check_and_merge_model_level_guardrails``: this runs + before routing has picked a strategy, so it takes the first marker deployment for + the alias rather than disambiguating by request tags. + """ + if llm_router is None: + return None + deployments: Final = llm_router.get_model_list(model_name=model_alias, team_id=team_id) or [] + for deployment in deployments: + litellm_params: Final = deployment.get("litellm_params") or {} + model_field = litellm_params.get("model") + if not isinstance(model_field, str) or not model_field.startswith(AUTO_ROUTER_MODEL_PREFIX): + continue + policy = policy_from_litellm_params(litellm_params) + if policy is not None: + return policy + return None + + +def _active_compression_guardrail_names() -> frozenset[str]: + """Names of every currently-active guardrail whose type is a compression guardrail.""" + import litellm + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.proxy.guardrails.guardrail_registry import guardrail_class_registry + + compression_classes: Final = tuple( + cls for name, cls in guardrail_class_registry.items() if name in COMPRESSION_GUARDRAIL_PROVIDERS + ) + if not compression_classes: + return frozenset() + active: Final = litellm.logging_callback_manager.get_custom_loggers_for_type(callback_type=CustomGuardrail) + return frozenset( + cb.guardrail_name for cb in active if isinstance(cb, compression_classes) and cb.guardrail_name + ) + + +async def arm_pre_call(data: dict, llm_router: "Router | None") -> dict: + """Apply an auto router's compression policy, if any, before guardrails run. + + Suppresses every other compression guardrail, re-enables the model-side + guardrail the policy names (if any) even when it isn't ``default_on``, and + snapshots the pre-compression messages so the routing decision can compress + them independently of whatever the model-side guardrail does to `data`. + """ + if llm_router is None: + return data + + model_alias: Final = data.get("model") + if not isinstance(model_alias, str) or not model_alias: + return data + + # Read-only until a policy is confirmed: creating the metadata bucket for every + # request, including the vast majority with no auto-router compression policy, + # would be an unwanted side effect of merely checking for one. + metadata_key: Final = get_metadata_variable_name_from_kwargs(data) + existing_bucket: Final = data.get(metadata_key) + other_bucket: Final = data.get("metadata" if metadata_key == "litellm_metadata" else "litellm_metadata") + team_id: Final = (existing_bucket.get("user_api_key_team_id") if isinstance(existing_bucket, dict) else None) or ( + other_bucket.get("user_api_key_team_id") if isinstance(other_bucket, dict) else None + ) + + policy: Final = policy_for_model(llm_router=llm_router, model_alias=model_alias, team_id=team_id) + if policy is None: + return data + + _, metadata = get_or_create_metadata_bucket(data) + suppressed: Final = _active_compression_guardrail_names() - ({policy.model} if policy.model else set()) + if suppressed: + metadata[AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] = sorted(suppressed) + + if policy.model is not None: + requested = metadata.get("guardrails") + if isinstance(requested, list): + if policy.model not in requested: + requested.append(policy.model) + else: + metadata["guardrails"] = [policy.model] + + from litellm.litellm_core_utils.prompt_templates.factory import resolve_structured_messages + + snapshot: Final = resolve_structured_messages(messages=data.get("messages"), request_kwargs=data) + if snapshot is not None: + metadata[AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY] = copy.deepcopy(snapshot) + + return data + + +async def messages_for_routing( + policy: AutoRouterCompressionPolicy | None, + messages: list[dict[str, Any]] | None, + request_kwargs: Mapping[str, object], +) -> list[dict[str, Any]] | None: + """Messages to use for a routing decision, compressed per `policy.routing`. + + Returns None when there is no policy or the policy's routing side names no + compression, meaning the caller should route on whatever messages it already + has. The model call is untouched by this function either way: model-side + compression, if any, already ran as an ordinary pre-call guardrail before the + router was ever reached. + """ + if policy is None or policy.routing is None: + return None + + from litellm.proxy.common_utils.registry_read_through import ( + get_initialized_guardrail_with_read_through, + ) + + metadata_key: Final = get_metadata_variable_name_from_kwargs(request_kwargs) + metadata: Final = request_kwargs.get(metadata_key) + snapshot: Final = metadata.get(AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY) if isinstance(metadata, dict) else None + original: Final = snapshot if isinstance(snapshot, list) else messages + if not original: + return None + + guardrail: Final = await get_initialized_guardrail_with_read_through(policy.routing) + if guardrail is None: + verbose_proxy_logger.warning( + "AutoRouter compression: guardrail '%s' not found; routing on uncompressed messages", policy.routing + ) + return None + + inputs: GenericGuardrailAPIInputs = {"structured_messages": [dict(m) for m in original]} + # A throwaway request_data: apply_guardrail writes its stats onto this dict, not + # the real request's metadata, so routing-side compression never double-counts + # against extract_compression_saved_tokens's model-savings accounting. + throwaway_request_data: Final[dict[str, object]] = { + "messages": original, + "model": request_kwargs.get("model"), + } + result: Final = await guardrail.apply_guardrail( + inputs=inputs, request_data=throwaway_request_data, input_type="request" + ) + compressed = result.get("structured_messages") + return compressed if isinstance(compressed, list) else original diff --git a/litellm/router.py b/litellm/router.py index 6c7611c6236..8149ec60ddc 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -13037,13 +13037,46 @@ class Router: ) return None + from litellm.proxy.guardrails.auto_router_compression import ( + messages_for_routing, + policy_from_litellm_params, + ) + + marker_params: Final = self._alias_marker_litellm_params(registered_model_name, selected_strategy.tags) + compression_policy: Final = policy_from_litellm_params(marker_params) if marker_params else None + # When both hops share the same compression, the model-side guardrail already + # ran in the proxy's ordinary pre-call hook and compressed `messages` in place + # (arm_pre_call armed it whether or not it is `default_on`); reuse that result + # for routing too instead of paying for a second compression call against the + # same content. + needs_independent_routing_compression: Final = compression_policy is not None and not ( + compression_policy.is_same and compression_policy.model is not None + ) + routing_messages: Final = ( + await messages_for_routing(policy=compression_policy, messages=messages, request_kwargs=request_kwargs) + if needs_independent_routing_compression + else None + ) + pre_routing_hook_response: Final = await selected_strategy.strategy.async_pre_routing_hook( model=registered_model_name, request_kwargs=request_kwargs, - messages=messages, + messages=routing_messages if routing_messages is not None else messages, input=input, specific_deployment=specific_deployment, ) + # The strategy only echoes back whatever `messages` it was handed, so a + # routing-only compression must not leak into the response: the model call + # and downstream deployment-context filtering both key off this field. + # Compared by value, not identity: PreRoutingHookResponse is a pydantic model, + # and pydantic reconstructs a validated list field rather than keeping the + # exact object passed in, even when nothing about it changed. + if ( + pre_routing_hook_response is not None + and routing_messages is not None + and pre_routing_hook_response.messages == routing_messages + ): + pre_routing_hook_response = pre_routing_hook_response.model_copy(update={"messages": messages}) self._record_routing_decision( request_kwargs=request_kwargs, routing_decision=(pre_routing_hook_response.routing_decision if pre_routing_hook_response else None), @@ -13100,9 +13133,16 @@ class Router: return pre_routing_hook_response - def _forwardable_alias_marker_params( + def _alias_marker_litellm_params( self, model: str, strategy_tags: tuple[str, ...] - ) -> tuple[tuple[str, object], ...]: + ) -> Mapping[str, object] | None: + """The auto-router marker deployment's own `litellm_params` for `model`, tag-scoped. + + Shared by `_forwardable_alias_marker_params` (forwarding api_base/api_key/... + gaps onto the routed deployment) and the auto-router compression policy lookup + (reading `auto_router_routing_compression`/`auto_router_model_compression`), so + both read the same marker row when an alias has more than one, tag-scoped marker. + """ marker_params: Final = tuple( litellm_params for idx in self.model_name_to_deployment_indices.get(model, ()) @@ -13112,7 +13152,12 @@ class Router: tag_matched: Final = tuple( params for params in marker_params if tuple(params.get("tags") or ()) == strategy_tags ) - selected: Final = tag_matched[0] if tag_matched else (marker_params[0] if marker_params else None) + return tag_matched[0] if tag_matched else (marker_params[0] if marker_params else None) + + def _forwardable_alias_marker_params( + self, model: str, strategy_tags: tuple[str, ...] + ) -> tuple[tuple[str, object], ...]: + selected: Final = self._alias_marker_litellm_params(model, strategy_tags) if selected is None: return () return tuple( diff --git a/litellm/types/router.py b/litellm/types/router.py index 7ebd50f1328..f5295d6569c 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -359,6 +359,10 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams): auto_router_default_model: str | None = None auto_router_embedding_model: str | None = None auto_router_max_input_chars: int | None = None + # Compression policy for the two hops of a routed request. Both unset means the + # request's own compression guardrails apply to both, as they always have. + auto_router_routing_compression: str | None = None + auto_router_model_compression: str | None = None # complexity-router params complexity_router_config: dict | None = None diff --git a/litellm/types/utils.py b/litellm/types/utils.py index c1d90694f14..a1fda3d0524 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3713,6 +3713,8 @@ all_litellm_params = ( "auto_router_default_model", "auto_router_embedding_model", "auto_router_max_input_chars", + "auto_router_routing_compression", + "auto_router_model_compression", "complexity_router_config", "complexity_router_default_model", "adaptive_router_config", diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 6edc2c9bf77..1fb4299cb56 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -518,6 +518,50 @@ class TestCustomGuardrailShouldRunGuardrail: is True ) + def test_should_run_guardrail_suppressed_by_auto_router_compression(self): + """An auto router's own compression policy can suppress an otherwise-eligible + guardrail, even one that is default_on and explicitly requested.""" + from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY + from litellm.types.guardrails import GuardrailEventHooks + + always_on = CustomGuardrail( + guardrail_name="headroom-default", + default_on=True, + event_hook=GuardrailEventHooks.pre_call, + ) + data = { + "model": "smart-router", + "metadata": { + AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: ["headroom-default"], + }, + } + + assert ( + always_on.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) + is False + ) + + def test_should_run_guardrail_suppression_list_does_not_affect_other_names(self): + from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY + from litellm.types.guardrails import GuardrailEventHooks + + always_on = CustomGuardrail( + guardrail_name="headroom-default", + default_on=True, + event_hook=GuardrailEventHooks.pre_call, + ) + data = { + "model": "smart-router", + "metadata": { + AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: ["some-other-guardrail"], + }, + } + + assert ( + always_on.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) + is True + ) + class TestApplyGuardrailCheck: def test_apply_guardrail_check_only_on_direct_implementation(self): diff --git a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py new file mode 100644 index 00000000000..b2e83e75768 --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py @@ -0,0 +1,285 @@ +""" +Unit tests for litellm.proxy.guardrails.auto_router_compression. + +Covers: +- policy_from_litellm_params: absent keys mean no policy; the "none" sentinel + normalizes to explicit no-compression within an active policy; is_same +- policy_for_model: finds the auto-router marker deployment for an alias +- arm_pre_call: no-op without a policy; suppresses active compression guardrails; + arms the model-side guardrail even when it isn't default_on; snapshots messages +- messages_for_routing: no-op without a policy or an unset routing side; compresses + via the named guardrail's apply_guardrail; never writes stats onto the caller's + own request_kwargs (regression for double-counted compression savings) +""" + +from typing import Any + +import pytest + +from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.proxy.guardrails.auto_router_compression import ( + AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY, + AutoRouterCompressionPolicy, + arm_pre_call, + messages_for_routing, + policy_for_model, + policy_from_litellm_params, +) +from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY +from litellm.types.utils import GenericGuardrailAPIInputs + + +class TestPolicyFromLitellmParams: + def test_neither_key_set_is_no_policy(self): + assert policy_from_litellm_params({}) is None + + def test_routing_only(self): + policy = policy_from_litellm_params({"auto_router_routing_compression": "headroom-a"}) + assert policy == AutoRouterCompressionPolicy(routing="headroom-a", model=None) + + def test_none_sentinel_normalizes_to_no_compression(self): + policy = policy_from_litellm_params( + {"auto_router_routing_compression": "headroom-a", "auto_router_model_compression": "none"} + ) + assert policy == AutoRouterCompressionPolicy(routing="headroom-a", model=None) + + def test_none_sentinel_is_case_insensitive(self): + policy = policy_from_litellm_params({"auto_router_routing_compression": "NONE"}) + assert policy == AutoRouterCompressionPolicy(routing=None, model=None) + + def test_is_same_true_for_matching_names(self): + policy = policy_from_litellm_params( + {"auto_router_routing_compression": "x", "auto_router_model_compression": "x"} + ) + assert policy.is_same is True + + def test_is_same_false_for_different_names(self): + policy = policy_from_litellm_params( + {"auto_router_routing_compression": "x", "auto_router_model_compression": "y"} + ) + assert policy.is_same is False + + def test_is_same_true_when_both_no_compression(self): + policy = policy_from_litellm_params( + {"auto_router_routing_compression": "none", "auto_router_model_compression": "none"} + ) + assert policy.is_same is True + + +class _FakeRouter: + """Minimal stand-in for litellm.Router.get_model_list, for policy_for_model.""" + + def __init__(self, deployments: list[dict[str, Any]]): + self._deployments = deployments + + def get_model_list(self, model_name, team_id=None): + return [d for d in self._deployments if d.get("model_name") == model_name] + + +class TestPolicyForModel: + def test_no_router_returns_none(self): + assert policy_for_model(llm_router=None, model_alias="smart-router", team_id=None) is None + + def test_no_marker_deployment_returns_none(self): + router = _FakeRouter( + [{"model_name": "smart-router", "litellm_params": {"model": "openai/gpt-4o-mini"}}] + ) + assert policy_for_model(llm_router=router, model_alias="smart-router", team_id=None) is None + + def test_marker_deployment_without_policy_returns_none(self): + router = _FakeRouter( + [{"model_name": "smart-router", "litellm_params": {"model": "auto_router/complexity_router"}}] + ) + assert policy_for_model(llm_router=router, model_alias="smart-router", team_id=None) is None + + def test_marker_deployment_with_policy_is_found(self): + router = _FakeRouter( + [ + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "auto_router_routing_compression": "headroom-a", + "auto_router_model_compression": "none", + }, + } + ] + ) + policy = policy_for_model(llm_router=router, model_alias="smart-router", team_id=None) + assert policy == AutoRouterCompressionPolicy(routing="headroom-a", model=None) + + +class _RecordingCompressionGuardrail(CustomGuardrail): + """A guardrail whose apply_guardrail marks every text message as compressed.""" + + def __init__(self, guardrail_name: str): + super().__init__(guardrail_name=guardrail_name) + self.request_data_seen: list[dict] = [] + + async def apply_guardrail( + self, inputs: GenericGuardrailAPIInputs, request_data: dict, input_type: str, logging_obj=None + ) -> GenericGuardrailAPIInputs: + self.request_data_seen.append(request_data) + structured_messages = inputs.get("structured_messages") or [] + compressed = [ + {**m, "content": f"[COMPRESSED] {m.get('content')}"} for m in structured_messages + ] + return {**inputs, "structured_messages": compressed} + + +@pytest.fixture +def registered_guardrail(): + import litellm + + guardrail = _RecordingCompressionGuardrail(guardrail_name="fake-compress") + litellm.logging_callback_manager.add_litellm_callback(guardrail) + yield guardrail + litellm.logging_callback_manager.remove_callback_from_all_lists(guardrail) + + +class TestArmPreCall: + @pytest.mark.asyncio + async def test_no_router_is_noop(self): + data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} + result = await arm_pre_call(data=data, llm_router=None) + assert result == data + assert "metadata" not in result + + @pytest.mark.asyncio + async def test_no_policy_does_not_create_metadata_bucket(self): + router = _FakeRouter( + [{"model_name": "smart-router", "litellm_params": {"model": "openai/gpt-4o-mini"}}] + ) + data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} + result = await arm_pre_call(data=data, llm_router=router) + assert "metadata" not in result + assert "litellm_metadata" not in result + + @pytest.mark.asyncio + async def test_policy_suppresses_active_compression_guardrails(self, monkeypatch): + from litellm.proxy.guardrails import guardrail_registry + + monkeypatch.setitem( + guardrail_registry.guardrail_class_registry, "fake-provider", _RecordingCompressionGuardrail + ) + monkeypatch.setattr( + "litellm.proxy.guardrails.auto_router_compression.COMPRESSION_GUARDRAIL_PROVIDERS", + frozenset({"fake-provider"}), + ) + import litellm + + always_on = _RecordingCompressionGuardrail(guardrail_name="always-on-compression") + litellm.logging_callback_manager.add_litellm_callback(always_on) + try: + router = _FakeRouter( + [ + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "auto_router_routing_compression": "headroom-a", + "auto_router_model_compression": "none", + }, + } + ] + ) + data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} + result = await arm_pre_call(data=data, llm_router=router) + suppressed = result["metadata"][AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] + assert "always-on-compression" in suppressed + finally: + litellm.logging_callback_manager.remove_callback_from_all_lists(always_on) + + @pytest.mark.asyncio + async def test_model_side_guardrail_is_requested_even_when_not_default_on(self): + router = _FakeRouter( + [ + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "auto_router_routing_compression": "none", + "auto_router_model_compression": "headroom-b", + }, + } + ] + ) + data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} + result = await arm_pre_call(data=data, llm_router=router) + assert result["metadata"]["guardrails"] == ["headroom-b"] + + @pytest.mark.asyncio + async def test_snapshots_original_messages(self): + router = _FakeRouter( + [ + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "auto_router_routing_compression": "headroom-a", + "auto_router_model_compression": "none", + }, + } + ] + ) + original_messages = [{"role": "user", "content": "hi"}] + data = {"model": "smart-router", "messages": original_messages} + result = await arm_pre_call(data=data, llm_router=router) + snapshot = result["metadata"][AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY] + assert snapshot == original_messages + assert snapshot is not original_messages # a copy, not the live reference + + +class TestMessagesForRouting: + @pytest.mark.asyncio + async def test_no_policy_returns_none(self): + assert await messages_for_routing(policy=None, messages=[], request_kwargs={}) is None + + @pytest.mark.asyncio + async def test_routing_side_unset_returns_none(self): + policy = AutoRouterCompressionPolicy(routing=None, model="headroom-a") + assert await messages_for_routing(policy=policy, messages=[], request_kwargs={}) is None + + @pytest.mark.asyncio + async def test_unknown_guardrail_name_returns_none(self): + policy = AutoRouterCompressionPolicy(routing="does-not-exist", model=None) + messages = [{"role": "user", "content": "hi"}] + result = await messages_for_routing(policy=policy, messages=messages, request_kwargs={}) + assert result is None + + @pytest.mark.asyncio + async def test_compresses_via_the_named_guardrail(self, registered_guardrail): + policy = AutoRouterCompressionPolicy(routing="fake-compress", model=None) + messages = [{"role": "user", "content": "hello world"}] + result = await messages_for_routing(policy=policy, messages=messages, request_kwargs={}) + assert result == [{"role": "user", "content": "[COMPRESSED] hello world"}] + + @pytest.mark.asyncio + async def test_uses_the_snapshot_when_present(self, registered_guardrail): + policy = AutoRouterCompressionPolicy(routing="fake-compress", model=None) + snapshot = [{"role": "user", "content": "original"}] + request_kwargs = {"metadata": {AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY: snapshot}} + # `messages` here stands in for whatever a model-side guardrail already + # rewrote `data["messages"]` to -- routing must ignore it and compress the + # pristine snapshot instead. + already_rewritten = [{"role": "user", "content": "rewritten by another guardrail"}] + result = await messages_for_routing( + policy=policy, messages=already_rewritten, request_kwargs=request_kwargs + ) + assert result == [{"role": "user", "content": "[COMPRESSED] original"}] + + @pytest.mark.asyncio + async def test_guardrail_receives_a_throwaway_request_data_not_the_real_request_kwargs( + self, registered_guardrail + ): + """Regression: a real compression guardrail writes its stats onto whatever + `request_data` dict it's given (`add_standard_logging_guardrail_information_to_ + request_data`). If that were the caller's own `request_kwargs`, routing-side + compression would double-count into extract_compression_saved_tokens, which + sums every guardrail_information entry on the real request's metadata.""" + policy = AutoRouterCompressionPolicy(routing="fake-compress", model=None) + messages = [{"role": "user", "content": "hi"}] + request_kwargs = {"metadata": {}} + await messages_for_routing(policy=policy, messages=messages, request_kwargs=request_kwargs) + assert registered_guardrail.request_data_seen[0] is not request_kwargs + assert request_kwargs == {"metadata": {}} diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index f7fe6ad9d39..c0809e53d2e 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -376,6 +376,60 @@ class TestProxyBaseLLMRequestProcessing: assert "litellm_logging_obj" not in persisted_body json.dumps(persisted_body) + @pytest.mark.asyncio + async def test_common_processing_pre_call_logic_arms_auto_router_compression_before_guardrails( + self, monkeypatch + ): + """arm_pre_call must run before pre_call_hook: an auto router's own compression + policy has to be in `data["metadata"]` (naming the model-side guardrail so it + runs even if it isn't default_on) by the time guardrails see the request.""" + processing_obj = ProxyBaseLLMRequestProcessing(data={}) + mock_request = MagicMock(spec=Request) + mock_request.headers = {} + + async def mock_add_litellm_data_to_request(*args, **kwargs): + return {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} + + seen_metadata: dict = {} + + async def mock_pre_call_hook(user_api_key_dict, data, call_type): + seen_metadata.update(data.get("metadata") or {}) + return data + + mock_proxy_logging_obj = MagicMock(spec=ProxyLogging) + mock_proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=mock_pre_call_hook) + monkeypatch.setattr( + litellm.proxy.common_request_processing, + "add_litellm_data_to_request", + mock_add_litellm_data_to_request, + ) + + fake_llm_router = MagicMock() + fake_llm_router.get_model_list.return_value = [ + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "auto_router_routing_compression": "none", + "auto_router_model_compression": "headroom-model", + }, + } + ] + mock_proxy_config = MagicMock(spec=ProxyConfig) + mock_proxy_config._get_hierarchical_router_settings = AsyncMock(return_value=None) + + await processing_obj.common_processing_pre_call_logic( + request=mock_request, + general_settings={}, + user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"), + proxy_logging_obj=mock_proxy_logging_obj, + proxy_config=mock_proxy_config, + route_type="acompletion", + llm_router=fake_llm_router, + ) + + assert seen_metadata.get("guardrails") == ["headroom-model"] + def test_add_dd_apm_tags_for_litellm_call_id_uses_dd_tracing_helper(self, monkeypatch): mock_set_active_span_tag = MagicMock(return_value=True) import litellm.proxy.dd_span_tagger diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 5fc96bcfbb1..cdae3b131ae 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -18,6 +18,7 @@ import pytest import litellm from litellm import Router from litellm.exceptions import MidStreamFallbackError +from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.integrations.custom_logger import CustomLogger from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import ( @@ -9995,6 +9996,156 @@ class TestModelGroupAliasReachesPreRoutingStrategies: ) +class TestAutoRouterCompressionDecoupling: + """An auto router's `auto_router_routing_compression` / `auto_router_model_compression` + decouple what the routing decision sees from what the model call sees. The one + assertion that must hold under any mutation: the strategy can be routed on + compressed text while the caller's own `messages` list - the one that would reach + the model - is never touched.""" + + class _RecordingStrategy: + """Echoes back whatever `messages` it was handed, like every real strategy does.""" + + def __init__(self): + self.received_messages: list[dict] | None = None + + async def async_pre_routing_hook( + self, model, request_kwargs, messages=None, input=None, specific_deployment=False + ): + from litellm.types.router import PreRoutingHookResponse + + self.received_messages = messages + return PreRoutingHookResponse(model="gemini-flash", messages=messages) + + class _CompressingGuardrail(CustomGuardrail): + def __init__(self, guardrail_name: str): + super().__init__(guardrail_name=guardrail_name) + self.call_count = 0 + + async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None): + self.call_count += 1 + structured_messages = inputs.get("structured_messages") or [] + compressed = [ + {**m, "content": f"[COMPRESSED] {m.get('content')}"} for m in structured_messages + ] + return {**inputs, "structured_messages": compressed} + + @staticmethod + def _messages() -> list[dict[str, str]]: + return [{"role": "user", "content": "What is the capital of France?"}] + + def _router(self, marker_litellm_params: dict) -> tuple[litellm.Router, "_RecordingStrategy"]: + from litellm.types.router import TaggedPreRoutingStrategy + + tiers = dict.fromkeys(("SIMPLE", "MEDIUM", "COMPLEX", "REASONING"), "gemini-flash") + router = litellm.Router( + model_list=[ + { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": {"tiers": tiers}, + "complexity_router_default_model": "gemini-flash", + **marker_litellm_params, + }, + }, + { + "model_name": "gemini-flash", + "litellm_params": {"model": "gemini/gemini-3.6-flash", "mock_response": "routed by the tier"}, + }, + ], + ) + for name in ("auto_routers", "complexity_routers", "adaptive_routers", "quality_routers"): + setattr(router, name, {}) + strategy = self._RecordingStrategy() + router.complexity_routers = {"smart-router": [TaggedPreRoutingStrategy(tags=(), strategy=strategy)]} + return router, strategy + + @pytest.fixture + def registered_guardrail(self): + guardrail = self._CompressingGuardrail(guardrail_name="fake-compress") + litellm.logging_callback_manager.add_litellm_callback(guardrail) + yield guardrail + litellm.logging_callback_manager.remove_callback_from_all_lists(guardrail) + + @pytest.mark.asyncio + async def test_routing_side_compression_never_reaches_the_caller_messages(self, registered_guardrail): + router, strategy = self._router( + { + "auto_router_routing_compression": "fake-compress", + "auto_router_model_compression": "none", + } + ) + original_messages = self._messages() + + response = await router.async_pre_routing_hook( + model="smart-router", request_kwargs={"metadata": {}}, messages=original_messages + ) + + assert strategy.received_messages == [ + {"role": "user", "content": "[COMPRESSED] What is the capital of France?"} + ] + assert response.messages == original_messages + + @pytest.mark.asyncio + async def test_model_side_compression_alone_leaves_routing_uncompressed(self, registered_guardrail): + router, strategy = self._router( + { + "auto_router_routing_compression": "none", + "auto_router_model_compression": "fake-compress", + } + ) + original_messages = self._messages() + + response = await router.async_pre_routing_hook( + model="smart-router", request_kwargs={"metadata": {}}, messages=original_messages + ) + + assert strategy.received_messages == original_messages + assert response.messages == original_messages + assert registered_guardrail.call_count == 0 + + @pytest.mark.asyncio + async def test_same_compression_on_both_hops_compresses_once(self, registered_guardrail): + """The same/different distinction exists so a shared choice does not pay for + compression twice: by the time the router runs, `messages` already reflects + whatever the ordinary pre-call guardrail pipeline did for the model call, so + the routing decision must reuse it rather than calling the guardrail again.""" + router, strategy = self._router( + { + "auto_router_routing_compression": "fake-compress", + "auto_router_model_compression": "fake-compress", + } + ) + # Stands in for what the proxy's ordinary pre-call guardrail pipeline would + # have already produced for the model call, since `auto_router_model_compression` + # names a guardrail: the router never triggers that pipeline itself. + already_compressed_messages = [ + {"role": "user", "content": "[COMPRESSED] What is the capital of France?"} + ] + + response = await router.async_pre_routing_hook( + model="smart-router", request_kwargs={"metadata": {}}, messages=already_compressed_messages + ) + + assert strategy.received_messages == already_compressed_messages + assert response.messages == already_compressed_messages + assert registered_guardrail.call_count == 0 + + @pytest.mark.asyncio + async def test_no_policy_is_fully_unaffected(self, registered_guardrail): + router, strategy = self._router({}) + original_messages = self._messages() + + response = await router.async_pre_routing_hook( + model="smart-router", request_kwargs={"metadata": {}}, messages=original_messages + ) + + assert strategy.received_messages is original_messages + assert response.messages == original_messages + assert registered_guardrail.call_count == 0 + + @pytest.mark.usefixtures("local_model_cost_map") class TestAzureBaseModelFallbackLogging: """When an azure deployment has no base_model but its model name is a known diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 2a024ab7fdf..42115265034 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -48,6 +48,8 @@ import EscalationKeywords from "./EscalationKeywords"; import KeywordTierRules, { KeywordTierRule } from "./KeywordTierRules"; import SemanticKeywordMatching from "./SemanticKeywordMatching"; import { type DimensionWeights, type TierBoundaries, type TokenThresholds } from "./heuristic_scoring_knobs"; +import CompressionControls from "./CompressionControls"; +import { type AutoRouterCompressionState, DEFAULT_AUTO_ROUTER_COMPRESSION } from "./buildAutoRouterCompression"; export type { DimensionWeights, TierBoundaries, TokenThresholds }; export type { CustomTierSet, TierRow } from "./tier_rows"; @@ -490,6 +492,10 @@ interface ComplexityRouterConfigProps { onMatchThresholdChange?: (threshold: number) => void; escalationKeywords?: string[]; onEscalationKeywordsChange?: (keywords: string[]) => void; + // Optional: not part of complexity_router_config, since it applies to every + // pre-routing strategy, not just the complexity router. + autoRouterCompression?: AutoRouterCompressionState; + onAutoRouterCompressionChange?: (state: AutoRouterCompressionState) => void; showValidationErrors?: boolean; } @@ -611,6 +617,8 @@ const ComplexityRouterConfig: React.FC = ({ onMatchThresholdChange = () => {}, escalationKeywords = [], onEscalationKeywordsChange, + autoRouterCompression = DEFAULT_AUTO_ROUTER_COMPRESSION, + onAutoRouterCompressionChange, showValidationErrors = false, }) => { const customTierSet = value.custom_tier_set; @@ -875,6 +883,32 @@ const ComplexityRouterConfig: React.FC = ({ }, ] : []), + ...(onAutoRouterCompressionChange + ? [ + { + key: "compression", + label: Advanced: Compression, + children: ( + + onAutoRouterCompressionChange({ + ...autoRouterCompression, + routing, + sameAsRouting: routing === undefined ? true : autoRouterCompression.sameAsRouting, + }) + } + sameAsRouting={autoRouterCompression.sameAsRouting} + onSameAsRoutingChange={(sameAsRouting) => + onAutoRouterCompressionChange({ ...autoRouterCompression, sameAsRouting }) + } + model={autoRouterCompression.model} + onModelChange={(model) => onAutoRouterCompressionChange({ ...autoRouterCompression, model })} + /> + ), + }, + ] + : []), ...(onKeywordTierRulesChange || onSemanticMatchingEnabledChange ? [ { diff --git a/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx b/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx new file mode 100644 index 00000000000..a0a240f76b0 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx @@ -0,0 +1,93 @@ +import { SimpleTooltip } from "@/components/ui/tooltip"; +import { SearchSelect, SearchSelectOption } from "@/components/shared/SearchSelect"; +import { Label } from "@/components/ui/label"; +import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; +import { Info } from "lucide-react"; +import React from "react"; +import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails"; +import { COMPRESSION_GUARDRAIL_PROVIDER } from "@/app/(dashboard)/cost-optimization/_components/helpers"; +import { NO_COMPRESSION } from "./buildAutoRouterCompression"; + +interface CompressionControlsProps { + routing: string | undefined; + onRoutingChange: (value: string | undefined) => void; + sameAsRouting: boolean; + onSameAsRoutingChange: (same: boolean) => void; + model: string | undefined; + onModelChange: (value: string | undefined) => void; +} + +const NONE_OPTION: SearchSelectOption = { label: "None (no compression)", value: NO_COMPRESSION }; + +const CompressionControls: React.FC = ({ + routing, + onRoutingChange, + sameAsRouting, + onSameAsRoutingChange, + model, + onModelChange, +}) => { + const { data } = useGuardrails(); + const compressionOptions: SearchSelectOption[] = (data?.guardrails ?? []) + .filter((g) => (g.litellm_params?.guardrail ?? "").toString().toLowerCase() === COMPRESSION_GUARDRAIL_PROVIDER) + .map((g) => ({ label: g.guardrail_name, value: g.guardrail_name })); + const options: SearchSelectOption[] = [NONE_OPTION, ...compressionOptions]; + + return ( +
+
+
+ Routing decision + + + +
+ onRoutingChange(value === "" ? undefined : value)} + placeholder="Inherit from the request's own compression guardrails" + emptyText="No compression guardrails found" + aria-label="Routing decision compression" + /> +
+ + {routing !== undefined && ( +
+ Model call + onSameAsRoutingChange(value === "same")} + className="w-full" + > +
+ + +
+
+ + {!sameAsRouting && ( +
+ onModelChange(value === "" ? undefined : value)} + placeholder="None (no compression)" + emptyText="No compression guardrails found" + aria-label="Model call compression" + /> +
+ )} +
+ )} +
+ ); +}; + +export default CompressionControls; diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx index d2f6b10c3a6..5605a993ded 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx @@ -1,4 +1,12 @@ -import { renderWithProviders, screen, waitFor, within, fireEvent, testQueryClient } from "../../../tests/test-utils"; +import { + renderWithProviders, + screen, + waitFor, + within, + fireEvent, + testQueryClient, + chooseSelectOption, +} from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; import AddAutoRouterTab from "./add_auto_router_tab"; @@ -522,6 +530,71 @@ describe("AddAutoRouterTab", () => { ); }); + describe("prompt compression", () => { + it("leaves both compression keys out of the create payload when the section is untouched", async () => { + const user = userEvent.setup(); + vi.mocked(getMissingTiersError).mockReturnValue(null); + + renderWithProviders(); + + await user.type(screen.getByPlaceholderText(/smart_router/i), "no-compression-router"); + await user.click(screen.getByRole("button", { name: /add auto router/i })); + + await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled()); + const submitted = vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]; + expect(submitted).not.toHaveProperty("auto_router_routing_compression"); + expect(submitted).not.toHaveProperty("auto_router_model_compression"); + }); + + it("mirrors an explicit no-compression routing choice onto the model call by default", async () => { + const user = userEvent.setup(); + vi.mocked(getMissingTiersError).mockReturnValue(null); + + renderWithProviders(); + + await user.type(screen.getByPlaceholderText(/smart_router/i), "no-compression-explicit-router"); + expandDetailedConfiguration(); + await user.click(screen.getByText("Advanced: Compression")); + await chooseSelectOption( + user, + screen.getByRole("combobox", { name: "Routing decision compression" }), + "None (no compression)", + ); + + await user.click(screen.getByRole("button", { name: /add auto router/i })); + + await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled()); + const submitted = vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]; + expect(submitted?.auto_router_routing_compression).toBe("none"); + expect(submitted?.auto_router_model_compression).toBe("none"); + }); + + it("defaults the model call to none when different is chosen but nothing is picked there", async () => { + const user = userEvent.setup(); + vi.mocked(getMissingTiersError).mockReturnValue(null); + + renderWithProviders(); + + await user.type(screen.getByPlaceholderText(/smart_router/i), "different-compression-router"); + expandDetailedConfiguration(); + await user.click(screen.getByText("Advanced: Compression")); + await chooseSelectOption( + user, + screen.getByRole("combobox", { name: "Routing decision compression" }), + "None (no compression)", + ); + await user.click(screen.getByText("Use a different compression")); + expect(screen.getByRole("combobox", { name: "Model call compression" })).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: /add auto router/i })); + + await waitFor(() => expect(handleAddAutoRouterSubmit).toHaveBeenCalled()); + const submitted = vi.mocked(handleAddAutoRouterSubmit).mock.calls.at(-1)?.[0]; + expect(submitted?.auto_router_routing_compression).toBe("none"); + expect(submitted?.auto_router_model_compression).toBe("none"); + }); + }); + // The scalar floor is the one scorer knob with no group dict behind it, so its wiring into the create // payload is only proven end to end. 0 is the case a truthy check would silently drop. it("carries a reasoning override floor of 0 through to the create payload", async () => { diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx index a548c2c6533..decacac6501 100644 --- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx +++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx @@ -32,6 +32,11 @@ import ComplexityRouterConfig, { } from "./ComplexityRouterConfig"; import { KeywordTierRule } from "./KeywordTierRules"; import { DEFAULT_ESCALATION_KEYWORDS } from "./EscalationKeywords"; +import { + type AutoRouterCompressionState, + buildAutoRouterCompressionParams, + DEFAULT_AUTO_ROUTER_COMPRESSION, +} from "./buildAutoRouterCompression"; import { DEFAULT_MATCH_THRESHOLD } from "./SemanticKeywordMatching"; import { BuildComplexityRouterConfigParams, @@ -194,6 +199,9 @@ const AddAutoRouterTab: React.FC = ({ const [embeddingModel, setEmbeddingModel] = useState(undefined); const [matchThreshold, setMatchThreshold] = useState(DEFAULT_MATCH_THRESHOLD); const [escalationKeywords, setEscalationKeywords] = useState(DEFAULT_ESCALATION_KEYWORDS); + const [autoRouterCompression, setAutoRouterCompression] = useState( + DEFAULT_AUTO_ROUTER_COMPRESSION, + ); const [showValidationErrors, setShowValidationErrors] = useState(false); const [editingTiers, setEditingTiers] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); @@ -461,6 +469,7 @@ const AddAutoRouterTab: React.FC = ({ model_type: "complexity_router", complexity_router_config: complexityRouterConfigPayload, model_access_group: form.getValues("model_access_group"), + ...buildAutoRouterCompressionParams(autoRouterCompression), }; await handleAddAutoRouterSubmit(submitValues, accessToken, () => form.reset(EMPTY_FORM_VALUES), handleOk); @@ -666,6 +675,8 @@ const AddAutoRouterTab: React.FC = ({ onMatchThresholdChange={setMatchThreshold} escalationKeywords={escalationKeywords} onEscalationKeywordsChange={setEscalationKeywords} + autoRouterCompression={autoRouterCompression} + onAutoRouterCompressionChange={setAutoRouterCompression} showValidationErrors={showValidationErrors} />
diff --git a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts new file mode 100644 index 00000000000..b917fcedaa2 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts @@ -0,0 +1,93 @@ +import { + buildAutoRouterCompressionParams, + DEFAULT_AUTO_ROUTER_COMPRESSION, + hydrateAutoRouterCompression, + NO_COMPRESSION, +} from "./buildAutoRouterCompression"; + +describe("buildAutoRouterCompressionParams", () => { + it("omits both keys when routing was never configured", () => { + expect(buildAutoRouterCompressionParams(DEFAULT_AUTO_ROUTER_COMPRESSION)).toEqual({}); + }); + + it("mirrors routing onto model when same-as-routing is chosen", () => { + const params = buildAutoRouterCompressionParams({ + routing: "headroom-a", + sameAsRouting: true, + model: undefined, + }); + expect(params).toEqual({ + auto_router_routing_compression: "headroom-a", + auto_router_model_compression: "headroom-a", + }); + }); + + it("uses the explicit model choice when different is chosen", () => { + const params = buildAutoRouterCompressionParams({ + routing: "headroom-a", + sameAsRouting: false, + model: "headroom-b", + }); + expect(params).toEqual({ + auto_router_routing_compression: "headroom-a", + auto_router_model_compression: "headroom-b", + }); + }); + + it("defaults the model side to none when different is chosen but nothing is picked", () => { + const params = buildAutoRouterCompressionParams({ + routing: "headroom-a", + sameAsRouting: false, + model: undefined, + }); + expect(params).toEqual({ + auto_router_routing_compression: "headroom-a", + auto_router_model_compression: NO_COMPRESSION, + }); + }); + + it("sends the none sentinel when routing itself is explicitly turned off", () => { + const params = buildAutoRouterCompressionParams({ + routing: NO_COMPRESSION, + sameAsRouting: true, + model: undefined, + }); + expect(params).toEqual({ + auto_router_routing_compression: NO_COMPRESSION, + auto_router_model_compression: NO_COMPRESSION, + }); + }); +}); + +describe("hydrateAutoRouterCompression", () => { + it("returns the default state when neither key is set", () => { + expect(hydrateAutoRouterCompression({})).toEqual(DEFAULT_AUTO_ROUTER_COMPRESSION); + }); + + it("is same-as-routing when the model value matches routing", () => { + const state = hydrateAutoRouterCompression({ + auto_router_routing_compression: "headroom-a", + auto_router_model_compression: "headroom-a", + }); + expect(state).toEqual({ routing: "headroom-a", sameAsRouting: true, model: undefined }); + }); + + it("is different when the model value diverges from routing", () => { + const state = hydrateAutoRouterCompression({ + auto_router_routing_compression: "headroom-a", + auto_router_model_compression: "headroom-b", + }); + expect(state).toEqual({ routing: "headroom-a", sameAsRouting: false, model: "headroom-b" }); + }); + + it("treats a missing model key as same-as-routing", () => { + const state = hydrateAutoRouterCompression({ auto_router_routing_compression: "headroom-a" }); + expect(state).toEqual({ routing: "headroom-a", sameAsRouting: true, model: undefined }); + }); + + it("round-trips through buildAutoRouterCompressionParams", () => { + const original = { auto_router_routing_compression: "headroom-a", auto_router_model_compression: "none" }; + const rebuilt = buildAutoRouterCompressionParams(hydrateAutoRouterCompression(original)); + expect(rebuilt).toEqual(original); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts new file mode 100644 index 00000000000..49180d5b4ce --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts @@ -0,0 +1,52 @@ +/** + * Maps the auto router's compression form state to the two flat litellm_params keys + * the backend reads (litellm.proxy.guardrails.auto_router_compression), and back. + * + * `routing` being undefined means the section was never touched: both keys are + * omitted from the payload, and the request's own compression guardrails apply to + * both hops unchanged. Once `routing` has a value (a guardrail name, or the "none" + * sentinel for explicit no-compression), the auto router is authoritative and the + * model side always gets a concrete value too, mirroring `routing` when same-as + * is chosen and defaulting to "none" otherwise. + */ + +export const NO_COMPRESSION = "none"; + +export interface AutoRouterCompressionState { + routing: string | undefined; + sameAsRouting: boolean; + model: string | undefined; +} + +export interface AutoRouterCompressionLitellmParams { + auto_router_routing_compression?: string; + auto_router_model_compression?: string; +} + +export const DEFAULT_AUTO_ROUTER_COMPRESSION: AutoRouterCompressionState = { + routing: undefined, + sameAsRouting: true, + model: undefined, +}; + +export const buildAutoRouterCompressionParams = ( + state: AutoRouterCompressionState, +): AutoRouterCompressionLitellmParams => { + if (state.routing === undefined) return {}; + return { + auto_router_routing_compression: state.routing, + auto_router_model_compression: state.sameAsRouting ? state.routing : (state.model ?? NO_COMPRESSION), + }; +}; + +export const hydrateAutoRouterCompression = (litellmParams: { + auto_router_routing_compression?: string | null; + auto_router_model_compression?: string | null; +}): AutoRouterCompressionState => { + const routing = litellmParams.auto_router_routing_compression ?? undefined; + if (routing === undefined) return DEFAULT_AUTO_ROUTER_COMPRESSION; + + const model = litellmParams.auto_router_model_compression ?? undefined; + const sameAsRouting = model === undefined || model === routing; + return { routing, sameAsRouting, model: sameAsRouting ? undefined : model }; +}; diff --git a/ui/litellm-dashboard/src/components/add_model/handle_add_auto_router_submit.tsx b/ui/litellm-dashboard/src/components/add_model/handle_add_auto_router_submit.tsx index 9385836ce1a..59d9ecf205e 100644 --- a/ui/litellm-dashboard/src/components/add_model/handle_add_auto_router_submit.tsx +++ b/ui/litellm-dashboard/src/components/add_model/handle_add_auto_router_submit.tsx @@ -1,8 +1,9 @@ import { modelCreateCall } from "../networking"; import { toast } from "@/lib/toast"; import type { ComplexityRouterConfigPayload } from "./build_complexity_router_config"; +import type { AutoRouterCompressionLitellmParams } from "./buildAutoRouterCompression"; -export interface AddAutoRouterValues { +export interface AddAutoRouterValues extends AutoRouterCompressionLitellmParams { auto_router_name: string; auto_router_default_model: string | undefined; model_type: "complexity_router"; @@ -24,6 +25,8 @@ export const handleAddAutoRouterSubmit = async ( model: "auto_router/complexity_router", complexity_router_config: values.complexity_router_config, complexity_router_default_model: values.auto_router_default_model, + auto_router_routing_compression: values.auto_router_routing_compression, + auto_router_model_compression: values.auto_router_model_compression, }, model_info: { ...(values.team_id ? { team_id: values.team_id } : {}), diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx index 970bcaa545f..b93db8d963e 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx @@ -1029,3 +1029,84 @@ describe("EditAutoRouterModal with a stored custom tier set", () => { expect(savedConfig().tier_model_configs).toEqual(CUSTOM_STORED.tier_model_configs); }); }); + +describe("EditAutoRouterModal prompt compression", () => { + beforeEach(() => { + modelPatchUpdateCall.mockClear(); + }); + + const savedLitellmParams = () => { + const [, payload] = modelPatchUpdateCall.mock.calls.at(-1) ?? []; + return payload?.litellm_params; + }; + + const renderWithStoredCompression = ( + compression?: { auto_router_routing_compression?: string; auto_router_model_compression?: string }, + ) => + renderWithProviders( + , + ); + + it("leaves both compression keys out of an untouched save when none were stored", async () => { + const user = userEvent.setup(); + renderWithStoredCompression(); + + await user.click(await screen.findByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedLitellmParams()).not.toHaveProperty("auto_router_routing_compression"); + expect(savedLitellmParams()).not.toHaveProperty("auto_router_model_compression"); + }); + + it("preserves a stored same-as-routing compression through an untouched open-and-save", async () => { + const user = userEvent.setup(); + renderWithStoredCompression({ + auto_router_routing_compression: "headroom-a", + auto_router_model_compression: "headroom-a", + }); + + await user.click(await screen.findByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedLitellmParams()?.auto_router_routing_compression).toBe("headroom-a"); + expect(savedLitellmParams()?.auto_router_model_compression).toBe("headroom-a"); + }); + + it("shows a stored different-compression choice as Use a different compression, not Same", async () => { + const user = userEvent.setup(); + renderWithStoredCompression({ + auto_router_routing_compression: "headroom-a", + auto_router_model_compression: "none", + }); + + await user.click(await screen.findByText("Advanced: Compression")); + + expect(await screen.findByRole("combobox", { name: "Routing decision compression" })).toHaveValue("headroom-a"); + expect(screen.getByRole("radio", { name: "Use a different compression" })).toBeChecked(); + expect(screen.getByRole("combobox", { name: "Model call compression" })).toHaveValue("None (no compression)"); + }); + + it("preserves a stored different-compression choice through an untouched open-and-save", async () => { + const user = userEvent.setup(); + renderWithStoredCompression({ + auto_router_routing_compression: "headroom-a", + auto_router_model_compression: "none", + }); + + await user.click(await screen.findByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedLitellmParams()?.auto_router_routing_compression).toBe("headroom-a"); + expect(savedLitellmParams()?.auto_router_model_compression).toBe("none"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx index ea1e5cba6a3..a4852f9e784 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.tsx @@ -41,6 +41,12 @@ import { } from "../add_model/build_complexity_router_config"; import { KeywordTierRule } from "../add_model/KeywordTierRules"; import { DEFAULT_MATCH_THRESHOLD } from "../add_model/SemanticKeywordMatching"; +import { + type AutoRouterCompressionState, + buildAutoRouterCompressionParams, + DEFAULT_AUTO_ROUTER_COMPRESSION, + hydrateAutoRouterCompression, +} from "../add_model/buildAutoRouterCompression"; import { hydrateKeywordTierRules } from "../add_model/complexity_router_keywords"; import { hydrateDimensionWeights, @@ -424,6 +430,9 @@ const EditAutoRouterModal: React.FC = ({ const [semanticMatchingEnabled, setSemanticMatchingEnabled] = useState(false); const [embeddingModel, setEmbeddingModel] = useState(undefined); const [matchThreshold, setMatchThreshold] = useState(DEFAULT_MATCH_THRESHOLD); + const [autoRouterCompression, setAutoRouterCompression] = useState( + DEFAULT_AUTO_ROUTER_COMPRESSION, + ); const [complexityRouterConfig, setComplexityRouterConfig] = useState({ tiers: { SIMPLE: [], MEDIUM: [], COMPLEX: [], REASONING: [] }, classifier_type: "heuristic", @@ -516,6 +525,12 @@ const EditAutoRouterModal: React.FC = ({ setMatchThreshold( typeof parsedConfig.match_threshold === "number" ? parsedConfig.match_threshold : DEFAULT_MATCH_THRESHOLD, ); + setAutoRouterCompression( + hydrateAutoRouterCompression({ + auto_router_routing_compression: modelData.litellm_params?.auto_router_routing_compression, + auto_router_model_compression: modelData.litellm_params?.auto_router_model_compression, + }), + ); form.reset({ ...EMPTY_FORM_VALUES, @@ -628,6 +643,7 @@ const EditAutoRouterModal: React.FC = ({ ...modelData.litellm_params, complexity_router_config: updatedConfig, complexity_router_default_model: defaultModel, + ...buildAutoRouterCompressionParams(autoRouterCompression), }; const updatedModelInfo = { ...modelData.model_info, @@ -749,6 +765,8 @@ const EditAutoRouterModal: React.FC = ({ onMatchThresholdChange={setMatchThreshold} escalationKeywords={escalationKeywords} onEscalationKeywordsChange={setEscalationKeywords} + autoRouterCompression={autoRouterCompression} + onAutoRouterCompressionChange={setAutoRouterCompression} />
) : ( diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 5f3cca49644..8f6a0700517 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -29222,6 +29222,10 @@ export interface components { auto_router_embedding_model?: string | null; /** Auto Router Max Input Chars */ auto_router_max_input_chars?: number | null; + /** Auto Router Model Compression */ + auto_router_model_compression?: string | null; + /** Auto Router Routing Compression */ + auto_router_routing_compression?: string | null; /** Aws Access Key Id */ aws_access_key_id?: string | null; /** Aws Batch Role Arn */ @@ -39275,6 +39279,10 @@ export interface components { auto_router_embedding_model?: string | null; /** Auto Router Max Input Chars */ auto_router_max_input_chars?: number | null; + /** Auto Router Model Compression */ + auto_router_model_compression?: string | null; + /** Auto Router Routing Compression */ + auto_router_routing_compression?: string | null; /** Aws Access Key Id */ aws_access_key_id?: string | null; /** Aws Batch Role Arn */ From 2f5bfae1a61b0821b6af9eabb045522adfa7b28a Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:21:13 -0700 Subject: [PATCH 121/410] refactor(shadow_eval): tighten the judge cap comment and type the test helper --- litellm/integrations/shadow_eval_logger.py | 9 +++------ .../integrations/test_shadow_eval_logger.py | 10 +++++----- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index b554c4bc668..2c56ecb8721 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -60,12 +60,9 @@ _MAX_CONCURRENT_SHADOW_TASKS: Final = 16 _MAX_JUDGE_RESPONSE_CHARS: Final = 8_000 _MAX_JUDGE_PROMPT_CHARS: Final = 24_000 -# The judge answers with a small JSON object, but the cap covers reasoning tokens too. A -# judge_model deployment configured with an elevated reasoning_effort or thinking budget -# (a realistic pick: an admin's best reasoning model doubling as the judge) spends most or -# all of a tight cap on that reasoning, invisibly to this call, and the reply arrives empty -# or truncated mid-object, which the attempt records as an unparseable verdict. Headroom is -# free: max_tokens is a ceiling, and only generated tokens bill. +# The judge answers with a small JSON object, but the cap covers reasoning tokens too: a +# judge deployment carrying an elevated reasoning_effort spends a tight cap before it ever +# answers, and the truncated reply is recorded as an unparseable verdict. JUDGE_MAX_OUTPUT_TOKENS: Final = 4096 _MAX_ERROR_CHARS: Final = 500 diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 367ad758772..9fcbd116f63 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -120,12 +120,12 @@ def _router( return router -def _reasoning_judge_router(reasoning_tokens, verdict='{"preference": "A", "confidence": 0.9}'): +def _reasoning_judge_router( + reasoning_tokens: int, verdict: str = '{"preference": "A", "confidence": 0.9}' +) -> MagicMock: """A router whose judge arm reasons before it answers, the way a deployment carrying an - elevated reasoning_effort does. Reasoning is billed against the caller's own max_tokens - and the reply is cut off at that cap, so a cap that does not clear the reasoning budget - yields a truncated verdict or no verdict at all. One character stands in for one token, - which is what makes the cap the thing under test.""" + elevated reasoning_effort does: reasoning bills against the caller's own max_tokens and + the reply is cut off at that cap. One character stands in for one token.""" router = MagicMock() router.model_group_alias = {} router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) From 541ab50c043be762fb73d73cf2ae648235e06112 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 4 Sep 2026 16:23:08 -0700 Subject: [PATCH 122/410] test(router): fake the upstream with respx in the retry policy attempt test The test-quality gate rejects patching litellm.acompletion, and faking the HTTP boundary is the stronger test anyway: the 503, 500 and 502 responses now travel through the real OpenAI SDK and exception mapping before the router decides how many times to retry. Adds a case showing that a 503 key does not govern a 502. --- tests/test_litellm/test_router.py | 39 ++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 5d83d0f8877..31eb46f1458 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -12,6 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import httpx import openai import pytest +import respx @@ -567,7 +568,6 @@ async def test_async_router_acancel_batch_does_not_fall_back_across_model_groups model string, and the fallback provider is then asked to cancel a batch it never issued, which can only answer not-found. The router re-raises the owner's error after that wasted round trip, so the pin's observable is the foreign call never happening.""" - import respx monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) router = litellm.Router( @@ -716,7 +716,6 @@ async def test_async_router_acreate_file_litellm_proxy_sends_target_model_names_ from io import BytesIO import httpx - import respx jsonl_file = BytesIO( json.dumps({"body": {"model": "chained-batch", "messages": [{"role": "user", "content": "hi"}]}}).encode( @@ -12897,31 +12896,45 @@ async def test_prompt_management_factory_marks_injection_for_every_deployment(mo @pytest.mark.asyncio @pytest.mark.parametrize( - "retry_policy,error_type,expected_calls", + "retry_policy,upstream_status,error_type,expected_upstream_calls", [ - ({"ServiceUnavailableErrorRetries": 0}, litellm.ServiceUnavailableError, 1), - ({"ServiceUnavailableErrorRetries": 1}, litellm.ServiceUnavailableError, 2), - ({"InternalServerErrorRetries": 0}, litellm.InternalServerError, 1), - ({"DefaultRetries": 0}, litellm.BadGatewayError, 1), - ({"DefaultRetries": 0, "ServiceUnavailableErrorRetries": 1}, litellm.ServiceUnavailableError, 2), + ({"ServiceUnavailableErrorRetries": 0}, 503, litellm.ServiceUnavailableError, 1), + ({"ServiceUnavailableErrorRetries": 1}, 503, litellm.ServiceUnavailableError, 2), + ({"InternalServerErrorRetries": 0}, 500, litellm.InternalServerError, 1), + ({"DefaultRetries": 0}, 502, litellm.BadGatewayError, 1), + ({"DefaultRetries": 0, "ServiceUnavailableErrorRetries": 1}, 503, litellm.ServiceUnavailableError, 2), + ({"ServiceUnavailableErrorRetries": 0}, 502, litellm.BadGatewayError, 3), ], ) -async def test_router_retry_policy_controls_attempt_count(retry_policy, error_type, expected_calls): +async def test_router_retry_policy_controls_upstream_attempt_count( + monkeypatch: pytest.MonkeyPatch, retry_policy, upstream_status, error_type, expected_upstream_calls +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) router = litellm.Router( model_list=[ { "model_name": "gpt-5.6", - "litellm_params": {"model": "openai/gpt-5.6", "api_key": "fake-key"}, + "litellm_params": { + "model": "openai/gpt-5.6", + "api_key": "sk-fake", + "api_base": "https://retry-policy.local/v1", + }, } ], num_retries=2, retry_policy=retry_policy, disable_cooldowns=True, ) - error = error_type(message="model is down", llm_provider="openai", model="gpt-5.6") - with patch.object(litellm, "acompletion", AsyncMock(side_effect=error)) as mock_acompletion: + with respx.mock(assert_all_called=True) as respx_mock: + upstream = respx_mock.post("https://retry-policy.local/v1/chat/completions").mock( + return_value=httpx.Response( + upstream_status, + headers={"retry-after": "0"}, + json={"error": {"message": "model is down", "type": "server_error"}}, + ) + ) with pytest.raises(error_type): await router.acompletion(model="gpt-5.6", messages=[{"role": "user", "content": "hi"}]) - assert mock_acompletion.call_count == expected_calls + assert upstream.call_count == expected_upstream_calls From da5e38ce9c1fe2ba4854952eac0cc2a694e1c38b Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:33:16 -0700 Subject: [PATCH 123/410] refactor(shadow_eval): state the cap's constraint without the rationale --- litellm/integrations/shadow_eval_logger.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index 2c56ecb8721..fc82ebafe09 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -60,9 +60,8 @@ _MAX_CONCURRENT_SHADOW_TASKS: Final = 16 _MAX_JUDGE_RESPONSE_CHARS: Final = 8_000 _MAX_JUDGE_PROMPT_CHARS: Final = 24_000 -# The judge answers with a small JSON object, but the cap covers reasoning tokens too: a -# judge deployment carrying an elevated reasoning_effort spends a tight cap before it ever -# answers, and the truncated reply is recorded as an unparseable verdict. +# Covers the judge's reasoning tokens as well as its small JSON answer: a judge deployment +# carrying an elevated reasoning_effort spends a tight cap before it ever answers. JUDGE_MAX_OUTPUT_TOKENS: Final = 4096 _MAX_ERROR_CHARS: Final = 500 From 9e286fe94bf18d29b7bb56e3c2f77d114c10acc5 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:42:11 -0700 Subject: [PATCH 124/410] fix(auto-router): close review findings on per-hop compression - Suppression markers now carry the per-process token `_pre_call_marker` already uses, so a caller cannot switch off an always-on PII, content-filter or compression guardrail by naming it in its own request metadata. - Routing set to "none" with the model side compressed now classifies on the pre-compression snapshot instead of the model-side guardrail's output. - Both the proxy's pre-call arming and the router's routing hook resolve the policy through one tag-aware `policy_for_model`, so an alias with several tag-scoped markers can no longer suppress one marker's guardrail and then route under another marker's policy. - The pre-compression snapshot moved from request metadata to a ContextVar: `refresh_proxy_server_request_body_snapshot` copies metadata into `proxy_server_request.body`, which deployments persist, and the snapshot holds the prompt as it was before any masking guardrail rewrote it. - The compression selector lists Compresr guardrails too, not just Headroom. --- litellm/integrations/custom_guardrail.py | 22 ++- .../guardrails/auto_router_compression.py | 148 +++++++++------ litellm/router.py | 32 ++-- .../integrations/test_custom_guardrail.py | 35 +++- .../test_auto_router_compression.py | 177 +++++++++++++----- tests/test_litellm/test_router.py | 29 +++ .../add_model/CompressionControls.tsx | 5 +- .../add_model/buildAutoRouterCompression.ts | 7 + 8 files changed, 322 insertions(+), 133 deletions(-) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 7f8effa2317..558e97cfc16 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -941,17 +941,29 @@ class CustomGuardrail(CustomLogger): """ return False - def _suppressed_by_auto_router_compression(self, data: dict) -> bool: - """True when an auto router's own compression policy suppresses this guardrail. + def auto_router_suppression_marker(self) -> str | None: + """The value `arm_pre_call` must write to suppress this guardrail. - Set only by litellm.proxy.guardrails.auto_router_compression.arm_pre_call, never - by the caller, so a request cannot suppress its own guardrails this way. + Carries the per-process token for the same reason `_pre_call_marker` does: a + caller controls request metadata, so a bare guardrail name there would let any + request switch off a PII, content-filter, or compression guardrail for itself. + The token is never sent to the caller, so the marker cannot be forged. """ + name: Final = self.guardrail_name + if not name: + return None + return f"{_PRE_CALL_EXECUTED_TOKEN}:{name}" + + def _suppressed_by_auto_router_compression(self, data: dict[str, object]) -> bool: + """True when an auto router's own compression policy suppresses this guardrail.""" + marker: Final = self.auto_router_suppression_marker() + if marker is None: + return False for meta_key in ("metadata", "litellm_metadata"): meta = data.get(meta_key) if isinstance(meta, dict): suppressed = meta.get(AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY) - if isinstance(suppressed, list) and self.guardrail_name in suppressed: + if isinstance(suppressed, list) and marker in suppressed: return True return False diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py index c3fba937d22..7ccd1937543 100644 --- a/litellm/proxy/guardrails/auto_router_compression.py +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -12,34 +12,32 @@ guardrail is suppressed for that request, and only these two settings decide wha each hop sees. """ -import copy -from collections.abc import Mapping +import contextvars +from collections.abc import Mapping, MutableMapping, Sequence from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Final from litellm._logging import verbose_proxy_logger from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY -from litellm.litellm_core_utils.core_helpers import ( - get_metadata_variable_name_from_kwargs, - get_or_create_metadata_bucket, -) +from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket from litellm.router_utils.auto_router_model_naming import AUTO_ROUTER_MODEL_PREFIX from litellm.types.utils import GenericGuardrailAPIInputs if TYPE_CHECKING: from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.router import Router -else: - CustomGuardrail = Any - Router = Any COMPRESSION_GUARDRAIL_PROVIDERS: Final = frozenset({"headroom", "compresr"}) _NO_COMPRESSION: Final = "none" -# Metadata key stashing the pre-compression messages so a routing decision that -# names a different compression than the model call still compresses the -# original text, not whatever the model-side guardrail already rewrote it to. -AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY: Final = "_auto_router_routing_messages_snapshot" +# The pre-compression messages, so a routing decision that does not share the model +# call's compression still classifies on the original text. Deliberately a ContextVar +# rather than a metadata key: `refresh_proxy_server_request_body_snapshot` copies +# metadata into `proxy_server_request.body`, which deployments persist to spend logs, +# and this holds the prompt as it was before any masking guardrail rewrote it. +_routing_messages_snapshot: Final[contextvars.ContextVar[tuple[Mapping[str, object], ...] | None]] = ( + contextvars.ContextVar("litellm_auto_router_routing_messages_snapshot", default=None) +) @dataclass(frozen=True, slots=True) @@ -72,30 +70,51 @@ def policy_from_litellm_params(litellm_params: Mapping[str, object]) -> AutoRout def policy_for_model( - llm_router: "Router | None", model_alias: str, team_id: str | None + llm_router: "Router | None", + model_alias: str, + team_id: str | None, + request_tags: Sequence[str], ) -> AutoRouterCompressionPolicy | None: - """The compression policy declared by the auto router marker deployment `model_alias` resolves to. + """The compression policy of the auto router marker `model_alias` resolves to. - Mirrors the alias lookup in ``_check_and_merge_model_level_guardrails``: this runs - before routing has picked a strategy, so it takes the first marker deployment for - the alias rather than disambiguating by request tags. + Both the proxy's pre-call arming and the router's routing hook resolve the policy + through here, with the same tag rule, so an alias carrying several tag-scoped + markers can never suppress one marker's guardrail and then route under another + marker's policy. """ if llm_router is None: return None deployments: Final = llm_router.get_model_list(model_name=model_alias, team_id=team_id) or [] - for deployment in deployments: - litellm_params: Final = deployment.get("litellm_params") or {} - model_field = litellm_params.get("model") - if not isinstance(model_field, str) or not model_field.startswith(AUTO_ROUTER_MODEL_PREFIX): - continue - policy = policy_from_litellm_params(litellm_params) + markers: Final = tuple( + litellm_params + for deployment in deployments + if isinstance(litellm_params := deployment.get("litellm_params") or {}, Mapping) + and str(litellm_params.get("model", "")).startswith(AUTO_ROUTER_MODEL_PREFIX) + ) + requested: Final = frozenset(request_tags) + tag_matched: Final = tuple( + params for params in markers if requested.issuperset(frozenset(params.get("tags") or ())) + ) + for params in (*tag_matched, *markers): + policy = policy_from_litellm_params(params) if policy is not None: return policy return None -def _active_compression_guardrail_names() -> frozenset[str]: - """Names of every currently-active guardrail whose type is a compression guardrail.""" +def team_id_from_request(request_kwargs: Mapping[str, object]) -> str | None: + """The caller's team id, from whichever metadata bucket this surface writes to.""" + for meta_key in ("metadata", "litellm_metadata"): + meta = request_kwargs.get(meta_key) + if isinstance(meta, Mapping): + team_id = meta.get("user_api_key_team_id") + if isinstance(team_id, str): + return team_id + return None + + +def _active_compression_guardrails() -> tuple["CustomGuardrail", ...]: + """Every currently-active guardrail whose type is a compression guardrail.""" import litellm from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy.guardrails.guardrail_registry import guardrail_class_registry @@ -104,21 +123,20 @@ def _active_compression_guardrail_names() -> frozenset[str]: cls for name, cls in guardrail_class_registry.items() if name in COMPRESSION_GUARDRAIL_PROVIDERS ) if not compression_classes: - return frozenset() + return () active: Final = litellm.logging_callback_manager.get_custom_loggers_for_type(callback_type=CustomGuardrail) - return frozenset( - cb.guardrail_name for cb in active if isinstance(cb, compression_classes) and cb.guardrail_name - ) + return tuple(cb for cb in active if isinstance(cb, compression_classes) and cb.guardrail_name) -async def arm_pre_call(data: dict, llm_router: "Router | None") -> dict: +async def arm_pre_call(data: MutableMapping[str, object], llm_router: "Router | None") -> MutableMapping[str, object]: """Apply an auto router's compression policy, if any, before guardrails run. Suppresses every other compression guardrail, re-enables the model-side guardrail the policy names (if any) even when it isn't ``default_on``, and - snapshots the pre-compression messages so the routing decision can compress - them independently of whatever the model-side guardrail does to `data`. + snapshots the pre-compression messages so the routing decision can read them + independently of whatever the model-side guardrail does to `data`. """ + _routing_messages_snapshot.set(None) if llm_router is None: return data @@ -129,21 +147,27 @@ async def arm_pre_call(data: dict, llm_router: "Router | None") -> dict: # Read-only until a policy is confirmed: creating the metadata bucket for every # request, including the vast majority with no auto-router compression policy, # would be an unwanted side effect of merely checking for one. - metadata_key: Final = get_metadata_variable_name_from_kwargs(data) - existing_bucket: Final = data.get(metadata_key) - other_bucket: Final = data.get("metadata" if metadata_key == "litellm_metadata" else "litellm_metadata") - team_id: Final = (existing_bucket.get("user_api_key_team_id") if isinstance(existing_bucket, dict) else None) or ( - other_bucket.get("user_api_key_team_id") if isinstance(other_bucket, dict) else None - ) + from litellm.router_strategy.tag_based_routing import _get_tags_from_request_kwargs - policy: Final = policy_for_model(llm_router=llm_router, model_alias=model_alias, team_id=team_id) + policy: Final = policy_for_model( + llm_router=llm_router, + model_alias=model_alias, + team_id=team_id_from_request(data), + request_tags=_get_tags_from_request_kwargs(data), + ) if policy is None: return data _, metadata = get_or_create_metadata_bucket(data) - suppressed: Final = _active_compression_guardrail_names() - ({policy.model} if policy.model else set()) + # Markers carry a per-process token so a caller cannot suppress a guardrail by + # naming it in its own request metadata. + suppressed: Final = tuple( + marker + for guardrail in _active_compression_guardrails() + if guardrail.guardrail_name != policy.model and (marker := guardrail.auto_router_suppression_marker()) + ) if suppressed: - metadata[AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] = sorted(suppressed) + metadata[AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] = list(suppressed) if policy.model is not None: requested = metadata.get("guardrails") @@ -157,44 +181,52 @@ async def arm_pre_call(data: dict, llm_router: "Router | None") -> dict: snapshot: Final = resolve_structured_messages(messages=data.get("messages"), request_kwargs=data) if snapshot is not None: - metadata[AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY] = copy.deepcopy(snapshot) + _routing_messages_snapshot.set(tuple(dict(message) for message in snapshot)) return data +def _snapshot_messages() -> list[dict[str, Any]] | None: + snapshot: Final = _routing_messages_snapshot.get() + return None if snapshot is None else [dict(message) for message in snapshot] + + async def messages_for_routing( policy: AutoRouterCompressionPolicy | None, messages: list[dict[str, Any]] | None, request_kwargs: Mapping[str, object], ) -> list[dict[str, Any]] | None: - """Messages to use for a routing decision, compressed per `policy.routing`. + """Messages to use for a routing decision, per `policy.routing`. - Returns None when there is no policy or the policy's routing side names no - compression, meaning the caller should route on whatever messages it already - has. The model call is untouched by this function either way: model-side - compression, if any, already ran as an ordinary pre-call guardrail before the - router was ever reached. + Returns None when the caller should route on whatever messages it already has. + The model call is untouched either way: model-side compression, if any, already + ran as an ordinary pre-call guardrail before the router was reached, so when the + two hops differ the routing decision reads the pre-compression snapshot rather + than what that guardrail left behind. """ - if policy is None or policy.routing is None: + if policy is None: + return None + + original: Final = _snapshot_messages() or messages + + if policy.routing is None: + # Explicitly no compression for routing. When the model side compressed, the + # messages in hand are its output, so fall back to the untouched snapshot. + return _snapshot_messages() if policy.model is not None else None + + if not original: return None from litellm.proxy.common_utils.registry_read_through import ( get_initialized_guardrail_with_read_through, ) - metadata_key: Final = get_metadata_variable_name_from_kwargs(request_kwargs) - metadata: Final = request_kwargs.get(metadata_key) - snapshot: Final = metadata.get(AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY) if isinstance(metadata, dict) else None - original: Final = snapshot if isinstance(snapshot, list) else messages - if not original: - return None - guardrail: Final = await get_initialized_guardrail_with_read_through(policy.routing) if guardrail is None: verbose_proxy_logger.warning( "AutoRouter compression: guardrail '%s' not found; routing on uncompressed messages", policy.routing ) - return None + return original inputs: GenericGuardrailAPIInputs = {"structured_messages": [dict(m) for m in original]} # A throwaway request_data: apply_guardrail writes its stats onto this dict, not diff --git a/litellm/router.py b/litellm/router.py index 8149ec60ddc..bcb2e2aa7ff 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -13039,11 +13039,19 @@ class Router: from litellm.proxy.guardrails.auto_router_compression import ( messages_for_routing, - policy_from_litellm_params, + policy_for_model, + team_id_from_request, ) - marker_params: Final = self._alias_marker_litellm_params(registered_model_name, selected_strategy.tags) - compression_policy: Final = policy_from_litellm_params(marker_params) if marker_params else None + # Resolved through the same tag-aware lookup the proxy's pre-call arming used, + # so an alias carrying several tag-scoped markers cannot suppress one marker's + # guardrail and then route under a different marker's policy. + compression_policy: Final = policy_for_model( + llm_router=self, + model_alias=registered_model_name, + team_id=team_id_from_request(request_kwargs), + request_tags=_get_tags_from_request_kwargs(request_kwargs), + ) # When both hops share the same compression, the model-side guardrail already # ran in the proxy's ordinary pre-call hook and compressed `messages` in place # (arm_pre_call armed it whether or not it is `default_on`); reuse that result @@ -13133,16 +13141,9 @@ class Router: return pre_routing_hook_response - def _alias_marker_litellm_params( + def _forwardable_alias_marker_params( self, model: str, strategy_tags: tuple[str, ...] - ) -> Mapping[str, object] | None: - """The auto-router marker deployment's own `litellm_params` for `model`, tag-scoped. - - Shared by `_forwardable_alias_marker_params` (forwarding api_base/api_key/... - gaps onto the routed deployment) and the auto-router compression policy lookup - (reading `auto_router_routing_compression`/`auto_router_model_compression`), so - both read the same marker row when an alias has more than one, tag-scoped marker. - """ + ) -> tuple[tuple[str, object], ...]: marker_params: Final = tuple( litellm_params for idx in self.model_name_to_deployment_indices.get(model, ()) @@ -13152,12 +13153,7 @@ class Router: tag_matched: Final = tuple( params for params in marker_params if tuple(params.get("tags") or ()) == strategy_tags ) - return tag_matched[0] if tag_matched else (marker_params[0] if marker_params else None) - - def _forwardable_alias_marker_params( - self, model: str, strategy_tags: tuple[str, ...] - ) -> tuple[tuple[str, object], ...]: - selected: Final = self._alias_marker_litellm_params(model, strategy_tags) + selected: Final = tag_matched[0] if tag_matched else (marker_params[0] if marker_params else None) if selected is None: return () return tuple( diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 1fb4299cb56..f590903cb74 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -532,7 +532,9 @@ class TestCustomGuardrailShouldRunGuardrail: data = { "model": "smart-router", "metadata": { - AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: ["headroom-default"], + AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: [ + always_on.auto_router_suppression_marker() + ], }, } @@ -550,10 +552,13 @@ class TestCustomGuardrailShouldRunGuardrail: default_on=True, event_hook=GuardrailEventHooks.pre_call, ) + other = CustomGuardrail(guardrail_name="some-other-guardrail", default_on=True) data = { "model": "smart-router", "metadata": { - AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: ["some-other-guardrail"], + AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: [ + other.auto_router_suppression_marker() + ], }, } @@ -562,6 +567,32 @@ class TestCustomGuardrailShouldRunGuardrail: is True ) + def test_should_run_guardrail_ignores_a_forged_suppression_marker(self): + """A caller controls request metadata, so a bare guardrail name there must not + switch off an always-on guardrail: only the per-process marker counts.""" + from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY + from litellm.types.guardrails import GuardrailEventHooks + + always_on = CustomGuardrail( + guardrail_name="headroom-default", + default_on=True, + event_hook=GuardrailEventHooks.pre_call, + ) + forged = { + "model": "smart-router", + "metadata": { + AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: [ + "headroom-default", + "forged-token:headroom-default", + ], + }, + } + + assert ( + always_on.should_run_guardrail(data=forged, event_type=GuardrailEventHooks.pre_call) + is True + ) + class TestApplyGuardrailCheck: def test_apply_guardrail_check_only_on_direct_implementation(self): diff --git a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py index b2e83e75768..b906e60bb86 100644 --- a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py +++ b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py @@ -4,28 +4,33 @@ Unit tests for litellm.proxy.guardrails.auto_router_compression. Covers: - policy_from_litellm_params: absent keys mean no policy; the "none" sentinel normalizes to explicit no-compression within an active policy; is_same -- policy_for_model: finds the auto-router marker deployment for an alias -- arm_pre_call: no-op without a policy; suppresses active compression guardrails; - arms the model-side guardrail even when it isn't default_on; snapshots messages -- messages_for_routing: no-op without a policy or an unset routing side; compresses - via the named guardrail's apply_guardrail; never writes stats onto the caller's - own request_kwargs (regression for double-counted compression savings) +- policy_for_model: finds the auto-router marker deployment for an alias, and + picks the tag-scoped marker the request's tags actually match +- arm_pre_call: no-op without a policy; suppresses active compression guardrails + with a forgery-proof marker; arms the model-side guardrail even when it isn't + default_on; keeps the pre-compression snapshot out of persisted metadata +- messages_for_routing: no-op without a policy; routes on the pre-compression + snapshot when the two hops differ; compresses via the named guardrail's + apply_guardrail; never writes stats onto the caller's own request_kwargs + (regression for double-counted compression savings) """ +import json from typing import Any import pytest +from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY from litellm.integrations.custom_guardrail import CustomGuardrail +from litellm.proxy.guardrails import auto_router_compression from litellm.proxy.guardrails.auto_router_compression import ( - AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY, AutoRouterCompressionPolicy, arm_pre_call, messages_for_routing, policy_for_model, policy_from_litellm_params, ) -from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY +from litellm.types.guardrails import GuardrailEventHooks from litellm.types.utils import GenericGuardrailAPIInputs @@ -76,36 +81,61 @@ class _FakeRouter: return [d for d in self._deployments if d.get("model_name") == model_name] +def _marker(compression: dict[str, str], tags: list[str] | None = None) -> dict[str, Any]: + return { + "model_name": "smart-router", + "litellm_params": { + "model": "auto_router/complexity_router", + **compression, + **({"tags": tags} if tags is not None else {}), + }, + } + + class TestPolicyForModel: def test_no_router_returns_none(self): - assert policy_for_model(llm_router=None, model_alias="smart-router", team_id=None) is None + assert policy_for_model(llm_router=None, model_alias="smart-router", team_id=None, request_tags=()) is None def test_no_marker_deployment_returns_none(self): router = _FakeRouter( [{"model_name": "smart-router", "litellm_params": {"model": "openai/gpt-4o-mini"}}] ) - assert policy_for_model(llm_router=router, model_alias="smart-router", team_id=None) is None + assert policy_for_model(llm_router=router, model_alias="smart-router", team_id=None, request_tags=()) is None def test_marker_deployment_without_policy_returns_none(self): router = _FakeRouter( [{"model_name": "smart-router", "litellm_params": {"model": "auto_router/complexity_router"}}] ) - assert policy_for_model(llm_router=router, model_alias="smart-router", team_id=None) is None + assert policy_for_model(llm_router=router, model_alias="smart-router", team_id=None, request_tags=()) is None def test_marker_deployment_with_policy_is_found(self): + router = _FakeRouter( + [_marker({"auto_router_routing_compression": "headroom-a", "auto_router_model_compression": "none"})] + ) + policy = policy_for_model(llm_router=router, model_alias="smart-router", team_id=None, request_tags=()) + assert policy == AutoRouterCompressionPolicy(routing="headroom-a", model=None) + + def test_picks_the_marker_whose_tags_the_request_carries(self): + """Regression: an alias with several tag-scoped markers must not suppress one + marker's guardrail and then route under a different marker's policy.""" router = _FakeRouter( [ - { - "model_name": "smart-router", - "litellm_params": { - "model": "auto_router/complexity_router", - "auto_router_routing_compression": "headroom-a", - "auto_router_model_compression": "none", - }, - } + _marker({"auto_router_routing_compression": "headroom-eu"}, tags=["eu"]), + _marker({"auto_router_routing_compression": "headroom-us"}, tags=["us"]), ] ) - policy = policy_for_model(llm_router=router, model_alias="smart-router", team_id=None) + + eu = policy_for_model(llm_router=router, model_alias="smart-router", team_id=None, request_tags=("eu",)) + us = policy_for_model(llm_router=router, model_alias="smart-router", team_id=None, request_tags=("us",)) + + assert eu == AutoRouterCompressionPolicy(routing="headroom-eu", model=None) + assert us == AutoRouterCompressionPolicy(routing="headroom-us", model=None) + + def test_untagged_marker_matches_any_request(self): + router = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-a"})]) + policy = policy_for_model( + llm_router=router, model_alias="smart-router", team_id=None, request_tags=("anything",) + ) assert policy == AutoRouterCompressionPolicy(routing="headroom-a", model=None) @@ -186,10 +216,25 @@ class TestArmPreCall: data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} result = await arm_pre_call(data=data, llm_router=router) suppressed = result["metadata"][AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] - assert "always-on-compression" in suppressed + assert suppressed == [always_on.auto_router_suppression_marker()] + # The bare name alone must never suppress: that is what a caller could forge. + assert "always-on-compression" not in suppressed + assert always_on.should_run_guardrail(data=result, event_type=GuardrailEventHooks.pre_call) is False finally: litellm.logging_callback_manager.remove_callback_from_all_lists(always_on) + @pytest.mark.asyncio + async def test_a_caller_cannot_suppress_a_guardrail_by_naming_it_in_metadata(self): + """Regression: request metadata is caller-controlled, so a bare guardrail name + there must not switch off a PII, content-filter, or compression guardrail.""" + guardrail = _RecordingCompressionGuardrail(guardrail_name="always-on-compression") + forged = { + "model": "smart-router", + "metadata": {AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: ["always-on-compression"]}, + } + + assert guardrail._suppressed_by_auto_router_compression(forged) is False + @pytest.mark.asyncio async def test_model_side_guardrail_is_requested_even_when_not_default_on(self): router = _FakeRouter( @@ -209,43 +254,82 @@ class TestArmPreCall: assert result["metadata"]["guardrails"] == ["headroom-b"] @pytest.mark.asyncio - async def test_snapshots_original_messages(self): - router = _FakeRouter( - [ - { - "model_name": "smart-router", - "litellm_params": { - "model": "auto_router/complexity_router", - "auto_router_routing_compression": "headroom-a", - "auto_router_model_compression": "none", - }, - } - ] - ) - original_messages = [{"role": "user", "content": "hi"}] + async def test_snapshot_never_lands_in_persisted_metadata(self): + """Regression: refresh_proxy_server_request_body_snapshot copies metadata into + proxy_server_request.body, which deployments persist to spend logs. The + pre-compression snapshot holds the prompt before any masking guardrail ran, so + it must live outside anything that gets serialized.""" + router = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-a"})]) + original_messages = [{"role": "user", "content": "my ssn is 123-45-6789"}] data = {"model": "smart-router", "messages": original_messages} + result = await arm_pre_call(data=data, llm_router=router) - snapshot = result["metadata"][AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY] - assert snapshot == original_messages - assert snapshot is not original_messages # a copy, not the live reference + + assert "123-45-6789" not in json.dumps(result["metadata"]) + assert auto_router_compression._snapshot_messages() == original_messages + + @pytest.mark.asyncio + async def test_snapshot_is_a_copy_not_the_live_message_list(self): + router = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-a"})]) + original_messages = [{"role": "user", "content": "hi"}] + + await arm_pre_call(data={"model": "smart-router", "messages": original_messages}, llm_router=router) + original_messages[0]["content"] = "mutated after the snapshot" + + assert auto_router_compression._snapshot_messages() == [{"role": "user", "content": "hi"}] + + @pytest.mark.asyncio + async def test_a_request_without_a_policy_clears_a_previous_snapshot(self): + router_with = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-a"})]) + await arm_pre_call(data={"model": "smart-router", "messages": [{"role": "user", "content": "first"}]}, + llm_router=router_with) + + router_without = _FakeRouter( + [{"model_name": "plain", "litellm_params": {"model": "openai/gpt-4o-mini"}}] + ) + await arm_pre_call(data={"model": "plain", "messages": [{"role": "user", "content": "second"}]}, + llm_router=router_without) + + assert auto_router_compression._snapshot_messages() is None class TestMessagesForRouting: + @pytest.fixture(autouse=True) + def _clear_snapshot(self): + auto_router_compression._routing_messages_snapshot.set(None) + yield + auto_router_compression._routing_messages_snapshot.set(None) + @pytest.mark.asyncio async def test_no_policy_returns_none(self): assert await messages_for_routing(policy=None, messages=[], request_kwargs={}) is None @pytest.mark.asyncio - async def test_routing_side_unset_returns_none(self): - policy = AutoRouterCompressionPolicy(routing=None, model="headroom-a") + async def test_routing_none_with_no_model_compression_returns_none(self): + """Nothing compressed either hop, so the caller's own messages are already right.""" + policy = AutoRouterCompressionPolicy(routing=None, model=None) assert await messages_for_routing(policy=policy, messages=[], request_kwargs={}) is None @pytest.mark.asyncio - async def test_unknown_guardrail_name_returns_none(self): + async def test_routing_none_with_model_compression_routes_on_the_snapshot(self): + """Regression: with routing explicitly off and the model side compressed, the + messages in hand are the model-side guardrail's output. Routing asked for no + compression, so it must read the pre-compression snapshot instead.""" + original = [{"role": "user", "content": "the full original conversation"}] + auto_router_compression._routing_messages_snapshot.set(tuple(dict(m) for m in original)) + policy = AutoRouterCompressionPolicy(routing=None, model="headroom-a") + model_compressed = [{"role": "user", "content": "[COMPRESSED] the full original conversation"}] + + result = await messages_for_routing(policy=policy, messages=model_compressed, request_kwargs={}) + + assert result == original + + @pytest.mark.asyncio + async def test_unknown_guardrail_name_routes_on_the_uncompressed_messages(self): policy = AutoRouterCompressionPolicy(routing="does-not-exist", model=None) messages = [{"role": "user", "content": "hi"}] result = await messages_for_routing(policy=policy, messages=messages, request_kwargs={}) - assert result is None + assert result == messages @pytest.mark.asyncio async def test_compresses_via_the_named_guardrail(self, registered_guardrail): @@ -256,15 +340,14 @@ class TestMessagesForRouting: @pytest.mark.asyncio async def test_uses_the_snapshot_when_present(self, registered_guardrail): - policy = AutoRouterCompressionPolicy(routing="fake-compress", model=None) - snapshot = [{"role": "user", "content": "original"}] - request_kwargs = {"metadata": {AUTO_ROUTER_ROUTING_MESSAGES_SNAPSHOT_KEY: snapshot}} - # `messages` here stands in for whatever a model-side guardrail already + policy = AutoRouterCompressionPolicy(routing="fake-compress", model="headroom-b") + auto_router_compression._routing_messages_snapshot.set(({"role": "user", "content": "original"},)) + # `messages` here stands in for whatever the model-side guardrail already # rewrote `data["messages"]` to -- routing must ignore it and compress the # pristine snapshot instead. already_rewritten = [{"role": "user", "content": "rewritten by another guardrail"}] result = await messages_for_routing( - policy=policy, messages=already_rewritten, request_kwargs=request_kwargs + policy=policy, messages=already_rewritten, request_kwargs={} ) assert result == [{"role": "user", "content": "[COMPRESSED] original"}] diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index cdae3b131ae..4c5813911ac 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -10105,6 +10105,35 @@ class TestAutoRouterCompressionDecoupling: assert response.messages == original_messages assert registered_guardrail.call_count == 0 + @pytest.mark.asyncio + async def test_routing_none_routes_on_the_original_not_the_model_compressed_messages( + self, registered_guardrail + ): + """Regression: with routing explicitly off and the model side compressed, the + messages the router holds are the model-side guardrail's output. Routing asked + for no compression, so it has to classify on the pre-compression snapshot.""" + from litellm.proxy.guardrails import auto_router_compression + + router, strategy = self._router( + { + "auto_router_routing_compression": "none", + "auto_router_model_compression": "fake-compress", + } + ) + original_messages = self._messages() + auto_router_compression._routing_messages_snapshot.set(tuple(dict(m) for m in original_messages)) + model_compressed = [{"role": "user", "content": "[COMPRESSED] What is the capital of France?"}] + + try: + await router.async_pre_routing_hook( + model="smart-router", request_kwargs={"metadata": {}}, messages=model_compressed + ) + finally: + auto_router_compression._routing_messages_snapshot.set(None) + + assert strategy.received_messages == original_messages + assert registered_guardrail.call_count == 0 + @pytest.mark.asyncio async def test_same_compression_on_both_hops_compresses_once(self, registered_guardrail): """The same/different distinction exists so a shared choice does not pay for diff --git a/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx b/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx index a0a240f76b0..52ea7645034 100644 --- a/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx +++ b/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx @@ -5,8 +5,7 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Info } from "lucide-react"; import React from "react"; import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails"; -import { COMPRESSION_GUARDRAIL_PROVIDER } from "@/app/(dashboard)/cost-optimization/_components/helpers"; -import { NO_COMPRESSION } from "./buildAutoRouterCompression"; +import { isCompressionGuardrailProvider, NO_COMPRESSION } from "./buildAutoRouterCompression"; interface CompressionControlsProps { routing: string | undefined; @@ -29,7 +28,7 @@ const CompressionControls: React.FC = ({ }) => { const { data } = useGuardrails(); const compressionOptions: SearchSelectOption[] = (data?.guardrails ?? []) - .filter((g) => (g.litellm_params?.guardrail ?? "").toString().toLowerCase() === COMPRESSION_GUARDRAIL_PROVIDER) + .filter((g) => isCompressionGuardrailProvider(g.litellm_params?.guardrail)) .map((g) => ({ label: g.guardrail_name, value: g.guardrail_name })); const options: SearchSelectOption[] = [NONE_OPTION, ...compressionOptions]; diff --git a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts index 49180d5b4ce..5afdcf2b15c 100644 --- a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts +++ b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts @@ -12,6 +12,13 @@ export const NO_COMPRESSION = "none"; +/** Guardrail providers that compress prompts, mirroring COMPRESSION_GUARDRAIL_PROVIDERS in + * litellm/proxy/guardrails/auto_router_compression.py. Both are selectable per hop. */ +export const COMPRESSION_GUARDRAIL_PROVIDERS: readonly string[] = ["headroom", "compresr"]; + +export const isCompressionGuardrailProvider = (provider: unknown): boolean => + typeof provider === "string" && COMPRESSION_GUARDRAIL_PROVIDERS.includes(provider.toLowerCase()); + export interface AutoRouterCompressionState { routing: string | undefined; sameAsRouting: boolean; From 976f8625f34c9c0eb7ac5e5976493dde2c4cd997 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:42:29 -0700 Subject: [PATCH 125/410] test(proxy): cover default-tier end-user counter reset with rollover --- .../common_utils/test_reset_budget_job.py | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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 bc9926a314f..56c0efb41d2 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 @@ -3054,6 +3054,38 @@ def test_budget_cascade_carries_enduser_overage_when_rollover_enabled( } in enduser_writes +def test_budget_cascade_carries_default_tier_enduser_counter_when_rollover_enabled( + rollover_enabled, reset_budget_job, mock_prisma_client, monkeypatch +): + """An end user on the default budget (no budget_id on its row) 5 over the cap + keeps a counter of 5 in the next window and loses its cached object.""" + import litellm + + counter_cache: Final = _make_counter_invalidation_job(monkeypatch) + monkeypatch.setattr(litellm, "max_end_user_budget_id", "default-enduser-budget") + mock_prisma_client.data["budget"] = [ + _budget_row(budget_id="default-enduser-budget", budget_duration="1d", max_budget=10.0) + ] + implicit_enduser: Final = type( + "EndUserRow", + (), + { + "spend": 15.0, + "user_id": "enduser-implicit", + "budget_id": None, + "model_dump": lambda self=None: {"spend": 15.0, "user_id": "enduser-implicit", "budget_id": None, "blocked": False}, + }, + ) + mock_prisma_client.db.litellm_endusertable.set_find_many_results([implicit_enduser]) + + asyncio.run(reset_budget_job.reset_budget_for_litellm_budget_table()) + + counter_cache.in_memory_cache.set_cache.assert_any_call(key="spend:end_user:enduser-implicit", value=5.0, ttl=60) + counter_cache.redis_cache.async_set_cache.assert_any_await(key="spend:end_user:enduser-implicit", value=5.0, ttl=60) + deleted: Final = {call.kwargs.get("key") for call in counter_cache.user_api_key_cache.async_delete_cache.await_args_list} + assert "end_user_id:enduser-implicit" in deleted + + def _replay_spend_writes(writes, spend): """Apply the queued update_many statements in order, the way the DB transaction executes them, and return the row's final spend.""" From 5980055d7eae3d1ca28286979c5bd264cd37af57 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 16:50:35 -0700 Subject: [PATCH 126/410] feat(shadow_eval): say which shape produced an unparseable judge verdict The parser message alone cannot separate a judge that answered with nothing from one truncated mid-object, and the two want opposite fixes. Records the reply's shape, never its text, since no attempt row carries sampled content. --- litellm/integrations/shadow_eval_logger.py | 20 +++- .../integrations/test_shadow_eval_logger.py | 94 +++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index fc82ebafe09..fb75ef74db9 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -345,6 +345,22 @@ def _failure_detail(e: BaseException) -> str: return f"{type(e).__name__}{location}: {e}" +def _judge_reply_shape(response: object) -> str: + """How an unparseable judge reply was shaped. The parser's own message cannot separate a + judge that answered with nothing from one truncated mid-object, and those want opposite + fixes. Shape only, never the reply text: the judge quotes the sampled turns it compares, + and no attempt row carries sampled content today.""" + try: + choice: Final = response["choices"][0] # pyright: ignore[reportIndexIssue] # judge replies are subscriptable payloads + content: Final = choice["message"]["content"] + finish: Final = choice.get("finish_reason") or "unknown" + except (AttributeError, KeyError, IndexError, TypeError): + return "unreadable judge reply" + served: Final = str(getattr(response, "model", None) or "unknown") + body: Final = f"{len(str(content))} chars" if content else "no content" + return f"finish_reason={finish}, content={body}, model={served}" + + def _call_cost(response: object) -> float: """Price one eval-arm call with the figure the spend pipeline bills: the router client stamps _hidden_params.response_cost from the deployment's own pricing, which the public @@ -1139,7 +1155,9 @@ class ShadowEvalLogger(CustomLogger): verdict: Final = PairwiseVerdict.model_validate(parse_json_verdict(raw)) except Exception as e: # noqa: BLE001 # malformed verdicts become error rows verbose_logger.debug("shadow_eval: unparseable judge verdict: %s", e) - return _CallFailure(f"unparseable judge verdict: {e}", cost=_call_cost(response)) + return _CallFailure( + f"unparseable judge verdict: {e}; {_judge_reply_shape(response)}", cost=_call_cost(response) + ) return _JudgeVerdict( preference=_unmask_preference(verdict.preference, real_is_a), confidence=max(0.0, min(1.0, verdict.confidence)), diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 9fcbd116f63..dbc6d4ec915 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -141,6 +141,27 @@ def _reasoning_judge_router( return router +def _judge_reply_router(content: str | None, finish_reason: str = "stop", served_model: str = "judge-pick") -> MagicMock: + """A router whose judge arm returns a caller-shaped reply, so the shapes that all land + on the same parser error can be posed apart: no content at all, versus JSON cut off + mid-object.""" + router = MagicMock() + router.model_group_alias = {} + router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) + + async def acompletion(**kwargs): + if kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == SHADOW_EVAL_ROUTER_CALL_ORIGIN: + kwargs["metadata"]["routing_decision"] = {"tier_label": "SIMPLE", "routed_model": "cheap-model"} + return {"choices": [{"message": {"content": "shadow answer"}}]} + return ModelResponse( + model=served_model, + choices=[{"index": 0, "finish_reason": finish_reason, "message": {"role": "assistant", "content": content}}], + ) + + router.acompletion = MagicMock(side_effect=acompletion) + return router + + def _spend_counter(store=None): """In-memory stand-in for the proxy's cross-pod spend counter: reads take the max of the counter and the caller's fallback, exactly like get_current_spend does for a key @@ -1126,6 +1147,79 @@ class TestShadowPipeline: assert row["judge_cost"] == expected_cost assert row["shadow_cost"] == expected_shadow_cost + async def _judge_error(self, router: MagicMock, monkeypatch: pytest.MonkeyPatch) -> str: + import litellm as litellm_module + + monkeypatch.setattr(litellm_module, "completion_cost", lambda completion_response: 0.007) + prisma = _prisma() + await _logger(router=router, prisma=prisma)._run_shadow_eval( + job=_job(), + request_id="req-1", + messages=({"role": "user", "content": "hi"},), + real_text="real answer", + real_model="claude-opus", + real_cost=0.0, + real_classifier_cost=0.0, + real_cache_hit=False, + control_tier=None, + shadow_params={}, + parent_metadata={}, + ) + return prisma.db.litellm_shadowevalattempt.create.call_args.kwargs["data"]["error"] + + async def test_a_judge_that_answered_nothing_is_told_apart_from_one_cut_off( + self, monkeypatch: pytest.MonkeyPatch + ): + """Both land on the same parser message, and they want opposite fixes: a judge + returning no content points at the reply never being text, while one cut off + mid-object points at the output cap. The row has to say which.""" + truncated = '{"preference": "A", "confidence": 0.9, "reasoning": "' + answered_nothing = await self._judge_error(_judge_reply_router(None), monkeypatch) + cut_off = await self._judge_error( + _judge_reply_router(truncated, finish_reason="length"), monkeypatch + ) + + assert "content=no content" in answered_nothing + assert "finish_reason=stop" in answered_nothing + assert f"content={len(truncated)} chars" in cut_off + assert "finish_reason=length" in cut_off + + async def test_an_unparseable_verdict_names_the_model_that_served_it(self, monkeypatch: pytest.MonkeyPatch): + """A judge_model that fans out over deployments hides which one truncates: without + the served model the operator cannot tell a bad deployment from a bad cap.""" + error = await self._judge_error(_judge_reply_router(None, served_model="claude-sonnet-5"), monkeypatch) + + assert "model=claude-sonnet-5" in error + + async def test_a_diagnosed_verdict_error_stays_groupable(self, monkeypatch: pytest.MonkeyPatch): + """The customer groups attempt rows by error text. Every varying part has to sit + after the first semicolon or each row becomes its own group.""" + first = await self._judge_error(_judge_reply_router(None, served_model="model-a"), monkeypatch) + second = await self._judge_error(_judge_reply_router(None, served_model="model-b"), monkeypatch) + + assert first != second + assert first.split(";")[0] == second.split(";")[0] + + async def test_a_judge_reply_that_cannot_be_read_still_records_an_error(self, monkeypatch: pytest.MonkeyPatch): + """The shape reader runs inside the failure path: it must never raise a second time + and cost the row entirely.""" + router = MagicMock() + router.model_group_alias = {} + router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) + + async def acompletion(**kwargs): + if kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == SHADOW_EVAL_ROUTER_CALL_ORIGIN: + kwargs["metadata"]["routing_decision"] = {"tier_label": "SIMPLE", "routed_model": "cheap-model"} + return {"choices": [{"message": {"content": "shadow answer"}}]} + return {"choices": []} + + router.acompletion = MagicMock(side_effect=acompletion) + + error = await self._judge_error(router, monkeypatch) + + assert "unparseable judge verdict" in error + assert "unreadable judge reply" in error + async def test_an_empty_shadow_reply_still_bills_its_cost(self, monkeypatch: pytest.MonkeyPatch): """A shadow call that returns no extractable text has still billed; pricing it at zero would keep the dollar gate open while shadow calls keep charging the key.""" From 3202963f25482bb3a2fe312f93f1f12291f7d60e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 16:57:40 -0700 Subject: [PATCH 127/410] feat(cost-map): add azure/gpt-6-astra and azure/us/gpt-6-astra Foundry pricing --- ...odel_prices_and_context_window_backup.json | 94 +++++++++++++++++++ model_prices_and_context_window.json | 94 +++++++++++++++++++ .../llm_cost_calc/test_llm_cost_calc_utils.py | 43 +++++++++ .../test_reasoning_effort_capability.py | 16 ++++ 4 files changed, 247 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 7f5038e3073..d8b7287eba7 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -7157,6 +7157,53 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, + "azure/gpt-6-astra": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05, + "cache_read_input_token_cost": 1e-06, + "cache_read_input_token_cost_above_272k_tokens": 2e-06, + "input_cost_per_token": 1e-05, + "input_cost_per_token_above_272k_tokens": 2e-05, + "litellm_provider": "azure", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "output_cost_per_token_above_272k_tokens": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "azure/us/gpt-5.6": { "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, @@ -7376,6 +7423,53 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, + "azure/us/gpt-6-astra": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.75e-05, + "cache_read_input_token_cost": 1.1e-06, + "cache_read_input_token_cost_above_272k_tokens": 2.2e-06, + "input_cost_per_token": 1.1e-05, + "input_cost_per_token_above_272k_tokens": 2.2e-05, + "litellm_provider": "azure", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "output_cost_per_token_above_272k_tokens": 8.25e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "azure/eu/gpt-5.6": { "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 7f5038e3073..d8b7287eba7 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -7157,6 +7157,53 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, + "azure/gpt-6-astra": { + "cache_creation_input_token_cost": 1.25e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.5e-05, + "cache_read_input_token_cost": 1e-06, + "cache_read_input_token_cost_above_272k_tokens": 2e-06, + "input_cost_per_token": 1e-05, + "input_cost_per_token_above_272k_tokens": 2e-05, + "litellm_provider": "azure", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5e-05, + "output_cost_per_token_above_272k_tokens": 7.5e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "azure/us/gpt-5.6": { "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, @@ -7376,6 +7423,53 @@ "supports_xhigh_reasoning_effort": true, "supports_minimal_reasoning_effort": false }, + "azure/us/gpt-6-astra": { + "cache_creation_input_token_cost": 1.375e-05, + "cache_creation_input_token_cost_above_272k_tokens": 2.75e-05, + "cache_read_input_token_cost": 1.1e-06, + "cache_read_input_token_cost_above_272k_tokens": 2.2e-06, + "input_cost_per_token": 1.1e-05, + "input_cost_per_token_above_272k_tokens": 2.2e-05, + "litellm_provider": "azure", + "max_input_tokens": 922000, + "max_output_tokens": 128000, + "max_tokens": 128000, + "mode": "chat", + "output_cost_per_token": 5.5e-05, + "output_cost_per_token_above_272k_tokens": 8.25e-05, + "search_context_cost_per_query": { + "search_context_size_high": 0.01, + "search_context_size_low": 0.01, + "search_context_size_medium": 0.01 + }, + "supported_endpoints": [ + "/v1/chat/completions", + "/v1/responses" + ], + "supported_modalities": [ + "text", + "image" + ], + "supported_output_modalities": [ + "text" + ], + "supports_computer_use": true, + "supports_function_calling": true, + "supports_max_reasoning_effort": true, + "supports_minimal_reasoning_effort": false, + "supports_native_streaming": true, + "supports_none_reasoning_effort": false, + "supports_parallel_function_calling": true, + "supports_pdf_input": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_system_messages": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true, + "supports_xhigh_reasoning_effort": true + }, "azure/eu/gpt-5.6": { "cache_creation_input_token_cost": 6.875e-06, "cache_creation_input_token_cost_above_272k_tokens": 1.375e-05, diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 0f8084643ea..df680b7cb0e 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -2008,6 +2008,49 @@ def test_generic_cost_per_token_azure_gpt56(_local_model_cost_map, assert round(completion_cost, 10) == round(output_cost * completion_tokens, 10) +@pytest.mark.parametrize("model,zone_multiplier", [("azure/gpt-6-astra", 1.0), ("azure/us/gpt-6-astra", 1.1)]) +@pytest.mark.parametrize( + "prompt_tokens,input_side_multiplier,output_multiplier", + [(100000, 1.0, 1.0), (300000, 2.0, 1.5)], +) +def test_generic_cost_per_token_azure_gpt_6_astra_foundry_price_sheet( + _local_model_cost_map, + model, + zone_multiplier, + prompt_tokens, + input_side_multiplier, + output_multiplier, +): + """Microsoft Foundry sells gpt-6-astra at the OpenAI rates: $10 input, $1 cache read, $12.50 cache write, + $50 output per 1M tokens on Standard Global, with the input side doubling and output 1.5x above 272K + prompt tokens. Standard US Data Zone carries the usual 10% uplift on every rate. + """ + cached_tokens = 50000 + cache_write_tokens = 40000 + text_tokens = prompt_tokens - cached_tokens - cache_write_tokens + completion_tokens = 1000 + usage = Usage( + prompt_tokens=prompt_tokens, + completion_tokens=completion_tokens, + total_tokens=prompt_tokens + completion_tokens, + prompt_tokens_details=PromptTokensDetailsWrapper( + cached_tokens=cached_tokens, cache_write_tokens=cache_write_tokens + ), + ) + + prompt_cost, completion_cost = generic_cost_per_token( + model=model, + usage=usage, + custom_llm_provider="azure", + ) + + input_side = zone_multiplier * input_side_multiplier + assert prompt_cost == pytest.approx( + input_side * (text_tokens * 1e-5 + cached_tokens * 1e-6 + cache_write_tokens * 1.25e-5) + ) + assert completion_cost == pytest.approx(zone_multiplier * output_multiplier * completion_tokens * 5e-5) + + @pytest.mark.parametrize( "model,expected_none,expected_xhigh,expected_minimal", [ diff --git a/tests/test_litellm/router_utils/test_reasoning_effort_capability.py b/tests/test_litellm/router_utils/test_reasoning_effort_capability.py index 7b2e45ab3ed..504e87fb231 100644 --- a/tests/test_litellm/router_utils/test_reasoning_effort_capability.py +++ b/tests/test_litellm/router_utils/test_reasoning_effort_capability.py @@ -388,3 +388,19 @@ class TestGpt6AstraAdvertisesItsDocumentedLevels: "xhigh", "max", ) + + @pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra"]) + def test_a_foundry_deployment_advertises_the_same_levels(self, local_model_cost_map, model): + """Microsoft Foundry serves the same model, so an Azure deployment must offer low + through max and never none, exactly like the OpenAI entry.""" + from litellm.utils import _get_model_info_helper + + model_info = dict(_get_model_info_helper(model=model, custom_llm_provider="azure")) + + assert resolve_supported_reasoning_efforts(model_info, deployment_is_mapped=True) == ( + "low", + "medium", + "high", + "xhigh", + "max", + ) From 4b950cd94f86dbe174d515a468cb9b5288bf0ab8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:01:14 -0700 Subject: [PATCH 128/410] feat(fireworks_ai): add native Responses API config --- basedpyright-code-budget.json | 4 +- litellm/__init__.py | 3 + litellm/_lazy_imports_registry.py | 5 + litellm/llms/fireworks_ai/common_utils.py | 44 +-- .../fireworks_ai/responses/transformation.py | 68 +++++ litellm/utils.py | 2 + ...t_fireworks_ai_responses_transformation.py | 263 ++++++++++++++++++ .../test_responses_websocket_all_providers.py | 9 + type-discipline-budget.json | 4 +- 9 files changed, 382 insertions(+), 20 deletions(-) create mode 100644 litellm/llms/fireworks_ai/responses/transformation.py create mode 100644 tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 9b59480a0dc..e7f8aacf835 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -105,13 +105,13 @@ "limit": 109 }, "reportUnknownMemberType": { - "limit": 38309 + "limit": 38307 }, "reportUnknownParameterType": { "limit": 19622 }, "reportUnknownVariableType": { - "limit": 29846 + "limit": 29840 }, "reportUnnecessaryCast": { "limit": 111 diff --git a/litellm/__init__.py b/litellm/__init__.py index 42c0ea881fd..fdc4435e5ff 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -2001,6 +2001,9 @@ if TYPE_CHECKING: from .llms.hosted_vllm.responses.transformation import ( HostedVLLMResponsesAPIConfig as HostedVLLMResponsesAPIConfig, ) + from .llms.fireworks_ai.responses.transformation import ( + FireworksAIResponsesAPIConfig as FireworksAIResponsesAPIConfig, + ) from .llms.github_copilot.chat.transformation import ( GithubCopilotConfig as GithubCopilotConfig, ) diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index e9199e1ec80..dc323c8cc15 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -237,6 +237,7 @@ LLM_CONFIG_NAMES: Final = ( "XAIResponsesAPIConfig", "LiteLLMProxyResponsesAPIConfig", "HostedVLLMResponsesAPIConfig", + "FireworksAIResponsesAPIConfig", "VolcEngineResponsesAPIConfig", "PerplexityResponsesConfig", "DatabricksResponsesAPIConfig", @@ -957,6 +958,10 @@ _LLM_CONFIGS_IMPORT_MAP: Final = { ".llms.hosted_vllm.responses.transformation", "HostedVLLMResponsesAPIConfig", ), + "FireworksAIResponsesAPIConfig": ( + ".llms.fireworks_ai.responses.transformation", + "FireworksAIResponsesAPIConfig", + ), "VolcEngineResponsesAPIConfig": ( ".llms.volcengine.responses.transformation", "VolcEngineResponsesAPIConfig", diff --git a/litellm/llms/fireworks_ai/common_utils.py b/litellm/llms/fireworks_ai/common_utils.py index ac934ad0cb5..21a630a76d7 100644 --- a/litellm/llms/fireworks_ai/common_utils.py +++ b/litellm/llms/fireworks_ai/common_utils.py @@ -1,3 +1,5 @@ +from collections.abc import Mapping +from types import MappingProxyType from typing import Final from httpx import Headers @@ -13,7 +15,7 @@ class FireworksAIException(BaseLLMException): pass -def get_fireworks_session_id(litellm_params: dict) -> str | None: +def get_fireworks_session_id(litellm_params: Mapping[str, object]) -> str | None: """ Session id to send as `x-session-affinity`, or None when the caller gave none. @@ -23,19 +25,39 @@ def get_fireworks_session_id(litellm_params: dict) -> str | None: """ params: Final = litellm_params metadata: Final = params.get("metadata") - if isinstance(metadata, dict) and metadata.get(SESSION_ID_GENERATED_METADATA_KEY): + if isinstance(metadata, Mapping) and metadata.get(SESSION_ID_GENERATED_METADATA_KEY): return None for key in ("litellm_session_id", "session_id"): value = params.get(key) if value: return str(value) - if isinstance(metadata, dict): + if isinstance(metadata, Mapping): value = metadata.get("session_id") if value: return str(value) return None +def with_fireworks_session_affinity( + headers: Mapping[str, str], litellm_params: Mapping[str, object] +) -> Mapping[str, str]: + if any(key.lower() == "x-session-affinity" for key in headers): + return headers + session_id: Final = get_fireworks_session_id(litellm_params) + if not session_id: + return headers + return MappingProxyType({**headers, "x-session-affinity": session_id}) + + +def resolve_fireworks_api_key(api_key: str | None) -> str | None: + return api_key or ( + get_secret_str("FIREWORKS_API_KEY") + or get_secret_str("FIREWORKS_AI_API_KEY") + or get_secret_str("FIREWORKSAI_API_KEY") + or get_secret_str("FIREWORKS_AI_TOKEN") + ) + + AZURE_FOUNDRY_FIREWORKS_MODEL_ID_PREFIX: Final = "FW-" @@ -63,13 +85,7 @@ class FireworksAIMixin: ) def _get_api_key(self, api_key: str | None) -> str | None: - dynamic_api_key: Final = api_key or ( - get_secret_str("FIREWORKS_API_KEY") - or get_secret_str("FIREWORKS_AI_API_KEY") - or get_secret_str("FIREWORKSAI_API_KEY") - or get_secret_str("FIREWORKS_AI_TOKEN") - ) - return dynamic_api_key + return resolve_fireworks_api_key(api_key) def validate_environment( self, @@ -92,9 +108,5 @@ class FireworksAIMixin: return self._add_session_affinity_header({**auth_headers, **content_type_header}, litellm_params) def _add_session_affinity_header(self, headers: dict, litellm_params: dict) -> dict: - if any(key.lower() == "x-session-affinity" for key in headers): - return headers - session_id: Final = get_fireworks_session_id(litellm_params) - if not session_id: - return headers - return {**headers, "x-session-affinity": session_id} + pinned: Final = with_fireworks_session_affinity(headers, litellm_params) + return dict(pinned) # mutable-ok: the HTTP handler updates the returned headers in place diff --git a/litellm/llms/fireworks_ai/responses/transformation.py b/litellm/llms/fireworks_ai/responses/transformation.py new file mode 100644 index 00000000000..f36030bb50a --- /dev/null +++ b/litellm/llms/fireworks_ai/responses/transformation.py @@ -0,0 +1,68 @@ +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final + +from litellm.llms.fireworks_ai.common_utils import ( + resolve_fireworks_api_key, + resolve_fireworks_resource_name, + with_fireworks_session_affinity, +) +from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig +from litellm.secret_managers.main import get_secret_str +from litellm.types.llms.openai import ResponseInputParam +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders + +FIREWORKS_AI_DEFAULT_API_BASE: Final = "https://api.fireworks.ai/inference/v1" + + +def _session_params(litellm_params: GenericLiteLLMParams) -> Mapping[str, object]: + extras: Final[Mapping[str, object]] = litellm_params.model_extra or MappingProxyType({}) + return MappingProxyType( + {"litellm_session_id": extras.get("litellm_session_id"), "metadata": extras.get("litellm_metadata")} + ) + + +class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig): + @property + def custom_llm_provider(self) -> LlmProviders: + return LlmProviders.FIREWORKS_AI + + def validate_environment( + self, + headers: Mapping[str, str], + model: str, + litellm_params: GenericLiteLLMParams | None, + ) -> dict: # mutable-ok: overrides the base class signature + params: Final = litellm_params or GenericLiteLLMParams() + api_key: Final = resolve_fireworks_api_key(params.api_key) + if api_key is None: + raise ValueError("FIREWORKS_API_KEY is not set") + authorized: Final = MappingProxyType( + {"Content-Type": "application/json", **headers, "Authorization": f"Bearer {api_key}"} + ) + pinned: Final = with_fireworks_session_affinity(authorized, _session_params(params)) + return dict(pinned) # mutable-ok: the HTTP handler updates the returned headers in place + + def get_complete_url(self, api_base: str | None, litellm_params: Mapping[str, object]) -> str: + base: Final = (api_base or get_secret_str("FIREWORKS_API_BASE") or FIREWORKS_AI_DEFAULT_API_BASE).rstrip("/") + return f"{base}/responses" + + def transform_responses_api_request( + self, + model: str, + input: str | ResponseInputParam, + response_api_optional_request_params: dict, # mutable-ok: overrides the base class signature + litellm_params: GenericLiteLLMParams, + headers: dict, # mutable-ok: overrides the base class signature + ) -> dict: # mutable-ok: overrides the base class signature + return super().transform_responses_api_request( + model=resolve_fireworks_resource_name(model), + input=input, + response_api_optional_request_params=response_api_optional_request_params, + litellm_params=litellm_params, + headers=headers, + ) + + def supports_native_websocket(self) -> bool: + return False diff --git a/litellm/utils.py b/litellm/utils.py index 8b1b32ea328..3043a502aaa 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -8683,6 +8683,8 @@ class ProviderConfigManager: return litellm.OpenRouterResponsesAPIConfig() elif litellm.LlmProviders.HOSTED_VLLM == provider: return litellm.HostedVLLMResponsesAPIConfig() + elif litellm.LlmProviders.FIREWORKS_AI == provider: + return litellm.FireworksAIResponsesAPIConfig() elif litellm.LlmProviders.BEDROCK_MANTLE == provider: # Both decisions are data-driven from the model's price-map entry, with # no model-name logic. Capability (can it serve Responses?) comes from diff --git a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py new file mode 100644 index 00000000000..207fd518f89 --- /dev/null +++ b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py @@ -0,0 +1,263 @@ +import json +from collections.abc import Mapping +from types import MappingProxyType +from typing import Final, TypedDict +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from openai.types.responses import ( + ResponseFunctionToolCall, + ResponseOutputMessage, + ResponseOutputText, + ResponseReasoningItem, +) +from openai.types.responses.response_input_param import FunctionCallOutput +from openai.types.responses.tool_param import Mcp +from typing_extensions import ReadOnly + +import litellm +from litellm.llms.fireworks_ai.responses.transformation import FireworksAIResponsesAPIConfig +from litellm.types.llms.openai import InputTokensDetails, ResponseAPIUsage, ResponsesAPIResponse +from litellm.types.router import GenericLiteLLMParams +from litellm.types.utils import LlmProviders +from litellm.utils import ProviderConfigManager + +FIREWORKS_RESPONSES_URL: Final = "https://api.fireworks.ai/inference/v1/responses" +HTTPX_CLIENT_FACTORY: Final = "litellm.llms.custom_httpx.llm_http_handler._get_httpx_client" +NO_HEADERS: Final[Mapping[str, str]] = MappingProxyType({}) +NO_PARAMS: Final[Mapping[str, object]] = MappingProxyType({}) + + +class _GeneratedSessionMetadata(TypedDict): + litellm_session_id_generated: ReadOnly[bool] + + +def _fireworks_response(model: str) -> Mapping[str, object]: + return ResponsesAPIResponse( + id="resp_0e946f2d46bf4b49bf8b29ff78083583", + object="response", + created_at=1788550000, + model=model, + status="completed", + output=( + ResponseReasoningItem(id="rs_1", summary=(), type="reasoning"), + ResponseOutputMessage( + id="msg_1", + status="completed", + role="assistant", + type="message", + content=(ResponseOutputText(type="output_text", text="Paris is clear and 21C.", annotations=()),), + ), + ResponseFunctionToolCall( + id="fc_1", + call_id="call_abc123", + name="get_weather", + arguments='{"city": "Paris"}', + status="completed", + type="function_call", + ), + ), + usage=ResponseAPIUsage( + input_tokens=179, + output_tokens=100, + total_tokens=279, + input_tokens_details=InputTokensDetails(cached_tokens=0), + ), + ).model_dump(mode="json", exclude_none=True) + + +def _mock_http_client(response_body: Mapping[str, object]) -> MagicMock: + client: Final = MagicMock() + response: Final = MagicMock() + response.status_code = 200 + response.headers = httpx.Headers((("content-type", "application/json"),)) + response.json.return_value = response_body + response.text = json.dumps(response_body) + client.post.return_value = response + return client + + +def _sent_request(client: MagicMock) -> tuple[str, Mapping[str, str], Mapping[str, object]]: + kwargs: Final = client.post.call_args.kwargs + body: Final = kwargs["json"] if "json" in kwargs else json.loads(kwargs["data"]) + return kwargs["url"], kwargs["headers"], body + + +@pytest.fixture(autouse=True) +def fireworks_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ( + "FIREWORKS_API_KEY", + "FIREWORKS_AI_API_KEY", + "FIREWORKSAI_API_KEY", + "FIREWORKS_AI_TOKEN", + "FIREWORKS_API_BASE", + ): + monkeypatch.delenv(name, raising=False) + + +def test_fireworks_ai_provider_config_registration() -> None: + config: Final = ProviderConfigManager.get_provider_responses_api_config( + model="accounts/fireworks/models/kimi-k3", provider=LlmProviders.FIREWORKS_AI + ) + assert isinstance(config, FireworksAIResponsesAPIConfig) + assert config.custom_llm_provider == LlmProviders.FIREWORKS_AI + + +def test_responses_call_hits_native_endpoint_with_mcp_tool_untouched() -> None: + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) + mcp_tool: Final[Mcp] = { + "type": "mcp", + "server_label": "deepwiki", + "server_url": "https://mcp.deepwiki.com/mcp", + "require_approval": "never", + } + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + response: Final = litellm.responses( + model="fireworks_ai/accounts/fireworks/models/kimi-k3", + input="What is litellm?", + tools=[mcp_tool], # mutable-ok: the Responses API takes tools as a JSON list + api_key="fw-test-key", + ) + url, headers, body = _sent_request(client) + assert url == FIREWORKS_RESPONSES_URL + assert headers["Authorization"] == "Bearer fw-test-key" + assert body["model"] == "accounts/fireworks/models/kimi-k3" + assert tuple(body["tools"]) == (mcp_tool,) + assert "messages" not in body + assert isinstance(response, ResponsesAPIResponse) + function_calls: Final = tuple(item for item in response.output if getattr(item, "type", None) == "function_call") + assert getattr(function_calls[0], "call_id", None) == "call_abc123" + + +def test_responses_call_expands_bare_model_name_to_fireworks_resource() -> None: + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/glm-5p3")) + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + litellm.responses(model="fireworks_ai/glm-5p3", input="hi", api_key="fw-test-key") + _, _, body = _sent_request(client) + assert body["model"] == "accounts/fireworks/models/glm-5p3" + + +def test_responses_call_forwards_previous_response_id_and_store() -> None: + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) + tool_output: Final[FunctionCallOutput] = { + "type": "function_call_output", + "call_id": "call_abc123", + "output": "{}", + } + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + litellm.responses( + model="fireworks_ai/kimi-k3", + input=[tool_output], # mutable-ok: the Responses API takes input items as a JSON list + previous_response_id="resp_0e946f2d46bf4b49bf8b29ff78083583", + store=True, + api_key="fw-test-key", + ) + _, _, body = _sent_request(client) + assert body["previous_response_id"] == "resp_0e946f2d46bf4b49bf8b29ff78083583" + assert body["store"] is True + assert body["input"][0]["call_id"] == "call_abc123" + + +def test_responses_call_sends_session_affinity_for_caller_session_id() -> None: + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + litellm.responses(model="fireworks_ai/kimi-k3", input="hi", api_key="fw-test-key", litellm_session_id="sess-42") + _, headers, _ = _sent_request(client) + assert headers["x-session-affinity"] == "sess-42" + + +def test_responses_call_keeps_caller_supplied_session_affinity_header() -> None: + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) + pinned: Final[Mapping[str, str]] = MappingProxyType({"x-session-affinity": "explicit-node"}) + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + litellm.responses( + model="fireworks_ai/kimi-k3", + input="hi", + api_key="fw-test-key", + litellm_session_id="sess-42", + extra_headers=pinned, + ) + _, headers, _ = _sent_request(client) + assert headers["x-session-affinity"] == "explicit-node" + + +def test_responses_call_maps_provider_errors_to_fireworks_ai() -> None: + client: Final = MagicMock() + request: Final = httpx.Request("POST", FIREWORKS_RESPONSES_URL) + client.post.side_effect = httpx.HTTPStatusError( + "unauthorized", + request=request, + response=httpx.Response(401, text='{"error": {"message": "invalid api key"}}', request=request), + ) + with patch(HTTPX_CLIENT_FACTORY, return_value=client), pytest.raises(litellm.AuthenticationError) as raised: + litellm.responses(model="fireworks_ai/kimi-k3", input="hi", api_key="fw-bad-key") + assert raised.value.llm_provider == "fireworks_ai" + assert raised.value.status_code == 401 + assert "invalid api key" in str(raised.value) + + +def test_responses_call_skips_session_affinity_for_proxy_generated_session_id() -> None: + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) + generated: Final[_GeneratedSessionMetadata] = {"litellm_session_id_generated": True} + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + litellm.responses( + model="fireworks_ai/kimi-k3", + input="hi", + api_key="fw-test-key", + litellm_session_id="generated-1", + litellm_metadata=generated, + ) + _, headers, _ = _sent_request(client) + assert "x-session-affinity" not in headers + + +@pytest.mark.parametrize( + "api_base, expected", + ( + (None, FIREWORKS_RESPONSES_URL), + ("https://api.fireworks.ai/inference/v1", FIREWORKS_RESPONSES_URL), + ("https://api.fireworks.ai/inference/v1/", FIREWORKS_RESPONSES_URL), + ("https://gateway.example.com/fireworks", "https://gateway.example.com/fireworks/responses"), + ), +) +def test_get_complete_url(api_base: str | None, expected: str) -> None: + assert FireworksAIResponsesAPIConfig().get_complete_url(api_base=api_base, litellm_params=NO_PARAMS) == expected + + +def test_responses_call_reads_fireworks_api_base_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FIREWORKS_API_BASE", "https://self-hosted.example.com/v1") + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + litellm.responses(model="fireworks_ai/kimi-k3", input="hi", api_key="fw-test-key") + url, _, _ = _sent_request(client) + assert url == "https://self-hosted.example.com/v1/responses" + + +@pytest.mark.parametrize( + "env_name", ("FIREWORKS_API_KEY", "FIREWORKS_AI_API_KEY", "FIREWORKSAI_API_KEY", "FIREWORKS_AI_TOKEN") +) +def test_validate_environment_reads_every_fireworks_key_name(monkeypatch: pytest.MonkeyPatch, env_name: str) -> None: + monkeypatch.setenv(env_name, "env-key") + headers: Final = FireworksAIResponsesAPIConfig().validate_environment( + headers=NO_HEADERS, model="accounts/fireworks/models/kimi-k3", litellm_params=GenericLiteLLMParams() + ) + assert headers["Authorization"] == "Bearer env-key" + assert headers["Content-Type"] == "application/json" + + +def test_validate_environment_prefers_explicit_api_key(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("FIREWORKS_API_KEY", "env-key") + headers: Final = FireworksAIResponsesAPIConfig().validate_environment( + headers=NO_HEADERS, + model="accounts/fireworks/models/kimi-k3", + litellm_params=GenericLiteLLMParams(api_key="explicit"), + ) + assert headers["Authorization"] == "Bearer explicit" + + +def test_validate_environment_without_any_key_raises() -> None: + with pytest.raises(ValueError, match="FIREWORKS_API_KEY"): + FireworksAIResponsesAPIConfig().validate_environment( + headers=NO_HEADERS, model="accounts/fireworks/models/kimi-k3", litellm_params=None + ) diff --git a/tests/test_litellm/responses/test_responses_websocket_all_providers.py b/tests/test_litellm/responses/test_responses_websocket_all_providers.py index e8333214ea8..fe3c4a0640d 100644 --- a/tests/test_litellm/responses/test_responses_websocket_all_providers.py +++ b/tests/test_litellm/responses/test_responses_websocket_all_providers.py @@ -17,6 +17,9 @@ from litellm.llms.chatgpt.responses.transformation import ChatGPTResponsesAPICon from litellm.llms.databricks.responses.transformation import ( DatabricksResponsesAPIConfig, ) +from litellm.llms.fireworks_ai.responses.transformation import ( + FireworksAIResponsesAPIConfig, +) from litellm.llms.github_copilot.responses.transformation import ( GithubCopilotResponsesAPIConfig, ) @@ -102,6 +105,12 @@ class TestResponsesAPIWebSocketSupport: def test_openai_model_in_websocket_url_default(self): assert OpenAIResponsesAPIConfig().model_in_websocket_url() is True + def test_fireworks_ai_uses_managed_websocket(self): + """Fireworks AI should use managed websocket handler""" + assert ( + FireworksAIResponsesAPIConfig().supports_native_websocket() is False + ), "Fireworks AI should use managed websocket handler" + def test_xai_uses_managed_websocket(self): """XAI should use managed websocket handler""" config = XAIResponsesAPIConfig() diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 8589a9451cf..835b24b2b3c 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22328 + "limit": 22327 }, "LIT002": { - "limit": 26748 + "limit": 26747 }, "LIT003": { "limit": 261 From d0a80067377cb6978deac35492b6681a65c991fc Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 17:03:01 -0700 Subject: [PATCH 129/410] fix(auto-router compression): tag-scoped markers now take precedence over untagged An untagged marker (no tags key or empty tags list) was matching every request because requested.issuperset(frozenset()) is always true. When an alias carried multiple markers, the loop tried tag-matched markers first, but an untagged one could still match the tag-match query, and then the first one with a policy would be returned. Now only markers with a non-empty tags list can match via the tag-specific lookup; untagged markers are tried only after all tag-specific ones. Regression test added: test_tag_scoped_marker_takes_precedence_over_untagged fails with the old code. Also removed unused Any import per greptile's typing note. --- .../proxy/guardrails/auto_router_compression.py | 16 ++++++++++------ .../guardrails/test_auto_router_compression.py | 12 ++++++++++++ 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py index 7ccd1937543..490ce550003 100644 --- a/litellm/proxy/guardrails/auto_router_compression.py +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -15,7 +15,7 @@ each hop sees. import contextvars from collections.abc import Mapping, MutableMapping, Sequence from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Final from litellm._logging import verbose_proxy_logger from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY @@ -93,7 +93,9 @@ def policy_for_model( ) requested: Final = frozenset(request_tags) tag_matched: Final = tuple( - params for params in markers if requested.issuperset(frozenset(params.get("tags") or ())) + params + for params in markers + if (tags := params.get("tags")) and requested.issuperset(frozenset(tags)) ) for params in (*tag_matched, *markers): policy = policy_from_litellm_params(params) @@ -186,16 +188,16 @@ async def arm_pre_call(data: MutableMapping[str, object], llm_router: "Router | return data -def _snapshot_messages() -> list[dict[str, Any]] | None: +def _snapshot_messages() -> list[dict[str, object]] | None: snapshot: Final = _routing_messages_snapshot.get() return None if snapshot is None else [dict(message) for message in snapshot] async def messages_for_routing( policy: AutoRouterCompressionPolicy | None, - messages: list[dict[str, Any]] | None, + messages: list[dict[str, object]] | None, request_kwargs: Mapping[str, object], -) -> list[dict[str, Any]] | None: +) -> list[dict[str, object]] | None: """Messages to use for a routing decision, per `policy.routing`. Returns None when the caller should route on whatever messages it already has. @@ -228,7 +230,9 @@ async def messages_for_routing( ) return original - inputs: GenericGuardrailAPIInputs = {"structured_messages": [dict(m) for m in original]} + inputs: GenericGuardrailAPIInputs = { + "structured_messages": [dict(m) for m in original] # pyright: ignore[reportAssignmentType] # plain dicts, not AllMessageValues; see headroom.py's own use of this shape + } # A throwaway request_data: apply_guardrail writes its stats onto this dict, not # the real request's metadata, so routing-side compression never double-counts # against extract_compression_saved_tokens's model-savings accounting. diff --git a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py index b906e60bb86..79f069d8a2e 100644 --- a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py +++ b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py @@ -138,6 +138,18 @@ class TestPolicyForModel: ) assert policy == AutoRouterCompressionPolicy(routing="headroom-a", model=None) + def test_tag_scoped_marker_takes_precedence_over_untagged(self): + """Regression: when multiple markers exist, the tag-scoped one the request + actually matches should be used, not the first untagged one.""" + router = _FakeRouter( + [ + _marker({"auto_router_routing_compression": "headroom-untagged"}), + _marker({"auto_router_routing_compression": "headroom-eu"}, tags=["eu"]), + ] + ) + policy = policy_for_model(llm_router=router, model_alias="smart-router", team_id=None, request_tags=("eu",)) + assert policy == AutoRouterCompressionPolicy(routing="headroom-eu", model=None) + class _RecordingCompressionGuardrail(CustomGuardrail): """A guardrail whose apply_guardrail marks every text message as compressed.""" From c373645e217af55313e270bd7240e409b6d64c01 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:16:45 -0700 Subject: [PATCH 130/410] fix(proxy): recognize opencode's bare x-session-id header for session affinity (#39802) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/litellm_pre_call_utils.py | 17 ++++++ .../proxy/test_litellm_pre_call_utils.py | 56 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index f752d7cfa89..d026c5510e6 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -669,6 +669,21 @@ def _extract_codex_session_id_from_headers( ) +def _extract_bare_session_id_from_headers( + normalized: Mapping[str, str], +) -> str | None: + """ + Read a vendor-less ``x-session-id`` header (opencode sends ``X-Session-Id`` + alongside ``x-session-affinity`` on every turn of a session). Checked after + the ``x--session-id`` scan so a more specific header such as + opencode's ``x-parent-session-id`` on subagent calls keeps winning. + """ + value: Final = normalized.get("x-session-id") + if isinstance(value, str) and _SESSION_ID_VALUE_RE.match(value): + return value + return None + + def get_chain_id_from_headers(headers: dict[str, str] | None) -> str | None: """ Extract chain id for call chaining from request headers. @@ -679,6 +694,7 @@ def get_chain_id_from_headers(headers: dict[str, str] | None) -> str | None: 3. Any ``x--session-id`` header whose value looks like a session id (alphanumeric / UUID, at least 8 chars). E.g. ``x-claude-code-session-id``. 4. Codex's unprefixed ``session-id`` / ``thread-id``, for Codex callers only. + 5. A vendor-less ``x-session-id`` header (e.g. opencode), same value rules. Header keys are matched case-insensitively so this works with raw header dicts from any transport. @@ -694,6 +710,7 @@ def get_chain_id_from_headers(headers: dict[str, str] | None) -> str | None: or normalized.get("x-litellm-session-id") or _extract_generic_session_id_from_headers(normalized) or _extract_codex_session_id_from_headers(normalized) + or _extract_bare_session_id_from_headers(normalized) ) diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index 72d37650963..7070617ce3e 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -3343,6 +3343,62 @@ def test_add_litellm_metadata_groups_codex_turns_into_one_session(): assert turn["litellm_metadata"]["session_id"] == CODEX_SESSION_UUID +OPENCODE_SESSION_ID = "ses_f91e6e825ffeuhlu5EbglxjAN2" +OPENCODE_HEADERS = { + "x-session-affinity": OPENCODE_SESSION_ID, + "X-Session-Id": OPENCODE_SESSION_ID, + "User-Agent": "opencode/1.18.28", +} + + +def test_add_litellm_metadata_groups_opencode_turns_into_one_session(): + """Every turn of an opencode session must land on metadata.session_id, which is what + DeploymentAffinityCheck reads for session pinning, instead of a fresh per-call id.""" + turns = [{"metadata": {}}, {"metadata": {}}] + for turn in turns: + LiteLLMProxyRequestSetup.add_litellm_metadata_from_request_headers( + headers=OPENCODE_HEADERS, data=turn, _metadata_variable_name="metadata" + ) + + for turn in turns: + assert turn["metadata"]["session_id"] == OPENCODE_SESSION_ID + assert turn["metadata"]["trace_id"] == OPENCODE_SESSION_ID + assert turn["litellm_session_id"] == OPENCODE_SESSION_ID + assert turn["litellm_trace_id"] == OPENCODE_SESSION_ID + + +@pytest.mark.parametrize("value", ["short", "has spaces!!", ""]) +def test_get_chain_id_from_headers_bare_session_id_ignores_implausible_value(value: str): + from litellm.proxy.litellm_pre_call_utils import get_chain_id_from_headers + + assert get_chain_id_from_headers({"x-session-id": value}) is None + + +@pytest.mark.parametrize( + "other_header", + [ + "x-litellm-trace-id", + "x-litellm-session-id", + "x-claude-code-session-id", + "x-parent-session-id", + ], +) +def test_get_chain_id_from_headers_bare_session_id_loses_to_more_specific_header(other_header: str): + """opencode subagent calls carry x-parent-session-id next to X-Session-Id; explicit and + vendor-scoped headers must keep winning over the bare header.""" + from litellm.proxy.litellm_pre_call_utils import get_chain_id_from_headers + + assert ( + get_chain_id_from_headers( + { + "x-session-id": OPENCODE_SESSION_ID, + other_header: "e96634a3-fa28-4083-b354-55542e2dca01", + } + ) + == "e96634a3-fa28-4083-b354-55542e2dca01" + ) + + def test_trace_id_from_traceparent_valid(): from litellm.proxy.litellm_pre_call_utils import _trace_id_from_traceparent From a6b7384094e8178d40c7cfd0448470f40ca3103b Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:16:48 -0700 Subject: [PATCH 131/410] feat(cli): sync OpenCode models from /v1/models in lite opencode (#39789) * feat(cli): sync OpenCode models from /v1/models in lite opencode lite opencode now fetches the proxy's /v1/models with the resolved key and hands OpenCode an OPENCODE_CONFIG_CONTENT declaring a litellm provider (@ai-sdk/openai-compatible, proxy /v1 base URL, {env:OPENAI_API_KEY}) with one model entry per listed chat model, so the model picker mirrors the proxy without a hand-maintained opencode.json Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(cli): sync OpenCode models only after the key check passes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/client/cli/README.md | 2 +- litellm/proxy/client/cli/commands/agents.py | 176 +++++++++++- .../proxy/client/cli/test_agents.py | 272 +++++++++++++++++- 3 files changed, 441 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/client/cli/README.md b/litellm/proxy/client/cli/README.md index 47355f328dd..ed1447e4e65 100644 --- a/litellm/proxy/client/cli/README.md +++ b/litellm/proxy/client/cli/README.md @@ -489,7 +489,7 @@ lite codex exec "summarize the repo" Each command resolves your LiteLLM key (logging in via SSO when none is stored and you are at a terminal; otherwise it expects `LITELLM_PROXY_API_KEY` or `--api-key`), checks the key against the proxy so bad credentials fail immediately instead of deep inside the agent, exports the environment variables the agent reads, then replaces itself with the agent process. -The right variables are picked per agent. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins, and `ENABLE_TOOL_SEARCH=true` (unless you already set it) so Claude Code keeps tool search on even though the base URL is a proxy rather than a first-party Anthropic host. It also gets `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` (again unless you already set it) so Claude Code v2.1.129+ fills its `/model` picker from the proxy's `/v1/models`; Claude Code only lists entries whose id contains `claude` or `anthropic`, and older versions ignore the variable. Export it as `0` to turn discovery off. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`. Codex ignores `OPENAI_BASE_URL`, so it is additionally pointed at the proxy through a custom provider passed as `-c` config overrides (HTTP/SSE Responses transport, since the proxy does not speak the Responses WebSocket protocol). +The right variables are picked per agent. Claude Code gets `ANTHROPIC_BASE_URL` (the proxy root, so it appends `/v1/messages`) and `ANTHROPIC_AUTH_TOKEN`, with any stray `ANTHROPIC_API_KEY` cleared so the proxy token wins, and `ENABLE_TOOL_SEARCH=true` (unless you already set it) so Claude Code keeps tool search on even though the base URL is a proxy rather than a first-party Anthropic host. It also gets `CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` (again unless you already set it) so Claude Code v2.1.129+ fills its `/model` picker from the proxy's `/v1/models`; Claude Code only lists entries whose id contains `claude` or `anthropic`, and older versions ignore the variable. Export it as `0` to turn discovery off. Codex and OpenCode get `OPENAI_BASE_URL` (the proxy plus `/v1`) and `OPENAI_API_KEY`. Codex ignores `OPENAI_BASE_URL`, so it is additionally pointed at the proxy through a custom provider passed as `-c` config overrides (HTTP/SSE Responses transport, since the proxy does not speak the Responses WebSocket protocol). OpenCode additionally gets `OPENCODE_CONFIG_CONTENT` holding a generated `litellm` provider (`@ai-sdk/openai-compatible`, the proxy `/v1` URL, `{env:OPENAI_API_KEY}`) with one model entry per chat model your key can see on `/v1/models`, so its model picker mirrors the proxy without a hand-maintained `opencode.json`; OpenCode merges that over your own config files, and if you already export `OPENCODE_CONFIG_CONTENT` yours is left alone. When the list cannot be fetched, `lite opencode` says so on stderr and launches anyway. Options (these belong to the wrapper, so put them before the agent's own flags): diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index baa21996c7e..ce416ef237b 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -3,10 +3,13 @@ import shutil import subprocess import sys from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from types import MappingProxyType from typing import Final import click import requests +from pydantic import BaseModel, TypeAdapter, ValidationError from .auth import context_secret_vault, get_stored_api_key, login from .cmd_quoting import quote_for_cmd @@ -20,6 +23,12 @@ ENABLE_GATEWAY_MODEL_DISCOVERY_ENV: Final = "CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DI ENABLE_GATEWAY_MODEL_DISCOVERY_VALUE: Final = "1" OPENAI_BASE_URL_ENV: Final = "OPENAI_BASE_URL" OPENAI_API_KEY_ENV: Final = "OPENAI_API_KEY" +OPENCODE_CONFIG_CONTENT_ENV: Final = "OPENCODE_CONFIG_CONTENT" +OPENCODE_PROVIDER_ID: Final = "litellm" +OPENCODE_PROVIDER_NAME: Final = "LiteLLM" +OPENCODE_PROVIDER_NPM: Final = "@ai-sdk/openai-compatible" + +_SKIP_VERIFY_FLAG: Final = "--skip-verify" PROFILE_ANTHROPIC: Final = "anthropic" PROFILE_OPENAI: Final = "openai" @@ -131,6 +140,139 @@ def agent_launch_args(command: str, base_url: str) -> list[str]: return builder(base_url) if builder else [] +class ListedModel(BaseModel): + """The fields of a /v1/models entry that an OpenCode model entry is built from.""" + + id: str + mode: str | None = None + max_input_tokens: int | None = None + max_output_tokens: int | None = None + + +class _ModelListing(BaseModel): + data: tuple[ListedModel, ...] + + +_MODEL_LISTING: Final = TypeAdapter(_ModelListing) +_OPENCODE_CHAT_MODES: Final[frozenset[str]] = frozenset({"chat", "responses"}) +_NO_EXTRA_ENV: Final[Mapping[str, str]] = MappingProxyType({}) + + +@dataclass(frozen=True, slots=True) +class ModelSyncSkipped: + reason: str + + +class _OpenCodeLimit(BaseModel): + context: int + output: int + + +class _OpenCodeModel(BaseModel): + name: str + limit: _OpenCodeLimit | None = None + + +class _OpenCodeProviderOptions(BaseModel): + baseURL: str + apiKey: str + + +class _OpenCodeProvider(BaseModel): + npm: str + name: str + options: _OpenCodeProviderOptions + models: Mapping[str, _OpenCodeModel] + + +class _OpenCodeConfig(BaseModel): + provider: Mapping[str, _OpenCodeProvider] + + +def _opencode_model_entry(model: ListedModel) -> _OpenCodeModel: + if model.max_input_tokens is None or model.max_output_tokens is None: + return _OpenCodeModel(name=model.id) + return _OpenCodeModel( + name=model.id, limit=_OpenCodeLimit(context=model.max_input_tokens, output=model.max_output_tokens) + ) + + +def opencode_provider_config(base_url: str, models: Sequence[ListedModel]) -> str: + """OPENCODE_CONFIG_CONTENT declaring the proxy as OpenCode provider `litellm`. + + One model entry per chat-capable /v1/models row (mode chat, responses, or + unknown), so OpenCode's model picker mirrors what the key can call. The key + is read back through {env:OPENAI_API_KEY}, which build_agent_env exports, so + it never lands in the config text. OpenCode merges this inline config over + the user's own files, leaving unrelated keys and providers untouched. + """ + chat_models: Final = tuple(m for m in models if m.mode is None or m.mode in _OPENCODE_CHAT_MODES) + provider: Final = _OpenCodeProvider( + npm=OPENCODE_PROVIDER_NPM, + name=OPENCODE_PROVIDER_NAME, + options=_OpenCodeProviderOptions( + baseURL=base_url.rstrip("/") + "/v1", + apiKey=f"{{env:{OPENAI_API_KEY_ENV}}}", + ), + models=MappingProxyType({m.id: _opencode_model_entry(m) for m in chat_models}), + ) + config: Final = _OpenCodeConfig(provider=MappingProxyType({OPENCODE_PROVIDER_ID: provider})) + return config.model_dump_json(exclude_none=True) + + +def opencode_model_sync_env( + base_env: Mapping[str, str], + base_url: str, + api_key: str, + *, + get: Callable[..., requests.Response] = requests.get, +) -> Mapping[str, str] | ModelSyncSkipped: + """Env addition that hands OpenCode the proxy's model list, or why it was skipped. + + Fetches /v1/models with the key and packs it into OPENCODE_CONFIG_CONTENT. + An OPENCODE_CONFIG_CONTENT already in the environment is left alone, and a + failed fetch is reported rather than raised: OpenCode still launches on the + plain OPENAI_* env, just without a synced model list. + """ + if OPENCODE_CONFIG_CONTENT_ENV in base_env: + return ModelSyncSkipped(f"{OPENCODE_CONFIG_CONTENT_ENV} is already set") + url: Final = base_url.rstrip("/") + "/v1/models" + try: + resp: Final = get(url, headers=MappingProxyType({"Authorization": f"Bearer {api_key}"}), timeout=10) + except requests.RequestException as e: + return ModelSyncSkipped(f"could not reach {url}: {e}") + if resp.status_code != 200: + return ModelSyncSkipped(f"{url} returned HTTP {resp.status_code}") + try: + listing: Final = _MODEL_LISTING.validate_json(resp.content) + except ValidationError: + return ModelSyncSkipped(f"{url} returned an unexpected body") + return MappingProxyType({OPENCODE_CONFIG_CONTENT_ENV: opencode_provider_config(base_url, listing.data)}) + + +def agent_model_sync_env( + command: str, + base_env: Mapping[str, str], + base_url: str, + api_key: str, + skip_verify: bool, + *, + get: Callable[..., requests.Response] = requests.get, +) -> Mapping[str, str] | ModelSyncSkipped: + """Extra env an agent needs to see the proxy's model list. + + Only OpenCode needs one: Claude Code discovers models through + CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY and Codex takes the model by name. + skip_verify means the caller wants no pre-launch proxy call at all, so the + listing is skipped too rather than hanging on an offline proxy. + """ + if os.path.basename(command) != "opencode": + return _NO_EXTRA_ENV + if skip_verify: + return ModelSyncSkipped(f"{_SKIP_VERIFY_FLAG} was passed") + return opencode_model_sync_env(base_env, base_url, api_key, get=get) + + def verify_proxy_key( base_url: str, api_key: str, @@ -246,6 +388,10 @@ def _restore_controlling_terminal() -> None: os.close(fd) +def _warn(message: str) -> None: + click.echo(message, err=True) + + def run_agent( base_url: str, api_key: str, @@ -255,6 +401,10 @@ def run_agent( base_env: Mapping[str, str] | None = None, which: Callable[[str], str | None] = shutil.which, verify: Callable[[str, str], None] = verify_proxy_key, + sync_models: Callable[[str, Mapping[str, str], str, str, bool], Mapping[str, str] | ModelSyncSkipped] = ( + agent_model_sync_env + ), + warn: Callable[[str], None] = _warn, launcher: Callable[[str, Sequence[str], Mapping[str, str]], None] = _hand_off, reattach_terminal: Callable[[], None] | None = None, ) -> None: @@ -262,13 +412,15 @@ def run_agent( On success this never returns: POSIX replaces the current process, Windows waits on the agent and exits with its status. Raises AgentRunError for - missing binaries, an unreachable proxy, or a rejected key. + missing binaries, an unreachable proxy, or a rejected key. The model list is + synced only once the key check passed, so an unreachable proxy costs one + timeout rather than two, and --skip-verify keeps the launch fully offline. reattach_terminal, when given, runs just before handoff to restore stdin. """ if not command: raise AgentRunError("Nothing to run.") - _, profiles = agent_profile(command[0]) + display_name, profiles = agent_profile(command[0]) binary: Final = which(command[0]) if binary is None: docs: Final = _INSTALL_DOCS.get(os.path.basename(command[0])) @@ -278,11 +430,16 @@ def run_agent( if not skip_verify: verify(base_url, api_key) - env: Final = build_agent_env( - base_env if base_env is not None else os.environ, - base_url, - api_key, - profiles, + env_before_sync: Final = base_env if base_env is not None else os.environ + synced: Final = sync_models(command[0], env_before_sync, base_url, api_key, skip_verify) + if isinstance(synced, ModelSyncSkipped): + warn(f"litellm: not syncing {display_name} models from the proxy: {synced.reason}") + + env: Final = MappingProxyType( + { + **build_agent_env(env_before_sync, base_url, api_key, profiles), + **(_NO_EXTRA_ENV if isinstance(synced, ModelSyncSkipped) else synced), + } ) extra_args: Final = agent_launch_args(command[0], base_url) if reattach_terminal is not None: @@ -365,10 +522,15 @@ def agent_commands() -> tuple[click.Command, ...]: __all__ = [ "AgentRunError", + "ListedModel", + "ModelSyncSkipped", "agent_commands", "agent_launch_args", + "agent_model_sync_env", "agent_profile", "build_agent_env", + "opencode_model_sync_env", + "opencode_provider_config", "resolve_api_key", "run_agent", "verify_proxy_key", diff --git a/tests/test_litellm/proxy/client/cli/test_agents.py b/tests/test_litellm/proxy/client/cli/test_agents.py index 62b94e948be..5b99d368cbb 100644 --- a/tests/test_litellm/proxy/client/cli/test_agents.py +++ b/tests/test_litellm/proxy/client/cli/test_agents.py @@ -1,4 +1,5 @@ import inspect +import json import os import sys from unittest.mock import patch @@ -12,13 +13,16 @@ from click.testing import CliRunner from litellm.proxy.client.cli.commands.agents import ( AgentRunError, + ModelSyncSkipped, _hand_off, _replace_process, _spawn_and_wait, agent_commands, agent_launch_args, + agent_model_sync_env, agent_profile, build_agent_env, + opencode_model_sync_env, run_agent, verify_proxy_key, ) @@ -35,8 +39,9 @@ def _default_of(func, param): class _FakeResponse: - def __init__(self, status_code): + def __init__(self, status_code, body=None): self.status_code = status_code + self.content = json.dumps(body).encode() if body is not None else b"" class _Recorder: @@ -200,7 +205,259 @@ class TestVerifyProxyKey: ) +class TestOpencodeModelSync: + @staticmethod + def _listing(*models): + return {"object": "list", "data": list(models)} + + def _sync(self, listing, base_env=None, base_url="http://localhost:4000/"): + captured = {} + + def fake_get(url, headers, timeout): + captured["url"] = url + captured["headers"] = headers + return _FakeResponse(200, listing) + + env = opencode_model_sync_env(base_env or {}, base_url, "sk-key", get=fake_get) + return captured, env + + def test_declares_proxy_as_litellm_provider_with_listed_models(self): + listing = self._listing( + {"id": "gpt-5.5", "object": "model", "created": 1, "owned_by": "openai", "mode": "chat"}, + {"id": "claude-opus-4-7", "object": "model", "created": 1, "owned_by": "openai"}, + ) + captured, env = self._sync(listing) + + assert captured["url"] == "http://localhost:4000/v1/models" + assert captured["headers"] == {"Authorization": "Bearer sk-key"} + config = json.loads(env["OPENCODE_CONFIG_CONTENT"]) + provider = config["provider"]["litellm"] + assert provider["npm"] == "@ai-sdk/openai-compatible" + assert provider["name"] == "LiteLLM" + assert provider["options"] == { + "baseURL": "http://localhost:4000/v1", + "apiKey": "{env:OPENAI_API_KEY}", + } + assert provider["models"] == { + "gpt-5.5": {"name": "gpt-5.5"}, + "claude-opus-4-7": {"name": "claude-opus-4-7"}, + } + assert "sk-key" not in env["OPENCODE_CONFIG_CONTENT"] + + def test_token_limits_become_opencode_limits(self): + listing = self._listing( + { + "id": "gpt-5.5", + "object": "model", + "created": 1, + "owned_by": "openai", + "max_input_tokens": 400000, + "max_output_tokens": 128000, + }, + {"id": "half", "object": "model", "created": 1, "owned_by": "openai", "max_input_tokens": 8192}, + ) + _, env = self._sync(listing) + models = json.loads(env["OPENCODE_CONFIG_CONTENT"])["provider"]["litellm"]["models"] + assert models["gpt-5.5"]["limit"] == {"context": 400000, "output": 128000} + assert "limit" not in models["half"] + + def test_non_chat_models_are_left_out(self): + listing = self._listing( + {"id": "chat", "object": "model", "created": 1, "owned_by": "openai", "mode": "chat"}, + {"id": "resp", "object": "model", "created": 1, "owned_by": "openai", "mode": "responses"}, + {"id": "embed", "object": "model", "created": 1, "owned_by": "openai", "mode": "embedding"}, + {"id": "img", "object": "model", "created": 1, "owned_by": "openai", "mode": "image_generation"}, + ) + _, env = self._sync(listing) + models = json.loads(env["OPENCODE_CONFIG_CONTENT"])["provider"]["litellm"]["models"] + assert set(models) == {"chat", "resp"} + + def test_existing_config_content_is_left_alone(self): + calls = [] + + def fake_get(*a, **k): + calls.append(a) + return _FakeResponse(200, self._listing()) + + result = opencode_model_sync_env( + {"OPENCODE_CONFIG_CONTENT": "{}"}, "http://localhost:4000", "sk-key", get=fake_get + ) + assert isinstance(result, ModelSyncSkipped) + assert "OPENCODE_CONFIG_CONTENT" in result.reason + assert calls == [] + + def test_unreachable_proxy_is_reported_not_raised(self): + def boom(*a, **k): + raise requests.ConnectionError("refused") + + result = opencode_model_sync_env({}, "http://localhost:4000", "sk-key", get=boom) + assert isinstance(result, ModelSyncSkipped) + assert "refused" in result.reason + + def test_non_200_is_reported(self): + result = opencode_model_sync_env( + {}, "http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(500) + ) + assert isinstance(result, ModelSyncSkipped) + assert "HTTP 500" in result.reason + + def test_unexpected_body_is_reported(self): + result = opencode_model_sync_env( + {}, "http://localhost:4000", "sk-key", get=lambda *a, **k: _FakeResponse(200, {"data": "nope"}) + ) + assert isinstance(result, ModelSyncSkipped) + assert "unexpected body" in result.reason + + @pytest.mark.parametrize("command", ["claude", "codex", "/usr/bin/claude"]) + def test_only_opencode_syncs(self, command): + def boom(*a, **k): + raise AssertionError("no agent other than opencode should call the proxy") + + assert agent_model_sync_env(command, {}, "http://localhost:4000", "sk-key", False, get=boom) == {} + + def test_skip_verify_keeps_the_launch_offline(self): + def boom(*a, **k): + raise AssertionError("--skip-verify must not touch the proxy") + + result = agent_model_sync_env("opencode", {}, "http://localhost:4000", "sk-key", True, get=boom) + assert isinstance(result, ModelSyncSkipped) + assert "--skip-verify" in result.reason + + def test_full_path_opencode_syncs(self): + listing = self._listing({"id": "m", "object": "model", "created": 1, "owned_by": "x"}) + env = agent_model_sync_env( + "/opt/bin/opencode", + {}, + "http://localhost:4000", + "sk-key", + False, + get=lambda *a, **k: _FakeResponse(200, listing), + ) + assert "m" in json.loads(env["OPENCODE_CONFIG_CONTENT"])["provider"]["litellm"]["models"] + + def test_default_http_client_is_requests_get(self): + assert _default_of(agent_model_sync_env, "get") is requests.get + assert _default_of(opencode_model_sync_env, "get") is requests.get + + class TestRunAgent: + def test_synced_model_config_reaches_the_agent_alongside_profile_env(self): + calls = {} + run_agent( + "http://localhost:4000", + "sk-key", + ["opencode"], + base_env={"HOME": "/home/me"}, + sync_models=lambda *a: {"OPENCODE_CONFIG_CONTENT": '{"provider":{}}'}, + which=lambda name: "/usr/local/bin/opencode", + verify=lambda *a: None, + launcher=lambda p, a, e: calls.update(env=dict(e)), + ) + assert calls["env"]["OPENCODE_CONFIG_CONTENT"] == '{"provider":{}}' + assert calls["env"]["OPENAI_BASE_URL"] == "http://localhost:4000/v1" + assert calls["env"]["OPENAI_API_KEY"] == "sk-key" + assert calls["env"]["HOME"] == "/home/me" + + def test_sync_gets_the_launch_inputs_and_runs_after_verify(self): + order = [] + calls = {} + + def fake_sync(command, base_env, base_url, api_key, skip_verify): + order.append("sync") + calls["args"] = (command, dict(base_env), base_url, api_key, skip_verify) + return {"OPENCODE_CONFIG_CONTENT": '{"provider":{"litellm":{}}}'} + + run_agent( + "http://localhost:4000", + "sk-key", + ["opencode"], + base_env={"HOME": "/home/me"}, + sync_models=fake_sync, + which=lambda name: "/usr/local/bin/opencode", + verify=lambda *a: order.append("verify"), + launcher=lambda p, a, e: order.append("launch"), + ) + assert order == ["verify", "sync", "launch"] + assert calls["args"] == ("opencode", {"HOME": "/home/me"}, "http://localhost:4000", "sk-key", False) + + def test_unreachable_proxy_is_not_asked_for_models(self): + def failing_verify(*a): + raise AgentRunError("Could not reach the LiteLLM proxy") + + def boom(*a): + raise AssertionError("a failed key check must not be followed by a model fetch") + + with pytest.raises(AgentRunError): + run_agent( + "http://localhost:4000", + "sk-key", + ["opencode"], + base_env={}, + sync_models=boom, + which=lambda name: "/usr/local/bin/opencode", + verify=failing_verify, + launcher=lambda *a: None, + ) + + def test_skip_verify_reaches_the_sync_which_reports_the_skip(self): + warnings = [] + calls = {} + + def fake_sync(command, base_env, base_url, api_key, skip_verify): + calls["skip_verify"] = skip_verify + return ModelSyncSkipped("offline") + + run_agent( + "http://localhost:4000", + "sk-key", + ["opencode"], + skip_verify=True, + base_env={}, + sync_models=fake_sync, + warn=warnings.append, + which=lambda name: "/usr/local/bin/opencode", + verify=lambda *a: pytest.fail("--skip-verify must not verify"), + launcher=lambda p, a, e: calls.update(env=dict(e)), + ) + assert calls["skip_verify"] is True + assert "OPENCODE_CONFIG_CONTENT" not in calls["env"] + assert warnings == ["litellm: not syncing OpenCode models from the proxy: offline"] + + def test_skipped_sync_still_launches_with_plain_openai_env(self): + calls = {} + run_agent( + "http://localhost:4000", + "sk-key", + ["opencode"], + base_env={}, + sync_models=lambda *a: ModelSyncSkipped("proxy said no"), + warn=lambda message: calls.setdefault("warned", message), + which=lambda name: "/usr/local/bin/opencode", + verify=lambda *a: None, + launcher=lambda p, a, e: calls.update(env=dict(e)), + ) + assert calls["env"]["OPENAI_BASE_URL"] == "http://localhost:4000/v1" + assert "OPENCODE_CONFIG_CONTENT" not in calls["env"] + assert "proxy said no" in calls["warned"] + + def test_non_opencode_agent_is_not_warned_about_model_sync(self): + warnings = [] + run_agent( + "http://localhost:4000", + "sk-key", + ["claude"], + base_env={}, + warn=warnings.append, + which=lambda name: "/usr/local/bin/claude", + verify=lambda *a: None, + launcher=lambda *a: None, + sync_models=agent_model_sync_env, + ) + assert warnings == [] + + def test_default_sync_is_the_agent_model_sync(self): + assert _default_of(run_agent, "sync_models") is agent_model_sync_env + def test_wires_env_and_launches_resolved_binary(self): calls = {} @@ -662,6 +919,19 @@ class TestAgentCommands: assert captured["command"] == ["codex", "exec", "do a thing"] assert "routing Codex through proxy" in result.output + def test_opencode_launches_through_the_proxy(self): + captured = {} + with patch(f"{AGENTS_MODULE}.run_agent", side_effect=lambda b, k, c, **kw: captured.update(command=list(c))): + result = self.runner.invoke( + _agent_command("opencode"), + [], + obj={"base_url": "http://localhost:4000", "api_key": "sk-key"}, + ) + + assert result.exit_code == 0, result.output + assert captured["command"] == ["opencode"] + assert "routing OpenCode through proxy at http://localhost:4000" in result.output + def test_skip_verify_is_consumed_not_forwarded(self): captured = {} From 61bed7956689959c940fbb75d610c0dc7d5ffb44 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:18:17 -0700 Subject: [PATCH 132/410] feat(ci): add the cost map guard check Replace test-model-map.yml with a pull_request_target guard that validates the cost map, its backup, and its generated schema on every PR, and additionally enforces the sync bot contract on litellm_cost_map_sync_* branches: only the three cost map files may change, no model or field is removed, and the special root keys stay untouched. --- .github/workflows/cost-map-guard.yml | 45 +++++ .github/workflows/test-model-map.yml | 37 ---- ci_cd/cost_map_guard.py | 146 ++++++++++++++++ tests/test_litellm/test_cost_map_guard.py | 195 ++++++++++++++++++++++ 4 files changed, 386 insertions(+), 37 deletions(-) create mode 100644 .github/workflows/cost-map-guard.yml delete mode 100644 .github/workflows/test-model-map.yml create mode 100644 ci_cd/cost_map_guard.py create mode 100644 tests/test_litellm/test_cost_map_guard.py diff --git a/.github/workflows/cost-map-guard.yml b/.github/workflows/cost-map-guard.yml new file mode 100644 index 00000000000..61a56f1f47e --- /dev/null +++ b/.github/workflows/cost-map-guard.yml @@ -0,0 +1,45 @@ +name: Cost map guard + +on: # zizmor: ignore[dangerous-triggers] runs the base branch's code only; the PR's cost map files are read as data and never executed + pull_request_target: + branches: + - main + - litellm_internal_staging + - litellm_oss_staging + - "litellm_**" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + cost-map-guard: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + - name: Fetch the pull request head and its merge base + id: revisions + env: + GH_TOKEN: ${{ github.token }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + merge_base="$(gh api "repos/${GITHUB_REPOSITORY}/compare/${BASE_SHA}...${HEAD_SHA}" --jq '.merge_base_commit.sha')" + git fetch --no-tags --depth=1 origin "$merge_base" "$HEAD_SHA" + echo "merge_base=$merge_base" >> "$GITHUB_OUTPUT" + - name: Set up uv + uses: ./.github/actions/setup-uv-with-retries + with: + version: "0.10.9" + - name: Run the guard + env: + MERGE_BASE: ${{ steps.revisions.outputs.merge_base }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + HEAD_REF: ${{ github.event.pull_request.head.ref }} + run: | + uv run --frozen python ci_cd/cost_map_guard.py --base "$MERGE_BASE" --head "$HEAD_SHA" --head-ref "$HEAD_REF" diff --git a/.github/workflows/test-model-map.yml b/.github/workflows/test-model-map.yml deleted file mode 100644 index c2770e5da4c..00000000000 --- a/.github/workflows/test-model-map.yml +++ /dev/null @@ -1,37 +0,0 @@ -name: Validate model_prices_and_context_window.json - -on: - pull_request: - branches: - - main - - litellm_internal_staging - - litellm_oss_staging - - "litellm_**" - -permissions: - contents: read - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - validate-model-prices-json: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - persist-credentials: false - - - name: Validate model_prices_and_context_window.json - run: | - jq empty model_prices_and_context_window.json - - - name: Set up uv - uses: ./.github/actions/setup-uv-with-retries - with: - version: "0.10.9" - - - name: Check model_prices_and_context_window.schema.json is in sync - run: | - uv run --frozen python ci_cd/generate_model_prices_schema.py --check diff --git a/ci_cd/cost_map_guard.py b/ci_cd/cost_map_guard.py new file mode 100644 index 00000000000..50aa40ba220 --- /dev/null +++ b/ci_cd/cost_map_guard.py @@ -0,0 +1,146 @@ +"""Guard the cost map on pull requests. + +Every pull request gets the file checks: the three cost map files parse, the backup copy matches the root file, +and the JSON schema is in sync and validates the map. Pull requests from the cost map sync bot (branches named +litellm_cost_map_sync_*) additionally may only touch those three files and may only add or update models. +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Final + +from generate_model_prices_schema import SPECIAL_ROOT_KEYS, build_schema, render, validation_errors + +COST_MAP_PATH: Final = "model_prices_and_context_window.json" +BACKUP_PATH: Final = "litellm/model_prices_and_context_window_backup.json" +SCHEMA_PATH: Final = "model_prices_and_context_window.schema.json" +GUARDED_PATHS: Final = (COST_MAP_PATH, BACKUP_PATH, SCHEMA_PATH) +BOT_BRANCH_PREFIX: Final = "litellm_cost_map_sync_" + +CostMap = dict[str, object] + + +@dataclass(frozen=True, slots=True) +class Snapshot: + cost_map: str + backup: str + schema: str + + +def _parse_object(text: str, path: str) -> CostMap | str: + try: + parsed: Final = json.loads(text) + except json.JSONDecodeError as error: + return f"{path} is not valid JSON: {error}" + return parsed if isinstance(parsed, dict) else f"{path} must be a JSON object at the root" + + +def _rendered_schema(cost_map: CostMap) -> str: + try: + return render(build_schema(cost_map)) + except SystemExit as error: + return str(error) + + +def _file_failures(head: Snapshot, head_map: CostMap) -> tuple[str, ...]: + schema_text: Final = _rendered_schema(head_map) + if not schema_text.startswith("{"): + return (schema_text,) + backup_failure: Final = ( + () + if head.backup == head.cost_map + else (f"{BACKUP_PATH} differs from {COST_MAP_PATH}; copy the root file over it",) + ) + schema_failure: Final = ( + () + if head.schema == schema_text + else ( + f"{SCHEMA_PATH} is out of sync with {COST_MAP_PATH}; " + "run `python ci_cd/generate_model_prices_schema.py` and commit the result", + ) + ) + return ( + *backup_failure, + *schema_failure, + *( + f"{COST_MAP_PATH} does not validate against its schema: {error}" + for error in validation_errors(head_map, json.loads(schema_text))[:20] + ), + ) + + +def _entries(cost_map: CostMap) -> dict[str, dict[str, object]]: + return {key: entry for key, entry in cost_map.items() if isinstance(entry, dict)} + + +def _bot_failures(base: Snapshot, head_map: CostMap, changed_files: Sequence[str]) -> tuple[str, ...]: + base_map: Final = _parse_object(base.cost_map, COST_MAP_PATH) + if isinstance(base_map, str): + return (f"merge base: {base_map}",) + base_entries: Final = _entries(base_map) + head_entries: Final = _entries(head_map) + removed_fields: Final = tuple( + f"{key}.{field}" + for key, entry in base_entries.items() + if key in head_entries + for field in entry + if field not in head_entries[key] + ) + return ( + *( + f"bot PRs may only change the cost map files, not {path}" + for path in changed_files + if path not in GUARDED_PATHS + ), + *(f"bot PRs may not remove models: {key}" for key in base_map if key not in head_map), + *(f"bot PRs may not remove fields: {ref}" for ref in removed_fields), + *( + f"bot PRs may not change {key}" + for key in sorted(SPECIAL_ROOT_KEYS) + if base_map.get(key) != head_map.get(key) + ), + ) + + +def guard_failures(base: Snapshot, head: Snapshot, changed_files: Sequence[str], bot: bool) -> tuple[str, ...]: + head_map: Final = _parse_object(head.cost_map, COST_MAP_PATH) + if isinstance(head_map, str): + return (head_map,) + return (*_file_failures(head, head_map), *(_bot_failures(base, head_map, changed_files) if bot else ())) + + +def _git(*args: str) -> str: + result: Final = subprocess.run(("git", *args), check=False, capture_output=True, text=True) + return result.stdout if result.returncode == 0 else "" + + +def snapshot(revision: str) -> Snapshot: + return Snapshot(*(_git("show", f"{revision}:{path}") for path in GUARDED_PATHS)) + + +def main(argv: Sequence[str]) -> int: + parser: Final = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base", required=True, help="merge base of the pull request") + parser.add_argument("--head", required=True, help="head commit of the pull request") + parser.add_argument("--head-ref", required=True, help="head branch name of the pull request") + args: Final = parser.parse_args(argv) + bot: Final = args.head_ref.startswith(BOT_BRANCH_PREFIX) + changed_files: Final = tuple(_git("diff", "--name-only", args.base, args.head).splitlines()) + failures: Final = guard_failures(snapshot(args.base), snapshot(args.head), changed_files, bot) + contract: Final = "bot contract enforced" if bot else "human PR, file checks only" + if failures: + print(f"cost map guard failed ({contract}):") + print("\n".join(f"- {failure}" for failure in failures)) + return 1 + print(f"cost map guard passed ({contract})") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/tests/test_litellm/test_cost_map_guard.py b/tests/test_litellm/test_cost_map_guard.py new file mode 100644 index 00000000000..1b4330ed62c --- /dev/null +++ b/tests/test_litellm/test_cost_map_guard.py @@ -0,0 +1,195 @@ +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from types import ModuleType +from typing import Final + +import pytest + +ROOT: Final = Path(__file__).resolve().parents[2] +CI_CD: Final = ROOT / "ci_cd" + + +def _load(name: str) -> ModuleType: + spec = importlib.util.spec_from_file_location(name, CI_CD / f"{name}.py") + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + spec.loader.exec_module(module) + return module + + +schema_module: Final = _load("generate_model_prices_schema") +guard: Final = _load("cost_map_guard") + +MAP_FILES: Final = (guard.COST_MAP_PATH,) +BOT_REF: Final = "litellm_cost_map_sync_2026-09-04T12-00Z" + + +def _entry(price: float = 1e-06, **extra: object) -> dict[str, object]: + return { + "input_cost_per_token": price, + "output_cost_per_token": price * 2, + "litellm_provider": "openrouter", + "mode": "chat", + "max_tokens": 4096, + **extra, + } + + +BASE_MAP: Final = { + "sample_spec": {"input_cost_per_token": "USD per prompt token"}, + "fallback_generalizations": {"rules": [{"name": "r", "pattern": "^x"}]}, + "openrouter/a": _entry(supports_vision=True), + "openrouter/b": _entry(2e-06), +} + + +def _serialize(cost_map: dict[str, object]) -> str: + return json.dumps(cost_map, indent=4, ensure_ascii=False) + "\n" + + +def _snapshot(cost_map: dict[str, object], backup: str | None = None, schema: str | None = None) -> object: + text = _serialize(cost_map) + rendered = schema_module.render(schema_module.build_schema(cost_map)) + return guard.Snapshot( + cost_map=text, backup=text if backup is None else backup, schema=rendered if schema is None else schema + ) + + +BASE: Final = _snapshot(BASE_MAP) + + +def _failures(head: object, changed_files: tuple[str, ...] = MAP_FILES, bot: bool = True) -> tuple[str, ...]: + return guard.guard_failures(BASE, head, changed_files, bot) + + +def test_in_sync_files_pass_for_humans_and_bots() -> None: + assert _failures(BASE, bot=False) == () + assert _failures(BASE, bot=True) == () + + +def test_bot_may_add_and_reprice_models() -> None: + head = _snapshot({**BASE_MAP, "openrouter/a": _entry(9e-06, supports_vision=True), "openrouter/c": _entry()}) + assert _failures(head) == () + + +def test_broken_json_is_reported() -> None: + head = guard.Snapshot(cost_map="{not json", backup="{not json", schema="{}") + assert _failures(head, bot=False) == ( + f"{guard.COST_MAP_PATH} is not valid JSON: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)", + ) + + +def test_non_object_root_is_reported() -> None: + head = guard.Snapshot(cost_map="[]", backup="[]", schema="{}") + assert _failures(head, bot=False) == (f"{guard.COST_MAP_PATH} must be a JSON object at the root",) + + +def test_backup_drift_is_reported() -> None: + head = _snapshot(BASE_MAP, backup=_serialize({**BASE_MAP, "openrouter/b": _entry(3e-06)})) + assert [failure for failure in _failures(head, bot=False) if failure.startswith(guard.BACKUP_PATH)] + + +def test_schema_out_of_sync_is_reported() -> None: + head = _snapshot({**BASE_MAP, "openrouter/c": _entry(supports_audio_input=True)}, schema=BASE.schema) + assert [failure for failure in _failures(head, bot=False) if failure.startswith(guard.SCHEMA_PATH)] + + +def test_schema_validation_errors_are_reported() -> None: + head = _snapshot({**BASE_MAP, "openrouter/c": _entry(-1e-06)}) + prefix = f"{guard.COST_MAP_PATH} does not validate against its schema: openrouter/c." + assert [failure.removeprefix(prefix).split(":")[0] for failure in _failures(head, bot=False)] == [ + "input_cost_per_token", + "output_cost_per_token", + ] + + +def test_unclassified_entry_key_is_reported() -> None: + text = _serialize({**BASE_MAP, "openrouter/c": _entry(weird_thing=1)}) + head = guard.Snapshot(cost_map=text, backup=text, schema=BASE.schema) + (failure,) = _failures(head, bot=False) + assert "Unclassified keys" in failure and "weird_thing" in failure + + +def test_bot_may_only_touch_the_cost_map_files() -> None: + changed = (*guard.GUARDED_PATHS, "litellm/utils.py", ".github/workflows/cost-map-guard.yml") + assert _failures(BASE, changed_files=changed, bot=False) == () + assert _failures(BASE, changed_files=changed) == ( + "bot PRs may only change the cost map files, not litellm/utils.py", + "bot PRs may only change the cost map files, not .github/workflows/cost-map-guard.yml", + ) + + +def test_bot_may_not_remove_models() -> None: + head = _snapshot({key: value for key, value in BASE_MAP.items() if key != "openrouter/b"}) + assert _failures(head, bot=False) == () + assert _failures(head) == ("bot PRs may not remove models: openrouter/b",) + + +def test_bot_may_not_remove_fields() -> None: + head = _snapshot({**BASE_MAP, "openrouter/a": _entry()}) + assert _failures(head, bot=False) == () + assert _failures(head) == ("bot PRs may not remove fields: openrouter/a.supports_vision",) + + +def test_bot_may_not_change_special_root_keys() -> None: + head = _snapshot({**BASE_MAP, "fallback_generalizations": {"rules": []}}) + assert _failures(head, bot=False) == () + assert _failures(head) == ("bot PRs may not change fallback_generalizations",) + + +def _commit(repo: Path, cost_map: dict[str, object], message: str) -> str: + text = _serialize(cost_map) + (repo / guard.COST_MAP_PATH).write_text(text) + (repo / guard.BACKUP_PATH).parent.mkdir(exist_ok=True) + (repo / guard.BACKUP_PATH).write_text(text) + (repo / guard.SCHEMA_PATH).write_text(schema_module.render(schema_module.build_schema(cost_map))) + subprocess.run(("git", "add", "-A"), cwd=repo, check=True) + subprocess.run( + ("git", "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "-q", "-m", message), + cwd=repo, + check=True, + ) + return subprocess.run( + ("git", "rev-parse", "HEAD"), cwd=repo, check=True, capture_output=True, text=True + ).stdout.strip() + + +def _run_guard(repo: Path, base: str, head: str, head_ref: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + (sys.executable, str(CI_CD / "cost_map_guard.py"), "--base", base, "--head", head, "--head-ref", head_ref), + cwd=repo, + capture_output=True, + text=True, + check=False, + ) + + +@pytest.mark.parametrize( + ("head_ref", "expected_code", "expected_line"), + [ + (BOT_REF, 1, "- bot PRs may not remove models: openrouter/b"), + ("litellm_fix_pricing", 0, "cost map guard passed (human PR, file checks only)"), + ], +) +def test_main_reads_both_revisions_from_git( + tmp_path: Path, head_ref: str, expected_code: int, expected_line: str +) -> None: + subprocess.run(("git", "init", "-q", str(tmp_path)), check=True) + base = _commit(tmp_path, BASE_MAP, "base") + head = _commit(tmp_path, {key: value for key, value in BASE_MAP.items() if key != "openrouter/b"}, "head") + result = _run_guard(tmp_path, base, head, head_ref) + assert result.returncode == expected_code, result.stdout + result.stderr + assert expected_line in result.stdout.splitlines() + + +def test_main_rejects_a_bot_pr_that_edits_code(tmp_path: Path) -> None: + subprocess.run(("git", "init", "-q", str(tmp_path)), check=True) + base = _commit(tmp_path, BASE_MAP, "base") + (tmp_path / "litellm" / "utils.py").write_text("print('hi')\n") + head = _commit(tmp_path, {**BASE_MAP, "openrouter/c": _entry()}, "head") + assert _run_guard(tmp_path, base, head, BOT_REF).returncode == 1 + assert _run_guard(tmp_path, base, head, "litellm_fix_pricing").returncode == 0 From aa3d59d086acbe8ca945b3c6391d0037766c9e0a Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 17:18:59 -0700 Subject: [PATCH 133/410] fix(anthropic): never carry cache_control on translated thinking blocks (#39815) * fix(anthropic): never carry cache_control on translated thinking blocks The /v1/messages adapter built every thinking and redacted_thinking block with cache_control=content.get("cache_control", {}), so a block the client never marked still came out carrying an empty cache_control. anthropic_messages_pt replays thinking blocks verbatim and first, so that value landed at content[0] of the outbound assistant message and Anthropic rejected the request with messages.N.content.0.thinking.cache_control: Extra inputs are not permitted. Anthropic's schema has no cache_control on either block type, so there is nothing to gate or translate here, only to stop copying. Every sibling block type already routes through _add_cache_control_if_applicable; these two were the only ones setting the key unconditionally. This is reachable from any caller that round-trips Anthropic messages through the OpenAI shape, which is why shadow eval saw it on a majority of sampled Claude Code turns while the same traffic served natively was fine. * test(anthropic): assert the outbound wire body for redacted thinking blocks --- .../adapters/transformation.py | 6 +- ...al_pass_through_adapters_transformation.py | 86 ++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 573a461e89e..64f10046109 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -524,18 +524,20 @@ class LiteLLMAnthropicMessagesAdapter: self._add_cache_control_if_applicable(content, tool_call, model) tool_calls.append(tool_call) elif content.get("type") == "thinking": + # Anthropic's schema has no cache_control on thinking or + # redacted_thinking blocks, and anthropic_messages_pt replays + # these verbatim at content[0], so carrying one here (or + # inventing an empty one) is a guaranteed 400 on the way back. thinking_block = ChatCompletionThinkingBlock( type="thinking", thinking=content.get("thinking") or "", signature=content.get("signature") or "", - cache_control=content.get("cache_control", {}), ) thinking_blocks.append(thinking_block) elif content.get("type") == "redacted_thinking": redacted_thinking_block = ChatCompletionRedactedThinkingBlock( type="redacted_thinking", data=content.get("data") or "", - cache_control=content.get("cache_control", {}), ) thinking_blocks.append(redacted_thinking_block) diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py index 2d74c00071b..ea3b19fba2b 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_anthropic_experimental_pass_through_adapters_transformation.py @@ -1,5 +1,5 @@ import base64 -from typing import Any, cast +from typing import Any, Final, cast import pytest @@ -4630,3 +4630,87 @@ def test_a_bedrock_target_still_takes_output_config_not_the_declared_gate(): assert openai_request["output_config"] == {"effort": "max"} assert "reasoning_effort" not in openai_request assert openai_request["thinking"] == {"type": "adaptive", "display": "omitted"} + + +@pytest.mark.parametrize( + "client_cache_control", + [ + pytest.param(None, id="client_sent_none"), + pytest.param({"type": "ephemeral"}, id="client_sent_one"), + ], +) +def test_thinking_blocks_never_carry_cache_control_back_to_anthropic(client_cache_control): + """A cache_control surviving the round trip is a `messages.N.content.0.thinking. + cache_control: Extra inputs are not permitted` 400 from Anthropic, whether the client + sent one or the adapter invented an empty one.""" + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + thinking_block: Final = { + "type": "thinking", + "thinking": "let me think", + "signature": "sig_abc", + **({"cache_control": client_cache_control} if client_cache_control is not None else {}), + } + + openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( + { + "model": "claude-sonnet-5", + "max_tokens": 4096, + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + {"role": "assistant", "content": [thinking_block, {"type": "text", "text": "hello"}]}, + {"role": "user", "content": [{"type": "text", "text": "and now?"}]}, + ], + } + ) + + translated_blocks = openai_request["messages"][1]["thinking_blocks"] + assert [b["type"] for b in translated_blocks] == ["thinking"] + assert "cache_control" not in translated_blocks[0] + + outbound = AnthropicConfig().transform_request( + model="claude-sonnet-5", + messages=openai_request["messages"], + optional_params={"max_tokens": 4096}, + litellm_params={}, + headers={}, + ) + + replayed = outbound["messages"][1]["content"][0] + assert replayed["type"] == "thinking" + assert "cache_control" not in replayed + + +def test_redacted_thinking_blocks_never_carry_cache_control(): + """`redacted_thinking` carries no signature and is always replayed, so it hits the + same Anthropic 400 as `thinking` if it picks up a cache_control on the way through.""" + from litellm.llms.anthropic.chat.transformation import AnthropicConfig + + openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( + { + "model": "claude-sonnet-5", + "max_tokens": 4096, + "messages": [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + { + "role": "assistant", + "content": [ + {"type": "redacted_thinking", "data": "abc", "cache_control": {"type": "ephemeral"}}, + {"type": "text", "text": "hello"}, + ], + }, + ], + } + ) + + outbound: Final = AnthropicConfig().transform_request( + model="claude-sonnet-5", + messages=openai_request["messages"], + optional_params={"max_tokens": 4096}, + litellm_params={}, + headers={}, + ) + + replayed: Final = outbound["messages"][1]["content"][0] + assert replayed["type"] == "redacted_thinking" + assert "cache_control" not in replayed From e273cf301fc710a88bb3820e3d35f7fe6a6b20bb Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 17:21:04 -0700 Subject: [PATCH 134/410] fix(ci): satisfy ruff format, prettier, and eslint max-lines gates - ruff format on auto_router_compression.py (a long comprehension wrapped across three lines instead of one) - prettier on buildAutoRouterCompression.ts and the two test files it touched - ComplexityRouterConfig.tsx crossed the 800-line eslint max-lines ceiling once the compression accordion entry landed. Extracted TierRowSelect into its own file (already self-contained, used only within this file and PlanModeOverrideControls) and simplified CompressionControls' props to a single state/onChange pair instead of six individual callbacks, moving the per-field derivation into the component that already owns this state shape --- .../guardrails/auto_router_compression.py | 4 +- .../add_model/ComplexityRouterConfig.tsx | 40 +------------------ .../add_model/CompressionControls.tsx | 29 +++++++------- .../components/add_model/TierRowSelect.tsx | 25 ++++++++++++ .../add_model/buildAutoRouterCompression.ts | 2 +- .../edit_auto_router_modal.test.tsx | 7 ++-- 6 files changed, 47 insertions(+), 60 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/TierRowSelect.tsx diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py index 490ce550003..2122d353def 100644 --- a/litellm/proxy/guardrails/auto_router_compression.py +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -93,9 +93,7 @@ def policy_for_model( ) requested: Final = frozenset(request_tags) tag_matched: Final = tuple( - params - for params in markers - if (tags := params.get("tags")) and requested.issuperset(frozenset(tags)) + params for params in markers if (tags := params.get("tags")) and requested.issuperset(frozenset(tags)) ) for params in (*tag_matched, *markers): policy = policy_from_litellm_params(params) diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx index 42115265034..92fd9893995 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.tsx @@ -1,11 +1,11 @@ import { SimpleTooltip } from "@/components/ui/tooltip"; import { MultiSelect } from "@/components/shared/MultiSelect"; import { SearchSelect } from "@/components/shared/SearchSelect"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { ChevronRight, Info, Plus, Trash2, X } from "lucide-react"; import { Switch } from "@/components/ui/switch"; import { AffinityControls } from "./AffinityControls"; +import TierRowSelect from "./TierRowSelect"; import { ModalityRoutingControls } from "./ModalityRoutingControls"; import { Card, CardContent } from "@/components/ui/card"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; @@ -370,27 +370,6 @@ const TierRowEditFields: React.FC<{ ); -const TierRowSelect: React.FC<{ - label: string; - options: { value: string; label: string }[]; - value: string | null; - onValueChange: (rowId: string) => void; - placeholder?: string; -}> = ({ label, options, value, onValueChange, placeholder }) => ( - -); - export type AdaptiveEligible = "all" | "classified_tier"; export type ComplexityTierLabels = Partial>; @@ -889,22 +868,7 @@ const ComplexityRouterConfig: React.FC = ({ key: "compression", label: Advanced: Compression, children: ( - - onAutoRouterCompressionChange({ - ...autoRouterCompression, - routing, - sameAsRouting: routing === undefined ? true : autoRouterCompression.sameAsRouting, - }) - } - sameAsRouting={autoRouterCompression.sameAsRouting} - onSameAsRoutingChange={(sameAsRouting) => - onAutoRouterCompressionChange({ ...autoRouterCompression, sameAsRouting }) - } - model={autoRouterCompression.model} - onModelChange={(model) => onAutoRouterCompressionChange({ ...autoRouterCompression, model })} - /> + ), }, ] diff --git a/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx b/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx index 52ea7645034..c1817918f60 100644 --- a/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx +++ b/ui/litellm-dashboard/src/components/add_model/CompressionControls.tsx @@ -5,27 +5,26 @@ import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Info } from "lucide-react"; import React from "react"; import { useGuardrails } from "@/app/(dashboard)/hooks/guardrails/useGuardrails"; -import { isCompressionGuardrailProvider, NO_COMPRESSION } from "./buildAutoRouterCompression"; +import { + AutoRouterCompressionState, + isCompressionGuardrailProvider, + NO_COMPRESSION, +} from "./buildAutoRouterCompression"; interface CompressionControlsProps { - routing: string | undefined; - onRoutingChange: (value: string | undefined) => void; - sameAsRouting: boolean; - onSameAsRoutingChange: (same: boolean) => void; - model: string | undefined; - onModelChange: (value: string | undefined) => void; + value: AutoRouterCompressionState; + onChange: (state: AutoRouterCompressionState) => void; } const NONE_OPTION: SearchSelectOption = { label: "None (no compression)", value: NO_COMPRESSION }; -const CompressionControls: React.FC = ({ - routing, - onRoutingChange, - sameAsRouting, - onSameAsRoutingChange, - model, - onModelChange, -}) => { +const CompressionControls: React.FC = ({ value, onChange }) => { + const { routing, sameAsRouting, model } = value; + const onRoutingChange = (newRouting: string | undefined) => + onChange({ ...value, routing: newRouting, sameAsRouting: newRouting === undefined ? true : sameAsRouting }); + const onSameAsRoutingChange = (newSameAsRouting: boolean) => onChange({ ...value, sameAsRouting: newSameAsRouting }); + const onModelChange = (newModel: string | undefined) => onChange({ ...value, model: newModel }); + const { data } = useGuardrails(); const compressionOptions: SearchSelectOption[] = (data?.guardrails ?? []) .filter((g) => isCompressionGuardrailProvider(g.litellm_params?.guardrail)) diff --git a/ui/litellm-dashboard/src/components/add_model/TierRowSelect.tsx b/ui/litellm-dashboard/src/components/add_model/TierRowSelect.tsx new file mode 100644 index 00000000000..ad7d53f9eae --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/TierRowSelect.tsx @@ -0,0 +1,25 @@ +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import React from "react"; + +const TierRowSelect: React.FC<{ + label: string; + options: { value: string; label: string }[]; + value: string | null; + onValueChange: (rowId: string) => void; + placeholder?: string; +}> = ({ label, options, value, onValueChange, placeholder }) => ( + +); + +export default TierRowSelect; diff --git a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts index 5afdcf2b15c..c86416b507f 100644 --- a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts +++ b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts @@ -42,7 +42,7 @@ export const buildAutoRouterCompressionParams = ( if (state.routing === undefined) return {}; return { auto_router_routing_compression: state.routing, - auto_router_model_compression: state.sameAsRouting ? state.routing : (state.model ?? NO_COMPRESSION), + auto_router_model_compression: state.sameAsRouting ? state.routing : state.model ?? NO_COMPRESSION, }; }; diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx index b93db8d963e..d8474b492e1 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx @@ -1040,9 +1040,10 @@ describe("EditAutoRouterModal prompt compression", () => { return payload?.litellm_params; }; - const renderWithStoredCompression = ( - compression?: { auto_router_routing_compression?: string; auto_router_model_compression?: string }, - ) => + const renderWithStoredCompression = (compression?: { + auto_router_routing_compression?: string; + auto_router_model_compression?: string; + }) => renderWithProviders( Date: Sat, 5 Sep 2026 00:22:42 +0000 Subject: [PATCH 135/410] fix(cli): write pi models atomically and privately Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/client/cli/commands/pi.py | 19 ++++++++++-- .../test_litellm/proxy/client/cli/test_pi.py | 30 +++++++++++++++++++ 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/client/cli/commands/pi.py b/litellm/proxy/client/cli/commands/pi.py index 668b803a33d..b3b9d520a5a 100644 --- a/litellm/proxy/client/cli/commands/pi.py +++ b/litellm/proxy/client/cli/commands/pi.py @@ -6,6 +6,8 @@ the short-lived login token never lands on disk. """ import json +import os +import tempfile from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path @@ -156,13 +158,24 @@ def sync_models_json( **current, "providers": {**existing_providers, PI_PROVIDER_NAME: provider_block(base_url, model_ids, limits)}, } - staging: Final = path.with_name(path.name + ".tmp") try: path.parent.mkdir(parents=True, exist_ok=True) - staging.write_text(json.dumps(updated, indent=2) + "\n") - staging.replace(path) except OSError as e: return PiSyncError(f"Could not write {path}: {e}") + try: + fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp") + except OSError as e: + return PiSyncError(f"Could not write {path}: {e}") + try: + with os.fdopen(fd, "w") as file: + file.write(json.dumps(updated, indent=2) + "\n") + os.replace(tmp_name, path) + except OSError as e: + try: + os.unlink(tmp_name) + except FileNotFoundError: + pass + return PiSyncError(f"Could not write {path}: {e}") return None diff --git a/tests/test_litellm/proxy/client/cli/test_pi.py b/tests/test_litellm/proxy/client/cli/test_pi.py index ee3222a77e3..68c0ac70064 100644 --- a/tests/test_litellm/proxy/client/cli/test_pi.py +++ b/tests/test_litellm/proxy/client/cli/test_pi.py @@ -1,4 +1,7 @@ import json +import os +import stat +from concurrent.futures import ThreadPoolExecutor from pathlib import Path import requests @@ -179,6 +182,33 @@ class TestSyncModelsJson: assert sync_models_json(path, "http://localhost:4000", ("m-1",)) is None assert [p.name for p in tmp_path.iterdir()] == ["models.json"] + def test_written_file_is_private(self, tmp_path): + path = tmp_path / "models.json" + assert sync_models_json(path, "http://localhost:4000", ("m-1",)) is None + if os.name != "nt": + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + path.write_text(json.dumps({"providers": {"other": {"apiKey": "literal-secret"}}})) + path.chmod(0o644) + assert sync_models_json(path, "http://localhost:4000", ("m-2",)) is None + if os.name != "nt": + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + + def test_concurrent_syncs_do_not_collide(self, tmp_path): + path = tmp_path / "models.json" + model_lists = (("m-a",), ("m-b",)) + + def sync(model_ids): + return sync_models_json(path, "http://localhost:4000", model_ids) + + with ThreadPoolExecutor(max_workers=2) as executor: + results = [result for _ in range(30) for result in executor.map(sync, model_lists)] + + assert results == [None] * 60 + written = json.loads(path.read_text()) + assert written["providers"]["litellm"]["models"] in ([{"id": "m-a"}], [{"id": "m-b"}]) + assert list(tmp_path.glob("models.json.*.tmp")) == [] + def test_invalid_json_is_a_value_and_file_untouched(self, tmp_path): path = tmp_path / "models.json" path.write_text("{not json") From 217b5c404c42163add869108d672926758d257ba Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:24:09 -0700 Subject: [PATCH 136/410] fix(fireworks_ai): map the Fireworks delete response body to DeleteResponseResult --- .../fireworks_ai/responses/transformation.py | 17 ++++++++++++++++- ...est_fireworks_ai_responses_transformation.py | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/litellm/llms/fireworks_ai/responses/transformation.py b/litellm/llms/fireworks_ai/responses/transformation.py index f36030bb50a..7f29e03ff0f 100644 --- a/litellm/llms/fireworks_ai/responses/transformation.py +++ b/litellm/llms/fireworks_ai/responses/transformation.py @@ -1,6 +1,9 @@ from collections.abc import Mapping from types import MappingProxyType -from typing import Final +from typing import TYPE_CHECKING, Final +from urllib.parse import unquote + +import httpx from litellm.llms.fireworks_ai.common_utils import ( resolve_fireworks_api_key, @@ -10,9 +13,13 @@ from litellm.llms.fireworks_ai.common_utils import ( from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig from litellm.secret_managers.main import get_secret_str from litellm.types.llms.openai import ResponseInputParam +from litellm.types.responses.main import DeleteResponseResult from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + FIREWORKS_AI_DEFAULT_API_BASE: Final = "https://api.fireworks.ai/inference/v1" @@ -64,5 +71,13 @@ class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig): headers=headers, ) + def transform_delete_response_api_response( + self, + raw_response: httpx.Response, + logging_obj: "LiteLLMLoggingObj", + ) -> DeleteResponseResult: + deleted_id: Final = unquote(raw_response.request.url.path.rsplit("/", 1)[-1]) + return DeleteResponseResult(id=deleted_id, object="response", deleted=True) + def supports_native_websocket(self) -> bool: return False diff --git a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py index 207fd518f89..997949bd03a 100644 --- a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py @@ -3,6 +3,7 @@ from collections.abc import Mapping from types import MappingProxyType from typing import Final, TypedDict from unittest.mock import MagicMock, patch +from urllib.parse import quote import httpx import pytest @@ -261,3 +262,16 @@ def test_validate_environment_without_any_key_raises() -> None: FireworksAIResponsesAPIConfig().validate_environment( headers=NO_HEADERS, model="accounts/fireworks/models/kimi-k3", litellm_params=None ) + + +def test_delete_responses_maps_fireworks_message_only_body_to_deleted_result() -> None: + response_id: Final = "resp_xFaIJR9Nc_OXmqKRqL78UuAGj2Te5GY5BT_knpZiMrYoNOVmu5oc2mQW1HI7hCtEYB4mcx2lEYS0DYP1U5yEQskHunuB4==" + request: Final = httpx.Request("DELETE", f"{FIREWORKS_RESPONSES_URL}/{quote(response_id, safe='')}") + client: Final = MagicMock() + client.delete.return_value = httpx.Response(200, json={"message": "Response deleted successfully"}, request=request) + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + result: Final = litellm.delete_responses( + response_id=response_id, custom_llm_provider="fireworks_ai", api_key="fw-test-key" + ) + assert client.delete.call_args.kwargs["url"] == str(request.url) + assert (result.id, result.object, result.deleted) == (response_id, "response", True) From a93ba9229272100ddec777dedd77f02356b39954 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:28:51 -0700 Subject: [PATCH 137/410] test(fireworks_ai): cover native Responses API streaming end to end --- ...t_fireworks_ai_responses_transformation.py | 112 +++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py index 997949bd03a..8fac91ee475 100644 --- a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py @@ -265,7 +265,9 @@ def test_validate_environment_without_any_key_raises() -> None: def test_delete_responses_maps_fireworks_message_only_body_to_deleted_result() -> None: - response_id: Final = "resp_xFaIJR9Nc_OXmqKRqL78UuAGj2Te5GY5BT_knpZiMrYoNOVmu5oc2mQW1HI7hCtEYB4mcx2lEYS0DYP1U5yEQskHunuB4==" + response_id: Final = ( + "resp_xFaIJR9Nc_OXmqKRqL78UuAGj2Te5GY5BT_knpZiMrYoNOVmu5oc2mQW1HI7hCtEYB4mcx2lEYS0DYP1U5yEQskHunuB4==" + ) request: Final = httpx.Request("DELETE", f"{FIREWORKS_RESPONSES_URL}/{quote(response_id, safe='')}") client: Final = MagicMock() client.delete.return_value = httpx.Response(200, json={"message": "Response deleted successfully"}, request=request) @@ -275,3 +277,111 @@ def test_delete_responses_maps_fireworks_message_only_body_to_deleted_result() - ) assert client.delete.call_args.kwargs["url"] == str(request.url) assert (result.id, result.object, result.deleted) == (response_id, "response", True) + + +def _fireworks_stream_response(status: str, output: tuple[Mapping[str, object], ...]) -> Mapping[str, object]: + return { + "id": "resp_htnkJ8piNKeOHkn9LfAusC38O2OgcDQs4S8trSOJ6anLeqjUDGqu2PkWmg5N", + "object": "response", + "created_at": 1788567245, + "model": "accounts/fireworks/models/kimi-k3", + "status": status, + "output": output, + "usage": None + if status == "in_progress" + else { + "input_tokens": 95, + "output_tokens": 89, + "total_tokens": 184, + "input_tokens_details": {"cached_tokens": 94}, + }, + } + + +FIREWORKS_SSE_EVENTS: Final[tuple[Mapping[str, object], ...]] = ( + {"type": "response.created", "sequence_number": 0, "response": _fireworks_stream_response("in_progress", ())}, + { + "type": "response.output_item.added", + "sequence_number": 1, + "output_index": 0, + "item": {"id": "rs_1", "type": "reasoning", "summary": []}, + }, + { + "type": "response.reasoning_summary_text.delta", + "sequence_number": 2, + "item_id": "rs_1", + "output_index": 0, + "summary_index": 0, + "delta": "pong", + }, + { + "type": "response.output_item.added", + "sequence_number": 3, + "output_index": 1, + "item": {"id": "msg_1", "type": "message", "role": "assistant", "status": "in_progress", "content": []}, + }, + { + "type": "response.output_text.delta", + "sequence_number": 4, + "item_id": "msg_1", + "output_index": 1, + "content_index": 0, + "delta": "po", + }, + { + "type": "response.output_text.delta", + "sequence_number": 5, + "item_id": "msg_1", + "output_index": 1, + "content_index": 0, + "delta": "ng", + }, + { + "type": "response.completed", + "sequence_number": 6, + "response": _fireworks_stream_response( + "completed", + ( + {"id": "rs_1", "type": "reasoning", "summary": [{"type": "summary_text", "text": "pong"}]}, + { + "id": "msg_1", + "type": "message", + "role": "assistant", + "status": "completed", + "content": [{"type": "output_text", "text": "pong", "annotations": []}], + }, + ), + ), + }, +) + + +def _sse_body(events: tuple[Mapping[str, object], ...]) -> bytes: + return b"".join(f"data: {json.dumps(dict(event))}\n\n".encode() for event in events) + b"data: [DONE]\n\n" + + +def test_streaming_responses_call_hits_native_endpoint_and_yields_every_fireworks_event() -> None: + request: Final = httpx.Request("POST", FIREWORKS_RESPONSES_URL) + client: Final = MagicMock() + client.post.return_value = httpx.Response( + 200, content=_sse_body(FIREWORKS_SSE_EVENTS), headers={"content-type": "text/event-stream"}, request=request + ) + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + received: Final = tuple( + litellm.responses( + model="fireworks_ai/kimi-k3", + input="Reply with the single word pong.", + stream=True, + api_key="fw-test-key", + ) + ) + url, _, body = _sent_request(client) + assert (url, body["model"], body["stream"], client.post.call_args.kwargs["stream"]) == ( + FIREWORKS_RESPONSES_URL, + "accounts/fireworks/models/kimi-k3", + True, + True, + ) + assert tuple(event.type for event in received) == tuple(event["type"] for event in FIREWORKS_SSE_EVENTS) + assert "".join(event.delta for event in received if event.type == "response.output_text.delta") == "pong" + assert received[-1].response.usage.output_tokens == 89 From 6463994f78161ee4448380a5e9fb2ad28729b98f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:29:10 -0700 Subject: [PATCH 138/410] fix(ai-gateway): dial upstream WebSockets over an explicit rustls provider Build one ClientConfig that names ring and loads the native roots once, and hand it to every tokio-tungstenite dial as its connector instead of installing a process-wide default from the dial path. Each of the three dial sites gets a wss:// test that reproduces the panic if its connector is dropped. --- litellm-rust/Cargo.lock | 1 + litellm-rust/Cargo.toml | 1 + litellm-rust/crates/ai-gateway/Cargo.toml | 5 +- .../crates/ai-gateway/src/io/realtime.rs | 27 +++++ .../crates/ai-gateway/src/io/responses_ws.rs | 23 ++++ litellm-rust/crates/ai-gateway/src/io/tls.rs | 113 ++++++++++-------- .../tests/crypto_provider_wiring.rs | 19 ++- 7 files changed, 134 insertions(+), 55 deletions(-) diff --git a/litellm-rust/Cargo.lock b/litellm-rust/Cargo.lock index 2ed998174e4..d214e80d818 100644 --- a/litellm-rust/Cargo.lock +++ b/litellm-rust/Cargo.lock @@ -1416,6 +1416,7 @@ dependencies = [ "pyo3", "reqwest", "rustls 0.23.42", + "rustls-native-certs", "serde", "serde_json", "sha2 0.10.9", diff --git a/litellm-rust/Cargo.toml b/litellm-rust/Cargo.toml index 2e2e8809b7f..d3d25cbda8d 100644 --- a/litellm-rust/Cargo.toml +++ b/litellm-rust/Cargo.toml @@ -27,6 +27,7 @@ rand = "0.8" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls", "http2", "stream"] } rstest = "0.26.1" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } +rustls-native-certs = "0.8" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha2 = "0.10" diff --git a/litellm-rust/crates/ai-gateway/Cargo.toml b/litellm-rust/crates/ai-gateway/Cargo.toml index 414abc2356d..82bedd0c8a0 100644 --- a/litellm-rust/crates/ai-gateway/Cargo.toml +++ b/litellm-rust/crates/ai-gateway/Cargo.toml @@ -19,9 +19,10 @@ litellm-core = { workspace = true, features = ["bedrock-auth"] } # reqwest (rustls + json) is used by io/ocr and ships realtime logs to the # Python proxy callbacks API. reqwest.workspace = true -# rustls is a direct dependency so `io::tls` can install a process-level -# crypto provider; see that module for why the graph needs one. +# rustls and its root store are direct dependencies so `io::tls` can build the +# one TLS config the outbound dials use; see that module for why it has to. rustls.workspace = true +rustls-native-certs.workspace = true # `sync` powers the bounded mpsc channel the realtime logger drains. tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time", "sync"] } tokio-tungstenite.workspace = true diff --git a/litellm-rust/crates/ai-gateway/src/io/realtime.rs b/litellm-rust/crates/ai-gateway/src/io/realtime.rs index 53d87848342..207c31dffa0 100644 --- a/litellm-rust/crates/ai-gateway/src/io/realtime.rs +++ b/litellm-rust/crates/ai-gateway/src/io/realtime.rs @@ -286,6 +286,33 @@ mod tests { serde_json::from_str(raw).expect("valid event json") } + /// The realtime dial has to reach a `wss://` upstream without a process-wide + /// crypto provider installed, which is what dialing through `io::tls` buys. + #[tokio::test] + async fn dial_upstream_over_wss_reports_an_error_instead_of_panicking() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind a loopback port"); + let port = listener + .local_addr() + .expect("read the bound address") + .port(); + tokio::spawn(async move { + while let Ok((stream, _peer)) = listener.accept().await { + drop(stream); + } + }); + + let result = dial_upstream( + "gpt-realtime", + "sk-test", + Some(&format!("wss://127.0.0.1:{port}")), + ) + .await; + + assert!(matches!(result, Err(Error::Network(_)))); + } + #[test] fn resolve_api_key_prefers_param_then_blank_falls_through() { assert_eq!(resolve_api_key(Some("sk-test")).unwrap(), "sk-test"); diff --git a/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs b/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs index ee181791413..9df3d0c6cc5 100644 --- a/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs +++ b/litellm-rust/crates/ai-gateway/src/io/responses_ws.rs @@ -326,6 +326,29 @@ mod tests { use tokio::net::TcpListener; use tokio_tungstenite::accept_async; + /// The Responses dial has to reach a `wss://` upstream without a process-wide + /// crypto provider installed, which is what dialing through `io::tls` buys. + #[tokio::test] + async fn dial_upstream_over_wss_reports_an_error_instead_of_panicking() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind a loopback port"); + let port = listener + .local_addr() + .expect("read the bound address") + .port(); + tokio::spawn(async move { + while let Ok((stream, _peer)) = listener.accept().await { + drop(stream); + } + }); + + let result = + dial_upstream("gpt-5", "sk-test", Some(&format!("wss://127.0.0.1:{port}"))).await; + + assert!(matches!(result, Err(Error::Network(_)))); + } + async fn websocket_base() -> (String, tokio::task::JoinHandle<()>) { let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind"); let address = listener.local_addr().expect("local address"); diff --git a/litellm-rust/crates/ai-gateway/src/io/tls.rs b/litellm-rust/crates/ai-gateway/src/io/tls.rs index 96544adffb7..16fd11e2e79 100644 --- a/litellm-rust/crates/ai-gateway/src/io/tls.rs +++ b/litellm-rust/crates/ai-gateway/src/io/tls.rs @@ -1,29 +1,54 @@ -//! Outbound WebSocket dials, with the rustls crypto provider settled first. +//! Outbound WebSocket dials over a TLS config this crate builds once and owns. //! //! `reqwest/rustls-tls` enables `rustls/ring` and `litellm-core`'s `bedrock-auth` -//! enables `rustls/aws-lc-rs`, so `ClientConfig::builder()` — which is how -//! `tokio-tungstenite` builds its TLS config — panics rather than guess between -//! them. reqwest and the AWS SDK pick a provider explicitly and never panic. -//! -//! Installing from the dial rather than from a `main` also covers the `cdylib` -//! the Python bridge loads, the tests, and the benches, none of which have one. -//! ring is what reqwest already falls back to, so installing it changes no -//! working path, and whoever installs into this rustls build first still wins. +//! enables `rustls/aws-lc-rs`, so the bare `ClientConfig::builder()` that +//! `tokio-tungstenite` uses when handed no connector panics rather than guess +//! between them. Naming ring on a connector of our own settles that for these +//! dials without touching the process-wide default, and building the config +//! once keeps the platform trust store, which `tokio-tungstenite` would +//! otherwise re-read on every dial, off the dial path. -use std::sync::Once; +use std::io; +use std::sync::{Arc, OnceLock}; +use rustls::{ClientConfig, RootCertStore}; use tokio::net::TcpStream; use tokio_tungstenite::tungstenite::Error; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::error::TlsError; use tokio_tungstenite::tungstenite::handshake::client::Response; -use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async}; +use tokio_tungstenite::{ + Connector, MaybeTlsStream, WebSocketStream, connect_async_tls_with_config, +}; -static INSTALL_CRYPTO_PROVIDER: Once = Once::new(); +static TLS_CONFIG: OnceLock> = OnceLock::new(); -pub(crate) fn ensure_crypto_provider() { - INSTALL_CRYPTO_PROVIDER.call_once(|| { - let _ = rustls::crypto::ring::default_provider().install_default(); - }); +fn build_config() -> Result> { + let native = rustls_native_certs::load_native_certs(); + let roots = { + let mut store = RootCertStore::empty(); + let (added, _ignored) = store.add_parsable_certificates(native.certs); + if added == 0 { + return Err(Box::new(Error::Io(io::Error::other(format!( + "no usable native root certificates: {:?}", + native.errors + ))))); + } + store + }; + + ClientConfig::builder_with_provider(Arc::new(rustls::crypto::ring::default_provider())) + .with_safe_default_protocol_versions() + .map(|builder| builder.with_root_certificates(roots).with_no_client_auth()) + .map_err(|error| Box::new(Error::Tls(TlsError::Rustls(error)))) +} + +fn tls_config() -> Result, Box> { + if let Some(config) = TLS_CONFIG.get() { + return Ok(Arc::clone(config)); + } + let built = Arc::new(build_config()?); + Ok(Arc::clone(TLS_CONFIG.get_or_init(|| built))) } pub(crate) async fn connect_upstream( @@ -32,19 +57,25 @@ pub(crate) async fn connect_upstream( where R: IntoClientRequest + Unpin, { - ensure_crypto_provider(); - connect_async(request).await.map_err(Box::new) + let request = request.into_client_request().map_err(Box::new)?; + let connector = match request.uri().scheme_str() { + Some("wss") => Some(Connector::Rustls(tls_config()?)), + _ => None, + }; + connect_async_tls_with_config(request, None, false, connector) + .await + .map_err(Box::new) } #[cfg(test)] mod tests { - use rustls::crypto::CryptoProvider; + use rustls::CipherSuite; + use rustls::NamedGroup; + use rustls::crypto::{CryptoProvider, aws_lc_rs, ring}; - use super::ensure_crypto_provider; + use super::{Arc, build_config, tls_config}; - fn fingerprint( - provider: &CryptoProvider, - ) -> (Vec, Vec) { + fn fingerprint(provider: &CryptoProvider) -> (Vec, Vec) { ( provider .cipher_suites @@ -60,43 +91,31 @@ mod tests { } #[test] - fn client_config_builder_works_with_both_provider_features_enabled() { - ensure_crypto_provider(); - - assert!(CryptoProvider::get_default().is_some()); - - let config = rustls::ClientConfig::builder() - .with_root_certificates(rustls::RootCertStore::empty()) - .with_no_client_auth(); + fn builds_a_usable_config_with_both_provider_features_enabled() { + let config = build_config().expect("a client config"); assert!(!config.crypto_provider().cipher_suites.is_empty()); } #[test] - fn installs_ring_rather_than_aws_lc_rs() { - ensure_crypto_provider(); - - let installed = CryptoProvider::get_default().expect("a provider is installed"); + fn dials_with_ring_rather_than_aws_lc_rs() { + let config = build_config().expect("a client config"); assert_eq!( - fingerprint(installed), - fingerprint(&rustls::crypto::ring::default_provider()) + fingerprint(config.crypto_provider()), + fingerprint(&ring::default_provider()) ); assert_ne!( - fingerprint(installed), - fingerprint(&rustls::crypto::aws_lc_rs::default_provider()) + fingerprint(config.crypto_provider()), + fingerprint(&aws_lc_rs::default_provider()) ); } #[test] - fn ensure_crypto_provider_is_idempotent() { - ensure_crypto_provider(); - let first = CryptoProvider::get_default().cloned(); + fn the_trust_store_is_loaded_once_and_shared() { + let first = tls_config().expect("a client config"); + let second = tls_config().expect("a client config"); - ensure_crypto_provider(); - let second = CryptoProvider::get_default().cloned(); - - assert!(first.is_some()); - assert!(std::sync::Arc::ptr_eq(&first.unwrap(), &second.unwrap())); + assert!(Arc::ptr_eq(&first, &second)); } } diff --git a/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs b/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs index db5698f8460..05f7d9610d5 100644 --- a/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs +++ b/litellm-rust/crates/ai-gateway/tests/crypto_provider_wiring.rs @@ -1,5 +1,6 @@ -//! Guards the wiring, not just the helper: the dial itself has to install the -//! rustls provider, in a test binary where nothing else has installed one. +//! Guards the wiring, not just the helper: a `wss://` dial through the public +//! API has to resolve its own crypto provider, in a test binary where nothing +//! has installed a process-wide one, and has to leave it uninstalled. use std::collections::HashMap; use std::time::Duration; @@ -7,8 +8,7 @@ use std::time::Duration; use litellm_ai_gateway::io::responses_ws::ResponsesWebSocketConnection; use tokio::net::TcpListener; -#[tokio::test] -async fn dialing_wss_returns_an_error_instead_of_panicking() { +async fn dead_tls_server() -> u16 { let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind a loopback port"); @@ -23,6 +23,13 @@ async fn dialing_wss_returns_an_error_instead_of_panicking() { } }); + port +} + +#[tokio::test] +async fn dialing_wss_returns_an_error_instead_of_panicking() { + let port = dead_tls_server().await; + let result = ResponsesWebSocketConnection::connect_url( &format!("wss://127.0.0.1:{port}/"), &HashMap::new(), @@ -35,7 +42,7 @@ async fn dialing_wss_returns_an_error_instead_of_panicking() { "a plain TCP server cannot finish a TLS handshake" ); assert!( - rustls::crypto::CryptoProvider::get_default().is_some(), - "the dial is what installs the process-wide provider" + rustls::crypto::CryptoProvider::get_default().is_none(), + "the dial settles its provider on its own connector, not process-wide" ); } From 51514b9123a6569a0857bd7e941726c16def9bd2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:29:55 -0700 Subject: [PATCH 139/410] fix(cost-map): azure/gpt-6-astra accepts reasoning_effort none on Foundry --- ...odel_prices_and_context_window_backup.json | 4 +-- model_prices_and_context_window.json | 4 +-- .../chat/test_azure_gpt5_transformation.py | 28 +++++++++++++++++++ .../test_reasoning_effort_capability.py | 8 ++++-- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index d8b7287eba7..2459ed940e0 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -7192,7 +7192,7 @@ "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": false, + "supports_none_reasoning_effort": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -7458,7 +7458,7 @@ "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": false, + "supports_none_reasoning_effort": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index d8b7287eba7..2459ed940e0 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -7192,7 +7192,7 @@ "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": false, + "supports_none_reasoning_effort": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, @@ -7458,7 +7458,7 @@ "supports_max_reasoning_effort": true, "supports_minimal_reasoning_effort": false, "supports_native_streaming": true, - "supports_none_reasoning_effort": false, + "supports_none_reasoning_effort": true, "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, diff --git a/tests/test_litellm/llms/azure/chat/test_azure_gpt5_transformation.py b/tests/test_litellm/llms/azure/chat/test_azure_gpt5_transformation.py index bd0f16a695b..3b3ef1a9cd4 100644 --- a/tests/test_litellm/llms/azure/chat/test_azure_gpt5_transformation.py +++ b/tests/test_litellm/llms/azure/chat/test_azure_gpt5_transformation.py @@ -348,3 +348,31 @@ def test_azure_gpt_6_astra_takes_the_reasoning_series_request_shape(): assert params["max_completion_tokens"] == 100 assert "max_tokens" not in params assert params["reasoning_effort"] == "max" + + +@pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra"]) +def test_azure_gpt6_astra_reasoning_effort_none_unlocks_temperature(config: AzureOpenAIGPT5Config, model: str): + """Foundry's gpt-6-astra accepts reasoning_effort='none' and, only then, a non-default + temperature (verified live against a Foundry deployment), unlike OpenAI's gpt-6-astra.""" + params = config.map_openai_params( + non_default_params={"temperature": 0.2, "reasoning_effort": "none"}, + optional_params={}, + model=model, + drop_params=False, + api_version="2025-04-01-preview", + ) + assert params["temperature"] == 0.2 + assert params["reasoning_effort"] == "none" + + +@pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra"]) +def test_azure_gpt6_astra_rejects_reasoning_effort_minimal(config: AzureOpenAIGPT5Config, model: str): + """Foundry's gpt-6-astra lists none, low, medium, high, xhigh and max but not minimal.""" + with pytest.raises(litellm.utils.UnsupportedParamsError): + config.map_openai_params( + non_default_params={"reasoning_effort": "minimal"}, + optional_params={}, + model=model, + drop_params=False, + api_version="2025-04-01-preview", + ) diff --git a/tests/test_litellm/router_utils/test_reasoning_effort_capability.py b/tests/test_litellm/router_utils/test_reasoning_effort_capability.py index 504e87fb231..f181370455d 100644 --- a/tests/test_litellm/router_utils/test_reasoning_effort_capability.py +++ b/tests/test_litellm/router_utils/test_reasoning_effort_capability.py @@ -390,14 +390,16 @@ class TestGpt6AstraAdvertisesItsDocumentedLevels: ) @pytest.mark.parametrize("model", ["azure/gpt-6-astra", "azure/us/gpt-6-astra"]) - def test_a_foundry_deployment_advertises_the_same_levels(self, local_model_cost_map, model): - """Microsoft Foundry serves the same model, so an Azure deployment must offer low - through max and never none, exactly like the OpenAI entry.""" + def test_a_foundry_deployment_also_advertises_none(self, local_model_cost_map, model): + """Microsoft Foundry serves the same model but its API accepts reasoning_effort none + (verified live: 200 with zero reasoning tokens, and it unlocks temperature), which + OpenAI's rejects, so an Azure deployment offers none on top of low through max.""" from litellm.utils import _get_model_info_helper model_info = dict(_get_model_info_helper(model=model, custom_llm_provider="azure")) assert resolve_supported_reasoning_efforts(model_info, deployment_is_mapped=True) == ( + "none", "low", "medium", "high", From 59d42d36e6a0a9a81baef774ad900a8e9889722b Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Fri, 4 Sep 2026 17:34:25 -0700 Subject: [PATCH 140/410] fix(headroom): bound the /v1/compress and /v1/retrieve calls with a timeout (#39527) * fix(headroom): bound the /v1/compress and /v1/retrieve calls with a timeout The headroom guardrail builds its client with get_async_httpx_client(GuardrailCallback) and no params, and passes no timeout on either outbound call. That client's read, write and pool legs are 600s (litellm.request_timeout when set explicitly, default 6000s), so an unreachable or stalled compression service holds the caller's pre-call request open for the whole window before unreachable_fallback ever runs. Because the client is shared with every other no-params guardrail, each stalled call also pins a pooled connection for the same window, so a saturated pool makes unrelated requests block on the pool leg. Bound both calls at 60s by default, honoring litellm_params.timeout when set (the field already exists and documents itself as the per-guardrail API timeout; headroom accepted it and ignored it). The connect leg stays at the http_handler default, or the configured budget when that is shorter, so a dead host still fails fast. Live on a proxy against a stalled /v1/compress: 600.4s -> 60.2s before the 502, and 5.2s with timeout: 5 configured. * fix(headroom): reject non-finite timeouts and trim the timeout commentary `timeout: .inf` on a Headroom guardrail reached httpx and the aiohttp transport raised OverflowError, so every request came back as a raw 500 instead of going through unreachable_fallback. Reject non-finite values the same way as non-positive ones, and cut the comments and docstrings back to what the code does not already say. --- .../guardrail_hooks/headroom/__init__.py | 1 + .../guardrail_hooks/headroom/headroom.py | 33 ++++ .../guardrail_hooks/test_headroom.py | 156 +++++++++++++++++- 3 files changed, 187 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/guardrails/guardrail_hooks/headroom/__init__.py b/litellm/proxy/guardrails/guardrail_hooks/headroom/__init__.py index cffef84e966..d569802ce89 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/headroom/__init__.py +++ b/litellm/proxy/guardrails/guardrail_hooks/headroom/__init__.py @@ -35,6 +35,7 @@ def initialize_guardrail(litellm_params: LitellmParams, guardrail: Guardrail) -> event_hook=_coerce_event_hook(litellm_params.mode), default_on=litellm_params.default_on or False, unreachable_fallback=litellm_params.unreachable_fallback, + timeout=litellm_params.timeout, ) litellm.logging_callback_manager.add_litellm_callback( # pyright: ignore[reportUnknownMemberType] _callback diff --git a/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py b/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py index 9d993384461..685b90f1754 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py +++ b/litellm/proxy/guardrails/guardrail_hooks/headroom/headroom.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import math import re import time import uuid @@ -15,6 +16,7 @@ from pydantic import TypeAdapter import litellm from litellm._logging import verbose_proxy_logger from litellm.compression.compress import get_protected_indices +from litellm.constants import HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS from litellm.integrations.custom_guardrail import ( CustomGuardrail, log_guardrail_information, @@ -47,12 +49,16 @@ from litellm.types.utils import CallTypes, GenericGuardrailAPIInputs if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + from litellm.types.guardrails import LitellmParams from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel BYPASS_HEADER: Final = "x-headroom-bypass" _STREAM_CONVERTIBLE_CALL_TYPES: Final = frozenset( (CallTypes.completion, CallTypes.acompletion, CallTypes.responses, CallTypes.aresponses) ) +# The shared GuardrailCallback client carries no per-call bound, so without this a +# stalled service holds the caller's request and a pooled connection for 600s or more. +_COMPRESS_TIMEOUT_SECONDS: Final = 60.0 HEADROOM_RETRIEVE_TOOL_NAME: Final = "headroom_retrieve" _HASH_PATTERN: Final = re.compile(r"hash=([a-f0-9]{24})") _HASH_CACHE_TTL_SECONDS: Final = 15 * 60 @@ -472,6 +478,7 @@ class HeadroomGuardrail(CustomGuardrail): event_hook: GuardrailEventHooks | list[GuardrailEventHooks] | Mode | None = None, default_on: bool = False, unreachable_fallback: str | None = None, + timeout: float | None = None, ): self.headroom_api_base = (api_base or get_secret_str("HEADROOM_API_BASE") or "").rstrip("/") if not self.headroom_api_base: @@ -484,6 +491,7 @@ class HeadroomGuardrail(CustomGuardrail): self.unreachable_fallback: Literal["fail_closed", "fail_open"] = ( "fail_open" if unreachable_fallback == "fail_open" else "fail_closed" ) + self.timeout: httpx.Timeout = self._resolve_timeout(timeout) self.async_handler = get_async_httpx_client( llm_provider=httpxSpecialProvider.GuardrailCallback, ) @@ -511,6 +519,29 @@ class HeadroomGuardrail(CustomGuardrail): headers["Authorization"] = f"Bearer {self.headroom_api_key}" return headers + @staticmethod + def _resolve_timeout(timeout: float | None) -> httpx.Timeout: + """Budget for one call to the compression service, unset meaning the default. + + Zero, negative and non-finite values are rejected instead of passed through: + httpx accepts them, and the transport then reads 0 and inf as no deadline at + all and a negative one as a deadline already past. + """ + rejected: Final = timeout is not None and not (math.isfinite(timeout) and timeout > 0) + if rejected: + verbose_proxy_logger.warning( + "Headroom: ignoring unusable timeout %s, using %s seconds", + timeout, + _COMPRESS_TIMEOUT_SECONDS, + ) + seconds: Final = _COMPRESS_TIMEOUT_SECONDS if timeout is None or rejected else timeout + return httpx.Timeout(timeout=seconds, connect=min(seconds, HTTP_HANDLER_CONNECT_TIMEOUT_SECONDS)) + + def update_in_memory_litellm_params(self, litellm_params: LitellmParams) -> None: + """Re-resolve the timeout, which the base implementation would otherwise null out.""" + super().update_in_memory_litellm_params(litellm_params) + self.timeout = self._resolve_timeout(litellm_params.timeout) + def _prune_expired_hashes(self) -> None: now: Final = time.monotonic() self._issued_hashes_by_call_id = { @@ -548,6 +579,7 @@ class HeadroomGuardrail(CustomGuardrail): url=f"{self.headroom_api_base}/v1/compress", json=payload, headers=self._request_headers(), + timeout=self.timeout, ) except httpx.HTTPStatusError as e: return ( @@ -685,6 +717,7 @@ class HeadroomGuardrail(CustomGuardrail): url=f"{self.headroom_api_base}/v1/retrieve/{hash_value}", params=params, headers=self._request_headers(), + timeout=self.timeout, ) except (httpx.ConnectError, httpx.TimeoutException, httpx.TransportError, litellm.Timeout) as e: verbose_proxy_logger.warning("Headroom: retrieve failed for hash=%s: %s", hash_value, e) diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py index 400eaf8ab3f..a49d7723bcc 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_headroom.py @@ -1694,8 +1694,6 @@ async def test_apply_guardrail_litellm_timeout_fail_open_forwards_uncompressed() assert result["structured_messages"] == ORIGINAL_MESSAGES - - # --------------------------------------------------------------------------- # Content-parts flattening (LIT-4795) # @@ -2669,7 +2667,9 @@ async def _plan_for(guardrail: HeadroomGuardrail, response, messages: list): return_value=_make_retrieve_response("ORIGINAL CONTENT"), ): return await guardrail.async_build_agentic_loop_plan( - tools={"tool_calls": [{"id": "call_1", "name": HEADROOM_RETRIEVE_TOOL_NAME, "arguments": {"hash": "h" * 24}}]}, + tools={ + "tool_calls": [{"id": "call_1", "name": HEADROOM_RETRIEVE_TOOL_NAME, "arguments": {"hash": "h" * 24}}] + }, model="claude-sonnet-4-5-20250929", messages=messages, response=response, @@ -2732,3 +2732,153 @@ async def test_chat_followup_echoes_only_the_retrieve_call(guardrail: HeadroomGu assert assistant["content"] == "Getting the original first." assert [tc["id"] for tc in assistant["tool_calls"]] == ["call_1"] assert [m["tool_call_id"] for m in messages[2:]] == ["call_1"] + + +# --- LIT-5881: the calls to the compression service must be time-bounded --- + + +def _timeout_of(mock_call) -> httpx.Timeout: + timeout = mock_call.kwargs["timeout"] + assert isinstance(timeout, httpx.Timeout), timeout + return timeout + + +@pytest.mark.asyncio +async def test_compress_call_passes_bounded_timeout(guardrail: HeadroomGuardrail): + """Without an explicit timeout the call inherits the shared client's 600s read leg.""" + inputs = GenericGuardrailAPIInputs(texts=["A" * 5000], structured_messages=ORIGINAL_MESSAGES) + + with patch.object( + guardrail.async_handler, + "post", + new_callable=AsyncMock, + return_value=_make_compress_response(COMPRESSED_MESSAGES), + ) as mock_post: + await guardrail.apply_guardrail(inputs=inputs, request_data={}, input_type="request") + + timeout = _timeout_of(mock_post.call_args) + assert timeout.read == 60.0 + assert timeout.write == 60.0 + assert timeout.pool == 60.0 + assert timeout.connect == 5.0 + + +@pytest.mark.asyncio +async def test_retrieve_call_passes_bounded_timeout(guardrail: HeadroomGuardrail): + """The retrieval leg runs on the same request and needs the same bound.""" + with patch.object( + guardrail.async_handler, + "get", + new_callable=AsyncMock, + return_value=_make_retrieve_response("original"), + ) as mock_get: + result = await guardrail._call_retrieve("a" * 24) + + assert result == "original" + timeout = _timeout_of(mock_get.call_args) + assert timeout.read == 60.0 + assert timeout.connect == 5.0 + + +@pytest.mark.asyncio +async def test_configured_timeout_overrides_the_default(): + """Headroom accepted litellm_params.timeout and ignored it.""" + guardrail = _make_guardrail(timeout=3.5) + inputs = GenericGuardrailAPIInputs(texts=["A" * 5000], structured_messages=ORIGINAL_MESSAGES) + + with patch.object( + guardrail.async_handler, + "post", + new_callable=AsyncMock, + return_value=_make_compress_response(COMPRESSED_MESSAGES), + ) as mock_post: + await guardrail.apply_guardrail(inputs=inputs, request_data={}, input_type="request") + + timeout = _timeout_of(mock_post.call_args) + assert timeout.read == 3.5 + assert timeout.connect == 3.5 + + +@pytest.mark.asyncio +async def test_read_timeout_is_surfaced_as_unreachable_under_fail_closed(): + """A stalled service must reach the fail policy, not escape as a 500.""" + guardrail = _make_guardrail() + inputs = GenericGuardrailAPIInputs(texts=["hello"], structured_messages=ORIGINAL_MESSAGES) + + with patch.object( + guardrail.async_handler, + "post", + new_callable=AsyncMock, + side_effect=httpx.ReadTimeout("timed out"), + ): + with pytest.raises(HTTPException) as exc_info: + await guardrail.apply_guardrail(inputs=inputs, request_data={}, input_type="request") + + assert exc_info.value.status_code == 502 + assert "unreachable" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_read_timeout_forwards_uncompressed_under_fail_open(): + guardrail = _make_guardrail(unreachable_fallback="fail_open") + inputs = GenericGuardrailAPIInputs(texts=["hello"], structured_messages=ORIGINAL_MESSAGES) + + with patch.object( + guardrail.async_handler, + "post", + new_callable=AsyncMock, + side_effect=httpx.ReadTimeout("timed out"), + ): + result = await guardrail.apply_guardrail(inputs=inputs, request_data={}, input_type="request") + + assert result.get("structured_messages") == ORIGINAL_MESSAGES + + +def test_initializer_forwards_configured_timeout(monkeypatch: pytest.MonkeyPatch): + """Wiring it only in __init__ leaves `timeout:` in config.yaml silently ignored.""" + from litellm.proxy.guardrails.guardrail_hooks.headroom import initialize_guardrail + from litellm.types.guardrails import LitellmParams + + monkeypatch.setattr( + litellm.logging_callback_manager, + "add_litellm_callback", + lambda callback: None, + ) + params = LitellmParams( + guardrail="headroom", + mode="pre_call", + api_base=FAKE_API_BASE, + api_key=FAKE_API_KEY, + timeout=7.0, + ) + callback = initialize_guardrail(params, {"guardrail_name": "headroom"}) # type: ignore[arg-type] + + assert callback.timeout.read == 7.0 + + +def test_in_place_update_keeps_the_timeout_resolved(): + """The base implementation copies every attribute over, nulling an unset timeout.""" + from litellm.types.guardrails import LitellmParams + + guardrail = _make_guardrail(timeout=5.0) + assert guardrail.timeout.read == 5.0 + + guardrail.update_in_memory_litellm_params( + LitellmParams(guardrail="headroom", mode="pre_call", api_base=FAKE_API_BASE) + ) + assert isinstance(guardrail.timeout, httpx.Timeout) + assert guardrail.timeout.read == 60.0 + + guardrail.update_in_memory_litellm_params( + LitellmParams(guardrail="headroom", mode="pre_call", api_base=FAKE_API_BASE, timeout=7.0) + ) + assert guardrail.timeout.read == 7.0 + + +@pytest.mark.parametrize("configured", [0, 0.0, -1, -30.0, float("inf"), float("-inf"), float("nan")]) +def test_unusable_timeout_falls_back_to_the_default(configured: float): + """0 and inf read as no deadline at all, a negative one as a deadline already past.""" + guardrail = _make_guardrail(timeout=configured) + + assert guardrail.timeout.read == 60.0 + assert guardrail.timeout.connect == 5.0 From 7a9f466657c457b6c2eca7cceb47b2862db934ad Mon Sep 17 00:00:00 2001 From: yassin Date: Sat, 5 Sep 2026 00:37:04 +0000 Subject: [PATCH 141/410] fix(cli): satisfy pi type discipline budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/client/cli/commands/agents.py | 10 ++-- litellm/proxy/client/cli/commands/pi.py | 53 ++++++++++++------- .../proxy/client/cli/test_agents.py | 2 +- 3 files changed, 41 insertions(+), 24 deletions(-) diff --git a/litellm/proxy/client/cli/commands/agents.py b/litellm/proxy/client/cli/commands/agents.py index d9b8117c994..3dace0b94f4 100644 --- a/litellm/proxy/client/cli/commands/agents.py +++ b/litellm/proxy/client/cli/commands/agents.py @@ -154,7 +154,7 @@ def prepare_pi( base_env: Mapping[str, str], *, get: Callable[..., requests.Response] = requests.get, -) -> list[str]: +) -> tuple[str, ...]: """Sync the proxy's model list into pi's models.json before handoff. pi has no base-URL env vars, so this file is the only way to point it at the @@ -172,14 +172,14 @@ def prepare_pi( if error is not None: raise AgentRunError(error.message) click.echo(f"litellm: synced {len(ids)} proxy models into {path}") - return ["--model", f"{PI_PROVIDER_NAME}/{ids[0]}"] + return ("--model", f"{PI_PROVIDER_NAME}/{ids[0]}") _Preparer: TypeAlias = Callable[[str, str, Mapping[str, str]], Sequence[str]] -_PREPARERS: Final[dict[str, _Preparer]] = { - "pi": prepare_pi, -} +_PREPARERS: Final[Mapping[str, _Preparer]] = MappingProxyType( + {"pi": prepare_pi} # mutable-ok: MappingProxyType freezes the provider registry +) def agent_launch_args(command: str, base_url: str) -> list[str]: diff --git a/litellm/proxy/client/cli/commands/pi.py b/litellm/proxy/client/cli/commands/pi.py index b3b9d520a5a..7b0c1970c4e 100644 --- a/litellm/proxy/client/cli/commands/pi.py +++ b/litellm/proxy/client/cli/commands/pi.py @@ -38,7 +38,7 @@ class _Model(BaseModel): class _ModelList(BaseModel): - data: list[_Model] + data: tuple[_Model, ...] class _ModelGroup(BaseModel): @@ -48,7 +48,7 @@ class _ModelGroup(BaseModel): class _ModelGroupList(BaseModel): - data: list[_ModelGroup] + data: tuple[_ModelGroup, ...] def fetch_model_ids( @@ -59,7 +59,11 @@ def fetch_model_ids( ) -> tuple[str, ...] | PiSyncError: url: Final = base_url.rstrip("/") + "/v1/models" try: - resp: Final = get(url, headers={"Authorization": f"Bearer {api_key}"}, timeout=10) + resp: Final = get( + url, + headers={"Authorization": f"Bearer {api_key}"}, # mutable-ok: requests headers require a dict + timeout=10, + ) except requests.RequestException as e: return PiSyncError(f"Could not list models from the proxy: {e}") if resp.status_code != 200: @@ -87,7 +91,11 @@ def fetch_model_limits( so an unavailable /model_group/info must not block the launch.""" url: Final = base_url.rstrip("/") + "/model_group/info" try: - resp: Final = get(url, headers={"Authorization": f"Bearer {api_key}"}, timeout=10) + resp: Final = get( + url, + headers={"Authorization": f"Bearer {api_key}"}, # mutable-ok: requests headers require a dict + timeout=10, + ) if resp.status_code != 200: return _NO_LIMITS listing: Final = _ModelGroupList.model_validate(resp.json()) @@ -110,30 +118,34 @@ def models_json_path(env: Mapping[str, str]) -> Path: return root / "models.json" -def _model_entry(model_id: str, limits: Mapping[str, ModelLimits]) -> dict[str, JsonValue]: +def _model_entry( + model_id: str, limits: Mapping[str, ModelLimits] +) -> dict[str, JsonValue]: # mutable-ok: JSON object is serialized limit: Final = limits.get(model_id) - context: Final[dict[str, JsonValue]] = ( - {"contextWindow": limit.context_window} if limit and limit.context_window else {} + context: Final[dict[str, JsonValue]] = ( # mutable-ok: JSON field + {"contextWindow": limit.context_window} if limit and limit.context_window else {} # mutable-ok: JSON field ) - output: Final[dict[str, JsonValue]] = {"maxTokens": limit.max_tokens} if limit and limit.max_tokens else {} - return {"id": model_id, **context, **output} + output: Final[dict[str, JsonValue]] = ( # mutable-ok: JSON field + {"maxTokens": limit.max_tokens} if limit and limit.max_tokens else {} + ) # mutable-ok: JSON field + return {"id": model_id, **context, **output} # mutable-ok: JSON serialization requires a mutable object def provider_block( base_url: str, model_ids: tuple[str, ...], limits: Mapping[str, ModelLimits] = _NO_LIMITS, -) -> dict[str, JsonValue]: +) -> dict[str, JsonValue]: # mutable-ok: JSON object is serialized """openai-completions is the one API shape every LiteLLM model serves. Real contextWindow/maxTokens matter: pi otherwise assumes 128k/16384, which breaks compaction thresholds and over-asks models with smaller output caps. """ - return { + return { # mutable-ok: JSON serialization requires a mutable object "baseUrl": base_url.rstrip("/") + "/v1", "api": "openai-completions", "apiKey": f"${LITELLM_PROXY_API_KEY_ENV}", - "models": [_model_entry(model_id, limits) for model_id in model_ids], + "models": [_model_entry(model_id, limits) for model_id in model_ids], # mutable-ok: JSON array } @@ -148,15 +160,20 @@ def sync_models_json( ) -> PiSyncError | None: """Replace only the litellm provider entry, leaving the rest of the file intact.""" try: - current: Final = _MODELS_FILE_ADAPTER.validate_json(path.read_text()) if path.exists() else {} + current: Final = ( # mutable-ok: JSON object default + _MODELS_FILE_ADAPTER.validate_json(path.read_text()) if path.exists() else {} + ) except (OSError, ValidationError) as e: return PiSyncError(f"Could not read {path} as a JSON object: {e}. Fix or move the file, then retry.") - existing_providers: Final = current.get("providers", {}) + existing_providers: Final = current.get("providers", {}) # mutable-ok: JSON object default if not isinstance(existing_providers, dict): return PiSyncError(f'"providers" in {path} is not an object; fix or move the file, then retry.') - updated: Final = { + updated: Final = { # mutable-ok: JSON serialization requires a mutable object **current, - "providers": {**existing_providers, PI_PROVIDER_NAME: provider_block(base_url, model_ids, limits)}, + "providers": { # mutable-ok: JSON serialization requires a mutable object + **existing_providers, + PI_PROVIDER_NAME: provider_block(base_url, model_ids, limits), + }, } try: path.parent.mkdir(parents=True, exist_ok=True) @@ -179,7 +196,7 @@ def sync_models_json( return None -__all__ = [ +__all__ = ( "LITELLM_PROXY_API_KEY_ENV", "PI_CONFIG_DIR_ENV", "PI_PROVIDER_NAME", @@ -190,4 +207,4 @@ __all__ = [ "models_json_path", "provider_block", "sync_models_json", -] +) diff --git a/tests/test_litellm/proxy/client/cli/test_agents.py b/tests/test_litellm/proxy/client/cli/test_agents.py index bd18a5c6f94..a64adffb3a1 100644 --- a/tests/test_litellm/proxy/client/cli/test_agents.py +++ b/tests/test_litellm/proxy/client/cli/test_agents.py @@ -625,7 +625,7 @@ class TestRunAgent: get=fake_get, ) - assert pin == ["--model", "litellm/m-first"] + assert pin == ("--model", "litellm/m-first") import json written = json.loads((tmp_path / "models.json").read_text()) From 89086db28233514c3cc07333fcffdcd974cc8573 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:39:46 -0700 Subject: [PATCH 142/410] fix(proxy): floor end-user budget checks on the DB row after a reset The reset job evicts the cached end-user object only from its own worker's in-memory cache (plus Redis), so every other uvicorn worker and replica keeps the pre-reset spend for up to user_api_key_cache_ttl (60s by default). Those workers pass that stale spend as fallback_spend, and since the authoritative floor read returned None for spend:end_user: keys, get_current_spend handed the stale value straight back and the end user kept getting 429 after the rollover on every worker but the one that ran the reset. The floor read now consults LiteLLM_EndUserTable.spend for end-user counters, the same way keys, teams, users, and orgs already read their rows. It runs only when the shared counter sits below the cached spend (a reset or a Redis restart) and stays behind the existing 5s in-process marker, so the normal request path still does no DB read. Cold end-user counters keep seeding from the cached object rather than the row, so from_db is unchanged for them. --- litellm/proxy/db/spend_counter_reseed.py | 25 +++++- litellm/proxy/proxy_server.py | 46 +++++++---- .../proxy/db/test_spend_counter_reseed.py | 69 +++++++++++++++- .../proxy/proxy_server/test_spend_counters.py | 79 +++++++++++++++++-- 4 files changed, 196 insertions(+), 23 deletions(-) diff --git a/litellm/proxy/db/spend_counter_reseed.py b/litellm/proxy/db/spend_counter_reseed.py index 7b3c261036e..a38b8a47dbd 100644 --- a/litellm/proxy/db/spend_counter_reseed.py +++ b/litellm/proxy/db/spend_counter_reseed.py @@ -26,6 +26,7 @@ from litellm.proxy._types import Litellm_EntityType from litellm.repositories.organization_repository import OrganizationRepository from litellm.repositories.table_repositories import ( BudgetWindowSpendRepository, + EndUserRepository, SpendLogsRepository, TeamMembershipRepository, ) @@ -36,6 +37,8 @@ from litellm.repositories.verification_token_repository import ( ) if TYPE_CHECKING: + from prisma.types import LiteLLM_EndUserTableWhereUniqueInput + from litellm.caching.dual_cache import DualCache from litellm.proxy.utils import PrismaClient @@ -47,6 +50,8 @@ _WINDOW_SPEND_ENTITY_TYPES: Final[Mapping[str, str]] = MappingProxyType( } ) +END_USER_COUNTER_PREFIX: Final = "spend:end_user:" + _WINDOW_SPEND_LOG_FIELDS: Final[Mapping[str, str]] = MappingProxyType( { "Key": "api_key", @@ -74,6 +79,10 @@ class SpendCounterReseed: End-user and tag spend counters intentionally do not reseed here. Their auth paths already load the corresponding objects via get_end_user_object() and get_tag_objects_batch(); callers pass those values as fallback_spend. + end_user_from_db is the one end-user read, used only as the budget floor when + a counter sits below that cached spend: a worker that did not run the budget + reset still caches the pre-reset end-user object, and LiteLLM_EndUserTable + is the row the reset zeroed. """ _locks: ClassVar["OrderedDict[str, asyncio.Lock]"] = OrderedDict() @@ -129,7 +138,7 @@ class SpendCounterReseed: elif counter_key.startswith("spend:user:"): user_id = counter_key[len("spend:user:") :] row = await UserRepository(prisma_client).table.find_unique(where={"user_id": user_id}) - elif counter_key.startswith("spend:end_user:") or counter_key.startswith("spend:tag:"): + elif counter_key.startswith(END_USER_COUNTER_PREFIX) or counter_key.startswith("spend:tag:"): return None elif counter_key.startswith("spend:org:"): org_id: Final = counter_key[len("spend:org:") :] @@ -143,6 +152,20 @@ class SpendCounterReseed: return None return float(getattr(row, "spend", 0.0) or 0.0) + @staticmethod + async def end_user_from_db(prisma_client: Optional["PrismaClient"], counter_key: str) -> float | None: + if prisma_client is None or not counter_key.startswith(END_USER_COUNTER_PREFIX): + return None + where: Final[LiteLLM_EndUserTableWhereUniqueInput] = {"user_id": counter_key[len(END_USER_COUNTER_PREFIX) :]} + try: + row: Final = await EndUserRepository(prisma_client).table.find_unique(where=where) + except Exception: # noqa: BLE001 # a failed floor read falls back to the cached spend, like from_db + verbose_proxy_logger.exception("SpendCounterReseed.end_user_from_db: failed for %s", counter_key) + return None + if row is None: + return None + return float(row.spend or 0.0) + @staticmethod def _is_key_or_team_window_counter(counter_key: str) -> bool: for prefix in ("spend:key:", "spend:team:"): diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 1f39a78e12a..47c0811d903 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -423,7 +423,7 @@ from litellm.proxy.db.proxy_worker_heartbeat import ( PROXY_WORKER_HEARTBEAT_INTERVAL_SECONDS, ProxyWorkerHeartbeat, ) -from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed +from litellm.proxy.db.spend_counter_reseed import END_USER_COUNTER_PREFIX, SpendCounterReseed from litellm.proxy.discovery_endpoints import ui_discovery_endpoints_router from litellm.proxy.fine_tuning_endpoints.endpoints import router as fine_tuning_router from litellm.proxy.fine_tuning_endpoints.endpoints import set_fine_tuning_config @@ -2580,6 +2580,29 @@ async def reseed_spend_counter_from_db(counter_key: str) -> None: await _repair_stale_spend_counter(counter_key=counter_key, db_spend=db_spend) +async def _floor_spend_from_db( + counter_key: str, + window_entity_type: str | None, + window_entity_id: str | None, + window_duration: str | None, + window_start: datetime | None, +) -> float | None: + if counter_key.startswith(END_USER_COUNTER_PREFIX): + return await SpendCounterReseed.end_user_from_db(prisma_client=prisma_client, counter_key=counter_key) + entity_spend: Final = await SpendCounterReseed.from_db(prisma_client=prisma_client, counter_key=counter_key) + if entity_spend is not None: + return entity_spend + if window_entity_type is None or window_entity_id is None or window_start is None: + return None + return await SpendCounterReseed.window_from_db( + prisma_client=prisma_client, + entity_type=window_entity_type, + entity_id=window_entity_id, + window_duration=window_duration, + window_start=window_start, + ) + + async def _authoritative_floor_spend( counter_key: str, window_entity_type: str | None = None, @@ -2592,20 +2615,13 @@ async def _authoritative_floor_spend( if cached is not None: return float(cached) - db_spend = await SpendCounterReseed.from_db(prisma_client=prisma_client, counter_key=counter_key) - if ( - db_spend is None - and window_entity_type is not None - and window_entity_id is not None - and window_start is not None - ): - db_spend = await SpendCounterReseed.window_from_db( - prisma_client=prisma_client, - entity_type=window_entity_type, - entity_id=window_entity_id, - window_duration=window_duration, - window_start=window_start, - ) + db_spend: Final = await _floor_spend_from_db( + counter_key=counter_key, + window_entity_type=window_entity_type, + window_entity_id=window_entity_id, + window_duration=window_duration, + window_start=window_start, + ) if db_spend is None: return None diff --git a/tests/test_litellm/proxy/db/test_spend_counter_reseed.py b/tests/test_litellm/proxy/db/test_spend_counter_reseed.py index 816f9ae72f4..3bd6d93328d 100644 --- a/tests/test_litellm/proxy/db/test_spend_counter_reseed.py +++ b/tests/test_litellm/proxy/db/test_spend_counter_reseed.py @@ -18,7 +18,7 @@ from litellm.proxy.db.spend_counter_reseed import SpendCounterReseed WINDOW_START = datetime(2026, 8, 1, tzinfo=timezone.utc) -class _FakeWindowSpendTable: +class _FakeFindUniqueTable: def __init__(self, row: SimpleNamespace | None, error: Exception | None = None) -> None: self._row = row self._error = error @@ -47,10 +47,13 @@ class _FakePrismaClient: row: SimpleNamespace | None = None, spend_logs_total: float = 0.0, error: Exception | None = None, + end_user_row: SimpleNamespace | None = None, + end_user_error: Exception | None = None, ) -> None: self.db = SimpleNamespace( - litellm_budgetwindowspend=_FakeWindowSpendTable(row=row, error=error), + litellm_budgetwindowspend=_FakeFindUniqueTable(row=row, error=error), litellm_spendlogs=_FakeSpendLogsTable(total=spend_logs_total), + litellm_endusertable=_FakeFindUniqueTable(row=end_user_row, error=end_user_error), ) @@ -248,3 +251,65 @@ async def test_coalesced_window_seeds_a_cold_counter_from_the_row(): assert result == 4.5 assert cache.in_memory_cache.get_cache(key=counter_key) == 4.5 assert prisma.db.litellm_spendlogs.call_count == 0 + + +@pytest.mark.asyncio +async def test_end_user_from_db_reads_the_end_user_row_by_user_id(): + prisma = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="customer-42", spend=0.0)) + + result = await SpendCounterReseed.end_user_from_db( + prisma_client=prisma, counter_key="spend:end_user:customer-42" + ) + + assert result == 0.0 + assert prisma.db.litellm_endusertable.where_clauses == [{"user_id": "customer-42"}] + + +@pytest.mark.asyncio +async def test_end_user_from_db_returns_the_recorded_spend(): + prisma = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="customer-42", spend=12.5)) + + assert ( + await SpendCounterReseed.end_user_from_db(prisma_client=prisma, counter_key="spend:end_user:customer-42") + == 12.5 + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("counter_key", ["spend:key:hashed", "spend:team:t1", "spend:tag:t1"]) +async def test_end_user_from_db_ignores_other_counter_kinds_without_touching_the_db(counter_key): + prisma = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="x", spend=5.0)) + + assert await SpendCounterReseed.end_user_from_db(prisma_client=prisma, counter_key=counter_key) is None + assert prisma.db.litellm_endusertable.where_clauses == [] + + +@pytest.mark.asyncio +async def test_end_user_from_db_returns_none_without_a_row_a_client_or_on_db_error(): + assert ( + await SpendCounterReseed.end_user_from_db(prisma_client=None, counter_key="spend:end_user:customer-42") + is None + ) + assert ( + await SpendCounterReseed.end_user_from_db( + prisma_client=_FakePrismaClient(end_user_row=None), counter_key="spend:end_user:customer-42" + ) + is None + ) + assert ( + await SpendCounterReseed.end_user_from_db( + prisma_client=_FakePrismaClient(end_user_error=RuntimeError("db down")), + counter_key="spend:end_user:customer-42", + ) + is None + ) + + +@pytest.mark.asyncio +async def test_from_db_still_never_reads_the_end_user_row(): + """A cold end-user counter keeps seeding from the cached end-user object the auth + path already loaded; the row is read only as the budget floor.""" + prisma = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="customer-42", spend=5.0)) + + assert await SpendCounterReseed.from_db(prisma_client=prisma, counter_key="spend:end_user:customer-42") is None + assert prisma.db.litellm_endusertable.where_clauses == [] diff --git a/tests/test_litellm/proxy/proxy_server/test_spend_counters.py b/tests/test_litellm/proxy/proxy_server/test_spend_counters.py index fb3de990deb..7cc1390fcbd 100644 --- a/tests/test_litellm/proxy/proxy_server/test_spend_counters.py +++ b/tests/test_litellm/proxy/proxy_server/test_spend_counters.py @@ -223,21 +223,90 @@ async def test_get_current_spend_floor_caches_db_read(monkeypatch): @pytest.mark.asyncio async def test_get_current_spend_floors_end_user_tag_against_fallback(monkeypatch): - """End-user and tag counters have no DB row (from_db returns None). When the - counter is stale-low, enforcement falls back to the caller's recorded spend - (loaded fresh in auth) instead of trusting the stale counter.""" + """Tag counters have no DB row (from_db returns None), and an end-user counter has + none to read without a DB client. When such a counter is stale-low, enforcement + falls back to the caller's recorded spend (loaded fresh in auth) instead of + trusting the stale counter.""" fake_cache = _make_spend_counter_cache(redis_get_value=2.0) monkeypatch.setattr(ps, "spend_counter_cache", fake_cache) + monkeypatch.setattr(ps, "prisma_client", None) monkeypatch.setattr(ps.SpendCounterReseed, "from_db", AsyncMock(return_value=None)) + for counter_key in ("spend:end_user:e1", "spend:tag:t1"): + result = await ps.get_current_spend( + counter_key=counter_key, + fallback_spend=20.0, + max_budget=10.0, + ) + + assert result == 20.0 + # no DB row to repair against, so the shared counter is left untouched + fake_cache.redis_cache.async_set_max.assert_not_called() + + +def _make_prisma_with_end_user_row(spend: float | None): + prisma = MagicMock() + prisma.db.litellm_endusertable.find_unique = AsyncMock( + return_value=None if spend is None else MagicMock(spend=spend) + ) + return prisma + + +@pytest.mark.asyncio +async def test_get_current_spend_end_user_floor_admits_after_a_reset_on_a_stale_worker(monkeypatch): + """The reset job zeroes LiteLLM_EndUserTable.spend and the shared counter, but it + evicts the cached end-user object only on the worker that ran the reset. Every + other worker still passes the pre-reset spend as fallback_spend, and that stale + copy must not out-vote the reset row.""" + fake_cache = _make_spend_counter_cache(redis_get_value=0.0) + monkeypatch.setattr(ps, "spend_counter_cache", fake_cache) + prisma = _make_prisma_with_end_user_row(spend=0.0) + monkeypatch.setattr(ps, "prisma_client", prisma) + result = await ps.get_current_spend( - counter_key="spend:end_user:e1", + counter_key="spend:end_user:customer-42", + fallback_spend=0.000032, + max_budget=0.00003, + fallback_authoritative=True, + ) + + assert result == 0.0 + prisma.db.litellm_endusertable.find_unique.assert_awaited_once_with(where={"user_id": "customer-42"}) + fake_cache.redis_cache.async_set_max.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_current_spend_end_user_floor_repairs_a_stale_low_counter(monkeypatch): + """After a Redis restart the end-user counter can sit below the recorded spend; + the row wins and the shared counter is raised so other workers stop admitting on + the stale value.""" + fake_cache = _make_spend_counter_cache(redis_get_value=2.0) + monkeypatch.setattr(ps, "spend_counter_cache", fake_cache) + monkeypatch.setattr(ps, "prisma_client", _make_prisma_with_end_user_row(spend=12.0)) + + result = await ps.get_current_spend( + counter_key="spend:end_user:customer-42", + fallback_spend=12.0, + max_budget=10.0, + ) + + assert result == 12.0 + fake_cache.redis_cache.async_set_max.assert_awaited_once_with(key="spend:end_user:customer-42", value=12.0) + + +@pytest.mark.asyncio +async def test_get_current_spend_end_user_without_a_row_keeps_the_cached_spend(monkeypatch): + fake_cache = _make_spend_counter_cache(redis_get_value=0.0) + monkeypatch.setattr(ps, "spend_counter_cache", fake_cache) + monkeypatch.setattr(ps, "prisma_client", _make_prisma_with_end_user_row(spend=None)) + + result = await ps.get_current_spend( + counter_key="spend:end_user:customer-42", fallback_spend=20.0, max_budget=10.0, ) assert result == 20.0 - # no DB row to repair against, so the shared counter is left untouched fake_cache.redis_cache.async_set_max.assert_not_called() From dc634283956389b435e3d5b628b0be566035d51d Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 17:42:52 -0700 Subject: [PATCH 143/410] refactor(auto-router compression): satisfy the LIT001/LIT002 type-discipline gate The gate has no headroom, so the new module had to stop introducing mutable collections rather than spend budget on them: - the marker lookup falls back to () and drops an `or {}` that isinstance already covered - the suppression list is stored as the tuple it was built as; the read side in custom_guardrail accepts list or tuple, since JSON round-trips it to a list - the snapshot holds MappingProxyType entries, so it is immutable at rest and _snapshot_messages can hand back the stored tuple with no defensive copy - arm_pre_call returns None instead of echoing back the dict it mutates in place - _suppressed_by_auto_router_compression takes a Mapping, which is all it reads The four remaining mutable spots are external contracts, each suppressed with the reason: the pre-routing hook protocol types messages as list[dict], the metadata["guardrails"] key is extended by litellm_pre_call_utils via an isinstance(..., list) check, apply_guardrail takes a dict it writes stats into, and pydantic's model_copy takes a dict. --- litellm/integrations/custom_guardrail.py | 8 +- litellm/proxy/common_request_processing.py | 2 +- .../guardrails/auto_router_compression.py | 85 +++++++++++-------- litellm/router.py | 3 +- .../test_auto_router_compression.py | 65 ++++++-------- 5 files changed, 83 insertions(+), 80 deletions(-) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 558e97cfc16..1ec08641706 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -954,16 +954,18 @@ class CustomGuardrail(CustomLogger): return None return f"{_PRE_CALL_EXECUTED_TOKEN}:{name}" - def _suppressed_by_auto_router_compression(self, data: dict[str, object]) -> bool: + def _suppressed_by_auto_router_compression(self, data: Mapping[str, object]) -> bool: """True when an auto router's own compression policy suppresses this guardrail.""" marker: Final = self.auto_router_suppression_marker() if marker is None: return False for meta_key in ("metadata", "litellm_metadata"): meta = data.get(meta_key) - if isinstance(meta, dict): + if isinstance(meta, Mapping): + # arm_pre_call writes a tuple; it arrives as a list once the metadata + # has been round-tripped through JSON. suppressed = meta.get(AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY) - if isinstance(suppressed, list) and marker in suppressed: + if isinstance(suppressed, (list, tuple)) and marker in suppressed: return True return False diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 534b2db3e61..5e6c9b34332 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -2009,7 +2009,7 @@ class ProxyBaseLLMRequestProcessing: # request: suppress every other compression guardrail and arm whichever one # the policy names for the model call, before those guardrails get a chance # to run below. - self.data = await _arm_auto_router_compression(data=self.data, llm_router=llm_router) + await _arm_auto_router_compression(data=self.data, llm_router=llm_router) self.data = await proxy_logging_obj.pre_call_hook( user_api_key_dict=user_api_key_dict, diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py index 2122d353def..d26479f7de0 100644 --- a/litellm/proxy/guardrails/auto_router_compression.py +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -13,8 +13,9 @@ each hop sees. """ import contextvars -from collections.abc import Mapping, MutableMapping, Sequence +from collections.abc import Iterable, Mapping, MutableMapping, Sequence from dataclasses import dataclass +from types import MappingProxyType from typing import TYPE_CHECKING, Final from litellm._logging import verbose_proxy_logger @@ -84,11 +85,11 @@ def policy_for_model( """ if llm_router is None: return None - deployments: Final = llm_router.get_model_list(model_name=model_alias, team_id=team_id) or [] + deployments: Final = llm_router.get_model_list(model_name=model_alias, team_id=team_id) or () markers: Final = tuple( litellm_params for deployment in deployments - if isinstance(litellm_params := deployment.get("litellm_params") or {}, Mapping) + if isinstance(litellm_params := deployment.get("litellm_params"), Mapping) and str(litellm_params.get("model", "")).startswith(AUTO_ROUTER_MODEL_PREFIX) ) requested: Final = frozenset(request_tags) @@ -128,7 +129,10 @@ def _active_compression_guardrails() -> tuple["CustomGuardrail", ...]: return tuple(cb for cb in active if isinstance(cb, compression_classes) and cb.guardrail_name) -async def arm_pre_call(data: MutableMapping[str, object], llm_router: "Router | None") -> MutableMapping[str, object]: +async def arm_pre_call( + data: MutableMapping[str, object], # mutable-ok: arms the live request dict in place + llm_router: "Router | None", +) -> None: """Apply an auto router's compression policy, if any, before guardrails run. Suppresses every other compression guardrail, re-enables the model-side @@ -138,11 +142,11 @@ async def arm_pre_call(data: MutableMapping[str, object], llm_router: "Router | """ _routing_messages_snapshot.set(None) if llm_router is None: - return data + return model_alias: Final = data.get("model") if not isinstance(model_alias, str) or not model_alias: - return data + return # Read-only until a policy is confirmed: creating the metadata bucket for every # request, including the vast majority with no auto-router compression policy, @@ -156,7 +160,7 @@ async def arm_pre_call(data: MutableMapping[str, object], llm_router: "Router | request_tags=_get_tags_from_request_kwargs(data), ) if policy is None: - return data + return _, metadata = get_or_create_metadata_bucket(data) # Markers carry a per-process token so a caller cannot suppress a guardrail by @@ -167,35 +171,41 @@ async def arm_pre_call(data: MutableMapping[str, object], llm_router: "Router | if guardrail.guardrail_name != policy.model and (marker := guardrail.auto_router_suppression_marker()) ) if suppressed: - metadata[AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] = list(suppressed) + metadata[AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] = suppressed if policy.model is not None: - requested = metadata.get("guardrails") - if isinstance(requested, list): - if policy.model not in requested: - requested.append(policy.model) - else: - metadata["guardrails"] = [policy.model] + requested: Final = metadata.get("guardrails") + existing: Final = tuple(requested) if isinstance(requested, (list, tuple)) else () + if policy.model not in existing: + # A list, not a tuple: litellm_pre_call_utils tests this key with + # isinstance(..., list) and extends it, and would drop a tuple on the floor. + metadata["guardrails"] = [*existing, policy.model] # mutable-ok: this key's contract is a list from litellm.litellm_core_utils.prompt_templates.factory import resolve_structured_messages snapshot: Final = resolve_structured_messages(messages=data.get("messages"), request_kwargs=data) if snapshot is not None: - _routing_messages_snapshot.set(tuple(dict(message) for message in snapshot)) - - return data + _routing_messages_snapshot.set(tuple(MappingProxyType(dict(message)) for message in snapshot)) -def _snapshot_messages() -> list[dict[str, object]] | None: - snapshot: Final = _routing_messages_snapshot.get() - return None if snapshot is None else [dict(message) for message in snapshot] +def _snapshot_messages() -> tuple[Mapping[str, object], ...] | None: + return _routing_messages_snapshot.get() + + +def _as_routing_messages( + messages: Iterable[Mapping[str, object]], +) -> list[dict[str, object]]: # mutable-ok: shape fixed by the pre-routing hook protocol + """A fresh, independently mutable copy, the shape the pre-routing hook takes.""" + return [dict(message) for message in messages] # mutable-ok: shape fixed by the pre-routing hook protocol async def messages_for_routing( policy: AutoRouterCompressionPolicy | None, - messages: list[dict[str, object]] | None, + # list[dict], not Sequence[Mapping]: the async_pre_routing_hook protocol in + # litellm/types/router.py types `messages` as list[dict[str, Any]]. + messages: list[dict[str, object]] | None, # mutable-ok: shape fixed by the pre-routing hook protocol request_kwargs: Mapping[str, object], -) -> list[dict[str, object]] | None: +) -> list[dict[str, object]] | None: # mutable-ok: shape fixed by the pre-routing hook protocol """Messages to use for a routing decision, per `policy.routing`. Returns None when the caller should route on whatever messages it already has. @@ -207,12 +217,13 @@ async def messages_for_routing( if policy is None: return None - original: Final = _snapshot_messages() or messages + snapshot: Final = _snapshot_messages() + original: Final = snapshot if snapshot is not None else messages if policy.routing is None: # Explicitly no compression for routing. When the model side compressed, the # messages in hand are its output, so fall back to the untouched snapshot. - return _snapshot_messages() if policy.model is not None else None + return _as_routing_messages(snapshot) if policy.model is not None and snapshot is not None else None if not original: return None @@ -226,20 +237,20 @@ async def messages_for_routing( verbose_proxy_logger.warning( "AutoRouter compression: guardrail '%s' not found; routing on uncompressed messages", policy.routing ) - return original + return _as_routing_messages(original) - inputs: GenericGuardrailAPIInputs = { - "structured_messages": [dict(m) for m in original] # pyright: ignore[reportAssignmentType] # plain dicts, not AllMessageValues; see headroom.py's own use of this shape - } - # A throwaway request_data: apply_guardrail writes its stats onto this dict, not - # the real request's metadata, so routing-side compression never double-counts - # against extract_compression_saved_tokens's model-savings accounting. - throwaway_request_data: Final[dict[str, object]] = { - "messages": original, - "model": request_kwargs.get("model"), + inputs: Final[GenericGuardrailAPIInputs] = { + "structured_messages": _as_routing_messages(original) # pyright: ignore[reportAssignmentType] # plain dicts, not AllMessageValues; see headroom.py's own use of this shape } + model: Final = request_kwargs.get("model") + # A throwaway request_data: apply_guardrail writes its stats onto this dict, not the + # real request's metadata, so routing-side compression never double-counts against + # extract_compression_saved_tokens's model-savings accounting. + stats_sink: Final = {"messages": original, "model": model} # mutable-ok: apply_guardrail writes its stats here result: Final = await guardrail.apply_guardrail( - inputs=inputs, request_data=throwaway_request_data, input_type="request" + inputs=inputs, + request_data=stats_sink, + input_type="request", ) - compressed = result.get("structured_messages") - return compressed if isinstance(compressed, list) else original + compressed: Final = result.get("structured_messages") + return compressed if isinstance(compressed, list) else _as_routing_messages(original) diff --git a/litellm/router.py b/litellm/router.py index bcb2e2aa7ff..9637ad98c9a 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -13084,7 +13084,8 @@ class Router: and routing_messages is not None and pre_routing_hook_response.messages == routing_messages ): - pre_routing_hook_response = pre_routing_hook_response.model_copy(update={"messages": messages}) + restored: Final = {"messages": messages} # mutable-ok: pydantic's model_copy takes a dict + pre_routing_hook_response = pre_routing_hook_response.model_copy(update=restored) self._record_routing_decision( request_kwargs=request_kwargs, routing_decision=(pre_routing_hook_response.routing_decision if pre_routing_hook_response else None), diff --git a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py index 79f069d8a2e..de667e8ed48 100644 --- a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py +++ b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py @@ -97,9 +97,7 @@ class TestPolicyForModel: assert policy_for_model(llm_router=None, model_alias="smart-router", team_id=None, request_tags=()) is None def test_no_marker_deployment_returns_none(self): - router = _FakeRouter( - [{"model_name": "smart-router", "litellm_params": {"model": "openai/gpt-4o-mini"}}] - ) + router = _FakeRouter([{"model_name": "smart-router", "litellm_params": {"model": "openai/gpt-4o-mini"}}]) assert policy_for_model(llm_router=router, model_alias="smart-router", team_id=None, request_tags=()) is None def test_marker_deployment_without_policy_returns_none(self): @@ -163,9 +161,7 @@ class _RecordingCompressionGuardrail(CustomGuardrail): ) -> GenericGuardrailAPIInputs: self.request_data_seen.append(request_data) structured_messages = inputs.get("structured_messages") or [] - compressed = [ - {**m, "content": f"[COMPRESSED] {m.get('content')}"} for m in structured_messages - ] + compressed = [{**m, "content": f"[COMPRESSED] {m.get('content')}"} for m in structured_messages] return {**inputs, "structured_messages": compressed} @@ -183,19 +179,16 @@ class TestArmPreCall: @pytest.mark.asyncio async def test_no_router_is_noop(self): data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} - result = await arm_pre_call(data=data, llm_router=None) - assert result == data - assert "metadata" not in result + await arm_pre_call(data=data, llm_router=None) + assert "metadata" not in data @pytest.mark.asyncio async def test_no_policy_does_not_create_metadata_bucket(self): - router = _FakeRouter( - [{"model_name": "smart-router", "litellm_params": {"model": "openai/gpt-4o-mini"}}] - ) + router = _FakeRouter([{"model_name": "smart-router", "litellm_params": {"model": "openai/gpt-4o-mini"}}]) data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} - result = await arm_pre_call(data=data, llm_router=router) - assert "metadata" not in result - assert "litellm_metadata" not in result + await arm_pre_call(data=data, llm_router=router) + assert "metadata" not in data + assert "litellm_metadata" not in data @pytest.mark.asyncio async def test_policy_suppresses_active_compression_guardrails(self, monkeypatch): @@ -226,12 +219,12 @@ class TestArmPreCall: ] ) data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} - result = await arm_pre_call(data=data, llm_router=router) - suppressed = result["metadata"][AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] - assert suppressed == [always_on.auto_router_suppression_marker()] + await arm_pre_call(data=data, llm_router=router) + suppressed = data["metadata"][AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] + assert tuple(suppressed) == (always_on.auto_router_suppression_marker(),) # The bare name alone must never suppress: that is what a caller could forge. assert "always-on-compression" not in suppressed - assert always_on.should_run_guardrail(data=result, event_type=GuardrailEventHooks.pre_call) is False + assert always_on.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) is False finally: litellm.logging_callback_manager.remove_callback_from_all_lists(always_on) @@ -262,8 +255,8 @@ class TestArmPreCall: ] ) data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} - result = await arm_pre_call(data=data, llm_router=router) - assert result["metadata"]["guardrails"] == ["headroom-b"] + await arm_pre_call(data=data, llm_router=router) + assert data["metadata"]["guardrails"] == ["headroom-b"] @pytest.mark.asyncio async def test_snapshot_never_lands_in_persisted_metadata(self): @@ -275,10 +268,10 @@ class TestArmPreCall: original_messages = [{"role": "user", "content": "my ssn is 123-45-6789"}] data = {"model": "smart-router", "messages": original_messages} - result = await arm_pre_call(data=data, llm_router=router) + await arm_pre_call(data=data, llm_router=router) - assert "123-45-6789" not in json.dumps(result["metadata"]) - assert auto_router_compression._snapshot_messages() == original_messages + assert "123-45-6789" not in json.dumps(data["metadata"]) + assert [dict(m) for m in auto_router_compression._snapshot_messages()] == original_messages @pytest.mark.asyncio async def test_snapshot_is_a_copy_not_the_live_message_list(self): @@ -288,19 +281,19 @@ class TestArmPreCall: await arm_pre_call(data={"model": "smart-router", "messages": original_messages}, llm_router=router) original_messages[0]["content"] = "mutated after the snapshot" - assert auto_router_compression._snapshot_messages() == [{"role": "user", "content": "hi"}] + assert [dict(m) for m in auto_router_compression._snapshot_messages()] == [{"role": "user", "content": "hi"}] @pytest.mark.asyncio async def test_a_request_without_a_policy_clears_a_previous_snapshot(self): router_with = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-a"})]) - await arm_pre_call(data={"model": "smart-router", "messages": [{"role": "user", "content": "first"}]}, - llm_router=router_with) - - router_without = _FakeRouter( - [{"model_name": "plain", "litellm_params": {"model": "openai/gpt-4o-mini"}}] + await arm_pre_call( + data={"model": "smart-router", "messages": [{"role": "user", "content": "first"}]}, llm_router=router_with + ) + + router_without = _FakeRouter([{"model_name": "plain", "litellm_params": {"model": "openai/gpt-4o-mini"}}]) + await arm_pre_call( + data={"model": "plain", "messages": [{"role": "user", "content": "second"}]}, llm_router=router_without ) - await arm_pre_call(data={"model": "plain", "messages": [{"role": "user", "content": "second"}]}, - llm_router=router_without) assert auto_router_compression._snapshot_messages() is None @@ -358,15 +351,11 @@ class TestMessagesForRouting: # rewrote `data["messages"]` to -- routing must ignore it and compress the # pristine snapshot instead. already_rewritten = [{"role": "user", "content": "rewritten by another guardrail"}] - result = await messages_for_routing( - policy=policy, messages=already_rewritten, request_kwargs={} - ) + result = await messages_for_routing(policy=policy, messages=already_rewritten, request_kwargs={}) assert result == [{"role": "user", "content": "[COMPRESSED] original"}] @pytest.mark.asyncio - async def test_guardrail_receives_a_throwaway_request_data_not_the_real_request_kwargs( - self, registered_guardrail - ): + async def test_guardrail_receives_a_throwaway_request_data_not_the_real_request_kwargs(self, registered_guardrail): """Regression: a real compression guardrail writes its stats onto whatever `request_data` dict it's given (`add_standard_logging_guardrail_information_to_ request_data`). If that were the caller's own `request_kwargs`, routing-side From aedaf0d5a7e32a2608ef81d194a2de7bc83b6f99 Mon Sep 17 00:00:00 2001 From: mateo Date: Sat, 5 Sep 2026 00:45:00 +0000 Subject: [PATCH 144/410] chore: ratchet lint budgets after merging litellm_internal_staging Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- basedpyright-code-budget.json | 6 +++--- ruff-strict-budget.json | 2 +- type-discipline-budget.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 669107bb5b1..9aadf0f974f 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,6 +1,6 @@ { "reportAny": { - "limit": 14074 + "limit": 14072 }, "reportArgumentType": { "limit": 2206 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 4124 + "limit": 4121 }, "reportFunctionMemberAccess": { "limit": 7 @@ -108,7 +108,7 @@ "limit": 38309 }, "reportUnknownParameterType": { - "limit": 19621 + "limit": 19620 }, "reportUnknownVariableType": { "limit": 29844 diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 5eaecd27d63..b485eb76f4b 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -78,7 +78,7 @@ "limit": 1 }, "C901": { - "limit": 307 + "limit": 306 }, "D419": { "limit": 6 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 3d01c08e8eb..ad7d7327b7e 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22326 + "limit": 22325 }, "LIT002": { - "limit": 26748 + "limit": 26746 }, "LIT003": { "limit": 261 From c02f198a4ac4ca58d53c6c9b625a02c610e4d367 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:45:06 -0700 Subject: [PATCH 145/410] fix(azure): responses none-effort temperature gate reads the azure/ cost-map entry --- .../llms/azure/responses/transformation.py | 9 ++++ .../response/test_azure_transformation.py | 43 +++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/litellm/llms/azure/responses/transformation.py b/litellm/llms/azure/responses/transformation.py index 0dd5e87e4ba..cfd0e96639f 100644 --- a/litellm/llms/azure/responses/transformation.py +++ b/litellm/llms/azure/responses/transformation.py @@ -6,6 +6,7 @@ from openai.types.responses import ResponseReasoningItem from litellm._logging import verbose_logger from litellm.litellm_core_utils.url_utils import encode_url_path_segment +from litellm.llms.azure.chat.gpt_5_transformation import AzureOpenAIGPT5Config from litellm.llms.azure.common_utils import BaseAzureLLM from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig from litellm.types.llms.openai import * @@ -29,6 +30,14 @@ class AzureOpenAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def custom_llm_provider(self) -> LlmProviders: return LlmProviders.AZURE + @staticmethod + def _supports_reasoning_effort_none(model: str) -> bool: + return AzureOpenAIGPT5Config._supports_reasoning_effort_level(model, "none") + + @staticmethod + def _effort_resolves_to_none(model: str, effort: str | None) -> bool: + return AzureOpenAIGPT5Config.effort_resolves_to_none(model, effort) + def get_supported_openai_params(self, model: str) -> list: """ Azure Responses API does not support context_management (compaction). diff --git a/tests/test_litellm/llms/azure/response/test_azure_transformation.py b/tests/test_litellm/llms/azure/response/test_azure_transformation.py index f6bbf685f26..0cac2705ab0 100644 --- a/tests/test_litellm/llms/azure/response/test_azure_transformation.py +++ b/tests/test_litellm/llms/azure/response/test_azure_transformation.py @@ -1,11 +1,10 @@ from copy import deepcopy -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest - -from unittest.mock import MagicMock - +import litellm +from litellm.litellm_core_utils.get_model_cost_map import get_model_cost_map from litellm.llms.azure.responses.o_series_transformation import ( AzureOpenAIOSeriesResponsesAPIConfig, ) @@ -613,3 +612,39 @@ class TestAzureResponsesAPIConfig: assert result["tools"][0] is tool assert "anyOf" in result["tools"][0]["parameters"] + + +@pytest.fixture() +def local_model_cost_map(monkeypatch: pytest.MonkeyPatch) -> None: + """Pin the bundled cost map: the published map lags a key added in this repo.""" + monkeypatch.setenv("LITELLM_LOCAL_MODEL_COST_MAP", "True") + monkeypatch.setattr(litellm, "model_cost", get_model_cost_map(url=litellm.model_cost_map_url)) + litellm.add_known_models(model_cost_map=litellm.model_cost) + + +def test_azure_responses_gpt6_astra_reasoning_effort_none_unlocks_temperature(local_model_cost_map: None): + """Foundry's gpt-6-astra accepts reasoning.effort='none' with a non-default temperature + while OpenAI's gpt-6-astra does not, so the gate must read the azure/ cost-map entry + for the bare deployment name rather than OpenAI's.""" + params = AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + temperature=0.2, + reasoning={"effort": "none"}, + ), + model="gpt-6-astra", + drop_params=False, + ) + assert params["temperature"] == 0.2 + assert params["reasoning"] == {"effort": "none"} + + +def test_azure_responses_gpt6_astra_rejects_temperature_while_reasoning(local_model_cost_map: None): + with pytest.raises(litellm.UnsupportedParamsError): + AzureOpenAIResponsesAPIConfig().map_openai_params( + response_api_optional_params=ResponsesAPIOptionalRequestParams( + temperature=0.2, + reasoning={"effort": "low"}, + ), + model="gpt-6-astra", + drop_params=False, + ) From 199b44a475719500915dbd9059c0ea5dfeeae782 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:48:56 -0700 Subject: [PATCH 146/410] fix(async): move remote image fetches off the event loop for Snowflake, Bedrock invoke Claude, Mantle and Gemini --- .../prompt_templates/image_handling.py | 103 ++++++++++ litellm/llms/base_llm/chat/transformation.py | 4 + .../anthropic_claude3_transformation.py | 61 +----- .../bedrock/chat/mantle/transformation.py | 15 +- litellm/llms/custom_httpx/llm_http_handler.py | 194 ++++++++++-------- litellm/llms/snowflake/chat/transformation.py | 16 ++ .../llms/vertex_ai/gemini/transformation.py | 9 +- litellm/main.py | 8 +- tests/test_litellm/conftest.py | 35 ++++ .../litellm_core_utils/test_image_handling.py | 62 ++++++ ...ations_anthropic_claude3_transformation.py | 49 +++++ ...test_bedrock_chat_mantle_transformation.py | 51 +++++ .../custom_httpx/test_llm_http_handler.py | 106 +++++++++- .../test_snowflake_chat_transformation.py | 46 +++++ .../vertex_ai/gemini/test_transformation.py | 42 ++++ 15 files changed, 644 insertions(+), 157 deletions(-) create mode 100644 tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index 81132fa89a5..1890d4eb682 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -2,7 +2,11 @@ Helper functions to handle images passed in messages """ +import asyncio import base64 +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType from typing import Final from httpx import Response @@ -12,6 +16,7 @@ from litellm import verbose_logger from litellm.caching.caching import InMemoryCache from litellm.constants import MAX_IMAGE_URL_DOWNLOAD_SIZE_MB from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get +from litellm.types.llms.openai import AllMessageValues MAX_IMGS_IN_MEMORY: Final = 10 @@ -124,3 +129,101 @@ def convert_url_to_base64(url: str) -> str: raise litellm.ImageFetchError( f"Error: Unable to fetch image from URL after 3 attempts. url={url}", ) + + +_REMOTE_URL_PREFIXES: Final = ("http://", "https://") + + +@dataclass(frozen=True, slots=True) +class _RemoteImage: + part: Mapping[str, object] + image_url: Mapping[str, object] | None + url: str + + +@dataclass(frozen=True, slots=True) +class _RemoteFile: + part: Mapping[str, object] + file: Mapping[str, object] + url: str + + +def _as_mapping(value: object) -> Mapping[str, object] | None: + return value if isinstance(value, Mapping) else None # pyright: ignore[reportUnknownVariableType] # fields are parsed one by one + + +def _remote_url(candidate: object) -> str | None: + return candidate if isinstance(candidate, str) and candidate.startswith(_REMOTE_URL_PREFIXES) else None + + +def _parse_remote_part(part: object) -> _RemoteImage | _RemoteFile | None: + fields: Final = _as_mapping(part) + if fields is None: + return None + if fields.get("type") == "image_url": + image_url: Final = fields.get("image_url") + image_url_fields: Final = _as_mapping(image_url) + url: Final = _remote_url(image_url_fields.get("url") if image_url_fields is not None else image_url) + return _RemoteImage(fields, image_url_fields, url) if url is not None else None + file: Final = _as_mapping(fields.get("file")) if fields.get("type") == "file" else None + file_url: Final = _remote_url(file.get("file_id")) if file is not None else None + return _RemoteFile(fields, file, file_url) if file is not None and file_url is not None else None + + +_PDF_FORMAT: Final = MappingProxyType({"format": "application/pdf"}) + + +def _inferred_format(file: Mapping[str, object], url: str) -> Mapping[str, str]: + return _PDF_FORMAT if "format" not in file and url.lower().endswith(".pdf") else MappingProxyType({}) + + +def _inlined_image_url(image_url: Mapping[str, object] | None, data_url: str) -> Mapping[str, object] | str: + return {**image_url, "url": data_url} if image_url is not None else data_url # mutable-ok: json-serialized part + + +def _inlined_file(file: Mapping[str, object], url: str, data_url: str) -> Mapping[str, object]: + kept: Final = {k: v for k, v in file.items() if k != "file_id"} # mutable-ok: json-serialized message part + return {**kept, **_inferred_format(file, url), "file_data": data_url} # mutable-ok: json-serialized part + + +def _inline(remote: _RemoteImage | _RemoteFile, data_url: str) -> Mapping[str, object]: + match remote: + case _RemoteImage(part, image_url, _): + return {**part, "image_url": _inlined_image_url(image_url, data_url)} # mutable-ok: json-serialized part + case _RemoteFile(part, file, url): + return {**part, "file": _inlined_file(file, url, data_url)} # mutable-ok: json-serialized message part + + +def _content_parts(message: Mapping[str, object]) -> tuple[object, ...]: + content: Final = message.get("content") + return tuple(content) if isinstance(content, list) else () # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] # parts are parsed one by one + + +def _inline_message(message: AllMessageValues, data_urls: Mapping[str, str]) -> AllMessageValues: + parts: Final = _content_parts(message) + if not parts: + return message + inlined_parts: Final = [ # mutable-ok: content must stay a list for the transforms' isinstance checks + _inline(remote, data_urls[remote.url]) if (remote := _parse_remote_part(part)) is not None else part + for part in parts + ] + inlined_message: Final = {**message, "content": inlined_parts} # mutable-ok: json-serialized message + return inlined_message # pyright: ignore[reportReturnType] # the same message with its remote parts inlined + + +async def async_inline_remote_media( + messages: list[AllMessageValues], # mutable-ok: every transform_request takes list[AllMessageValues] +) -> list[AllMessageValues]: # mutable-ok: every transform_request takes list[AllMessageValues] + remote_urls: Final = tuple( + dict.fromkeys( + remote.url + for message in messages + for part in _content_parts(message) + if (remote := _parse_remote_part(part)) is not None + ) + ) + if not remote_urls: + return messages + data_urls: Final = await asyncio.gather(*(async_convert_url_to_base64(url) for url in remote_urls)) + inlined: Final = MappingProxyType(dict(zip(remote_urls, data_urls, strict=True))) + return [_inline_message(message, inlined) for message in messages] # mutable-ok: transform_request takes a list diff --git a/litellm/llms/base_llm/chat/transformation.py b/litellm/llms/base_llm/chat/transformation.py index bbe1cc85df1..7bfc87a30d6 100644 --- a/litellm/llms/base_llm/chat/transformation.py +++ b/litellm/llms/base_llm/chat/transformation.py @@ -411,6 +411,10 @@ class BaseConfig(ABC): def has_custom_stream_wrapper(self) -> bool: return False + @property + def uses_async_transform_request(self) -> bool: + return False + @property def supports_stream_param_in_request_body(self) -> bool: """ diff --git a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py index 67720451c00..38f280eef03 100644 --- a/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py +++ b/litellm/llms/bedrock/chat/invoke_transformations/anthropic_claude3_transformation.py @@ -8,7 +8,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_anthropic_image_obj, ) from litellm.litellm_core_utils.prompt_templates.image_handling import ( - async_convert_url_to_base64, + async_inline_remote_media, convert_url_to_base64, ) from litellm.llms.anthropic.chat.transformation import AnthropicConfig @@ -172,6 +172,10 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): return _anthropic_request + @property + def uses_async_transform_request(self) -> bool: + return True + async def async_transform_request( self, model: str, @@ -180,26 +184,14 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): litellm_params: dict, headers: dict, ) -> dict: - _anthropic_request: Final = self._build_bedrock_anthropic_request_base( + return self.transform_request( model=model, - messages=messages, + messages=await async_inline_remote_media(messages), optional_params=optional_params, litellm_params=litellm_params, headers=headers, ) - await self._async_convert_document_url_sources_to_base64(_anthropic_request) - beta_list: Final = self._compute_bedrock_invoke_beta_headers( - model=model, - messages=messages, - optional_params=optional_params, - headers=headers, - ) - if beta_list: - _anthropic_request["anthropic_beta"] = beta_list - - return _anthropic_request - def _build_bedrock_anthropic_request_base( self, model: str, @@ -321,45 +313,6 @@ class AmazonAnthropicClaudeConfig(AmazonInvokeConfig, AnthropicConfig): "data": image_chunk["data"], } - async def _async_convert_document_url_sources_to_base64(self, anthropic_request: dict) -> None: - """ - Async version of document URL conversion for async completion paths. - """ - messages: Final = anthropic_request.get("messages") - if not isinstance(messages, list): - return - - for message in messages: - if not isinstance(message, dict): - continue - content = message.get("content") - if not isinstance(content, list): - continue - - for block in content: - if not isinstance(block, dict) or block.get("type") != "document": - continue - source = block.get("source") - if not isinstance(source, dict) or source.get("type") != "url": - continue - source_url = source.get("url") - if not isinstance(source_url, str): - continue - - inferred_format: str | None = None - if source_url.lower().endswith(".pdf"): - inferred_format = "application/pdf" - base64_url = await async_convert_url_to_base64(url=source_url) - image_chunk = convert_to_anthropic_image_obj( - openai_image_url=base64_url, - format=inferred_format, - ) - block["source"] = { - "type": "base64", - "media_type": image_chunk["media_type"], - "data": image_chunk["data"], - } - def _normalize_bedrock_tool_search_tools(self, optional_params: dict) -> dict: """ Convert tool search entries to the format supported by the Bedrock Invoke API. diff --git a/litellm/llms/bedrock/chat/mantle/transformation.py b/litellm/llms/bedrock/chat/mantle/transformation.py index 31fc079c0c9..7e2037c33f1 100644 --- a/litellm/llms/bedrock/chat/mantle/transformation.py +++ b/litellm/llms/bedrock/chat/mantle/transformation.py @@ -10,6 +10,7 @@ at a different endpoint (bedrock-mantle.{region}.api.aws) with AWS SigV4 auth. from collections.abc import AsyncIterator, Iterator from typing import TYPE_CHECKING, Any, Final +from litellm.litellm_core_utils.prompt_templates.image_handling import async_inline_remote_media from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeConfig, ) @@ -110,21 +111,13 @@ class AmazonMantleConfig(AmazonAnthropicClaudeConfig): litellm_params: dict, headers: dict, ) -> dict: - model_id: Final = model.replace("mantle/", "", 1) - - request: Final = self._build_bedrock_anthropic_request_base( - model=model_id, - messages=messages, + return self.transform_request( + model=model, + messages=await async_inline_remote_media(messages), optional_params=optional_params, litellm_params=litellm_params, headers=headers, ) - await self._async_convert_document_url_sources_to_base64(request) - return self._restore_mantle_body_fields( - request=request, - model_id=model_id, - optional_params=optional_params, - ) @staticmethod def _restore_mantle_body_fields(request: dict, model_id: str, optional_params: dict) -> dict: diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index f281c249c72..54c21f8d5b3 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -116,6 +116,7 @@ from litellm.types.llms.anthropic_skills import ( Skill, ) from litellm.types.llms.openai import ( + AllMessageValues, CreateBatchRequest, CreateFileRequest, FileContentRequest, @@ -488,7 +489,7 @@ class BaseLLMHTTPHandler: def completion( self, model: str, - messages: list, + messages: list[AllMessageValues], api_base: str | None, custom_llm_provider: str, model_response: ModelResponse, @@ -507,7 +508,7 @@ class BaseLLMHTTPHandler: shared_session: Optional["ClientSession"] = None, ): json_mode: Final[bool] = optional_params.pop("json_mode", False) - extra_body: Final[dict | None] = optional_params.pop("extra_body", None) + extra_body: Final[Mapping[str, object] | None] = optional_params.pop("extra_body", None) provider_config = provider_config or ProviderConfigManager.get_provider_chat_config( model=model, provider=litellm.LlmProviders(custom_llm_provider) @@ -522,14 +523,17 @@ class BaseLLMHTTPHandler: ) # get config from model, custom llm provider - headers = provider_config.validate_environment( - api_key=api_key, - headers=headers or {}, - model=model, - messages=messages, - optional_params=optional_params, - api_base=api_base, - litellm_params=litellm_params, + request_headers: Final = cast( # cast-ok: validate_environment is declared as a bare dict + "dict[str, object]", + provider_config.validate_environment( + api_key=api_key, + headers=headers or {}, + model=model, + messages=messages, + optional_params=optional_params, + api_base=api_base, + litellm_params=litellm_params, + ), ) api_base = provider_config.get_complete_url( @@ -541,93 +545,117 @@ class BaseLLMHTTPHandler: litellm_params=litellm_params, ) - data: dict[str, object] = provider_config.transform_request( - model=model, - messages=messages, - optional_params=optional_params, - litellm_params=litellm_params, - headers=headers, - ) - - if extra_body is not None: - data = {**data, **extra_body} - - headers, signed_json_body = provider_config.sign_request( - headers=headers, - optional_params={ - **optional_params, - **_aws_signing_overrides(optional_params, litellm_params), - }, - request_data=data, - api_base=api_base, - api_key=api_key, - stream=stream, - fake_stream=fake_stream, - model=model, - ) - - ## LOGGING - logging_obj.pre_call( - input=messages, - api_key=api_key, - additional_args={ - "complete_input_dict": data, - "api_base": api_base, - "headers": headers, - }, - ) - - # Check if stream was converted for WebSearch interception - # This is set by the async_pre_request_hook in WebSearchInterceptionLogger - if litellm_params.get("_websearch_interception_converted_stream", False): - logging_obj.model_call_details["websearch_interception_converted_stream"] = True - - if acompletion is True: - if stream is True: - data = self._add_stream_param_to_request_body( - data=data, - provider_config=provider_config, + def sign_and_log( + transformed: dict[str, object], # mutable-ok: async_completion takes dict + ) -> tuple[dict[str, object], dict[str, object], bytes | None]: # mutable-ok: async_completion takes dict + data: Final = {**transformed, **extra_body} if extra_body is not None else transformed + signed: Final = cast( # cast-ok: sign_request is declared as a bare dict + "tuple[dict[str, object], bytes | None]", + provider_config.sign_request( + headers=request_headers, + optional_params={ + **optional_params, + **_aws_signing_overrides(optional_params, litellm_params), + }, + request_data=data, + api_base=api_base, + api_key=api_key, + stream=stream, fake_stream=fake_stream, - ) + model=model, + ), + ) + logging_obj.pre_call( + input=messages, + api_key=api_key, + additional_args={ + "complete_input_dict": data, + "api_base": api_base, + "headers": signed[0], + }, + ) + if litellm_params.get("_websearch_interception_converted_stream", False): + logging_obj.model_call_details["websearch_interception_converted_stream"] = True + return data, signed[0], signed[1] + + def dispatch_async( + data: dict[str, object], # mutable-ok: async_completion takes dict + signed_headers: dict[str, object], # mutable-ok: async_completion takes dict + signed_json_body: bytes | None, + ): + async_client: Final = client if isinstance(client, AsyncHTTPHandler) else None + if stream is True: return self.acompletion_stream_function( model=model, messages=messages, api_base=api_base, - headers=headers, + headers=signed_headers, custom_llm_provider=custom_llm_provider, provider_config=provider_config, timeout=timeout, logging_obj=logging_obj, - data=data, + data=self._add_stream_param_to_request_body( + data=data, + provider_config=provider_config, + fake_stream=fake_stream, + ), fake_stream=fake_stream, - client=(client if client is not None and isinstance(client, AsyncHTTPHandler) else None), + client=async_client, litellm_params=litellm_params, json_mode=json_mode, optional_params=optional_params, signed_json_body=signed_json_body, ) + return self.async_completion( + custom_llm_provider=custom_llm_provider, + provider_config=provider_config, + api_base=api_base, + headers=signed_headers, + data=data, + timeout=timeout, + model=model, + model_response=model_response, + logging_obj=logging_obj, + api_key=api_key, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + encoding=encoding, + client=async_client, + json_mode=json_mode, + signed_json_body=signed_json_body, + shared_session=shared_session, + ) - else: - return self.async_completion( - custom_llm_provider=custom_llm_provider, - provider_config=provider_config, - api_base=api_base, - headers=headers, - data=data, - timeout=timeout, - model=model, - model_response=model_response, - logging_obj=logging_obj, - api_key=api_key, - messages=messages, - optional_params=optional_params, - litellm_params=litellm_params, - encoding=encoding, - client=(client if client is not None and isinstance(client, AsyncHTTPHandler) else None), - json_mode=json_mode, - signed_json_body=signed_json_body, - shared_session=shared_session, + if acompletion is True and provider_config.uses_async_transform_request: + + async def transform_then_dispatch(): + transformed: Final = cast( # cast-ok: async_transform_request is declared as a bare dict + "dict[str, object]", + await provider_config.async_transform_request( + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=request_headers, + ), ) + return await dispatch_async(*sign_and_log(transformed)) + + return transform_then_dispatch() + + data, signed_headers, signed_json_body = sign_and_log( + provider_config.transform_request( + model=model, + messages=messages, + optional_params=optional_params, + litellm_params=litellm_params, + headers=request_headers, + ) + ) + + if acompletion is True: + return dispatch_async(data, signed_headers, signed_json_body) if stream is True: data = self._add_stream_param_to_request_body( @@ -641,7 +669,7 @@ class BaseLLMHTTPHandler: custom_llm_provider=custom_llm_provider, logging_obj=logging_obj, api_base=api_base, - headers=headers, + headers=signed_headers, data=data, signed_json_body=signed_json_body, messages=messages, @@ -651,7 +679,7 @@ class BaseLLMHTTPHandler: completion_stream, headers = self.make_sync_call( provider_config=provider_config, api_base=api_base, - headers=headers, + headers=signed_headers, data=data, signed_json_body=signed_json_body, original_data=data, @@ -684,7 +712,7 @@ class BaseLLMHTTPHandler: sync_httpx_client=sync_httpx_client, provider_config=provider_config, api_base=api_base, - headers=headers, + headers=signed_headers, data=data, signed_json_body=signed_json_body, timeout=timeout, diff --git a/litellm/llms/snowflake/chat/transformation.py b/litellm/llms/snowflake/chat/transformation.py index c64fc583edc..f65b0876202 100644 --- a/litellm/llms/snowflake/chat/transformation.py +++ b/litellm/llms/snowflake/chat/transformation.py @@ -23,6 +23,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( create_anthropic_image_param, select_anthropic_content_block_type_for_file, ) +from litellm.litellm_core_utils.prompt_templates.image_handling import async_inline_remote_media from litellm.llms.anthropic.chat.handler import ModelResponseIterator as AnthropicStreamParser from litellm.llms.anthropic.chat.transformation import AnthropicConfig from litellm.llms.anthropic.common_utils import normalize_cache_control_in_anthropic_payload @@ -421,6 +422,21 @@ class SnowflakeConfig(SnowflakeBaseConfig, OpenAIGPTConfig): return self._transform_request_anthropic(model, messages, optional_params, stream, extra_body) return self._transform_request_openai(model, messages, optional_params, stream, extra_body) + @property + def uses_async_transform_request(self) -> bool: + return True + + async def async_transform_request( + self, + model: str, + messages: list[AllMessageValues], # mutable-ok: BaseConfig signature + optional_params: dict[str, object], # mutable-ok: BaseConfig signature + litellm_params: dict[str, object], # mutable-ok: BaseConfig signature + headers: dict[str, object], # mutable-ok: BaseConfig signature + ) -> dict[str, object]: # mutable-ok: BaseConfig signature + inlined_messages: Final = await async_inline_remote_media(messages) if _is_claude_model(model) else messages + return self.transform_request(model, inlined_messages, optional_params, litellm_params, headers) + def _transform_request_openai( self, model: str, diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index e2d62be6a69..c9480f07150 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -27,6 +27,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_gemini_tool_call_result, response_schema_prompt, ) +from litellm.litellm_core_utils.prompt_templates.image_handling import async_inline_remote_media from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.vertex_ai.common_utils import pop_vertex_request_labels from litellm.types.files import ( @@ -1348,13 +1349,15 @@ async def async_transform_request_body( vertex_auth_header=vertex_auth_header, ) - if _openai_messages_may_need_sync_gcs_metadata_fetch(messages): + inlined_messages: Final = await async_inline_remote_media(messages) if custom_llm_provider == "gemini" else messages + + if _openai_messages_may_need_sync_gcs_metadata_fetch(inlined_messages): # _transform_request_body may issue a sync httpx.get (up to 5s timeout) # via _get_gcs_object_content_type to fetch GCS object metadata. Run the # whole sync transformation on a worker thread so it does not block the # async event loop. return await asyncify(_transform_request_body)( - messages=messages, + messages=inlined_messages, model=model, custom_llm_provider=custom_llm_provider, litellm_params=litellm_params, @@ -1363,7 +1366,7 @@ async def async_transform_request_body( ) return _transform_request_body( - messages=messages, + messages=inlined_messages, model=model, custom_llm_provider=custom_llm_provider, litellm_params=litellm_params, diff --git a/litellm/main.py b/litellm/main.py index 2929790f2bd..9970c203e03 100644 --- a/litellm/main.py +++ b/litellm/main.py @@ -4474,7 +4474,7 @@ def _complete_snowflake(ctx: _CompletionDispatchContext) -> _CompletionDispatchR acompletion: Final = ctx.acompletion api_base: Final = ctx.api_base api_key: Final = ctx.api_key - client = _dispatch_client_http(ctx) + injected_client: Final = _dispatch_client_http(ctx) custom_llm_provider: Final = ctx.custom_llm_provider headers: Final = ctx.headers litellm_params: Final = ctx.litellm_params @@ -4486,11 +4486,11 @@ def _complete_snowflake(ctx: _CompletionDispatchContext) -> _CompletionDispatchR shared_session: Final = ctx.shared_session stream: Final = ctx.stream timeout: Final = ctx.timeout + client: Final = ( + injected_client if injected_client is not None else (HTTPHandler(timeout=timeout) if stream is False else None) + ) # Keep this here, otherwise, the httpx.client closes and streaming is impossible try: - client = ( - HTTPHandler(timeout=timeout) if stream is False else None - ) # Keep this here, otherwise, the httpx.client closes and streaming is impossible response: Final = base_llm_http_handler.completion( model=model, messages=messages, diff --git a/tests/test_litellm/conftest.py b/tests/test_litellm/conftest.py index 62c95cb100b..aa8ba168ecc 100644 --- a/tests/test_litellm/conftest.py +++ b/tests/test_litellm/conftest.py @@ -7,9 +7,12 @@ # 4. Added proper cleanup in fixtures # 5. Added worker-specific isolation for parallel execution +import base64 import importlib import os from pathlib import Path +from types import SimpleNamespace +import httpx import pytest import asyncio @@ -595,3 +598,35 @@ def pytest_sessionfinish(session, exitstatus): _close_handler_if_needed(getattr(litellm, "aclient", None)) _close_handler_if_needed(getattr(litellm, "client", None)) _run_coroutine_if_needed(close_litellm_async_clients()) + + +ONE_PIXEL_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" +) + + +@pytest.fixture +def async_only_image_fetch(monkeypatch): + from litellm.litellm_core_utils.prompt_templates import image_handling + + fetch = SimpleNamespace( + fetched=[], + base64_png=base64.b64encode(ONE_PIXEL_PNG).decode(), + data_url="data:image/png;base64," + base64.b64encode(ONE_PIXEL_PNG).decode(), + ) + + def forbid_sync_fetch(client, url, **kwargs): + raise litellm.ImageFetchError(f"sync image fetch ran on the event loop: {url}") + + async def serve_png(client, url, **kwargs): + fetch.fetched.append(url) + return httpx.Response( + 200, + content=ONE_PIXEL_PNG, + headers={"content-type": "image/png"}, + request=httpx.Request("GET", url), + ) + + monkeypatch.setattr(image_handling, "safe_get", forbid_sync_fetch) + monkeypatch.setattr(image_handling, "async_safe_get", serve_png) + return fetch diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index 893472d63ae..5b6d403fa21 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -1,3 +1,5 @@ +import copy +import uuid from unittest.mock import patch import pytest @@ -8,6 +10,7 @@ from litellm import constants from litellm.litellm_core_utils.prompt_templates import image_handling from litellm.litellm_core_utils.prompt_templates.image_handling import ( async_convert_url_to_base64, + async_inline_remote_media, convert_url_to_base64, ) @@ -268,3 +271,62 @@ def test_image_size_limit_disabled(monkeypatch): assert "Image URL download is disabled" in str(excinfo.value) assert "MAX_IMAGE_URL_DOWNLOAD_SIZE_MB=0" in str(excinfo.value) + + +async def test_async_inline_remote_media_inlines_every_remote_part_shape(async_only_image_fetch): + image_url = f"http://img.example/{uuid.uuid4()}.png" + pdf_url = f"http://docs.example/{uuid.uuid4()}.pdf" + messages = [ + {"role": "system", "content": "be terse"}, + { + "role": "user", + "content": [ + {"type": "text", "text": "what is this?"}, + {"type": "image_url", "image_url": {"url": image_url, "detail": "low"}}, + {"type": "image_url", "image_url": image_url}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}}, + {"type": "file", "file": {"file_id": pdf_url}}, + {"type": "file", "file": {"file_id": image_url, "format": "image/png"}}, + ], + }, + ] + snapshot = copy.deepcopy(messages) + + inlined = await async_inline_remote_media(messages) + + data_url = async_only_image_fetch.data_url + assert inlined[0] == {"role": "system", "content": "be terse"} + assert inlined[1]["content"] == [ + {"type": "text", "text": "what is this?"}, + {"type": "image_url", "image_url": {"url": data_url, "detail": "low"}}, + {"type": "image_url", "image_url": data_url}, + {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}}, + {"type": "file", "file": {"format": "application/pdf", "file_data": data_url}}, + {"type": "file", "file": {"format": "image/png", "file_data": data_url}}, + ] + assert sorted(async_only_image_fetch.fetched) == sorted([image_url, pdf_url]) + assert messages == snapshot + + +async def test_async_inline_remote_media_leaves_messages_without_remote_parts_alone(async_only_image_fetch): + messages = [ + {"role": "user", "content": [{"type": "text", "text": "hi"}]}, + { + "role": "user", + "content": [{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}}], + }, + ] + + assert await async_inline_remote_media(messages) is messages + assert async_only_image_fetch.fetched == [] + + +async def test_async_inline_remote_media_raises_image_fetch_error_when_the_fetch_fails(monkeypatch): + async def serve_404(client, url, **kwargs): + return Response(404, request=Request("GET", url)) + + monkeypatch.setattr(image_handling, "async_safe_get", serve_404) + url = f"http://img.example/{uuid.uuid4()}.png" + + with pytest.raises(litellm.ImageFetchError, match="Status code: 404"): + await async_inline_remote_media([{"role": "user", "content": [{"type": "image_url", "image_url": url}]}]) diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py index 0d7573a2536..e808a087e3d 100644 --- a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py @@ -1,15 +1,19 @@ import asyncio import json +import uuid from unittest.mock import patch +import httpx import pytest # Ensure the project root is on the import path so `litellm` can be imported when # tests are executed from any working directory. +import litellm from litellm.llms.bedrock.chat.invoke_transformations.anthropic_claude3_transformation import ( AmazonAnthropicClaudeConfig, ) +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler def test_get_supported_params_thinking(): @@ -714,3 +718,48 @@ def test_bedrock_chat_invoke_response_format_stub_still_upgrades_legacy_thinking assert result["thinking"] == {"type": "adaptive"} assert result["output_config"] == {"effort": "high"} + + +async def test_bedrock_invoke_claude_async_completion_inlines_remote_images_off_the_event_loop(async_only_image_fetch): + image_url = f"http://img.example/{uuid.uuid4()}.png" + captured = {} + + def handle(request): + captured["body"] = request.content.decode() + return httpx.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "us.anthropic.claude-sonnet-5", + "content": [{"type": "text", "text": "Green"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + + response = await litellm.acompletion( + model="bedrock/invoke/us.anthropic.claude-sonnet-5", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What colour is this?"}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + } + ], + aws_access_key_id="AKIAEXAMPLE", + aws_secret_access_key="fake-secret", + aws_region_name="us-east-1", + client=client, + ) + + assert response.choices[0].message.content == "Green" + assert async_only_image_fetch.fetched == [image_url] + assert image_url not in captured["body"] + assert async_only_image_fetch.base64_png in captured["body"] diff --git a/tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py b/tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py new file mode 100644 index 00000000000..9b491d305c7 --- /dev/null +++ b/tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py @@ -0,0 +1,51 @@ +import uuid + +import httpx + +import litellm +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler + + +async def test_bedrock_mantle_claude_async_completion_inlines_remote_images_off_the_event_loop(async_only_image_fetch): + image_url = f"http://img.example/{uuid.uuid4()}.png" + captured = {} + + def handle(request): + captured["body"] = request.content.decode() + return httpx.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "us.anthropic.claude-sonnet-5", + "content": [{"type": "text", "text": "Green"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + + response = await litellm.acompletion( + model="bedrock/mantle/us.anthropic.claude-sonnet-5", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What colour is this?"}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + } + ], + aws_access_key_id="AKIAEXAMPLE", + aws_secret_access_key="fake-secret", + aws_region_name="us-east-1", + client=client, + ) + + assert response.choices[0].message.content == "Green" + assert async_only_image_fetch.fetched == [image_url] + assert image_url not in captured["body"] + assert async_only_image_fetch.base64_png in captured["body"] 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 023e2d8843f..e86e671b939 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 @@ -17,7 +17,7 @@ from litellm.llms.base_llm.audio_transcription.transformation import ( AudioTranscriptionRequestData, BaseAudioTranscriptionConfig, ) -from litellm.llms.base_llm.chat.transformation import BaseLLMException +from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.custom_httpx.llm_http_handler import ( BaseLLMHTTPHandler, @@ -30,7 +30,7 @@ from litellm.llms.azure.videos.transformation import AzureVideoConfig from litellm.llms.openai.videos.transformation import OpenAIVideoConfig from litellm.types.llms.openai import ResponsesAPIResponse from litellm.types.router import GenericLiteLLMParams -from litellm.types.utils import TranscriptionResponse +from litellm.types.utils import ModelResponse, TranscriptionResponse _ACTIVE_KEY = "_code_interpreter_interception_active" _SANDBOX_KEY = "_code_interpreter_interception_sandbox_key" @@ -3186,3 +3186,105 @@ async def test_async_container_list_handler_transforms_success_response(): assert [container.id for container in response.data] == ["cntr_a"] assert response.has_more is True + + +class _TransformRecordingConfig(BaseConfig): + def __init__(self, transform_async: bool): + self.transform_async = transform_async + self.transform_calls = [] + + @property + def uses_async_transform_request(self) -> bool: + return self.transform_async + + def get_supported_openai_params(self, model): + return [] + + def map_openai_params(self, non_default_params, optional_params, model, drop_params): + return optional_params + + def validate_environment( + self, headers, model, messages, optional_params, litellm_params, api_key=None, api_base=None + ): + return {} + + def transform_request(self, model, messages, optional_params, litellm_params, headers): + self.transform_calls.append("sync") + return {"transformed_by": "sync"} + + async def async_transform_request(self, model, messages, optional_params, litellm_params, headers): + self.transform_calls.append("async") + return {"transformed_by": "async"} + + def transform_response( + self, + model, + raw_response, + model_response, + logging_obj, + request_data, + messages, + optional_params, + litellm_params, + encoding, + api_key=None, + json_mode=None, + ): + model_response.choices[0].message.content = raw_response.json()["transformed_by"] + return model_response + + def get_error_class(self, error_message, status_code, headers): + return BaseLLMException(status_code=status_code, message=error_message, headers=headers) + + +def _start_async_completion(config): + captured = {} + + def handle(request): + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=captured["body"]) + + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + pending = BaseLLMHTTPHandler().completion( + model="stub-model", + messages=[{"role": "user", "content": "hi"}], + api_base="https://llm.example/v1/chat", + custom_llm_provider="openai", + model_response=ModelResponse(), + encoding=None, + logging_obj=Mock(dynamic_success_callbacks=None, model_call_details={}), + optional_params={}, + timeout=10.0, + litellm_params={}, + acompletion=True, + client=client, + provider_config=config, + ) + return pending, captured + + +async def test_completion_awaits_async_transform_request_when_config_opts_in(): + config = _TransformRecordingConfig(transform_async=True) + + pending, captured = _start_async_completion(config) + assert config.transform_calls == [] + + response = await pending + + assert config.transform_calls == ["async"] + assert captured["body"] == {"transformed_by": "async"} + assert response.choices[0].message.content == "async" + + +async def test_completion_keeps_sync_transform_request_before_returning_by_default(): + config = _TransformRecordingConfig(transform_async=False) + + pending, captured = _start_async_completion(config) + assert config.transform_calls == ["sync"] + + response = await pending + + assert config.transform_calls == ["sync"] + assert captured["body"] == {"transformed_by": "sync"} + assert response.choices[0].message.content == "sync" diff --git a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py index 25a961c3413..5687a319f06 100644 --- a/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py +++ b/tests/test_litellm/llms/snowflake/chat/test_snowflake_chat_transformation.py @@ -6,6 +6,7 @@ Tests tool calling request/response transformations and chat completions import asyncio import os import copy +import uuid import json from typing import Any, Dict, List @@ -945,3 +946,48 @@ class TestSnowflakeChatCompletion: assert len(chunks_received) > 0 content = "".join(c.choices[0].delta.content for c in chunks_received if c.choices[0].delta.content) + + +async def test_snowflake_claude_async_completion_inlines_remote_images_off_the_event_loop(async_only_image_fetch): + image_url = f"http://img.example/{uuid.uuid4()}.png" + captured = {} + + def handle(request): + captured["body"] = request.content.decode() + return httpx.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "claude-sonnet-4-6", + "content": [{"type": "text", "text": "Green"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + + response = await litellm.acompletion( + model="snowflake/claude-sonnet-4-6", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What colour is this?"}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + } + ], + api_key="fake-jwt", + account_id="FAKE-ACCOUNT", + api_base=FAKE_API_BASE, + client=client, + ) + + assert response.choices[0].message.content == "Green" + assert async_only_image_fetch.fetched == [image_url] + assert image_url not in captured["body"] + assert async_only_image_fetch.base64_png in captured["body"] diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py index fad310fc5c0..ec445342523 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py @@ -1,6 +1,10 @@ +import uuid +import httpx import pytest +import litellm +from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.llms.vertex_ai.gemini import transformation from litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini import ( VertexGeminiConfig, @@ -338,3 +342,41 @@ def test_map_function_enterprise_web_search_snake_case(): assert len(result) == 1 assert "enterpriseWebSearch" in result[0] + + +async def test_gemini_ai_studio_async_completion_inlines_remote_images_off_the_event_loop(async_only_image_fetch): + image_url = f"http://img.example/{uuid.uuid4()}.png" + captured = {} + + def handle(request): + captured["body"] = request.content.decode() + return httpx.Response( + 200, + json={ + "candidates": [{"content": {"parts": [{"text": "Green"}], "role": "model"}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2}, + }, + ) + + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + + response = await litellm.acompletion( + model="gemini/gemini-3.8-flash", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What colour is this?"}, + {"type": "image_url", "image_url": {"url": image_url}}, + ], + } + ], + api_key="fake-gemini-key", + client=client, + ) + + assert response.choices[0].message.content == "Green" + assert async_only_image_fetch.fetched == [image_url] + assert image_url not in captured["body"] + assert async_only_image_fetch.base64_png in captured["body"] From b4fd63f621cd8dd98755944aeca0e9dfaa78daa1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:53:45 -0700 Subject: [PATCH 147/410] chore(proxy): annotate the new spend-counter test locals and correct the floor comments --- litellm/proxy/proxy_server.py | 7 ++++--- .../proxy/db/test_spend_counter_reseed.py | 11 ++++++----- .../proxy/proxy_server/test_spend_counters.py | 17 +++++++++-------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 47c0811d903..9ad3c910f58 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -2477,7 +2477,8 @@ async def get_current_spend( authoritative source depends on the counter: primary key/team/user/org counters read the DB row; per-window counters (``window_start`` supplied) read the maintained window-spend row and only aggregate spend logs when - that row is missing or stale; end-user/tag counters have no DB row, so the caller's + that row is missing or stale; end-user counters read ``LiteLLM_EndUserTable``, the + row the budget reset zeroes; tag counters have no DB row, so the caller's ``fallback_spend`` (loaded fresh in auth) is authoritative. The DB read is skipped for healthy primary counters (counter at or above recorded spend) and cached in-process for a few seconds, so a persistently stale counter @@ -2511,8 +2512,8 @@ async def get_current_spend( await _repair_stale_spend_counter(counter_key=counter_key, db_spend=authoritative) return authoritative elif fallback_spend > current: - # end-user / tag counters have no DB row; fallback_spend is the - # authoritative recorded value loaded in auth. + # nothing to read (tag counters, an end user without a row or a DB client, a + # failed read); fallback_spend is the authoritative recorded value loaded in auth. return fallback_spend # Opt-in hard guarantee: when the spend backing this admit decision came diff --git a/tests/test_litellm/proxy/db/test_spend_counter_reseed.py b/tests/test_litellm/proxy/db/test_spend_counter_reseed.py index 3bd6d93328d..8cb3fc665eb 100644 --- a/tests/test_litellm/proxy/db/test_spend_counter_reseed.py +++ b/tests/test_litellm/proxy/db/test_spend_counter_reseed.py @@ -9,6 +9,7 @@ from __future__ import annotations from datetime import datetime, timedelta, timezone from types import SimpleNamespace +from typing import Final import pytest @@ -255,9 +256,9 @@ async def test_coalesced_window_seeds_a_cold_counter_from_the_row(): @pytest.mark.asyncio async def test_end_user_from_db_reads_the_end_user_row_by_user_id(): - prisma = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="customer-42", spend=0.0)) + prisma: Final = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="customer-42", spend=0.0)) - result = await SpendCounterReseed.end_user_from_db( + result: Final = await SpendCounterReseed.end_user_from_db( prisma_client=prisma, counter_key="spend:end_user:customer-42" ) @@ -267,7 +268,7 @@ async def test_end_user_from_db_reads_the_end_user_row_by_user_id(): @pytest.mark.asyncio async def test_end_user_from_db_returns_the_recorded_spend(): - prisma = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="customer-42", spend=12.5)) + prisma: Final = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="customer-42", spend=12.5)) assert ( await SpendCounterReseed.end_user_from_db(prisma_client=prisma, counter_key="spend:end_user:customer-42") @@ -278,7 +279,7 @@ async def test_end_user_from_db_returns_the_recorded_spend(): @pytest.mark.asyncio @pytest.mark.parametrize("counter_key", ["spend:key:hashed", "spend:team:t1", "spend:tag:t1"]) async def test_end_user_from_db_ignores_other_counter_kinds_without_touching_the_db(counter_key): - prisma = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="x", spend=5.0)) + prisma: Final = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="x", spend=5.0)) assert await SpendCounterReseed.end_user_from_db(prisma_client=prisma, counter_key=counter_key) is None assert prisma.db.litellm_endusertable.where_clauses == [] @@ -309,7 +310,7 @@ async def test_end_user_from_db_returns_none_without_a_row_a_client_or_on_db_err async def test_from_db_still_never_reads_the_end_user_row(): """A cold end-user counter keeps seeding from the cached end-user object the auth path already loaded; the row is read only as the budget floor.""" - prisma = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="customer-42", spend=5.0)) + prisma: Final = _FakePrismaClient(end_user_row=SimpleNamespace(user_id="customer-42", spend=5.0)) assert await SpendCounterReseed.from_db(prisma_client=prisma, counter_key="spend:end_user:customer-42") is None assert prisma.db.litellm_endusertable.where_clauses == [] diff --git a/tests/test_litellm/proxy/proxy_server/test_spend_counters.py b/tests/test_litellm/proxy/proxy_server/test_spend_counters.py index 7cc1390fcbd..ef6f8120c82 100644 --- a/tests/test_litellm/proxy/proxy_server/test_spend_counters.py +++ b/tests/test_litellm/proxy/proxy_server/test_spend_counters.py @@ -22,6 +22,7 @@ from __future__ import annotations import asyncio from datetime import datetime +from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest @@ -233,7 +234,7 @@ async def test_get_current_spend_floors_end_user_tag_against_fallback(monkeypatc monkeypatch.setattr(ps.SpendCounterReseed, "from_db", AsyncMock(return_value=None)) for counter_key in ("spend:end_user:e1", "spend:tag:t1"): - result = await ps.get_current_spend( + result: Final = await ps.get_current_spend( counter_key=counter_key, fallback_spend=20.0, max_budget=10.0, @@ -245,7 +246,7 @@ async def test_get_current_spend_floors_end_user_tag_against_fallback(monkeypatc def _make_prisma_with_end_user_row(spend: float | None): - prisma = MagicMock() + prisma: Final = MagicMock() prisma.db.litellm_endusertable.find_unique = AsyncMock( return_value=None if spend is None else MagicMock(spend=spend) ) @@ -258,9 +259,9 @@ async def test_get_current_spend_end_user_floor_admits_after_a_reset_on_a_stale_ evicts the cached end-user object only on the worker that ran the reset. Every other worker still passes the pre-reset spend as fallback_spend, and that stale copy must not out-vote the reset row.""" - fake_cache = _make_spend_counter_cache(redis_get_value=0.0) + fake_cache: Final = _make_spend_counter_cache(redis_get_value=0.0) monkeypatch.setattr(ps, "spend_counter_cache", fake_cache) - prisma = _make_prisma_with_end_user_row(spend=0.0) + prisma: Final = _make_prisma_with_end_user_row(spend=0.0) monkeypatch.setattr(ps, "prisma_client", prisma) result = await ps.get_current_spend( @@ -280,11 +281,11 @@ async def test_get_current_spend_end_user_floor_repairs_a_stale_low_counter(monk """After a Redis restart the end-user counter can sit below the recorded spend; the row wins and the shared counter is raised so other workers stop admitting on the stale value.""" - fake_cache = _make_spend_counter_cache(redis_get_value=2.0) + fake_cache: Final = _make_spend_counter_cache(redis_get_value=2.0) monkeypatch.setattr(ps, "spend_counter_cache", fake_cache) monkeypatch.setattr(ps, "prisma_client", _make_prisma_with_end_user_row(spend=12.0)) - result = await ps.get_current_spend( + result: Final = await ps.get_current_spend( counter_key="spend:end_user:customer-42", fallback_spend=12.0, max_budget=10.0, @@ -296,11 +297,11 @@ async def test_get_current_spend_end_user_floor_repairs_a_stale_low_counter(monk @pytest.mark.asyncio async def test_get_current_spend_end_user_without_a_row_keeps_the_cached_spend(monkeypatch): - fake_cache = _make_spend_counter_cache(redis_get_value=0.0) + fake_cache: Final = _make_spend_counter_cache(redis_get_value=0.0) monkeypatch.setattr(ps, "spend_counter_cache", fake_cache) monkeypatch.setattr(ps, "prisma_client", _make_prisma_with_end_user_row(spend=None)) - result = await ps.get_current_spend( + result: Final = await ps.get_current_spend( counter_key="spend:end_user:customer-42", fallback_spend=20.0, max_budget=10.0, From 4ffd2ffb25017c861a350f24a27b6441ceeb2fd1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:00:40 -0700 Subject: [PATCH 148/410] test(proxy): parametrize the stale end-user counter case so no Final local sits in a loop --- .../proxy/proxy_server/test_spend_counters.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_litellm/proxy/proxy_server/test_spend_counters.py b/tests/test_litellm/proxy/proxy_server/test_spend_counters.py index ef6f8120c82..86e97a334df 100644 --- a/tests/test_litellm/proxy/proxy_server/test_spend_counters.py +++ b/tests/test_litellm/proxy/proxy_server/test_spend_counters.py @@ -223,24 +223,24 @@ async def test_get_current_spend_floor_caches_db_read(monkeypatch): @pytest.mark.asyncio -async def test_get_current_spend_floors_end_user_tag_against_fallback(monkeypatch): +@pytest.mark.parametrize("counter_key", ("spend:end_user:e1", "spend:tag:t1")) +async def test_get_current_spend_floors_end_user_tag_against_fallback(monkeypatch, counter_key): """Tag counters have no DB row (from_db returns None), and an end-user counter has none to read without a DB client. When such a counter is stale-low, enforcement falls back to the caller's recorded spend (loaded fresh in auth) instead of trusting the stale counter.""" - fake_cache = _make_spend_counter_cache(redis_get_value=2.0) + fake_cache: Final = _make_spend_counter_cache(redis_get_value=2.0) monkeypatch.setattr(ps, "spend_counter_cache", fake_cache) monkeypatch.setattr(ps, "prisma_client", None) monkeypatch.setattr(ps.SpendCounterReseed, "from_db", AsyncMock(return_value=None)) - for counter_key in ("spend:end_user:e1", "spend:tag:t1"): - result: Final = await ps.get_current_spend( - counter_key=counter_key, - fallback_spend=20.0, - max_budget=10.0, - ) + result: Final = await ps.get_current_spend( + counter_key=counter_key, + fallback_spend=20.0, + max_budget=10.0, + ) - assert result == 20.0 + assert result == 20.0 # no DB row to repair against, so the shared counter is left untouched fake_cache.redis_cache.async_set_max.assert_not_called() From 35ae1ca151643af4c9bc84b649fba7df5d3f7674 Mon Sep 17 00:00:00 2001 From: yassin Date: Sat, 5 Sep 2026 01:01:41 +0000 Subject: [PATCH 149/410] test(cli): drop redundant comment in pi arg ordering test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/proxy/client/cli/test_agents.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_litellm/proxy/client/cli/test_agents.py b/tests/test_litellm/proxy/client/cli/test_agents.py index a64adffb3a1..a8a6659fe9a 100644 --- a/tests/test_litellm/proxy/client/cli/test_agents.py +++ b/tests/test_litellm/proxy/client/cli/test_agents.py @@ -582,7 +582,6 @@ class TestRunAgent: launcher=lambda p, a, e: calls.update(args=tuple(a), env=dict(e)), preparers={"pi": lambda *a: ["--model", "litellm/m-1"]}, ) - # user args come last so a user-supplied --model wins in pi's parser assert calls["args"] == ("pi", "--model", "litellm/m-1", "-p", "hello") assert calls["env"]["LITELLM_PROXY_API_KEY"] == "sk-key" assert "OPENAI_API_KEY" not in calls["env"] From e355203014326462c3e70e6b6c8d37fcc0abe656 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:11:41 -0700 Subject: [PATCH 150/410] fix(gemini): keep Files API refs out of the async remote media walker Gemini AI Studio file URIs under generativelanguage.googleapis.com/v1beta/files/ answer 403 when fetched and must pass through as file_data.file_uri, which the sync transform already did. Give the shared walker a skip_url_prefixes parameter, pass that prefix from the Gemini body builder, and skip it in the AI Studio message transform for both image_url and file parts --- .../prompt_templates/image_handling.py | 12 +++-- litellm/llms/gemini/chat/transformation.py | 14 ++++-- .../llms/vertex_ai/gemini/transformation.py | 9 +++- .../litellm_core_utils/test_image_handling.py | 28 ++++++++++++ .../vertex_ai/gemini/test_transformation.py | 44 +++++++++++++++++++ 5 files changed, 99 insertions(+), 8 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index 1890d4eb682..5ae8d224556 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -199,13 +199,18 @@ def _content_parts(message: Mapping[str, object]) -> tuple[object, ...]: return tuple(content) if isinstance(content, list) else () # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] # parts are parsed one by one +def _inline_part(part: object, data_urls: Mapping[str, str]) -> object: + remote: Final = _parse_remote_part(part) + data_url: Final = data_urls.get(remote.url) if remote is not None else None + return _inline(remote, data_url) if remote is not None and data_url is not None else part + + def _inline_message(message: AllMessageValues, data_urls: Mapping[str, str]) -> AllMessageValues: parts: Final = _content_parts(message) if not parts: return message inlined_parts: Final = [ # mutable-ok: content must stay a list for the transforms' isinstance checks - _inline(remote, data_urls[remote.url]) if (remote := _parse_remote_part(part)) is not None else part - for part in parts + _inline_part(part, data_urls) for part in parts ] inlined_message: Final = {**message, "content": inlined_parts} # mutable-ok: json-serialized message return inlined_message # pyright: ignore[reportReturnType] # the same message with its remote parts inlined @@ -213,13 +218,14 @@ def _inline_message(message: AllMessageValues, data_urls: Mapping[str, str]) -> async def async_inline_remote_media( messages: list[AllMessageValues], # mutable-ok: every transform_request takes list[AllMessageValues] + skip_url_prefixes: tuple[str, ...] = (), ) -> list[AllMessageValues]: # mutable-ok: every transform_request takes list[AllMessageValues] remote_urls: Final = tuple( dict.fromkeys( remote.url for message in messages for part in _content_parts(message) - if (remote := _parse_remote_part(part)) is not None + if (remote := _parse_remote_part(part)) is not None and not remote.url.startswith(skip_url_prefixes) ) ) if not remote_urls: diff --git a/litellm/llms/gemini/chat/transformation.py b/litellm/llms/gemini/chat/transformation.py index 1a67b33665b..42c9ef13730 100644 --- a/litellm/llms/gemini/chat/transformation.py +++ b/litellm/llms/gemini/chat/transformation.py @@ -12,7 +12,7 @@ from litellm.types.llms.openai import AllMessageValues, ChatCompletionFileObject from litellm.types.llms.vertex_ai import ContentType, PartType from litellm.utils import supports_reasoning -from ...vertex_ai.gemini.transformation import _gemini_convert_messages_with_history +from ...vertex_ai.gemini.transformation import GEMINI_FILES_API_URI_PREFIX, _gemini_convert_messages_with_history from ...vertex_ai.gemini.vertex_and_google_ai_studio_gemini import VertexGeminiConfig @@ -127,7 +127,11 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig): if element.get("type") == "image_url": img_element = cast(ChatCompletionImageObject, element) # cast-ok: runtime type tag checked _image_url, format, detail = _image_url_fields(img_element) - if _image_url and "https://" in _image_url: + if ( + _image_url + and "https://" in _image_url + and not _image_url.startswith(GEMINI_FILES_API_URI_PREFIX) + ): image_obj = convert_to_anthropic_image_obj(_image_url, format=format) converted_image_url = convert_generic_image_chunk_to_openai_image_obj(image_obj) if detail is not None: @@ -147,7 +151,11 @@ class GoogleAIStudioGeminiConfig(VertexGeminiConfig): llm_provider="gemini", ) file_id = _file_field.get("file_id") - if file_id and ("http://" in file_id or "https://" in file_id): + if ( + file_id + and ("http://" in file_id or "https://" in file_id) + and not file_id.startswith(GEMINI_FILES_API_URI_PREFIX) + ): # Convert HTTP/HTTPS file URL to base64 data try: base64_data = convert_url_to_base64(file_id) diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index c9480f07150..6d100143e52 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -69,6 +69,7 @@ _GCS_METADATA_VERTEX_BASE: Any | None = None # Shared sync client for GCS JSON API metadata reads so proxy/SSL settings # from litellm's HTTP stack apply (see Greptile review on PR #27278). _GCS_METADATA_HTTP_HANDLER: HTTPHandler | None = None +GEMINI_FILES_API_URI_PREFIX: Final = "https://generativelanguage.googleapis.com/v1beta/files/" _GEMINI_MIME_TYPE_ALIASES: Final[dict[str, str]] = { "image/jpg": "image/jpeg", } @@ -557,7 +558,7 @@ def _process_gemini_media( file_data = FileDataType(mime_type=mime_type, file_uri=image_url) part: PartType = {"file_data": file_data} return _apply_gemini_metadata(part, model, media_resolution_enum, video_metadata) - elif image_url.startswith("https://generativelanguage.googleapis.com/v1beta/files/"): + elif image_url.startswith(GEMINI_FILES_API_URI_PREFIX): # Gemini Files API URIs — the file is already uploaded to Google's # servers; pass the URI through as file_data without fetching it. # These URLs return 403 when accessed directly, so we must not try @@ -1349,7 +1350,11 @@ async def async_transform_request_body( vertex_auth_header=vertex_auth_header, ) - inlined_messages: Final = await async_inline_remote_media(messages) if custom_llm_provider == "gemini" else messages + inlined_messages: Final = ( + await async_inline_remote_media(messages, skip_url_prefixes=(GEMINI_FILES_API_URI_PREFIX,)) + if custom_llm_provider == "gemini" + else messages + ) if _openai_messages_may_need_sync_gcs_metadata_fetch(inlined_messages): # _transform_request_body may issue a sync httpx.get (up to 5s timeout) diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index 5b6d403fa21..bcf4c7cb6ff 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -308,6 +308,34 @@ async def test_async_inline_remote_media_inlines_every_remote_part_shape(async_o assert messages == snapshot +async def test_async_inline_remote_media_leaves_skipped_url_prefixes_untouched(async_only_image_fetch): + skipped_prefix = "https://generativelanguage.googleapis.com/v1beta/files/" + skipped_file = f"{skipped_prefix}{uuid.uuid4().hex}" + skipped_image = f"{skipped_prefix}{uuid.uuid4().hex}" + fetched_image = f"https://img.example/{uuid.uuid4()}.png" + messages = [ + { + "role": "user", + "content": [ + {"type": "file", "file": {"file_id": skipped_file, "format": "application/pdf"}}, + {"type": "image_url", "image_url": {"url": skipped_image}}, + {"type": "image_url", "image_url": fetched_image}, + ], + } + ] + snapshot = copy.deepcopy(messages) + + inlined = await async_inline_remote_media(messages, skip_url_prefixes=(skipped_prefix,)) + + assert inlined[0]["content"] == [ + {"type": "file", "file": {"file_id": skipped_file, "format": "application/pdf"}}, + {"type": "image_url", "image_url": {"url": skipped_image}}, + {"type": "image_url", "image_url": async_only_image_fetch.data_url}, + ] + assert async_only_image_fetch.fetched == [fetched_image] + assert messages == snapshot + + async def test_async_inline_remote_media_leaves_messages_without_remote_parts_alone(async_only_image_fetch): messages = [ {"role": "user", "content": [{"type": "text", "text": "hi"}]}, diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py index ec445342523..d31254746d4 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py @@ -1,4 +1,5 @@ +import json import uuid import httpx import pytest @@ -380,3 +381,46 @@ async def test_gemini_ai_studio_async_completion_inlines_remote_images_off_the_e assert async_only_image_fetch.fetched == [image_url] assert image_url not in captured["body"] assert async_only_image_fetch.base64_png in captured["body"] + + +async def test_gemini_ai_studio_async_completion_passes_files_api_uris_through_unfetched(async_only_image_fetch): + files_api_pdf = f"https://generativelanguage.googleapis.com/v1beta/files/{uuid.uuid4().hex}" + files_api_image = f"https://generativelanguage.googleapis.com/v1beta/files/{uuid.uuid4().hex}" + captured = {} + + def handle(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "candidates": [{"content": {"parts": [{"text": "A report"}], "role": "model"}, "finishReason": "STOP"}], + "usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2}, + }, + ) + + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + + response = await litellm.acompletion( + model="gemini/gemini-3.8-flash", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "Summarize these"}, + {"type": "file", "file": {"file_id": files_api_pdf, "format": "application/pdf"}}, + {"type": "image_url", "image_url": {"url": files_api_image, "format": "image/png"}}, + ], + } + ], + api_key="fake-gemini-key", + client=client, + ) + + assert response.choices[0].message.content == "A report" + assert async_only_image_fetch.fetched == [] + file_parts = [part["file_data"] for part in captured["body"]["contents"][0]["parts"] if "file_data" in part] + assert file_parts == [ + {"mime_type": "application/pdf", "file_uri": files_api_pdf}, + {"mime_type": "image/png", "file_uri": files_api_image}, + ] From cd113c3a2e46bd8d468ea48219d861c107e06260 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:13:51 -0700 Subject: [PATCH 151/410] ci: allowlist the bounded _unqualified qualifier peel in the recursion detector --- tests/code_coverage_tests/recursive_detector.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/code_coverage_tests/recursive_detector.py b/tests/code_coverage_tests/recursive_detector.py index 0578dc60119..e9f87ba6cae 100644 --- a/tests/code_coverage_tests/recursive_detector.py +++ b/tests/code_coverage_tests/recursive_detector.py @@ -66,6 +66,7 @@ IGNORE_FUNCTIONS = [ "_json_safe", # max depth set (_MAX_DEPTH) plus a seen-ids cycle guard for self-referential input. "_redact_agent_params_tree", # max depth set (default 10), same shape as _redact_sensitive_litellm_params. "_restore_redacted_nested_value", # max depth set (default 10), mirrors _redact_agent_params_tree on the write side. + "_unqualified", # bounded by the qualifier depth of a static TypedDict annotation (Annotated, Required/NotRequired, ReadOnly around one type, no cycles possible). ] From a2d5215a4fd7e8eb9ff4d2112545bef798b0e86e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:14:44 -0700 Subject: [PATCH 152/410] fix(proxy): gate the OpenAI websocket passthrough behind an explicit opt-in --- litellm/proxy/_types.py | 4 + .../llm_passthrough_endpoints.py | 100 +++++- test-quality-budget.json | 4 +- .../test_openai_ws_passthrough_routes.py | 326 +++++++++++------- ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 + 5 files changed, 296 insertions(+), 143 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index b33e2fe7ff6..f83011835fd 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2638,6 +2638,10 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase): default=None, description="Set-up pass-through endpoints for provider-specific endpoints. Docs - https://docs.litellm.ai/docs/proxy/pass_through", ) + enable_openai_websocket_passthrough: bool | None = Field( + default=None, + description="Serve the OpenAI pass-through WebSocket route, which relays frames to OpenAI under the proxy's own provider credential without reading them. Off by default.", + ) user_header_name: str | None = Field( None, description="[DEPRECATED] Use 'user_header_mappings' instead. When set, the header value is treated as the end user id unless overridden by user_header_mappings.", diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 6b1d6405a6a..97d25e20939 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -14,8 +14,9 @@ import json import os import re from collections.abc import AsyncGenerator, Callable, Mapping +from dataclasses import dataclass from types import MappingProxyType -from typing import TYPE_CHECKING, Annotated, Final, cast +from typing import TYPE_CHECKING, Annotated, Final, Protocol, cast import httpx from fastapi import APIRouter, Depends, HTTPException, Request, Response, WebSocket @@ -2345,19 +2346,99 @@ def _key_has_model_restrictions(user_api_key_dict: UserAPIKeyAuth) -> bool: return any(str(model) not in _OPENAI_WS_ALL_MODEL_ACCESS for model in scoped_models) +@dataclass(frozen=True, slots=True) +class _OpenAIWebsocketRefusal: + close_reason: str + message: str + + +_OPENAI_WS_DISABLED_REFUSAL: Final = _OpenAIWebsocketRefusal( + close_reason="OpenAI websocket passthrough is disabled", + message=( + "OpenAI websocket passthrough is disabled on this gateway. A proxy admin can turn it on by " + "setting general_settings.enable_openai_websocket_passthrough to true." + ), +) + +_OPENAI_WS_MODEL_RESTRICTED_REFUSAL: Final = _OpenAIWebsocketRefusal( + close_reason="Keys with model restrictions cannot use OpenAI websocket passthrough", + message=( + "Keys with model restrictions cannot use OpenAI websocket passthrough, because this route " + "relays frames to the provider without reading which model they ask for." + ), +) + + +def _is_openai_websocket_passthrough_enabled(general_settings: Mapping[str, object]) -> bool: + setting: Final = general_settings.get("enable_openai_websocket_passthrough") + if isinstance(setting, str): + return str_to_bool(setting) is True + return setting is True + + +def _openai_websocket_refusal( + user_api_key_dict: UserAPIKeyAuth, general_settings: Mapping[str, object] +) -> _OpenAIWebsocketRefusal | None: + if not _is_openai_websocket_passthrough_enabled(general_settings): + return _OPENAI_WS_DISABLED_REFUSAL + if _key_has_model_restrictions(user_api_key_dict): + return _OPENAI_WS_MODEL_RESTRICTED_REFUSAL + return None + + +class _OpenAIWebsocketRelay(Protocol): + async def __call__( + self, + *, + websocket: WebSocket, + target: str, + custom_headers: dict[str, str], # mutable-ok: the relay takes a plain dict of upstream headers + user_api_key_dict: UserAPIKeyAuth, + forward_headers: bool, + endpoint: str, + accept_websocket: bool, + ) -> None: ... + + +def _proxy_general_settings() -> Mapping[str, object]: + from litellm.proxy.proxy_server import general_settings + + return general_settings + + +def _openai_websocket_relay() -> _OpenAIWebsocketRelay: + return websocket_passthrough_request + + @router.websocket("/openai_passthrough/{endpoint:path}") @router.websocket("/openai/{endpoint:path}") async def openai_websocket_proxy_route( websocket: WebSocket, endpoint: str, user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth_websocket)], + general_settings: Annotated[Mapping[str, object], Depends(_proxy_general_settings)], + relay: Annotated[_OpenAIWebsocketRelay, Depends(_openai_websocket_relay)], ) -> None: """WebSocket passthrough for OpenAI prefixes (realtime / responses.connect).""" - if _key_has_model_restrictions(user_api_key_dict): - await websocket.close( - code=1008, - reason="Keys with model restrictions cannot use OpenAI websocket passthrough", + requested_subprotocols: Final = tuple( + protocol.strip() + for protocol in (websocket.headers.get("sec-websocket-protocol") or "").split(",") + if protocol.strip() + ) + negotiated_subprotocol: Final = requested_subprotocols[0] if requested_subprotocols else None + + refusal: Final = _openai_websocket_refusal(user_api_key_dict, general_settings) + if refusal is not None: + await websocket.accept(subprotocol=negotiated_subprotocol) + await websocket.send_text( + json.dumps( + { + "type": "error", + "error": {"type": "invalid_request_error", "message": refusal.message}, + } + ) ) + await websocket.close(code=1008, reason=refusal.close_reason) return base_target_url: Final = os.getenv("OPENAI_API_BASE") or "https://api.openai.com/" @@ -2393,14 +2474,9 @@ async def openai_websocket_proxy_route( "Authorization": f"Bearer {openai_api_key}" } - requested_subprotocols: Final = tuple( - protocol.strip() - for protocol in (websocket.headers.get("sec-websocket-protocol") or "").split(",") - if protocol.strip() - ) - await websocket.accept(subprotocol=requested_subprotocols[0] if requested_subprotocols else None) + await websocket.accept(subprotocol=negotiated_subprotocol) - await websocket_passthrough_request( + await relay( websocket=websocket, target=wss_target, custom_headers=custom_headers, diff --git a/test-quality-budget.json b/test-quality-budget.json index 7ca563d25af..3c12371f02f 100644 --- a/test-quality-budget.json +++ b/test-quality-budget.json @@ -3,7 +3,7 @@ "limit": 733 }, "TQ002": { - "limit": 741 + "limit": 737 }, "TQ003": { "limit": 62 @@ -21,6 +21,6 @@ "limit": 117 }, "TQ008": { - "limit": 11003 + "limit": 10993 } } diff --git a/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py b/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py index b22e202d9e0..6578b75ace1 100644 --- a/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py +++ b/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py @@ -1,16 +1,35 @@ -"""OpenAI passthrough must register WebSocket catch-all routes (#36088).""" +"""OpenAI passthrough WebSocket route: registration, opt-in gating, and refusals.""" -from unittest.mock import AsyncMock, MagicMock, patch +import json +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType, SimpleNamespace +from typing import Final +from unittest.mock import patch import pytest from starlette.routing import WebSocketRoute from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( + _OPENAI_WS_DISABLED_REFUSAL, + _OPENAI_WS_MODEL_RESTRICTED_REFUSAL, + _openai_websocket_refusal, openai_websocket_proxy_route, router, ) +ENABLED: Final = MappingProxyType({"enable_openai_websocket_passthrough": True}) +DISABLED_SETTINGS: Final = ( + MappingProxyType({}), + MappingProxyType({"enable_openai_websocket_passthrough": False}), + MappingProxyType({"enable_openai_websocket_passthrough": "false"}), + MappingProxyType({"enable_openai_websocket_passthrough": None}), +) +GET_CREDENTIALS: Final = ( + "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials" +) + def test_openai_websocket_passthrough_routes_registered(): ws_paths = {route.path for route in router.routes if isinstance(route, WebSocketRoute)} @@ -18,164 +37,213 @@ def test_openai_websocket_passthrough_routes_registered(): assert "/openai_passthrough/{endpoint:path}" in ws_paths -def _mock_websocket(path: str, query: str, headers: dict[str, str] | None = None) -> MagicMock: - websocket = MagicMock() - websocket.url.path = path - websocket.url.query = query - websocket.headers = headers or {} - websocket.accept = AsyncMock() - websocket.close = AsyncMock() - return websocket +class _FakeWebSocket: + def __init__(self, path: str, query: str, subprotocols: str | None = None) -> None: + self.url = SimpleNamespace(path=path, query=query) + self.headers = {"sec-websocket-protocol": subprotocols} if subprotocols else {} + self.accepts: list[str | None] = [] + self.sent: list[str] = [] + self.closed: tuple[int, str] | None = None + + async def accept(self, subprotocol: str | None = None) -> None: + self.accepts.append(subprotocol) + + async def send_text(self, data: str) -> None: + self.sent.append(data) + + async def close(self, code: int = 1000, reason: str = "") -> None: + self.closed = (code, reason) + + def error_message(self) -> str: + assert len(self.sent) == 1 + frame = json.loads(self.sent[0]) + assert frame["type"] == "error" + return frame["error"]["message"] + + +@dataclass(frozen=True, slots=True) +class _RelayCall: + target: str + custom_headers: Mapping[str, str] + forward_headers: bool + endpoint: str + accept_websocket: bool + + +class _FakeRelay: + def __init__(self) -> None: + self.calls: list[_RelayCall] = [] + + async def __call__( + self, + *, + websocket: _FakeWebSocket, + target: str, + custom_headers: dict[str, str], + user_api_key_dict: UserAPIKeyAuth, + forward_headers: bool, + endpoint: str, + accept_websocket: bool, + ) -> None: + self.calls.append( + _RelayCall( + target=target, + custom_headers=MappingProxyType(dict(custom_headers)), + forward_headers=forward_headers, + endpoint=endpoint, + accept_websocket=accept_websocket, + ) + ) + + +async def _serve( + websocket: _FakeWebSocket, + endpoint: str, + user_api_key_dict: UserAPIKeyAuth, + general_settings: Mapping[str, object], +) -> _FakeRelay: + relay = _FakeRelay() + await openai_websocket_proxy_route( + websocket=websocket, + endpoint=endpoint, + user_api_key_dict=user_api_key_dict, + general_settings=general_settings, + relay=relay, + ) + return relay @pytest.mark.asyncio @pytest.mark.parametrize("prefix", ["openai", "openai_passthrough"]) -async def test_openai_websocket_forwards_query_and_keeps_provider_auth(prefix): - websocket = _mock_websocket(f"/{prefix}/v1/realtime", "model=gpt-4o-realtime-preview") +async def test_openai_websocket_forwards_query_and_keeps_provider_auth(prefix, monkeypatch): + monkeypatch.delenv("OPENAI_API_BASE", raising=False) + websocket = _FakeWebSocket(f"/{prefix}/v1/realtime", "model=gpt-4o-realtime-preview") - with ( - patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", - return_value="sk-provider", - ), - patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints._join_url_paths", - return_value="https://api.openai.com/v1/realtime", - ), - patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.websocket_passthrough_request", - new_callable=AsyncMock, - ) as mock_ws, - ): - await openai_websocket_proxy_route( - websocket=websocket, - endpoint="v1/realtime", - user_api_key_dict=UserAPIKeyAuth(), + with patch(GET_CREDENTIALS, return_value="sk-provider"): + relay = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) + + assert relay.calls == [ + _RelayCall( + target="wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview", + custom_headers=MappingProxyType({"Authorization": "Bearer sk-provider"}), + forward_headers=False, + endpoint=f"/{prefix}/v1/realtime", + accept_websocket=False, ) - - kwargs = mock_ws.await_args.kwargs - assert kwargs["target"] == "wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview" - assert kwargs["custom_headers"] == {"Authorization": "Bearer sk-provider"} - assert kwargs["forward_headers"] is False - assert kwargs["endpoint"] == f"/{prefix}/v1/realtime" - assert kwargs["accept_websocket"] is False - websocket.accept.assert_awaited_once_with(subprotocol=None) - websocket.close.assert_not_awaited() + ] + assert websocket.accepts == [None] + assert websocket.sent == [] + assert websocket.closed is None @pytest.mark.asyncio async def test_openai_websocket_accepts_first_client_subprotocol(): - websocket = _mock_websocket( + websocket = _FakeWebSocket( "/openai/v1/realtime", "model=gpt-4o-realtime-preview", - headers={ - "sec-websocket-protocol": "realtime, openai-insecure-api-key.sk-abc, openai-beta.realtime-v1" - }, + subprotocols="realtime, openai-insecure-api-key.sk-abc, openai-beta.realtime-v1", ) - with ( - patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", - return_value="sk-provider", - ), - patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.websocket_passthrough_request", - new_callable=AsyncMock, - ) as mock_ws, - ): - await openai_websocket_proxy_route( - websocket=websocket, - endpoint="v1/realtime", - user_api_key_dict=UserAPIKeyAuth(), - ) + with patch(GET_CREDENTIALS, return_value="sk-provider"): + relay = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) - websocket.accept.assert_awaited_once_with(subprotocol="realtime") - assert mock_ws.await_args.kwargs["accept_websocket"] is False - websocket.close.assert_not_awaited() + assert websocket.accepts == ["realtime"] + assert [call.accept_websocket for call in relay.calls] == [False] + assert websocket.closed is None @pytest.mark.asyncio async def test_openai_websocket_closes_cleanly_when_provider_credentials_missing(): - websocket = _mock_websocket("/openai/v1/realtime", "model=gpt-4o-realtime-preview") + websocket = _FakeWebSocket("/openai/v1/realtime", "model=gpt-4o-realtime-preview") - with ( - patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", - return_value=None, - ), - patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.websocket_passthrough_request", - new_callable=AsyncMock, - ) as mock_ws, - ): - await openai_websocket_proxy_route( - websocket=websocket, - endpoint="v1/realtime", - user_api_key_dict=UserAPIKeyAuth(), - ) + with patch(GET_CREDENTIALS, return_value=None): + relay = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) - websocket.close.assert_awaited_once() - assert websocket.close.await_args.kwargs["code"] == 1011 - websocket.accept.assert_not_awaited() - mock_ws.assert_not_awaited() + assert websocket.closed is not None + assert websocket.closed[0] == 1011 + assert "OPENAI_API_KEY" in websocket.closed[1] + assert websocket.accepts == [] + assert relay.calls == [] @pytest.mark.asyncio -@pytest.mark.parametrize( - "user_api_key_dict", - [ - UserAPIKeyAuth(models=["gpt-4o"]), - UserAPIKeyAuth(team_models=["gpt-4o-realtime-preview"]), - UserAPIKeyAuth(models=["all-team-models"], team_models=["gpt-4o"]), - ], +@pytest.mark.parametrize("prefix", ["openai", "openai_passthrough"]) +@pytest.mark.parametrize("general_settings", DISABLED_SETTINGS) +async def test_openai_websocket_refused_unless_explicitly_enabled(prefix, general_settings): + websocket = _FakeWebSocket(f"/{prefix}/v1/realtime", "model=gpt-4o-realtime-preview") + + relay = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), general_settings) + + assert "enable_openai_websocket_passthrough" in websocket.error_message() + assert websocket.accepts == [None] + assert websocket.closed == (1008, _OPENAI_WS_DISABLED_REFUSAL.close_reason) + assert relay.calls == [] + + +@pytest.mark.parametrize("general_settings", DISABLED_SETTINGS) +def test_openai_websocket_refusal_is_disabled_for_falsy_settings(general_settings): + assert _openai_websocket_refusal(UserAPIKeyAuth(), general_settings) is _OPENAI_WS_DISABLED_REFUSAL + + +@pytest.mark.parametrize("value", [True, "true", "True"]) +def test_openai_websocket_refusal_is_none_for_truthy_settings(value): + settings = MappingProxyType({"enable_openai_websocket_passthrough": value}) + assert _openai_websocket_refusal(UserAPIKeyAuth(), settings) is None + + +@pytest.mark.asyncio +async def test_openai_websocket_refusal_echoes_requested_subprotocol(): + websocket = _FakeWebSocket( + "/openai_passthrough/v1/realtime", + "model=gpt-4o-realtime-preview", + subprotocols="realtime, openai-beta.realtime-v1", + ) + + relay = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), MappingProxyType({})) + + assert websocket.accepts == ["realtime"] + assert websocket.closed == (1008, _OPENAI_WS_DISABLED_REFUSAL.close_reason) + assert relay.calls == [] + + +RESTRICTED_KEYS: Final = ( + UserAPIKeyAuth(models=["gpt-4o"]), + UserAPIKeyAuth(team_models=["gpt-4o-realtime-preview"]), + UserAPIKeyAuth(models=["all-team-models"], team_models=["gpt-4o"]), ) +UNRESTRICTED_KEYS: Final = ( + UserAPIKeyAuth(), + UserAPIKeyAuth(models=["all-proxy-models"]), + UserAPIKeyAuth(models=["*"]), + UserAPIKeyAuth(models=["all-team-models"], team_models=["all-proxy-models"]), +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("user_api_key_dict", RESTRICTED_KEYS) async def test_openai_websocket_rejects_model_restricted_keys(user_api_key_dict): - websocket = _mock_websocket("/openai/v1/realtime", "model=gpt-4o-realtime-preview") + websocket = _FakeWebSocket("/openai/v1/realtime", "model=gpt-4o-realtime-preview") - with patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.websocket_passthrough_request", - new_callable=AsyncMock, - ) as mock_ws: - await openai_websocket_proxy_route( - websocket=websocket, - endpoint="v1/realtime", - user_api_key_dict=user_api_key_dict, - ) + relay = await _serve(websocket, "v1/realtime", user_api_key_dict, ENABLED) - websocket.close.assert_awaited_once() - assert websocket.close.await_args.kwargs["code"] == 1008 - websocket.accept.assert_not_awaited() - mock_ws.assert_not_awaited() + assert "model restrictions" in websocket.error_message() + assert websocket.closed == (1008, _OPENAI_WS_MODEL_RESTRICTED_REFUSAL.close_reason) + assert relay.calls == [] + + +@pytest.mark.parametrize("user_api_key_dict", RESTRICTED_KEYS) +def test_openai_websocket_refusal_prefers_disabled_over_model_restriction(user_api_key_dict): + assert _openai_websocket_refusal(user_api_key_dict, MappingProxyType({})) is _OPENAI_WS_DISABLED_REFUSAL @pytest.mark.asyncio -@pytest.mark.parametrize( - "user_api_key_dict", - [ - UserAPIKeyAuth(), - UserAPIKeyAuth(models=["all-proxy-models"]), - UserAPIKeyAuth(models=["*"]), - UserAPIKeyAuth(models=["all-team-models"], team_models=["all-proxy-models"]), - ], -) +@pytest.mark.parametrize("user_api_key_dict", UNRESTRICTED_KEYS) async def test_openai_websocket_allows_unrestricted_keys(user_api_key_dict): - websocket = _mock_websocket("/openai/v1/responses", "") + websocket = _FakeWebSocket("/openai/v1/responses", "") - with ( - patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.passthrough_endpoint_router.get_credentials", - return_value="sk-provider", - ), - patch( - "litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints.websocket_passthrough_request", - new_callable=AsyncMock, - ) as mock_ws, - ): - await openai_websocket_proxy_route( - websocket=websocket, - endpoint="v1/responses", - user_api_key_dict=user_api_key_dict, - ) + with patch(GET_CREDENTIALS, return_value="sk-provider"): + relay = await _serve(websocket, "v1/responses", user_api_key_dict, ENABLED) - mock_ws.assert_awaited_once() - websocket.close.assert_not_awaited() + assert len(relay.calls) == 1 + assert websocket.sent == [] + assert websocket.closed is None diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index de1b7fe699e..cda1a3834ce 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -25731,6 +25731,11 @@ export interface components { * @description If True and SSO is configured (MICROSOFT_CLIENT_ID, GOOGLE_CLIENT_ID, GENERIC_CLIENT_ID, or SAML_IDP_METADATA_URL/XML), disables username/password login on /login, /v2/login, and /v3/login so SSO is the only way to reach the Admin UI. An admin locked out of the UI can still administer the proxy over the API with the master key; unset this setting and restart the proxy to restore UI username/password login. Default is False. */ disable_password_login_when_sso_enabled?: boolean | null; + /** + * Enable Openai Websocket Passthrough + * @description Serve the OpenAI pass-through WebSocket route, which relays frames to OpenAI under the proxy's own provider credential without reading them. Off by default. + */ + enable_openai_websocket_passthrough?: boolean | null; /** * Enable Public Model Hub * @description Public model hub for users to see what models they have access to, supported openai params, etc. From f27699a1c9dc9d23a27e2568d339706018b2f107 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:23:36 -0700 Subject: [PATCH 153/410] test(store_model_in_db): assert the 400 contract in the unknown-model spend log test --- tests/store_model_in_db_tests/test_openai_error_handling.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/store_model_in_db_tests/test_openai_error_handling.py b/tests/store_model_in_db_tests/test_openai_error_handling.py index 9a18d7f3420..9433375c16d 100644 --- a/tests/store_model_in_db_tests/test_openai_error_handling.py +++ b/tests/store_model_in_db_tests/test_openai_error_handling.py @@ -157,6 +157,9 @@ async def test_chat_completion_bad_model_with_spend_logs(): except json.JSONDecodeError: print(f"Could not parse response body as JSON: {response.text}") + assert ( + response.status_code == 400 + ), f"expected HTTP 400, got {response.status_code}: {response.text}" assert ( litellm_call_id is not None ), "Failed to get LiteLLM Call ID from response headers" @@ -191,7 +194,6 @@ async def test_chat_completion_bad_model_with_spend_logs(): # Verify the structure of the log entry assert log_entry["request_id"] == litellm_call_id assert log_entry["model"] == "non-existent-model" - assert log_entry["model_group"] == "non-existent-model" assert log_entry["spend"] == 0.0 assert log_entry["total_tokens"] == 0 assert log_entry["prompt_tokens"] == 0 @@ -206,8 +208,6 @@ async def test_chat_completion_bad_model_with_spend_logs(): error_info = log_entry["metadata"]["error_information"] assert "traceback" in error_info assert error_info["error_code"] == "400" - assert error_info["error_class"] == "BadRequestError" - assert "litellm.BadRequestError" in error_info["error_message"] assert "non-existent-model" in error_info["error_message"] # Verify request details From e2741b564376f8b30ec93848cf2d9c3e94aa32af Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Fri, 4 Sep 2026 18:24:16 -0700 Subject: [PATCH 154/410] fix(datadog_llm_obs): keep the guardrail audit record under message redaction (#39702) * fix(datadog_llm_obs): keep the guardrail audit record under message redaction Redaction nulled `guardrail_information` on the span whole, so an operator running `turn_off_message_logging` (or a caller sending `x-litellm-enable-message-redaction`) lost the record of which guardrails ran, what they returned, and what they masked. Four of the record's fields can quote the prompt; the rest report what the guardrail decided without reproducing it. Replace only those four, the way `_sanitize_guardrail_information_for_spend_logs` already does for spend logs, and declare the field list once in `litellm/types/utils.py` so both readers share it. * fix(datadog_llm_obs): keep a lone guardrail record, and test through the span Review round 1. A guardrail that writes the metadata key itself leaves a single record where the type says list, which Prometheus already normalizes at `_guardrail_overhead_seconds`. Redaction dropped that shape and the latency extraction raised on it, so the span was lost outright. Normalize once and use it in both places. The new tests now drive `create_llm_obs_payload` instead of reading the module's private helpers and the record's declared field names. --- .../integrations/datadog/datadog_llm_obs.py | 64 +++++++-- .../spend_tracking/spend_tracking_utils.py | 10 +- litellm/types/utils.py | 44 ++++++ .../datadog/test_datadog_llm_obs.py | 129 +++++++++++++++++- 4 files changed, 228 insertions(+), 19 deletions(-) diff --git a/litellm/integrations/datadog/datadog_llm_obs.py b/litellm/integrations/datadog/datadog_llm_obs.py index ec86c0ae1d9..728bf41856f 100644 --- a/litellm/integrations/datadog/datadog_llm_obs.py +++ b/litellm/integrations/datadog/datadog_llm_obs.py @@ -19,7 +19,7 @@ import httpx import litellm from litellm._logging import verbose_logger from litellm._uuid import uuid -from litellm.constants import REDACTED_BY_LITELLM +from litellm.constants import REDACTED_BY_LITELLM, REDACTED_BY_LITELM_STRING from litellm.integrations.custom_batch_logger import CustomBatchLogger from litellm.integrations.datadog.datadog_handler import ( get_datadog_base_url_from_env, @@ -46,9 +46,10 @@ from litellm.llms.custom_httpx.http_handler import ( from litellm.proxy.spend_tracking.savings import extract_cache_creation_tokens, extract_cache_read_tokens from litellm.types.integrations.datadog_llm_obs import * from litellm.types.utils import ( + AUDIT_GUARDRAIL_FIELDS, + PROMPT_CARRYING_GUARDRAIL_FIELDS, PROMPT_QUOTING_ROUTING_DECISION_FIELDS, CallTypes, - StandardLoggingGuardrailInformation, StandardLoggingPayload, StandardLoggingPayloadErrorInformation, ) @@ -60,6 +61,8 @@ _SAFE_REDACTED_MESSAGE_ROLES: Final = frozenset( {"agent", "assistant", "developer", "function", "model", "system", "tool", "user"} ) +_CLASSIFIED_GUARDRAIL_FIELDS: Final = AUDIT_GUARDRAIL_FIELDS | PROMPT_CARRYING_GUARDRAIL_FIELDS + _PROMPT_CARRYING_METADATA_FIELDS: Final = frozenset( { "routing_decision", @@ -108,6 +111,49 @@ def _router_span_fields( ) +def _guardrail_entries(guardrail_information: object) -> tuple[Mapping[str, object], ...]: + """The guardrail records as a sequence, whatever shape the payload carries. + + `guardrail_information` is typed as a list, but a guardrail that writes the metadata key itself + can leave a single record there; Prometheus normalizes the same shape at + `_guardrail_overhead_seconds`. + """ + if isinstance(guardrail_information, Mapping): + return (guardrail_information,) + if isinstance(guardrail_information, (list, tuple)): + return tuple(entry for entry in guardrail_information if isinstance(entry, Mapping)) + return () + + +def _guardrail_entry_without_prompt_carriers(entry: Mapping[str, object]) -> Mapping[str, object]: + """One guardrail record kept as its audit fields, with the prompt-quoting ones marked redacted. + + Built as an allow-list rather than a deny-list: a key neither set classifies is dropped, so a + guardrail that records its own extra detail cannot put the caller's prompt on a redacted span. + """ + return { # mutable-ok: a fresh record built per entry, handed straight to the span serializer + field: REDACTED_BY_LITELM_STRING if field in PROMPT_CARRYING_GUARDRAIL_FIELDS else value + for field, value in entry.items() + if field in _CLASSIFIED_GUARDRAIL_FIELDS + } + + +def _guardrail_information_without_prompt_carriers( + guardrail_information: object, +) -> tuple[Mapping[str, object], ...] | None: + """The guardrail records reduced to what a redacted span may carry. + + Redaction removes the prompt, not the record that a guardrail ran: the name, mode, status, + timings and masked-entity counts are what an operator reads to answer whether a guardrail + caught anything on a request, and none of them reproduce the prompt. Field-level rather than + dropping the list, which is what `_sanitize_guardrail_information_for_spend_logs` already does + for spend logs. + """ + if guardrail_information is None: + return None + return tuple(_guardrail_entry_without_prompt_carriers(entry) for entry in _guardrail_entries(guardrail_information)) + + def _metadata_without_prompt_carriers(standard_logging_metadata: Mapping[str, Any]) -> Mapping[str, Any]: """The metadata minus the records that quote prompts, tool arguments, tool results, or retrieved text.""" return MappingProxyType( @@ -872,7 +918,9 @@ class DataDogLLMObsLogger(CustomBatchLogger): "cache_key": standard_logging_payload.get("cache_key", "unknown"), "saved_cache_cost": standard_logging_payload.get("saved_cache_cost", 0), "guardrail_information": ( - None if redact_prompt_text else standard_logging_payload.get("guardrail_information", None) + _guardrail_information_without_prompt_carriers(standard_logging_payload.get("guardrail_information")) + if redact_prompt_text + else standard_logging_payload.get("guardrail_information", None) ), "is_streamed_request": self._get_stream_value_from_payload(standard_logging_payload), "latency_metrics": dict(self._get_latency_metrics(standard_logging_payload)), @@ -904,14 +952,12 @@ class DataDogLLMObsLogger(CustomBatchLogger): latency_metrics["litellm_overhead_time_ms"] = litellm_overhead_ms # Guardrail overhead latency - guardrail_info: Final[list[StandardLoggingGuardrailInformation] | None] = standard_logging_payload.get( - "guardrail_information" - ) - if guardrail_info is not None: + guardrail_info: Final = _guardrail_entries(standard_logging_payload.get("guardrail_information")) + if guardrail_info: total_duration = 0.0 for info in guardrail_info: - _guardrail_duration_seconds: float | None = info.get("duration") - if _guardrail_duration_seconds is not None: + _guardrail_duration_seconds = info.get("duration") + if isinstance(_guardrail_duration_seconds, (int, float, str)): total_duration += float(_guardrail_duration_seconds) if total_duration > 0: diff --git a/litellm/proxy/spend_tracking/spend_tracking_utils.py b/litellm/proxy/spend_tracking/spend_tracking_utils.py index 9ea2170d6ab..8a06bf68b81 100644 --- a/litellm/proxy/spend_tracking/spend_tracking_utils.py +++ b/litellm/proxy/spend_tracking/spend_tracking_utils.py @@ -37,6 +37,7 @@ from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload, SpendLogsR from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error from litellm.proxy.utils import PrismaClient, hash_token from litellm.types.utils import ( + PROMPT_CARRYING_GUARDRAIL_FIELDS, CallTypes, CostBreakdown, StandardLoggingGuardrailInformation, @@ -1073,13 +1074,6 @@ def _sanitize_guardrail_information_for_spend_logs( return [_redact_prompt_fields_in_guardrail_entry(entry) for entry in entries if isinstance(entry, dict)] -_PROMPT_CARRYING_GUARDRAIL_FIELDS: Final = ( - "guardrail_request", - "guardrail_response", - "match_details", - "classification", -) - _NUMERIC_COMPRESSION_STAT_KEYS: Final = ( "tokens_before", "tokens_after", @@ -1114,7 +1108,7 @@ def _redact_prompt_fields_in_guardrail_entry( preserved_stats: Final = _numeric_compression_stats_from_guardrail_response(entry.get("guardrail_response")) redacted: Final[StandardLoggingGuardrailInformation] = { **entry, - **{key: REDACTED_BY_LITELM_STRING for key in _PROMPT_CARRYING_GUARDRAIL_FIELDS if key in entry}, + **{key: REDACTED_BY_LITELM_STRING for key in PROMPT_CARRYING_GUARDRAIL_FIELDS if key in entry}, } if preserved_stats is None: return redacted diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 6745e65f81e..238986f86a6 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3080,6 +3080,50 @@ class GuardrailMode(TypedDict, total=False): GuardrailStatus = Literal["success", "guardrail_intervened", "guardrail_failed_to_respond", "not_run"] +# Fields on a guardrail record whose values can quote the caller's prompt: the payload sent to the +# guardrail, the provider response that echoes it back, and the two first-party hooks that inline +# prompt substrings (``block_code_execution`` and ``litellm_content_filter``). Every other field +# reports what the guardrail decided without reproducing the prompt, so redaction replaces these +# four and keeps the rest of the record. +PROMPT_CARRYING_GUARDRAIL_FIELDS: Final[frozenset[str]] = frozenset( + { + "guardrail_request", + "guardrail_response", + "match_details", + "classification", + } +) + +# The rest of the record: what the guardrail is, what it decided, how long it took and what it cost. +# None of these reproduce the prompt, so a redacted record keeps them and stays explainable. +# `test_every_guardrail_field_is_classified` fails if a field is added to the record without being +# placed in one set or the other, so a new field is dropped from redacted records rather than +# shipped unexamined. +AUDIT_GUARDRAIL_FIELDS: Final[frozenset[str]] = frozenset( + { + "guardrail_name", + "guardrail_provider", + "guardrail_mode", + "guardrail_status", + "start_time", + "end_time", + "duration", + "masked_entity_count", + "guardrail_id", + "policy_template", + "detection_method", + "confidence_score", + "patterns_checked", + "alert_recipients", + "risk_score", + "violation_categories", + "guardrail_action", + "guardrail_usage", + "guardrail_cost", + "guardrail_cost_in_spend", + } +) + class StandardLoggingGuardrailInformation(TypedDict, total=False): guardrail_name: str | None diff --git a/tests/test_litellm/integrations/datadog/test_datadog_llm_obs.py b/tests/test_litellm/integrations/datadog/test_datadog_llm_obs.py index 34c62864c4e..0555447e34f 100644 --- a/tests/test_litellm/integrations/datadog/test_datadog_llm_obs.py +++ b/tests/test_litellm/integrations/datadog/test_datadog_llm_obs.py @@ -12,7 +12,7 @@ spelling of prompt-cache counts (`prompt_tokens_details.cached_tokens`). import json import os from datetime import datetime, timedelta -from typing import Any +from typing import Any, Final from unittest.mock import patch import pytest @@ -20,6 +20,8 @@ import pytest import litellm from litellm.integrations.datadog.datadog_llm_obs import DataDogLLMObsLogger from litellm.litellm_core_utils.safe_json_dumps import safe_dumps +from litellm.types.guardrails import GuardrailEventHooks +from litellm.types.utils import StandardLoggingGuardrailInformation TOOL_DEFINITION: dict[str, Any] = { "type": "function", @@ -631,10 +633,133 @@ def test_redaction_drops_every_prompt_carrying_metadata_record(logger: DataDogLL for record in sensitive_metadata: assert record not in redacted["meta"]["metadata"] assert record in unredacted["meta"]["metadata"] - assert redacted["meta"]["metadata"]["guardrail_information"] is None + assert redacted["meta"]["metadata"]["guardrail_information"] == [ + {"guardrail_name": "g", "guardrail_request": "REDACTED_BY_LITELM"} + ] # the record survives; only the field quoting the prompt is replaced assert unredacted["meta"]["metadata"]["guardrail_information"] is not None +_AUDIT_RECORD: Final[StandardLoggingGuardrailInformation] = StandardLoggingGuardrailInformation( + guardrail_name="bedrock-pii", + guardrail_provider="bedrock", + guardrail_mode=GuardrailEventHooks.pre_call, + guardrail_status="guardrail_intervened", + guardrail_response={"action": "MASK", "match": "alice@acme.com"}, + match_details=[{"pattern": "email", "match": "alice@acme.com"}], + classification="the user asked for alice@acme.com", + masked_entity_count={"EMAIL": 2}, + violation_categories=["pii"], + duration=0.01, +) + + +def _payload_with_guardrail_record(guardrail_information: object) -> dict[str, Any]: + payload = build_payload() + payload["standard_logging_object"]["guardrail_information"] = guardrail_information + return payload + + +def test_redaction_keeps_the_guardrail_audit_record(logger: DataDogLLMObsLogger) -> None: + """Redaction removes the prompt, not the operator's record that a guardrail intervened.""" + redacted = _span_json( + _redacting_logger(turn_off_message_logging=True), + _payload_with_guardrail_record([dict(_AUDIT_RECORD)]), + ) + record = redacted["meta"]["metadata"]["guardrail_information"][0] + + for field in ("guardrail_request", "guardrail_response", "match_details", "classification"): + assert record.get(field, "REDACTED_BY_LITELM") == "REDACTED_BY_LITELM" + assert record["guardrail_name"] == "bedrock-pii" + assert record["guardrail_provider"] == "bedrock" + assert record["guardrail_mode"] == "pre_call" + assert record["guardrail_status"] == "guardrail_intervened" + assert record["masked_entity_count"] == {"EMAIL": 2} + assert record["violation_categories"] == ["pii"] + assert record["duration"] == 0.01 + assert "alice@acme.com" not in safe_dumps(redacted["meta"]["metadata"]) + + +def test_a_caller_supplied_redaction_header_cannot_blank_the_guardrail_record( + logger: DataDogLLMObsLogger, +) -> None: + """Any key may redact its own prompts with the header; none may erase what a guardrail caught.""" + payload = _payload_with_guardrail_record([dict(_AUDIT_RECORD)]) + payload["litellm_params"] = {"metadata": {"headers": {"x-litellm-enable-message-redaction": "true"}}} + + span = _span_json(logger, payload) + record = span["meta"]["metadata"]["guardrail_information"][0] + + assert span["meta"]["input"]["messages"] == [{"role": "user", "content": "redacted-by-litellm"}] + assert record["guardrail_status"] == "guardrail_intervened" + assert record["masked_entity_count"] == {"EMAIL": 2} + assert "alice@acme.com" not in safe_dumps(span["meta"]["metadata"]) + + +def test_a_guardrails_own_extra_field_never_reaches_a_redacted_span(logger: DataDogLLMObsLogger) -> None: + """A guardrail may record whatever it likes; only classified fields survive redaction.""" + span = _span_json( + _redacting_logger(turn_off_message_logging=True), + _payload_with_guardrail_record([{**_AUDIT_RECORD, "matched_text": "the caller asked about alice@acme.com"}]), + ) + record = span["meta"]["metadata"]["guardrail_information"][0] + + assert "matched_text" not in record + assert record["guardrail_status"] == "guardrail_intervened" + assert "alice@acme.com" not in safe_dumps(span["meta"]["metadata"]) + + +def test_a_lone_guardrail_record_survives_redaction(logger: DataDogLLMObsLogger) -> None: + """A guardrail that writes the metadata key itself leaves one record, not a list of them.""" + span = _span_json( + _redacting_logger(turn_off_message_logging=True), + _payload_with_guardrail_record(dict(_AUDIT_RECORD)), + ) + metadata = span["meta"]["metadata"] + + assert metadata["guardrail_information"] == [ + { + "guardrail_name": "bedrock-pii", + "guardrail_provider": "bedrock", + "guardrail_mode": "pre_call", + "guardrail_status": "guardrail_intervened", + "guardrail_response": "REDACTED_BY_LITELM", + "match_details": "REDACTED_BY_LITELM", + "classification": "REDACTED_BY_LITELM", + "masked_entity_count": {"EMAIL": 2}, + "violation_categories": ["pii"], + "duration": 0.01, + } + ] + assert metadata["latency_metrics"]["guardrail_overhead_time_ms"] == 10.0 + + +@pytest.mark.parametrize("guardrail_information", [None, [], 5, "abc", [None, "x"], {}]) +def test_odd_guardrail_shapes_still_produce_a_span( + guardrail_information: object, +) -> None: + """The redacted branch replaced an expression that could not fail, so it must not start failing.""" + span = _span_json( + _redacting_logger(turn_off_message_logging=True), + _payload_with_guardrail_record(guardrail_information), + ) + + assert span["meta"]["input"]["messages"] == [{"role": "user", "content": "redacted-by-litellm"}] + assert span["meta"]["metadata"]["guardrail_information"] in (None, [], [{}]) + + +def test_a_redacted_span_carries_every_declared_guardrail_field() -> None: + """A field added to the record without a redaction decision would be dropped, so it fails here.""" + declared = dict.fromkeys(StandardLoggingGuardrailInformation.__annotations__, "alice@acme.com") + payload = _payload_with_guardrail_record([{**declared, "duration": 0.01}]) + + span = _span_json(_redacting_logger(turn_off_message_logging=True), payload) + record = span["meta"]["metadata"]["guardrail_information"][0] + + assert set(record) == set(declared) + for field in ("guardrail_request", "guardrail_response", "match_details", "classification"): + assert record[field] == "REDACTED_BY_LITELM" + + def test_tool_definitions_accept_the_bare_anthropic_shape(logger: DataDogLLMObsLogger) -> None: """The Anthropic surface declares tools unwrapped, with input_schema instead of parameters.""" payload = build( From 08bb7de868f0d740c937585835333ac226faccad Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:28:17 -0700 Subject: [PATCH 155/410] fix(image_handling): cap in-flight remote media fetches per request --- .../prompt_templates/image_handling.py | 9 +++++++- .../litellm_core_utils/test_image_handling.py | 22 +++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index 5ae8d224556..4beaabc8b24 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -19,6 +19,7 @@ from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get from litellm.types.llms.openai import AllMessageValues MAX_IMGS_IN_MEMORY: Final = 10 +MAX_CONCURRENT_REMOTE_MEDIA_FETCHES: Final = 20 in_memory_cache: Final = InMemoryCache(max_size_in_memory=MAX_IMGS_IN_MEMORY) @@ -216,6 +217,11 @@ def _inline_message(message: AllMessageValues, data_urls: Mapping[str, str]) -> return inlined_message # pyright: ignore[reportReturnType] # the same message with its remote parts inlined +async def _fetch_data_url(url: str, in_flight: asyncio.Semaphore) -> str: + async with in_flight: + return await async_convert_url_to_base64(url) + + async def async_inline_remote_media( messages: list[AllMessageValues], # mutable-ok: every transform_request takes list[AllMessageValues] skip_url_prefixes: tuple[str, ...] = (), @@ -230,6 +236,7 @@ async def async_inline_remote_media( ) if not remote_urls: return messages - data_urls: Final = await asyncio.gather(*(async_convert_url_to_base64(url) for url in remote_urls)) + in_flight: Final = asyncio.Semaphore(MAX_CONCURRENT_REMOTE_MEDIA_FETCHES) + data_urls: Final = await asyncio.gather(*(_fetch_data_url(url, in_flight) for url in remote_urls)) inlined: Final = MappingProxyType(dict(zip(remote_urls, data_urls, strict=True))) return [_inline_message(message, inlined) for message in messages] # mutable-ok: transform_request takes a list diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index bcf4c7cb6ff..ae9016f2f9e 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -1,3 +1,4 @@ +import asyncio import copy import uuid from unittest.mock import patch @@ -9,6 +10,7 @@ import litellm from litellm import constants from litellm.litellm_core_utils.prompt_templates import image_handling from litellm.litellm_core_utils.prompt_templates.image_handling import ( + MAX_CONCURRENT_REMOTE_MEDIA_FETCHES, async_convert_url_to_base64, async_inline_remote_media, convert_url_to_base64, @@ -336,6 +338,26 @@ async def test_async_inline_remote_media_leaves_skipped_url_prefixes_untouched(a assert messages == snapshot +async def test_async_inline_remote_media_caps_in_flight_fetches_per_request(monkeypatch): + in_flight = {"now": 0, "peak": 0} + + async def serve_png_slowly(client, url, **kwargs): + in_flight["now"] += 1 + in_flight["peak"] = max(in_flight["peak"], in_flight["now"]) + await asyncio.sleep(0.01) + in_flight["now"] -= 1 + return Response(200, content=b"\x89PNG", headers={"content-type": "image/png"}, request=Request("GET", url)) + + monkeypatch.setattr(image_handling, "async_safe_get", serve_png_slowly) + urls = [f"https://img.example/{uuid.uuid4()}.png" for _ in range(MAX_CONCURRENT_REMOTE_MEDIA_FETCHES + 5)] + messages = [{"role": "user", "content": [{"type": "image_url", "image_url": {"url": url}} for url in urls]}] + + inlined = await async_inline_remote_media(messages) + + assert in_flight["peak"] == MAX_CONCURRENT_REMOTE_MEDIA_FETCHES + assert all(part["image_url"]["url"].startswith("data:image/png;base64,") for part in inlined[0]["content"]) + + async def test_async_inline_remote_media_leaves_messages_without_remote_parts_alone(async_only_image_fetch): messages = [ {"role": "user", "content": [{"type": "text", "text": "hi"}]}, From 2674934e45ffe7fc08d93be9684835e0297b1e59 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:28:53 -0700 Subject: [PATCH 156/410] feat(helm): render nodeSelector, tolerations, and affinity on the componentized chart migrations Job --- helm/litellm/templates/migrations-job.yaml | 12 ++++ helm/litellm/tests/migration_job_tests.yaml | 68 ++++++++++++++++++++- helm/litellm/values.yaml | 7 +++ 3 files changed, 86 insertions(+), 1 deletion(-) diff --git a/helm/litellm/templates/migrations-job.yaml b/helm/litellm/templates/migrations-job.yaml index 8d33081e72f..de1cc2b103b 100644 --- a/helm/litellm/templates/migrations-job.yaml +++ b/helm/litellm/templates/migrations-job.yaml @@ -77,4 +77,16 @@ spec: volumes: {{- toYaml . | nindent 8 }} {{- end }} + {{- with .Values.migrationJob.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.migrationJob.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.migrationJob.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} {{- end }} diff --git a/helm/litellm/tests/migration_job_tests.yaml b/helm/litellm/tests/migration_job_tests.yaml index c3f3083ece5..2ebb1b44926 100644 --- a/helm/litellm/tests/migration_job_tests.yaml +++ b/helm/litellm/tests/migration_job_tests.yaml @@ -1,4 +1,4 @@ -suite: test migrations Job ServiceAccount resolution and pod hardening +suite: test migrations Job ServiceAccount resolution, pod hardening, and scheduling templates: - migrations-job.yaml values: @@ -188,3 +188,69 @@ tests: asserts: - notExists: path: spec.activeDeadlineSeconds + + - it: renders no scheduling fields by default + asserts: + - isNull: + path: spec.template.spec.nodeSelector + - isNull: + path: spec.template.spec.tolerations + - isNull: + path: spec.template.spec.affinity + + - it: renders nodeSelector, tolerations, and affinity from the migrationJob values + set: + migrationJob.nodeSelector: + intent: no-csi-nodes + migrationJob.tolerations: + - key: intent + operator: Equal + value: no-csi-nodes + effect: NoSchedule + migrationJob.affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: intent + operator: In + values: + - no-csi-nodes + asserts: + - equal: + path: spec.template.spec.nodeSelector + value: + intent: no-csi-nodes + - equal: + path: spec.template.spec.tolerations + value: + - key: intent + operator: Equal + value: no-csi-nodes + effect: NoSchedule + - equal: + path: spec.template.spec.affinity + value: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: intent + operator: In + values: + - no-csi-nodes + + - it: does not inherit the gateway's scheduling values + set: + gateway.nodeSelector: + intent: no-csi-nodes + gateway.tolerations: + - key: intent + operator: Equal + value: no-csi-nodes + effect: NoSchedule + asserts: + - isNull: + path: spec.template.spec.nodeSelector + - isNull: + path: spec.template.spec.tolerations diff --git a/helm/litellm/values.yaml b/helm/litellm/values.yaml index 461330ba491..6c9fb9440c7 100644 --- a/helm/litellm/values.yaml +++ b/helm/litellm/values.yaml @@ -152,6 +152,13 @@ migrationJob: # the writable scratch space a read-only root filesystem needs. volumes: [] volumeMounts: [] + # Scheduling for the Job pod, same shape as gateway.nodeSelector / + # gateway.tolerations / gateway.affinity. The Job does not inherit the other + # components' scheduling values: a migration usually needs a larger node + # than the gateway, so pin it here explicitly. + nodeSelector: {} + tolerations: [] + affinity: {} image: repository: ghcr.io/berriai/litellm-migrations tag: "" # defaults to .Chart.AppVersion From f022b5eda8fbe7b4b5c005065f2003751b432296 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:30:55 -0700 Subject: [PATCH 157/410] test(e2e/batches): assert Bedrock batch cancel and list in the lifecycle Bedrock batch cancel (StopModelInvocationJob) and the managed list view both work through the proxy since LIT-4774, but the batches e2e still gated them off and the coverage registry claimed no cell for either. Flip can_cancel/can_list for the Bedrock provider, assert cancel the same way the OpenAI leg does, add the two registry cells the gates select, and update COVERAGE.md --- tests/e2e/batches/COVERAGE.md | 17 ++++++++++------- tests/e2e/batches/capabilities.py | 11 +++++++---- tests/e2e/batches/test_batches_e2e.py | 2 +- .../llm_nonconversational.yaml | 2 ++ 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/e2e/batches/COVERAGE.md b/tests/e2e/batches/COVERAGE.md index 6d50cb436e2..f5881d3df98 100644 --- a/tests/e2e/batches/COVERAGE.md +++ b/tests/e2e/batches/COVERAGE.md @@ -19,11 +19,14 @@ failures are hard test failures (see `tests/e2e/CLAUDE.md`). | OpenAI | yes | yes | yes | yes | yes (lifecycle + terminal output) | OpenAI Files | | Azure | yes | yes | yes | yes | yes (byte-verbatim) | Azure Files | | Vertex AI | yes | yes | yes | yes | yes (provider-transformed) | GCS (`gcs_bucket_name` / `GCS_BUCKET_NAME` on model) | -| Bedrock | yes (unified only) | yes | no (limited upstream) | no | yes (provider-transformed) | S3 (`s3_bucket_name` + `aws_*` + `AWS_BATCH_ROLE_ARN` on model) | +| Bedrock | yes (unified only) | yes | yes | yes (unfiltered managed list) | yes (provider-transformed) | S3 (`s3_bucket_name` + `aws_*` + `AWS_BATCH_ROLE_ARN` on model) | -Bedrock cancel is unreliable upstream and list is unsupported, so both are gated off -(`can_cancel=False`, `can_list=False`) when that provider is enabled in the matrix; -flipping those gates is tracked in LIT-4774 and deliberately not part of this suite. +Bedrock cancel maps to `StopModelInvocationJob` and comes back `cancelling`; the +lifecycle asserts it the same way it does for OpenAI (`_CANCEL_ASSERTED_PROVIDERS`). +Bedrock has no provider-side list, so list is the proxy's DB-backed managed view: the +gateway rejects a provider-filtered list under managed batches with a 400 and the +lifecycle falls back to the unfiltered `GET /v1/batches`, where the unified batch must +appear. Both were gated off until LIT-5730, after LIT-4774 landed cancel support. Bedrock file upload requires a model on the request (`encoded` / `unified` scenarios only); `model_param` and `provider_fallback` are omitted because `POST /bedrock/v1/files` has no model-less passthrough path. @@ -148,6 +151,6 @@ never landed. Unified (managed) batch cost is owned by the hourly `CheckBatchCost` poller, and a terminal DB status short-circuits retrieve for those ids, so the terminal-state cell uses the encoded path; poller timing does not fit an e2e gate and belongs in a -DI-stubbed proxy integration test under `tests/test_litellm/proxy/`. Bedrock -cancel/list stay gated pending LIT-4774. Gemini (non-Vertex) file content raises -`NotImplementedError` upstream and is not a coverage cell. +DI-stubbed proxy integration test under `tests/test_litellm/proxy/`. Gemini +(non-Vertex) file content raises `NotImplementedError` upstream and is not a +coverage cell. diff --git a/tests/e2e/batches/capabilities.py b/tests/e2e/batches/capabilities.py index ee44a50d215..1bcea0a61ee 100644 --- a/tests/e2e/batches/capabilities.py +++ b/tests/e2e/batches/capabilities.py @@ -143,8 +143,8 @@ PROVIDERS: tuple[Provider, ...] = ( "bedrock", batch_model_name("bedrock-batch"), "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", - can_cancel=False, - can_list=False, + can_cancel=True, + can_list=True, ), ) @@ -248,8 +248,9 @@ def coverage_cells_for_lifecycle(cap: Capability) -> tuple[str, ...]: """Registry cell ids that the parametrized lifecycle test covers for one capability. OpenAI has per-scenario cells plus granular create/retrieve/cancel/list/file - cells. Other providers have one basic cell each. File-upload cells for the - batch-backing path are included when the lifecycle uploads for that provider. + cells. Bedrock adds cancel and list cells behind its gates. Other providers + have one basic cell each. File-upload cells for the batch-backing path are + included when the lifecycle uploads for that provider. """ match cap.provider: case "openai": @@ -279,6 +280,8 @@ def coverage_cells_for_lifecycle(cap: Capability) -> tuple[str, ...]: return ( "llm.batches.bedrock.basic.nonstream.works", "llm.files.bedrock.upload.nonstream.works", + *(("llm.batches.bedrock.cancel.nonstream.works",) if cap.can_cancel else ()), + *(("llm.batches.bedrock.list.nonstream.works",) if cap.can_list else ()), ) case _: return () diff --git a/tests/e2e/batches/test_batches_e2e.py b/tests/e2e/batches/test_batches_e2e.py index 7af064b1fdd..cb9954b8e09 100644 --- a/tests/e2e/batches/test_batches_e2e.py +++ b/tests/e2e/batches/test_batches_e2e.py @@ -77,7 +77,7 @@ BATCH_OP_RETRIES = 5 # (connection refused, brief 500s) and the registry only has one basic cell per # provider (shared across scenarios). Create + retrieve already prove routing; # cancel is still deferred for cleanup, just not asserted for these two. -_CANCEL_ASSERTED_PROVIDERS = frozenset({"openai"}) +_CANCEL_ASSERTED_PROVIDERS = frozenset({"openai", "bedrock"}) def _transient_status(status_code: int) -> bool: diff --git a/tests/e2e/coverage_registry/llm_nonconversational.yaml b/tests/e2e/coverage_registry/llm_nonconversational.yaml index 47d296e61f3..635ea3f7ea5 100644 --- a/tests/e2e/coverage_registry/llm_nonconversational.yaml +++ b/tests/e2e/coverage_registry/llm_nonconversational.yaml @@ -23,6 +23,8 @@ - {id: llm.batches.vertex.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:98", rationale: "Vertex batches"} - {id: llm.batches.bedrock.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "batches/capabilities.py:98", rationale: "Bedrock batches (encoded/unified only)"} - {id: llm.batches.bedrock.assume_role.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: bedrock_converse, capability: assume_role, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Bedrock batch create under STS assume-role credentials"} +- {id: llm.batches.bedrock.cancel.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Bedrock batch cancel (StopModelInvocationJob) returns the same id with a cancelling/cancelled status"} +- {id: llm.batches.bedrock.list.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: bedrock_converse, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "A Bedrock managed batch is present in the GET /v1/batches list envelope"} - {id: llm.batches.hosted_vllm.basic.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: hosted_vllm, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "hosted_vllm OpenAI-compatible batch create"} - {id: llm.batches.openai.key_model_access_denied.nonstream.works, module: llm, tier: P0, subject_endpoint: batches, route: openai, capability: basic, streaming: nonstream, assertions: [works], source: "test_batches_e2e.py", rationale: "Key model restriction 403 on upload/create"} - {id: llm.batches.openai.input_validation.nonstream.works, module: llm, tier: P1, subject_endpoint: batches, route: openai, capability: input_validation, streaming: nonstream, assertions: [works], source: "vendor strategy §9.18 / LIT-4778", rationale: "Missing input_file_id and invalid batch id rejected"} From a534b9fac595a743146a02dc2d1ffa1cab94e533 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:35:23 -0700 Subject: [PATCH 158/410] fix(fireworks_ai): send developer input items as system messages on the native responses path --- .../fireworks_ai/responses/transformation.py | 15 +++++++++++++- ...t_fireworks_ai_responses_transformation.py | 20 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/litellm/llms/fireworks_ai/responses/transformation.py b/litellm/llms/fireworks_ai/responses/transformation.py index 7f29e03ff0f..9265d12e75e 100644 --- a/litellm/llms/fireworks_ai/responses/transformation.py +++ b/litellm/llms/fireworks_ai/responses/transformation.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Final from urllib.parse import unquote import httpx +from openai.types.responses import EasyInputMessageParam, ResponseInputItemParam from litellm.llms.fireworks_ai.common_utils import ( resolve_fireworks_api_key, @@ -30,6 +31,18 @@ def _session_params(litellm_params: GenericLiteLLMParams) -> Mapping[str, object ) +def _developer_item_as_system(item: ResponseInputItemParam) -> ResponseInputItemParam: + if "role" not in item or item["role"] != "developer": + return item + return EasyInputMessageParam(role="system", content=item["content"], type="message") + + +def _developer_items_as_system(input: str | ResponseInputParam) -> str | ResponseInputParam: + if isinstance(input, str): + return input + return [_developer_item_as_system(item) for item in input] + + class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig): @property def custom_llm_provider(self) -> LlmProviders: @@ -65,7 +78,7 @@ class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig): ) -> dict: # mutable-ok: overrides the base class signature return super().transform_responses_api_request( model=resolve_fireworks_resource_name(model), - input=input, + input=_developer_items_as_system(input), response_api_optional_request_params=response_api_optional_request_params, litellm_params=litellm_params, headers=headers, diff --git a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py index 8fac91ee475..e6e92824ee1 100644 --- a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py @@ -160,6 +160,26 @@ def test_responses_call_forwards_previous_response_id_and_store() -> None: assert body["input"][0]["call_id"] == "call_abc123" +def test_responses_call_sends_developer_items_as_system_messages() -> None: + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + litellm.responses( + model="fireworks_ai/accounts/fireworks/models/kimi-k3", + input=[ # mutable-ok: the Responses API takes input as a JSON list + {"role": "user", "content": "Hi there"}, + {"role": "developer", "content": "Answer with exactly one word."}, + {"role": "user", "content": [{"type": "input_text", "text": "What is the capital of France?"}]}, + ], + api_key="fw-test-key", + ) + _, _, body = _sent_request(client) + assert tuple(body["input"]) == ( + {"role": "user", "content": "Hi there"}, + {"role": "system", "content": "Answer with exactly one word.", "type": "message"}, + {"role": "user", "content": [{"type": "input_text", "text": "What is the capital of France?"}]}, + ) + + def test_responses_call_sends_session_affinity_for_caller_session_id() -> None: client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) with patch(HTTPX_CLIENT_FACTORY, return_value=client): From 88ada40cdad9e711e7b40d5d174464667474585c Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 18:37:38 -0700 Subject: [PATCH 159/410] fix(type-checking): satisfy the basedpyright budget gate for auto-router compression Two fixes for the zero-headroom basedpyright budget: - arm_pre_call's data parameter is dict[str, object], not MutableMapping: the latter is itself banned by LIT001 with no benefit, and it mismatched every dict-typed helper (get_or_create_metadata_bucket, resolve_structured_messages, _get_tags_from_request_kwargs), which is what the budget was actually flagging. - Router.async_pre_routing_hook computed pre_routing_hook_response in one shot instead of reassigning a Final-annotated local. The remaining two reportArgumentType hits are pre-existing: LiteLLM_Params(**merged) in _create_deployment_object already fails this check for all ~165 of its other fields, since the merged dict's value type is partly untyped/float; adding two new string fields to the model just grows that existing pile by two. Suppressed at the one call site with a reason, since fixing the root typing is out of scope here. --- .../proxy/guardrails/auto_router_compression.py | 12 ++++++++---- litellm/router.py | 16 +++++++--------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py index d26479f7de0..3b0804e40e2 100644 --- a/litellm/proxy/guardrails/auto_router_compression.py +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -13,7 +13,7 @@ each hop sees. """ import contextvars -from collections.abc import Iterable, Mapping, MutableMapping, Sequence +from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType from typing import TYPE_CHECKING, Final @@ -89,7 +89,7 @@ def policy_for_model( markers: Final = tuple( litellm_params for deployment in deployments - if isinstance(litellm_params := deployment.get("litellm_params"), Mapping) + if isinstance(litellm_params := deployment.get("litellm_params"), Mapping) # pyright: ignore[reportUnnecessaryIsInstance] # filters out non-Mapping and str(litellm_params.get("model", "")).startswith(AUTO_ROUTER_MODEL_PREFIX) ) requested: Final = frozenset(request_tags) @@ -130,7 +130,7 @@ def _active_compression_guardrails() -> tuple["CustomGuardrail", ...]: async def arm_pre_call( - data: MutableMapping[str, object], # mutable-ok: arms the live request dict in place + data: dict[str, object], # mutable-ok: arms the live request dict in place llm_router: "Router | None", ) -> None: """Apply an auto router's compression policy, if any, before guardrails run. @@ -183,7 +183,11 @@ async def arm_pre_call( from litellm.litellm_core_utils.prompt_templates.factory import resolve_structured_messages - snapshot: Final = resolve_structured_messages(messages=data.get("messages"), request_kwargs=data) + raw_messages: Final = data.get("messages") + snapshot: Final = resolve_structured_messages( + messages=raw_messages if isinstance(raw_messages, list) else None, + request_kwargs=data, + ) if snapshot is not None: _routing_messages_snapshot.set(tuple(MappingProxyType(dict(message)) for message in snapshot)) diff --git a/litellm/router.py b/litellm/router.py index 9637ad98c9a..989914b1610 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8644,7 +8644,7 @@ class Router: raise ValueError(ptu_error) zeroed_pricing: Final = zeroed_ptu_pricing(_model_info, _litellm_params) if config_sourced else None litellm_params: Final[LiteLLM_Params] = LiteLLM_Params( - **( + **( # pyright: ignore[reportArgumentType] # untyped merged dict; already true for every field here _litellm_params if zeroed_pricing is None else MappingProxyType({**_litellm_params, **zeroed_pricing}) @@ -13066,7 +13066,7 @@ class Router: else None ) - pre_routing_hook_response: Final = await selected_strategy.strategy.async_pre_routing_hook( + routed: Final = await selected_strategy.strategy.async_pre_routing_hook( model=registered_model_name, request_kwargs=request_kwargs, messages=routing_messages if routing_messages is not None else messages, @@ -13079,13 +13079,11 @@ class Router: # Compared by value, not identity: PreRoutingHookResponse is a pydantic model, # and pydantic reconstructs a validated list field rather than keeping the # exact object passed in, even when nothing about it changed. - if ( - pre_routing_hook_response is not None - and routing_messages is not None - and pre_routing_hook_response.messages == routing_messages - ): - restored: Final = {"messages": messages} # mutable-ok: pydantic's model_copy takes a dict - pre_routing_hook_response = pre_routing_hook_response.model_copy(update=restored) + pre_routing_hook_response: Final = ( + routed.model_copy(update={"messages": messages}) # mutable-ok: pydantic's model_copy takes a dict + if routed is not None and routing_messages is not None and routed.messages == routing_messages + else routed + ) self._record_routing_decision( request_kwargs=request_kwargs, routing_decision=(pre_routing_hook_response.routing_decision if pre_routing_hook_response else None), From 1748dd81a7c206c030b6fd5d87d476c1bdf4b0b6 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:39:28 -0700 Subject: [PATCH 160/410] docs(e2e/batches): say the unified Bedrock lifecycle lists with plain GET /v1/batches --- tests/e2e/batches/COVERAGE.md | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/e2e/batches/COVERAGE.md b/tests/e2e/batches/COVERAGE.md index f5881d3df98..2b1f60cbda7 100644 --- a/tests/e2e/batches/COVERAGE.md +++ b/tests/e2e/batches/COVERAGE.md @@ -24,9 +24,8 @@ failures are hard test failures (see `tests/e2e/CLAUDE.md`). Bedrock cancel maps to `StopModelInvocationJob` and comes back `cancelling`; the lifecycle asserts it the same way it does for OpenAI (`_CANCEL_ASSERTED_PROVIDERS`). Bedrock has no provider-side list, so list is the proxy's DB-backed managed view: the -gateway rejects a provider-filtered list under managed batches with a 400 and the -lifecycle falls back to the unfiltered `GET /v1/batches`, where the unified batch must -appear. Both were gated off until LIT-5730, after LIT-4774 landed cancel support. +unified lifecycle lists with the plain `GET /v1/batches` and the batch must appear +there. Both were gated off until LIT-5730, after LIT-4774 landed cancel support. Bedrock file upload requires a model on the request (`encoded` / `unified` scenarios only); `model_param` and `provider_fallback` are omitted because `POST /bedrock/v1/files` has no model-less passthrough path. From 7351911b533717155599bdfcbf09701aa1760fe5 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:39:56 -0700 Subject: [PATCH 161/410] fix(proxy): refuse OpenAI websocket passthrough on every enforced model allowlist and propagate the DB opt-in --- litellm/proxy/auth/auth_checks.py | 56 ++ .../llm_passthrough_endpoints.py | 37 +- litellm/proxy/proxy_server.py | 5 + .../proxy/auth/test_auth_checks.py | 602 +++++++----------- .../test_openai_ws_passthrough_routes.py | 128 ++-- tests/test_litellm/proxy/test_proxy_server.py | 82 ++- 6 files changed, 461 insertions(+), 449 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index f83f0303deb..98d334ce2cc 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -4155,6 +4155,62 @@ async def _granted_model_lists( ) +async def enforced_model_allowlists( + valid_token: UserAPIKeyAuth, + prisma_client: PrismaClient | None, + user_api_key_cache: UserApiKeyCache, + proxy_logging_obj: ProxyLogging, +) -> tuple[Sequence[str], ...]: + """One model allowlist per level that ``common_checks`` enforces on a request from this identity.""" + key_models: Final = _resolve_key_models_for_auth_check(valid_token=valid_token) + if prisma_client is None: + return (key_models,) + team_object: Final = ( + None + if valid_token.team_id is None + else await get_team_object( + team_id=valid_token.team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + ) + user_object: Final = ( + None + if team_object is not None + else await get_user_object( + user_id=valid_token.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + ) + project_object: Final = ( + None + if valid_token.project_id is None + else await get_project_object( + project_id=valid_token.project_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + ) + return ( + key_models, + team_object.models if team_object is not None else (), + await _team_member_granted_models( + valid_token=valid_token, + team_object=team_object, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ), + user_object.models if user_object is not None else (), + project_object.models if project_object is not None else (), + ) + + async def collect_matched_model_access_groups( model: str | Sequence[str] | None, valid_token: UserAPIKeyAuth | None, diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 97d25e20939..e92c949299c 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -13,7 +13,7 @@ import inspect import json import os import re -from collections.abc import AsyncGenerator, Callable, Mapping +from collections.abc import AsyncGenerator, Callable, Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType from typing import TYPE_CHECKING, Annotated, Final, Protocol, cast @@ -36,6 +36,7 @@ from litellm.llms.custom_httpx.http_handler import get_async_httpx_client from litellm.llms.vertex_ai.vertex_llm_base import VertexBase from litellm.passthrough.main import AsyncPassthroughStreamingResponse from litellm.proxy._types import * +from litellm.proxy.auth.auth_checks import enforced_model_allowlists from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.auth.user_api_key_auth import ( @@ -2341,9 +2342,8 @@ _OPENAI_WS_ALL_MODEL_ACCESS: Final = frozenset( ) -def _key_has_model_restrictions(user_api_key_dict: UserAPIKeyAuth) -> bool: - scoped_models: Final = (*user_api_key_dict.models, *user_api_key_dict.team_models) - return any(str(model) not in _OPENAI_WS_ALL_MODEL_ACCESS for model in scoped_models) +def _has_model_restrictions(model_allowlists: tuple[Sequence[str], ...]) -> bool: + return any(str(model) not in _OPENAI_WS_ALL_MODEL_ACCESS for allowlist in model_allowlists for model in allowlist) @dataclass(frozen=True, slots=True) @@ -2376,12 +2376,18 @@ def _is_openai_websocket_passthrough_enabled(general_settings: Mapping[str, obje return setting is True -def _openai_websocket_refusal( - user_api_key_dict: UserAPIKeyAuth, general_settings: Mapping[str, object] +class _OpenAIWebsocketModelAllowlists(Protocol): + async def __call__(self, valid_token: UserAPIKeyAuth, /) -> tuple[Sequence[str], ...]: ... + + +async def _openai_websocket_refusal( + user_api_key_dict: UserAPIKeyAuth, + general_settings: Mapping[str, object], + model_allowlists: _OpenAIWebsocketModelAllowlists, ) -> _OpenAIWebsocketRefusal | None: if not _is_openai_websocket_passthrough_enabled(general_settings): return _OPENAI_WS_DISABLED_REFUSAL - if _key_has_model_restrictions(user_api_key_dict): + if _has_model_restrictions(await model_allowlists(user_api_key_dict)): return _OPENAI_WS_MODEL_RESTRICTED_REFUSAL return None @@ -2410,6 +2416,20 @@ def _openai_websocket_relay() -> _OpenAIWebsocketRelay: return websocket_passthrough_request +def _proxy_model_allowlists() -> _OpenAIWebsocketModelAllowlists: + from litellm.proxy.proxy_server import prisma_client, proxy_logging_obj, user_api_key_cache + + async def resolve(valid_token: UserAPIKeyAuth, /) -> tuple[Sequence[str], ...]: + return await enforced_model_allowlists( + valid_token=valid_token, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + + return resolve + + @router.websocket("/openai_passthrough/{endpoint:path}") @router.websocket("/openai/{endpoint:path}") async def openai_websocket_proxy_route( @@ -2418,6 +2438,7 @@ async def openai_websocket_proxy_route( user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth_websocket)], general_settings: Annotated[Mapping[str, object], Depends(_proxy_general_settings)], relay: Annotated[_OpenAIWebsocketRelay, Depends(_openai_websocket_relay)], + model_allowlists: Annotated[_OpenAIWebsocketModelAllowlists, Depends(_proxy_model_allowlists)], ) -> None: """WebSocket passthrough for OpenAI prefixes (realtime / responses.connect).""" requested_subprotocols: Final = tuple( @@ -2427,7 +2448,7 @@ async def openai_websocket_proxy_route( ) negotiated_subprotocol: Final = requested_subprotocols[0] if requested_subprotocols else None - refusal: Final = _openai_websocket_refusal(user_api_key_dict, general_settings) + refusal: Final = await _openai_websocket_refusal(user_api_key_dict, general_settings, model_allowlists) if refusal is not None: await websocket.accept(subprotocol=negotiated_subprotocol) await websocket.send_text( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 5a39b8c610a..e56395a6169 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -6793,6 +6793,11 @@ class ProxyConfig: else: general_settings["apply_user_budget_to_team_keys"] = db_value if db_value is None else bool(db_value) + if "enable_openai_websocket_passthrough" not in self._yaml_general_settings_keys: + general_settings["enable_openai_websocket_passthrough"] = _general_settings.get( + "enable_openai_websocket_passthrough" + ) + ## STORE MODEL IN DB ## if "store_model_in_db" in _general_settings: value = _general_settings["store_model_in_db"] diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index be83ca57e76..45e10948267 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -126,14 +126,10 @@ def invalid_sso_user_defined_values(): def test_get_experimental_ui_login_jwt_auth_token_valid(valid_sso_user_defined_values): """Test generating JWT token with valid user role""" - token = ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token( - valid_sso_user_defined_values - ) + token = ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(valid_sso_user_defined_values) # Decrypt and verify token contents - decrypted_token = decrypt_value_helper( - token, key="ui_hash_key", exception_type="debug" - ) + decrypted_token = decrypt_value_helper(token, key="ui_hash_key", exception_type="debug") # Check that decrypted_token is not None before using json.loads assert decrypted_token is not None token_data = json.loads(decrypted_token) @@ -159,9 +155,7 @@ def test_get_cli_jwt_auth_token_includes_team_alias(valid_sso_user_defined_value team_alias="test-team", ) - decrypted_token = decrypt_value_helper( - token, key="ui_hash_key", exception_type="debug" - ) + decrypted_token = decrypt_value_helper(token, key="ui_hash_key", exception_type="debug") assert decrypted_token is not None token_data = json.loads(decrypted_token) @@ -188,9 +182,7 @@ def test_get_cli_jwt_auth_token_carries_team_grants_not_user_allowlist( team_model_aliases={"team-fast": "gpt-4.1-mini"}, ) - decrypted_token = decrypt_value_helper( - token, key="ui_hash_key", exception_type="debug" - ) + decrypted_token = decrypt_value_helper(token, key="ui_hash_key", exception_type="debug") assert decrypted_token is not None token_data = json.loads(decrypted_token) @@ -207,9 +199,7 @@ def test_get_cli_jwt_auth_token_keeps_user_allowlist_when_no_team( """A session token with no team bound still carries the user's own allowlist.""" token = ExperimentalUIJWTToken.get_cli_jwt_auth_token(valid_sso_user_defined_values) - decrypted_token = decrypt_value_helper( - token, key="ui_hash_key", exception_type="debug" - ) + decrypted_token = decrypt_value_helper(token, key="ui_hash_key", exception_type="debug") assert decrypted_token is not None token_data = json.loads(decrypted_token) @@ -222,12 +212,8 @@ def test_get_experimental_ui_login_jwt_auth_token_uses_10_min_expiry( valid_sso_user_defined_values, ): """Test that Experimental UI token uses fixed 10-minute expiry (does not use LITELLM_UI_SESSION_DURATION).""" - token = ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token( - valid_sso_user_defined_values - ) - decrypted_token = decrypt_value_helper( - token, key="ui_hash_key", exception_type="debug" - ) + token = ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(valid_sso_user_defined_values) + decrypted_token = decrypt_value_helper(token, key="ui_hash_key", exception_type="debug") assert decrypted_token is not None token_data = json.loads(decrypted_token) expires = datetime.fromisoformat(token_data["expires"].replace("Z", "+00:00")) @@ -244,43 +230,33 @@ def test_experimental_ui_token_ignores_litellm_ui_session_duration( Experimental UI intentionally uses fixed 10-min expiry. If this test fails, the constant was incorrectly wired to the experimental flow.""" # Default LITELLM_UI_SESSION_DURATION is "24h" - token must still expire in ~10 min - token = ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token( - valid_sso_user_defined_values - ) - decrypted_token = decrypt_value_helper( - token, key="ui_hash_key", exception_type="debug" - ) + token = ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(valid_sso_user_defined_values) + decrypted_token = decrypt_value_helper(token, key="ui_hash_key", exception_type="debug") assert decrypted_token is not None token_data = json.loads(decrypted_token) expires = datetime.fromisoformat(token_data["expires"].replace("Z", "+00:00")) now = get_utc_datetime() # Must be ~10 min, NOT 24h. If LITELLM_UI_SESSION_DURATION were incorrectly used, this would fail. - assert expires <= now + timedelta( - minutes=11 - ), "Experimental UI must use 10-min expiry, not LITELLM_UI_SESSION_DURATION" + assert expires <= now + timedelta(minutes=11), ( + "Experimental UI must use 10-min expiry, not LITELLM_UI_SESSION_DURATION" + ) def test_get_experimental_ui_login_jwt_auth_token_invalid( invalid_sso_user_defined_values, ): """Test generating JWT token with missing user role""" - with pytest.raises(Exception, match='User role is required for experimental UI login') as exc_info: - ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token( - invalid_sso_user_defined_values - ) + with pytest.raises(Exception, match="User role is required for experimental UI login") as exc_info: + ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(invalid_sso_user_defined_values) assert str(exc_info.value) == "User role is required for experimental UI login" -def test_get_key_object_from_ui_hash_key_valid( - valid_sso_user_defined_values, monkeypatch -): +def test_get_key_object_from_ui_hash_key_valid(valid_sso_user_defined_values, monkeypatch): """Test getting key object from valid UI hash key""" monkeypatch.setenv("EXPERIMENTAL_UI_LOGIN", "True") # Generate a valid token - token = ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token( - valid_sso_user_defined_values - ) + token = ExperimentalUIJWTToken.get_experimental_ui_login_jwt_auth_token(valid_sso_user_defined_values) # Get key object key_object = ExperimentalUIJWTToken.get_key_object_from_ui_hash_key(token) @@ -309,9 +285,7 @@ def test_get_key_object_from_ui_hash_key_invalid(): ("project", ProxyErrorTypes.project_model_access_denied), ], ) -def test_can_object_call_model_denials_return_forbidden( - object_type, expected_error_type -): +def test_can_object_call_model_denials_return_forbidden(object_type, expected_error_type): with pytest.raises(ProxyException) as exc_info: _can_object_call_model( model="restricted-model", @@ -568,9 +542,7 @@ async def test_get_key_object_should_reconnect_once_on_db_connection_error(): @pytest.mark.asyncio async def test_get_key_object_should_raise_if_reconnect_fails_on_db_connection_error(): mock_prisma_client = MagicMock() - mock_prisma_client.get_data = AsyncMock( - side_effect=httpx.ConnectError("db not reachable after outage") - ) + mock_prisma_client.get_data = AsyncMock(side_effect=httpx.ConnectError("db not reachable after outage")) mock_prisma_client.attempt_db_reconnect = AsyncMock(return_value=False) mock_cache = MagicMock() @@ -613,9 +585,7 @@ class TestAuthCacheRedisWritePolicy: @pytest.mark.asyncio async def test_get_key_object_db_load_publishes_to_redis(self): mock_prisma_client = MagicMock() - mock_prisma_client.get_data = AsyncMock( - return_value=UserAPIKeyAuth(token="hashed-token-db") - ) + mock_prisma_client.get_data = AsyncMock(return_value=UserAPIKeyAuth(token="hashed-token-db")) fake_redis = _fake_redis_cache() cache = UserApiKeyCache() @@ -630,8 +600,7 @@ class TestAuthCacheRedisWritePolicy: assert key_obj.token == "hashed-token-db" fake_redis.async_set_cache.assert_awaited_once() assert ( - fake_redis.async_set_cache.await_args.kwargs.get("key") - or fake_redis.async_set_cache.await_args.args[0] + fake_redis.async_set_cache.await_args.kwargs.get("key") or fake_redis.async_set_cache.await_args.args[0] ) == "hashed-token-db" @@ -640,9 +609,7 @@ def test_get_cli_jwt_auth_token_default_expiration(valid_sso_user_defined_values token = ExperimentalUIJWTToken.get_cli_jwt_auth_token(valid_sso_user_defined_values) # Decrypt and verify token contents - decrypted_token = decrypt_value_helper( - token, key="ui_hash_key", exception_type="debug" - ) + decrypted_token = decrypt_value_helper(token, key="ui_hash_key", exception_type="debug") assert decrypted_token is not None token_data = json.loads(decrypted_token) @@ -664,9 +631,7 @@ def test_get_cli_jwt_auth_token_default_expiration(valid_sso_user_defined_values assert expires >= get_utc_datetime() + timedelta(hours=23, minutes=59) -def test_get_cli_jwt_auth_token_custom_expiration( - valid_sso_user_defined_values, monkeypatch -): +def test_get_cli_jwt_auth_token_custom_expiration(valid_sso_user_defined_values, monkeypatch): """Test generating CLI JWT token with custom expiration via environment variable""" import importlib @@ -681,14 +646,10 @@ def test_get_cli_jwt_auth_token_custom_expiration( # Also reload auth_checks to pick up the new constant value importlib.reload(auth_checks) - token = auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token( - valid_sso_user_defined_values - ) + token = auth_checks.ExperimentalUIJWTToken.get_cli_jwt_auth_token(valid_sso_user_defined_values) # Decrypt and verify token contents - decrypted_token = decrypt_value_helper( - token, key="ui_hash_key", exception_type="debug" - ) + decrypted_token = decrypt_value_helper(token, key="ui_hash_key", exception_type="debug") assert decrypted_token is not None token_data = json.loads(decrypted_token) @@ -706,18 +667,12 @@ def test_get_cli_jwt_auth_token_unique_per_session(valid_sso_user_defined_values from litellm.constants import CLI_SESSION_KEY_PREFIX def _decode(token: str) -> dict: - decrypted = decrypt_value_helper( - token, key="ui_hash_key", exception_type="debug" - ) + decrypted = decrypt_value_helper(token, key="ui_hash_key", exception_type="debug") assert decrypted is not None return json.loads(decrypted) - first = _decode( - ExperimentalUIJWTToken.get_cli_jwt_auth_token(valid_sso_user_defined_values) - ) - second = _decode( - ExperimentalUIJWTToken.get_cli_jwt_auth_token(valid_sso_user_defined_values) - ) + first = _decode(ExperimentalUIJWTToken.get_cli_jwt_auth_token(valid_sso_user_defined_values)) + second = _decode(ExperimentalUIJWTToken.get_cli_jwt_auth_token(valid_sso_user_defined_values)) assert first["token"].startswith(f"{CLI_SESSION_KEY_PREFIX}-") assert second["token"].startswith(f"{CLI_SESSION_KEY_PREFIX}-") @@ -740,9 +695,7 @@ def test_get_cli_jwt_auth_token_applies_fallback_budget(valid_sso_user_defined_v def test_get_cli_jwt_auth_token_no_fallback_when_budget_provided( valid_sso_user_defined_values, ): - token = ExperimentalUIJWTToken.get_cli_jwt_auth_token( - valid_sso_user_defined_values, max_budget=None - ) + token = ExperimentalUIJWTToken.get_cli_jwt_auth_token(valid_sso_user_defined_values, max_budget=None) decrypted = decrypt_value_helper(token, key="ui_hash_key", exception_type="debug") assert decrypted is not None assert json.loads(decrypted).get("max_budget") is None @@ -945,9 +898,7 @@ async def test_get_user_object_upsert_includes_user_email(): mock_prisma_client.db.litellm_usertable.create.assert_called_once() creation_args = mock_prisma_client.db.litellm_usertable.create.call_args[1]["data"] - assert ( - "user_email" in creation_args - ), "user_email should be included when upserting a new user" + assert "user_email" in creation_args, "user_email should be included when upserting a new user" assert creation_args["user_email"] == "test@example.com" assert creation_args["user_id"] == "new_test_user" @@ -962,12 +913,8 @@ async def test_get_user_object_backfills_null_email_from_cache_hit(): was returned unchanged and the DB was never updated. """ cache = UserApiKeyCache() - existing = LiteLLM_UserTable( - user_id="jwt-user-1", user_email=None, user_role="internal_user" - ) - await cache.async_set_cache( - key="jwt-user-1", value=existing, model_type=LiteLLM_UserTable - ) + existing = LiteLLM_UserTable(user_id="jwt-user-1", user_email=None, user_role="internal_user") + await cache.async_set_cache(key="jwt-user-1", value=existing, model_type=LiteLLM_UserTable) mock_prisma_client = MagicMock() mock_prisma_client.db.litellm_usertable.update_many = AsyncMock(return_value=1) @@ -996,9 +943,7 @@ async def test_get_user_object_backfills_null_email_from_cache_hit(): assert update_kwargs["where"] == {"user_id": "jwt-user-1", "user_email": None} assert update_kwargs["data"]["user_email"] == "jwt-user-1@example.com" - refreshed = await cache.async_get_cache( - key="jwt-user-1", model_type=LiteLLM_UserTable - ) + refreshed = await cache.async_get_cache(key="jwt-user-1", model_type=LiteLLM_UserTable) assert refreshed is not None assert refreshed.user_email == "jwt-user-1@example.com" @@ -1010,9 +955,7 @@ async def test_get_user_object_backfills_null_email_from_db_read(): backfilled from the JWT-provided email before it is cached and returned. """ cache = UserApiKeyCache() - db_row = LiteLLM_UserTable( - user_id="jwt-user-3", user_email=None, user_role="internal_user" - ) + db_row = LiteLLM_UserTable(user_id="jwt-user-3", user_email=None, user_role="internal_user") backfilled_row = LiteLLM_UserTable( user_id="jwt-user-3", user_email="jwt-user-3@example.com", @@ -1020,15 +963,11 @@ async def test_get_user_object_backfills_null_email_from_db_read(): ) mock_prisma_client = MagicMock() - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock( - side_effect=[db_row, backfilled_row] - ) + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(side_effect=[db_row, backfilled_row]) mock_prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None) mock_prisma_client.db.litellm_usertable.update_many = AsyncMock(return_value=1) - with patch( - "litellm.proxy.auth.auth_checks._should_check_db", return_value=True - ): + with patch("litellm.proxy.auth.auth_checks._should_check_db", return_value=True): result = await get_user_object( user_id="jwt-user-3", prisma_client=mock_prisma_client, @@ -1042,9 +981,7 @@ async def test_get_user_object_backfills_null_email_from_db_read(): assert result.user_email == "jwt-user-3@example.com" mock_prisma_client.db.litellm_usertable.update_many.assert_called_once() - refreshed = await cache.async_get_cache( - key="jwt-user-3", model_type=LiteLLM_UserTable - ) + refreshed = await cache.async_get_cache(key="jwt-user-3", model_type=LiteLLM_UserTable) assert refreshed is not None assert refreshed.user_email == "jwt-user-3@example.com" @@ -1062,9 +999,7 @@ async def test_get_user_object_does_not_overwrite_existing_email(): user_email="operator-set@example.com", user_role="internal_user", ) - await cache.async_set_cache( - key="jwt-user-2", value=existing, model_type=LiteLLM_UserTable - ) + await cache.async_set_cache(key="jwt-user-2", value=existing, model_type=LiteLLM_UserTable) mock_prisma_client = MagicMock() mock_prisma_client.db.litellm_usertable.update_many = AsyncMock(return_value=0) @@ -1091,12 +1026,8 @@ async def test_get_user_object_backfill_race_prefers_db_email(): with the value the DB accepted, not this request's proposed email. """ cache = UserApiKeyCache() - existing = LiteLLM_UserTable( - user_id="jwt-user-4", user_email=None, user_role="internal_user" - ) - await cache.async_set_cache( - key="jwt-user-4", value=existing, model_type=LiteLLM_UserTable - ) + existing = LiteLLM_UserTable(user_id="jwt-user-4", user_email=None, user_role="internal_user") + await cache.async_set_cache(key="jwt-user-4", value=existing, model_type=LiteLLM_UserTable) winner_row = LiteLLM_UserTable( user_id="jwt-user-4", @@ -1105,9 +1036,7 @@ async def test_get_user_object_backfill_race_prefers_db_email(): ) mock_prisma_client = MagicMock() mock_prisma_client.db.litellm_usertable.update_many = AsyncMock(return_value=0) - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock( - return_value=winner_row - ) + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=winner_row) result = await get_user_object( user_id="jwt-user-4", @@ -1121,9 +1050,7 @@ async def test_get_user_object_backfill_race_prefers_db_email(): assert result is not None assert result.user_email == "winner@example.com" - refreshed = await cache.async_get_cache( - key="jwt-user-4", model_type=LiteLLM_UserTable - ) + refreshed = await cache.async_get_cache(key="jwt-user-4", model_type=LiteLLM_UserTable) assert refreshed is not None assert refreshed.user_email == "winner@example.com" @@ -1138,12 +1065,8 @@ async def test_get_user_object_backfill_caches_persisted_email_not_proposed(): optimistically caching the proposed email would serve a stale value. """ cache = UserApiKeyCache() - existing = LiteLLM_UserTable( - user_id="jwt-user-5", user_email=None, user_role="internal_user" - ) - await cache.async_set_cache( - key="jwt-user-5", value=existing, model_type=LiteLLM_UserTable - ) + existing = LiteLLM_UserTable(user_id="jwt-user-5", user_email=None, user_role="internal_user") + await cache.async_set_cache(key="jwt-user-5", value=existing, model_type=LiteLLM_UserTable) persisted_row = LiteLLM_UserTable( user_id="jwt-user-5", @@ -1152,9 +1075,7 @@ async def test_get_user_object_backfill_caches_persisted_email_not_proposed(): ) mock_prisma_client = MagicMock() mock_prisma_client.db.litellm_usertable.update_many = AsyncMock(return_value=1) - mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock( - return_value=persisted_row - ) + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=persisted_row) result = await get_user_object( user_id="jwt-user-5", @@ -1168,9 +1089,7 @@ async def test_get_user_object_backfill_caches_persisted_email_not_proposed(): assert result is not None assert result.user_email == "admin-edited@example.com" - refreshed = await cache.async_get_cache( - key="jwt-user-5", model_type=LiteLLM_UserTable - ) + refreshed = await cache.async_get_cache(key="jwt-user-5", model_type=LiteLLM_UserTable) assert refreshed is not None assert refreshed.user_email == "admin-edited@example.com" @@ -1224,10 +1143,7 @@ async def test_get_user_object_upsert_routes_default_team_to_membership(monkeypa mock_add_to_team.assert_awaited_once() passed_teams = mock_add_to_team.await_args[1]["teams"] assert [team.team_id for team in passed_teams] == ["default-team"] - assert ( - mock_add_to_team.await_args[1]["user_api_key_dict"].user_role - == LitellmUserRoles.PROXY_ADMIN - ) + assert mock_add_to_team.await_args[1]["user_api_key_dict"].user_role == LitellmUserRoles.PROXY_ADMIN def test_log_budget_lookup_failure_dry_run(): @@ -1252,9 +1168,7 @@ def test_log_budget_lookup_failure_skips_user_not_found(): @pytest.mark.asyncio -@patch( - "litellm.proxy.management_endpoints.team_endpoints.new_team", new_callable=AsyncMock -) +@patch("litellm.proxy.management_endpoints.team_endpoints.new_team", new_callable=AsyncMock) async def test_get_team_db_check_calls_new_team_on_upsert(mock_new_team, monkeypatch): """ Test that _get_team_db_check correctly calls the `new_team` function @@ -1288,12 +1202,8 @@ async def test_get_team_db_check_calls_new_team_on_upsert(mock_new_team, monkeyp @pytest.mark.asyncio -@patch( - "litellm.proxy.management_endpoints.team_endpoints.new_team", new_callable=AsyncMock -) -async def test_get_team_db_check_does_not_call_new_team_if_exists( - mock_new_team, monkeypatch -): +@patch("litellm.proxy.management_endpoints.team_endpoints.new_team", new_callable=AsyncMock) +async def test_get_team_db_check_does_not_call_new_team_if_exists(mock_new_team, monkeypatch): """ Test that _get_team_db_check does NOT call the `new_team` function if the team already exists in the database. @@ -1327,9 +1237,7 @@ async def test_get_team_db_check_does_not_call_new_team_if_exists( (MagicMock(), MagicMock(), True), # No vector stores to run ], ) -async def test_vector_store_access_check_early_returns( - prisma_client, vector_store_registry, expected_result -): +async def test_vector_store_access_check_early_returns(prisma_client, vector_store_registry, expected_result): """Test vector_store_access_check returns True for early exit conditions""" request_body = {"messages": [{"role": "user", "content": "test"}]} @@ -1411,9 +1319,7 @@ async def test_vector_store_access_check_skips_db_lookup_when_no_vector_stores_r ), # Partial access ], ) -def test_can_object_call_vector_stores_scenarios( - object_permissions, vector_store_ids, should_raise, error_type -): +def test_can_object_call_vector_stores_scenarios(object_permissions, vector_store_ids, should_raise, error_type): """Test _can_object_call_vector_stores with various permission scenarios""" # Convert dict to object if not None if object_permissions is not None: @@ -1421,11 +1327,7 @@ def test_can_object_call_vector_stores_scenarios( mock_permissions.vector_stores = object_permissions["vector_stores"] object_permissions = mock_permissions - object_type = ( - "key" - if error_type == ProxyErrorTypes.key_vector_store_access_denied - else "team" - ) + object_type = "key" if error_type == ProxyErrorTypes.key_vector_store_access_denied else "team" if should_raise: with pytest.raises(ProxyException) as exc_info: @@ -1460,9 +1362,7 @@ async def test_vector_store_access_check_with_permissions(): mock_prisma_client = MagicMock() mock_permissions = MagicMock() mock_permissions.vector_stores = ["store-1", "store-2"] - mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock( - return_value=mock_permissions - ) + mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=mock_permissions) mock_vector_store_registry = MagicMock() mock_vector_store_registry.get_vector_store_ids_to_run.return_value = ["store-1"] @@ -1508,14 +1408,10 @@ async def test_vector_store_access_check_with_team_permissions(): mock_prisma_client = MagicMock() team_permissions = MagicMock() team_permissions.vector_stores = ["team-store-allowed"] - mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock( - return_value=team_permissions - ) + mock_prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=team_permissions) mock_vector_store_registry = MagicMock() - mock_vector_store_registry.get_vector_store_ids_to_run.return_value = [ - "team-store-allowed" - ] + mock_vector_store_registry.get_vector_store_ids_to_run.return_value = ["team-store-allowed"] with ( patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), @@ -1529,9 +1425,7 @@ async def test_vector_store_access_check_with_team_permissions(): assert result is True - mock_vector_store_registry.get_vector_store_ids_to_run.return_value = [ - "team-store-denied" - ] + mock_vector_store_registry.get_vector_store_ids_to_run.return_value = ["team-store-denied"] with ( patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), @@ -2092,9 +1986,7 @@ async def test_get_tag_objects_batch(): mock_cache.async_set_cache = AsyncMock() # Mock DB to return all uncached tags in ONE query - mock_prisma.db.litellm_tagtable.find_many = AsyncMock( - return_value=[uncached_tag_1, uncached_tag_2, uncached_tag_3] - ) + mock_prisma.db.litellm_tagtable.find_many = AsyncMock(return_value=[uncached_tag_1, uncached_tag_2, uncached_tag_3]) # Call batch fetch tag_objects = await get_tag_objects_batch( @@ -2196,9 +2088,7 @@ async def test_get_tag_objects_batch_never_queries_db_for_unregistered_tags(): from litellm.proxy.auth.auth_checks import get_tag_objects_batch mock_prisma = MagicMock() - mock_prisma.db.litellm_tagtable.find_many = AsyncMock( - return_value=[_tag_registry_row("some-other-tag")] - ) + mock_prisma.db.litellm_tagtable.find_many = AsyncMock(return_value=[_tag_registry_row("some-other-tag")]) cache = UserApiKeyCache() first = await get_tag_objects_batch( @@ -2209,9 +2099,7 @@ async def test_get_tag_objects_batch_never_queries_db_for_unregistered_tags(): assert first == {} # The only query is the names-only registry fetch; the tag itself is never looked up. - mock_prisma.db.litellm_tagtable.find_many.assert_called_once_with( - take=TAG_REGISTRY_MAX_SIZE + 1 - ) + mock_prisma.db.litellm_tagtable.find_many.assert_called_once_with(take=TAG_REGISTRY_MAX_SIZE + 1) second = await get_tag_objects_batch( tag_names=["unregistered-tag"], @@ -2380,9 +2268,7 @@ async def test_get_tag_objects_batch_oversized_registry_falls_back_and_stops_ref """Past the cap the registry is unusable: keep the old per-tag path, but stop rebuilding it.""" from litellm.proxy.auth.auth_checks import get_tag_objects_batch - oversized = [ - _tag_registry_row(f"tag-{index}") for index in range(TAG_REGISTRY_MAX_SIZE + 1) - ] + oversized = [_tag_registry_row(f"tag-{index}") for index in range(TAG_REGISTRY_MAX_SIZE + 1)] async def fake_find_many(**kwargs): if "where" not in kwargs: @@ -2399,10 +2285,7 @@ async def test_get_tag_objects_batch_oversized_registry_falls_back_and_stops_ref user_api_key_cache=cache, ) assert list(first) == ["tag-a"] - assert ( - await cache.async_get_cache(key=tag_registry_cache_key()) - == TAG_REGISTRY_OVERFLOW_SENTINEL - ) + assert await cache.async_get_cache(key=tag_registry_cache_key()) == TAG_REGISTRY_OVERFLOW_SENTINEL second = await get_tag_objects_batch( tag_names=["tag-b"], @@ -2427,17 +2310,12 @@ async def test_tag_max_budget_check_still_enforces_registered_tag_over_budget(): async def fake_find_many(**kwargs): if "where" not in kwargs: return [_tag_registry_row("paid-tag")] - return [ - _tag_db_row(name, max_budget=1.0) - for name in kwargs["where"]["tag_name"]["in"] - ] + return [_tag_db_row(name, max_budget=1.0) for name in kwargs["where"]["tag_name"]["in"]] mock_prisma = MagicMock() mock_prisma.db.litellm_tagtable.find_many = AsyncMock(side_effect=fake_find_many) - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:tag:paid-tag": return 1.5 return fallback_spend @@ -2846,8 +2724,7 @@ def _pass_through_request() -> Request: LITELLM_PASS_THROUGH_ENDPOINT_MARKER, ) - def pass_through_endpoint(): - ... + def pass_through_endpoint(): ... setattr(pass_through_endpoint, LITELLM_PASS_THROUGH_ENDPOINT_MARKER, True) return Request(scope={"type": "http", "headers": [], "endpoint": pass_through_endpoint}) @@ -2857,8 +2734,7 @@ def _builtin_request() -> Request: """A Request dispatched to a built-in (non-pass-through) handler, e.g. what a custom path colliding with a core route actually resolves to.""" - def chat_completions(): - ... + def chat_completions(): ... return Request(scope={"type": "http", "headers": [], "endpoint": chat_completions}) @@ -3023,9 +2899,7 @@ async def test_virtual_key_soft_budget_check_without_user_obj(): ], ) @pytest.mark.asyncio -async def test_virtual_key_soft_budget_check_scenarios( - spend, soft_budget, expect_alert -): +async def test_virtual_key_soft_budget_check_scenarios(spend, soft_budget, expect_alert): """Test _virtual_key_soft_budget_check with various spend and soft_budget scenarios""" alert_triggered = False @@ -3054,9 +2928,9 @@ async def test_virtual_key_soft_budget_check_scenarios( await asyncio.sleep(0.1) - assert ( - alert_triggered == expect_alert - ), f"Expected alert_triggered to be {expect_alert} for spend={spend}, soft_budget={soft_budget}" + assert alert_triggered == expect_alert, ( + f"Expected alert_triggered to be {expect_alert} for spend={spend}, soft_budget={soft_budget}" + ) @pytest.mark.asyncio @@ -3167,9 +3041,7 @@ async def test_virtual_key_max_budget_alert_check_without_user_obj(): ], ) @pytest.mark.asyncio -async def test_virtual_key_max_budget_alert_check_scenarios( - spend, max_budget, expect_alert -): +async def test_virtual_key_max_budget_alert_check_scenarios(spend, max_budget, expect_alert): """Test _virtual_key_max_budget_alert_check with various spend and max_budget scenarios""" alert_triggered = False @@ -3198,9 +3070,9 @@ async def test_virtual_key_max_budget_alert_check_scenarios( await asyncio.sleep(0.1) - assert ( - alert_triggered == expect_alert - ), f"Expected alert_triggered to be {expect_alert} for spend={spend}, max_budget={max_budget}" + assert alert_triggered == expect_alert, ( + f"Expected alert_triggered to be {expect_alert} for spend={spend}, max_budget={max_budget}" + ) @pytest.mark.asyncio @@ -3459,9 +3331,7 @@ async def test_custom_auth_common_checks_opt_in(): "prisma_client": None, "user_api_key_cache": MagicMock(), "proxy_logging_obj": MagicMock(), - "general_settings": ( - {"custom_auth_run_common_checks": True} if flag else {} - ), + "general_settings": ({"custom_auth_run_common_checks": True} if flag else {}), "llm_router": None, "user_custom_auth": user_custom_auth, "litellm_proxy_admin_name": "admin", @@ -3533,9 +3403,7 @@ async def test_virtual_key_budget_check_reads_from_spend_counter(): proxy_logging_obj = ProxyLogging(user_api_key_cache=None) proxy_logging_obj.budget_alerts = AsyncMock() - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:key:test-hashed-token": return 1.5 return fallback_spend @@ -3569,9 +3437,7 @@ async def test_virtual_key_budget_check_fallback_no_counter(): proxy_logging_obj.budget_alerts = AsyncMock() # get_current_spend returns fallback_spend when no counter exists - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): return fallback_spend with patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend): @@ -3601,9 +3467,7 @@ def _over_budget_token(**overrides) -> UserAPIKeyAuth: def _patched_spend(value: float): - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): return value return patch("litellm.proxy.proxy_server.get_current_spend", mock_get_current_spend) @@ -3664,9 +3528,7 @@ async def test_budget_throttle_decision_cleared_before_caching(): otherwise it would re-apply (and compound) on every subsequent request.""" from litellm.proxy.auth.auth_checks import _copy_user_api_key_auth_for_cache - valid_token = _over_budget_token( - tpm_limit=1000, rpm_limit=100, metadata={"throttle_on_budget_exceeded": True} - ) + valid_token = _over_budget_token(tpm_limit=1000, rpm_limit=100, metadata={"throttle_on_budget_exceeded": True}) valid_token.budget_throttle_pct = 0.1 cached = _copy_user_api_key_auth_for_cache(user_api_key_obj=valid_token) @@ -3762,9 +3624,7 @@ async def test_team_budget_check_reads_from_spend_counter(): proxy_logging_obj = ProxyLogging(user_api_key_cache=None) proxy_logging_obj.budget_alerts = AsyncMock() - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:team:test-team": return 1.5 return fallback_spend @@ -3791,9 +3651,7 @@ async def test_end_user_budget_check_reads_from_spend_counter(): litellm_budget_table=LiteLLM_BudgetTable(max_budget=1.0), ) - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:end_user:customer-1": return 1.5 return fallback_spend @@ -3821,9 +3679,7 @@ async def test_tag_budget_check_reads_from_spend_counter(): litellm_budget_table=LiteLLM_BudgetTable(max_budget=1.0), ) - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:tag:paid-tag": return 1.5 return fallback_spend @@ -3873,9 +3729,7 @@ async def test_team_member_budget_check_reads_from_spend_counter(): proxy_logging_obj = ProxyLogging(user_api_key_cache=None) - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:team_member:test-user:test-team": return 1.5 return fallback_spend @@ -3915,9 +3769,7 @@ class TestGuardrailModificationCheck: team_object = MagicMock() team_object.metadata = {} # no permission - return _guardrail_modification_check( - request_body=request_body, team_object=team_object - ) + return _guardrail_modification_check(request_body=request_body, team_object=team_object) def test_noop_when_no_guardrail_keys_present(self): # no-op — should return silently @@ -3965,9 +3817,7 @@ class TestGuardrailModificationCheck: return_value=False, ): with pytest.raises(HTTPException) as exc: - self._call( - {"metadata": {"opted_out_global_guardrails": ["some_guardrail"]}} - ) + self._call({"metadata": {"opted_out_global_guardrails": ["some_guardrail"]}}) assert exc.value.status_code == 403 @pytest.mark.parametrize( @@ -4101,18 +3951,12 @@ async def test_team_member_budget_check_falls_back_to_team_default_budget_id(): fake_budget_row = MagicMock() fake_budget_row.max_budget = 50.0 - fake_budget_row.dict = MagicMock( - return_value={"budget_id": "budget-default", "max_budget": 50.0} - ) + fake_budget_row.dict = MagicMock(return_value={"budget_id": "budget-default", "max_budget": 50.0}) prisma_client = MagicMock() - prisma_client.db.litellm_budgettable.find_unique = AsyncMock( - return_value=fake_budget_row - ) + prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=fake_budget_row) - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:team_member:test-user:test-team": return 70.0 return fallback_spend @@ -4203,15 +4047,11 @@ async def test_team_member_budget_check_per_member_override_wins_over_team_defau fake_budget_row.max_budget = 50.0 prisma_client = MagicMock() - prisma_client.db.litellm_budgettable.find_unique = AsyncMock( - return_value=fake_budget_row - ) + prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=fake_budget_row) mocked_spend = 70.0 - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:team_member:test-user:test-team": return mocked_spend return fallback_spend @@ -4292,18 +4132,12 @@ async def test_team_member_budget_check_null_clone_falls_back_to_team_default(): fake_default_row = MagicMock() fake_default_row.max_budget = 65.0 - fake_default_row.dict = MagicMock( - return_value={"budget_id": "budget-default", "max_budget": 65.0} - ) + fake_default_row.dict = MagicMock(return_value={"budget_id": "budget-default", "max_budget": 65.0}) prisma_client = MagicMock() - prisma_client.db.litellm_budgettable.find_unique = AsyncMock( - return_value=fake_default_row - ) + prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=fake_default_row) - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:team_member:test-user:test-team": return 500.0 return fallback_spend @@ -4361,18 +4195,12 @@ async def test_team_member_budget_check_null_clone_with_null_default_skips_enfor fake_default_row = MagicMock() fake_default_row.max_budget = None - fake_default_row.dict = MagicMock( - return_value={"budget_id": "budget-default", "max_budget": None} - ) + fake_default_row.dict = MagicMock(return_value={"budget_id": "budget-default", "max_budget": None}) prisma_client = MagicMock() - prisma_client.db.litellm_budgettable.find_unique = AsyncMock( - return_value=fake_default_row - ) + prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=fake_default_row) - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:team_member:test-user:test-team": return 1000.0 return fallback_spend @@ -4430,18 +4258,12 @@ async def test_team_member_budget_check_zero_team_default_treated_as_no_cap(): # Team default budget row with max_budget=0.0 (the regression trigger). fake_default_row = MagicMock() fake_default_row.max_budget = 0.0 - fake_default_row.dict = MagicMock( - return_value={"budget_id": "budget-default", "max_budget": 0.0} - ) + fake_default_row.dict = MagicMock(return_value={"budget_id": "budget-default", "max_budget": 0.0}) prisma_client = MagicMock() - prisma_client.db.litellm_budgettable.find_unique = AsyncMock( - return_value=fake_default_row - ) + prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=fake_default_row) - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:team_member:test-user:test-team": return 0.0 return fallback_spend @@ -4499,9 +4321,7 @@ async def test_team_member_budget_check_zero_per_member_row_still_blocks(): prisma_client = MagicMock() prisma_client.db.litellm_budgettable.find_unique = AsyncMock(return_value=None) - async def mock_get_current_spend( - counter_key, fallback_spend, max_budget=None, **kwargs - ): + async def mock_get_current_spend(counter_key, fallback_spend, max_budget=None, **kwargs): if counter_key == "spend:team_member:test-user:test-team": return 0.0 return fallback_spend @@ -4549,19 +4369,13 @@ def _patch_validation_helpers(monkeypatch, *, end_user=None, user=None, fuzzy=No """Stub out the DB helpers resolve_and_validate_end_user_id delegates to.""" from litellm.proxy.auth import auth_checks - monkeypatch.setattr( - auth_checks, "get_end_user_object", AsyncMock(return_value=end_user) - ) + monkeypatch.setattr(auth_checks, "get_end_user_object", AsyncMock(return_value=end_user)) monkeypatch.setattr(auth_checks, "get_user_object", AsyncMock(return_value=user)) - monkeypatch.setattr( - auth_checks, "_get_fuzzy_user_object", AsyncMock(return_value=fuzzy) - ) + monkeypatch.setattr(auth_checks, "_get_fuzzy_user_object", AsyncMock(return_value=fuzzy)) @pytest.mark.asyncio -async def test_resolve_end_user_returns_none_for_none_input( - _validate_flag_on, monkeypatch -): +async def test_resolve_end_user_returns_none_for_none_input(_validate_flag_on, monkeypatch): from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id _patch_validation_helpers(monkeypatch) @@ -4596,9 +4410,7 @@ async def test_resolve_end_user_passes_through_when_flag_disabled(monkeypatch): @pytest.mark.asyncio -async def test_resolve_end_user_passes_through_when_no_prisma_client( - _validate_flag_on, monkeypatch -): +async def test_resolve_end_user_passes_through_when_no_prisma_client(_validate_flag_on, monkeypatch): from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id _patch_validation_helpers(monkeypatch) @@ -4632,9 +4444,7 @@ async def test_resolve_end_user_matches_end_user_table(_validate_flag_on, monkey @pytest.mark.asyncio -async def test_resolve_end_user_matches_user_table_by_user_id( - _validate_flag_on, monkeypatch -): +async def test_resolve_end_user_matches_user_table_by_user_id(_validate_flag_on, monkeypatch): from litellm.proxy.auth import auth_checks from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id @@ -4652,9 +4462,7 @@ async def test_resolve_end_user_matches_user_table_by_user_id( @pytest.mark.asyncio -async def test_resolve_end_user_matches_user_table_by_email( - _validate_flag_on, monkeypatch -): +async def test_resolve_end_user_matches_user_table_by_email(_validate_flag_on, monkeypatch): """Email-shaped ids route through get_user_object with user_email set. The fuzzy lookup must happen inside get_user_object so it shares the @@ -4682,9 +4490,7 @@ async def test_resolve_end_user_matches_user_table_by_email( @pytest.mark.asyncio -async def test_resolve_end_user_non_email_id_does_not_pass_user_email( - _validate_flag_on, monkeypatch -): +async def test_resolve_end_user_non_email_id_does_not_pass_user_email(_validate_flag_on, monkeypatch): """Non-email ids skip the email fuzzy path to avoid a pointless DB hit.""" from litellm.proxy.auth import auth_checks from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id @@ -4703,9 +4509,7 @@ async def test_resolve_end_user_non_email_id_does_not_pass_user_email( @pytest.mark.asyncio -async def test_resolve_end_user_drops_codex_opaque_identifier( - _validate_flag_on, monkeypatch -): +async def test_resolve_end_user_drops_codex_opaque_identifier(_validate_flag_on, monkeypatch): from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id _patch_validation_helpers(monkeypatch) # all helpers return None @@ -4727,9 +4531,7 @@ async def test_resolve_end_user_drops_codex_opaque_identifier( @pytest.mark.asyncio -async def test_resolve_end_user_preserves_id_when_default_budget_configured( - _validate_flag_on, monkeypatch -): +async def test_resolve_end_user_preserves_id_when_default_budget_configured(_validate_flag_on, monkeypatch): """Don't drop unregistered ids when litellm.max_end_user_budget_id is set. The default end-user budget is applied downstream when the id is present @@ -4766,9 +4568,7 @@ async def test_resolve_end_user_drops_unknown_email(_validate_flag_on, monkeypat @pytest.mark.asyncio -async def test_resolve_end_user_uses_cached_valid_result( - _validate_flag_on, monkeypatch -): +async def test_resolve_end_user_uses_cached_valid_result(_validate_flag_on, monkeypatch): from litellm.proxy.auth import auth_checks from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id @@ -4788,9 +4588,7 @@ async def test_resolve_end_user_uses_cached_valid_result( @pytest.mark.asyncio -async def test_resolve_end_user_uses_cached_invalid_result( - _validate_flag_on, monkeypatch -): +async def test_resolve_end_user_uses_cached_invalid_result(_validate_flag_on, monkeypatch): from litellm.proxy.auth import auth_checks from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id @@ -4809,9 +4607,7 @@ async def test_resolve_end_user_uses_cached_invalid_result( @pytest.mark.asyncio -async def test_resolve_end_user_swallows_db_errors_and_returns_none( - _validate_flag_on, monkeypatch -): +async def test_resolve_end_user_swallows_db_errors_and_returns_none(_validate_flag_on, monkeypatch): from litellm.proxy.auth import auth_checks from litellm.proxy.auth.auth_checks import resolve_and_validate_end_user_id @@ -4932,19 +4728,13 @@ async def test_cache_team_object_writes_team_id_and_invalidates_team_alias(): ) # (1) team_id-keyed write fires with the refreshed object - written_keys = [ - (c.kwargs.get("key") or c.args[0]) - for c in cache.async_set_cache.await_args_list - ] + written_keys = [(c.kwargs.get("key") or c.args[0]) for c in cache.async_set_cache.await_args_list] assert written_keys == ["team_id:team-1234"], ( "Only the team_id-keyed write should fire; the alias key must be " "deleted, NOT written. " f"Got writes: {written_keys}" ) - written_value = ( - cache.async_set_cache.await_args.kwargs.get("value") - or cache.async_set_cache.await_args.args[1] - ) + written_value = cache.async_set_cache.await_args.kwargs.get("value") or cache.async_set_cache.await_args.args[1] assert written_value is team_table # (2) team_alias-keyed entry is deleted in BOTH the in-memory cache @@ -4978,10 +4768,7 @@ async def test_cache_team_object_writes_team_id_and_invalidates_team_alias(): logging_obj2.internal_usage_cache.dual_cache.async_delete_cache.assert_awaited_once_with( key="team_id:team-no-alias" ) - written_keys_aliasless = [ - (c.kwargs.get("key") or c.args[0]) - for c in cache2.async_set_cache.await_args_list - ] + written_keys_aliasless = [(c.kwargs.get("key") or c.args[0]) for c in cache2.async_set_cache.await_args_list] assert written_keys_aliasless == ["team_id:team-no-alias"] @@ -5061,9 +4848,7 @@ async def test_team_update_not_shadowed_by_internal_usage_cache_lit_4391(): await _cache_team_object( team_id=team_id, - team_table=LiteLLM_TeamTableCachedObj( - team_id=team_id, models=["model-a", "model-b"] - ), + team_table=LiteLLM_TeamTableCachedObj(team_id=team_id, models=["model-a", "model-b"]), user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) @@ -5173,9 +4958,7 @@ async def test_cache_team_object_tolerates_cache_invalidation_failures(): cache.async_set_cache = AsyncMock() cache.delete_cache = MagicMock(side_effect=Exception("redis down")) logging_obj = MagicMock() - logging_obj.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock( - side_effect=Exception("redis down") - ) + logging_obj.internal_usage_cache.dual_cache.async_delete_cache = AsyncMock(side_effect=Exception("redis down")) await _cache_team_object( team_id="team-cache-outage", @@ -5188,10 +4971,7 @@ async def test_cache_team_object_tolerates_cache_invalidation_failures(): proxy_logging_obj=logging_obj, ) - written_keys = [ - (c.kwargs.get("key") or c.args[0]) - for c in cache.async_set_cache.await_args_list - ] + written_keys = [(c.kwargs.get("key") or c.args[0]) for c in cache.async_set_cache.await_args_list] assert written_keys == ["team_id:team-cache-outage"] @@ -5467,8 +5247,9 @@ async def test_common_checks_budget_reads_run_concurrently(): probe = _BudgetSpendConcurrencyProbe(expected=4) - with patch("litellm.proxy.proxy_server.prisma_client", None), patch( - "litellm.proxy.proxy_server.get_current_spend", probe + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch("litellm.proxy.proxy_server.get_current_spend", probe), ): task = asyncio.create_task( common_checks( @@ -5536,8 +5317,9 @@ async def test_common_checks_budget_gather_raises_highest_priority_scope(): request=MagicMock(spec=Request), ) - with patch("litellm.proxy.proxy_server.prisma_client", None), patch( - "litellm.proxy.proxy_server.get_current_spend", _spend_by_counter + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch("litellm.proxy.proxy_server.get_current_spend", _spend_by_counter), ): # Both team and end-user over budget: team wins on priority. _spend_by_counter.team = 999.0 @@ -5572,8 +5354,9 @@ async def test_common_checks_personal_user_budget_blocks_in_gather(): async def _spend_by_counter(counter_key, fallback_spend, max_budget=None, **kwargs): return 999.0 if counter_key == "spend:user:u1" else 0.0 - with patch("litellm.proxy.proxy_server.prisma_client", None), patch( - "litellm.proxy.proxy_server.get_current_spend", _spend_by_counter + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch("litellm.proxy.proxy_server.get_current_spend", _spend_by_counter), ): with pytest.raises(litellm.BudgetExceededError) as over: await common_checks( @@ -5615,9 +5398,11 @@ async def test_common_checks_personal_user_budget_skipped_for_team_key(): async def _no_membership(*args, **kwargs): return None - with patch("litellm.proxy.proxy_server.prisma_client", None), patch( - "litellm.proxy.proxy_server.get_current_spend", _spend_by_counter - ), patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership): + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch("litellm.proxy.proxy_server.get_current_spend", _spend_by_counter), + patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership), + ): result = await common_checks( request_body={"messages": [{"role": "user", "content": "hi"}]}, team_object=team, @@ -5656,9 +5441,11 @@ async def test_common_checks_personal_user_budget_enforced_on_team_key_when_flag async def _no_membership(*args, **kwargs): return None - with patch("litellm.proxy.proxy_server.prisma_client", None), patch( - "litellm.proxy.proxy_server.get_current_spend", _spend_by_counter - ), patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership): + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch("litellm.proxy.proxy_server.get_current_spend", _spend_by_counter), + patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership), + ): with pytest.raises(litellm.BudgetExceededError) as exc_info: await common_checks( request_body={"messages": [{"role": "user", "content": "hi"}]}, @@ -5689,8 +5476,9 @@ async def test_common_checks_personal_user_budget_still_enforced_on_personal_key async def _spend_by_counter(counter_key, fallback_spend, max_budget=None, **kwargs): return 999.0 if counter_key == "spend:user:u1" else 0.0 - with patch("litellm.proxy.proxy_server.prisma_client", None), patch( - "litellm.proxy.proxy_server.get_current_spend", _spend_by_counter + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch("litellm.proxy.proxy_server.get_current_spend", _spend_by_counter), ): with pytest.raises(litellm.BudgetExceededError): await common_checks( @@ -5773,10 +5561,11 @@ async def test_budget_checks_only_run_on_llm_api_routes(scope, route, expect_blo request=MagicMock(spec=Request), ) - with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch( - "litellm.proxy.proxy_server.get_current_spend", _spend_by_counter - ), patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership), patch( - "litellm.proxy.auth.auth_checks.get_org_object", _get_org + with ( + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), + patch("litellm.proxy.proxy_server.get_current_spend", _spend_by_counter), + patch("litellm.proxy.auth.auth_checks.get_team_membership", _no_membership), + patch("litellm.proxy.auth.auth_checks.get_org_object", _get_org), ): if expect_blocked: with pytest.raises(litellm.BudgetExceededError): @@ -6275,9 +6064,7 @@ async def test_get_end_user_object_token_budget_gate_keeps_fetching_unrestricted mock_prisma = MagicMock() mock_prisma.db.litellm_endusertable.find_many = AsyncMock(return_value=[]) - mock_prisma.db.litellm_endusertable.find_unique = AsyncMock( - return_value=_end_user_db_row("eu-anon-1", spend=100.0) - ) + mock_prisma.db.litellm_endusertable.find_unique = AsyncMock(return_value=_end_user_db_row("eu-anon-1", spend=100.0)) cache = UserApiKeyCache() result = await get_end_user_object( @@ -6959,9 +6746,7 @@ async def test_common_checks_ignores_non_llm_route_when_enabled(monkeypatch): monkeypatch.setattr(litellm, "block_requests_for_models_without_pricing", True) router = _router_with_priced_and_unpriced_models() - result = await _run_common_checks( - model="unpriced-group", llm_router=router, route="/model/new" - ) + result = await _run_common_checks(model="unpriced-group", llm_router=router, route="/model/new") assert result is True @@ -7077,11 +6862,15 @@ def test_team_allowed_routes_exact_route_does_not_become_a_prefix_grant(): roles = LiteLLM_JWTAuth(team_allowed_routes=["/internal-models/model-a"]) assert ( - allowed_routes_check(user_role=LitellmUserRoles.TEAM, user_route="/internal-models/model-a", litellm_proxy_roles=roles) + allowed_routes_check( + user_role=LitellmUserRoles.TEAM, user_route="/internal-models/model-a", litellm_proxy_roles=roles + ) is True ) assert ( - allowed_routes_check(user_role=LitellmUserRoles.TEAM, user_route="/internal-models/model-b", litellm_proxy_roles=roles) + allowed_routes_check( + user_role=LitellmUserRoles.TEAM, user_route="/internal-models/model-b", litellm_proxy_roles=roles + ) is False ) @@ -7159,8 +6948,7 @@ async def test_invalidate_team_member_spend_state_sets_the_spend_counter_and_cle assert await real_cache.async_get_cache(key="team_membership:user-1:team-1") is None assert real_spend_counter_cache.in_memory_cache.get_cache(key="spend:team_member:user-1:team-1") == 0.0 assert ( - real_spend_counter_cache.in_memory_cache.get_cache(key="spend_db_floor:spend:team_member:user-1:team-1") - == 0.0 + real_spend_counter_cache.in_memory_cache.get_cache(key="spend_db_floor:spend:team_member:user-1:team-1") == 0.0 ), "the DB-floor marker kept the pre-reset value; a stale-floor read can raise the counter right back up" @@ -7356,9 +7144,9 @@ async def test_invalidate_team_member_spend_state_broadcasts_the_spend_counter_t ) assert remote_spend_counter_in_memory_cache.get_cache("spend:team_member:user-1:team-1") == 0.0 - assert ( - remote_spend_counter_in_memory_cache.get_cache("spend_db_floor:spend:team_member:user-1:team-1") == 0.0 - ), "the DB-floor marker was not broadcast; a remote worker can re-raise the counter off its stale floor" + assert remote_spend_counter_in_memory_cache.get_cache("spend_db_floor:spend:team_member:user-1:team-1") == 0.0, ( + "the DB-floor marker was not broadcast; a remote worker can re-raise the counter off its stale floor" + ) @pytest.mark.asyncio @@ -7541,3 +7329,81 @@ async def test_key_budget_error_keeps_the_masked_key_name(key_name): names are just as valid as the alphanumeric ones.""" message = await _run_key_budget_check(key_name) assert f"Key=prod-key ({key_name}) Current cost" in message + + +class _UntouchedPrisma: + def __getattr__(self, name: str) -> object: + raise AssertionError(f"database reached through {name}") + + +@pytest.mark.asyncio +async def test_enforced_model_allowlists_reads_every_level_from_cache(): + from litellm.proxy._types import ( + LiteLLM_BudgetTable, + LiteLLM_ProjectTableCachedObj, + LiteLLM_TeamMembership, + LiteLLM_TeamTableCachedObj, + LiteLLM_UserTable, + ) + from litellm.proxy.auth.auth_checks import enforced_model_allowlists + from litellm.proxy.common_utils.user_api_key_cache import ( + UserApiKeyCache, + team_membership_reservation_cache_key, + ) + from litellm.proxy.utils import ProxyLogging + + cache = UserApiKeyCache() + proxy_logging_obj = ProxyLogging(user_api_key_cache=cache) + await cache.async_set_cache( + key="team_id:team-fake", value=LiteLLM_TeamTableCachedObj(team_id="team-fake", models=["gpt-4o"]) + ) + await cache.async_set_cache( + key=team_membership_reservation_cache_key(user_id="user-fake", team_id="team-fake"), + value=LiteLLM_TeamMembership( + user_id="user-fake", + team_id="team-fake", + litellm_budget_table=LiteLLM_BudgetTable(allowed_models=["gpt-4o-mini"]), + ), + ) + await cache.async_set_cache( + key="project_id:project-fake", + value=LiteLLM_ProjectTableCachedObj(project_id="project-fake", models=["gpt-4.1"]), + ) + await cache.async_set_cache(key="user-fake", value=LiteLLM_UserTable(user_id="user-fake", models=["o3"])) + prisma_client = _UntouchedPrisma() + + team_scoped = await enforced_model_allowlists( + valid_token=UserAPIKeyAuth( + token="hashed-fake", + models=["all-team-models"], + team_models=["gpt-4o", "gpt-4o-mini"], + user_id="user-fake", + team_id="team-fake", + project_id="project-fake", + ), + prisma_client=prisma_client, + user_api_key_cache=cache, + proxy_logging_obj=proxy_logging_obj, + ) + personal = await enforced_model_allowlists( + valid_token=UserAPIKeyAuth(token="hashed-fake", user_id="user-fake"), + prisma_client=prisma_client, + user_api_key_cache=cache, + proxy_logging_obj=proxy_logging_obj, + ) + without_database = await enforced_model_allowlists( + valid_token=UserAPIKeyAuth(token="hashed-fake", models=["gpt-4o"], user_id="user-fake", team_id="team-fake"), + prisma_client=None, + user_api_key_cache=cache, + proxy_logging_obj=proxy_logging_obj, + ) + + assert [list(scope) for scope in team_scoped] == [ + ["gpt-4o", "gpt-4o-mini"], + ["gpt-4o"], + ["gpt-4o-mini"], + [], + ["gpt-4.1"], + ] + assert [list(scope) for scope in personal] == [[], [], [], ["o3"], []] + assert [list(scope) for scope in without_database] == [["gpt-4o"]] diff --git a/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py b/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py index 6578b75ace1..c96e7684e97 100644 --- a/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py +++ b/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py @@ -1,7 +1,7 @@ """OpenAI passthrough WebSocket route: registration, opt-in gating, and refusals.""" import json -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType, SimpleNamespace from typing import Final @@ -15,10 +15,13 @@ from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( _OPENAI_WS_DISABLED_REFUSAL, _OPENAI_WS_MODEL_RESTRICTED_REFUSAL, _openai_websocket_refusal, + _proxy_model_allowlists, openai_websocket_proxy_route, router, ) +Scopes = tuple[Sequence[str], ...] + ENABLED: Final = MappingProxyType({"enable_openai_websocket_passthrough": True}) DISABLED_SETTINGS: Final = ( MappingProxyType({}), @@ -96,21 +99,39 @@ class _FakeRelay: ) +class _FakeModelAllowlists: + def __init__(self, scopes: Scopes) -> None: + self.scopes = scopes + self.calls: list[UserAPIKeyAuth] = [] + + async def __call__(self, valid_token: UserAPIKeyAuth, /) -> Scopes: + self.calls.append(valid_token) + return self.scopes + + +@dataclass(frozen=True, slots=True) +class _Served: + relay: _FakeRelay + allowlists: _FakeModelAllowlists + + async def _serve( websocket: _FakeWebSocket, endpoint: str, user_api_key_dict: UserAPIKeyAuth, general_settings: Mapping[str, object], -) -> _FakeRelay: - relay = _FakeRelay() + scopes: Scopes = (), +) -> _Served: + served = _Served(relay=_FakeRelay(), allowlists=_FakeModelAllowlists(scopes)) await openai_websocket_proxy_route( websocket=websocket, endpoint=endpoint, user_api_key_dict=user_api_key_dict, general_settings=general_settings, - relay=relay, + relay=served.relay, + model_allowlists=served.allowlists, ) - return relay + return served @pytest.mark.asyncio @@ -120,9 +141,9 @@ async def test_openai_websocket_forwards_query_and_keeps_provider_auth(prefix, m websocket = _FakeWebSocket(f"/{prefix}/v1/realtime", "model=gpt-4o-realtime-preview") with patch(GET_CREDENTIALS, return_value="sk-provider"): - relay = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) + served = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) - assert relay.calls == [ + assert served.relay.calls == [ _RelayCall( target="wss://api.openai.com/v1/realtime?model=gpt-4o-realtime-preview", custom_headers=MappingProxyType({"Authorization": "Bearer sk-provider"}), @@ -145,10 +166,10 @@ async def test_openai_websocket_accepts_first_client_subprotocol(): ) with patch(GET_CREDENTIALS, return_value="sk-provider"): - relay = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) + served = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) assert websocket.accepts == ["realtime"] - assert [call.accept_websocket for call in relay.calls] == [False] + assert [call.accept_websocket for call in served.relay.calls] == [False] assert websocket.closed is None @@ -157,13 +178,13 @@ async def test_openai_websocket_closes_cleanly_when_provider_credentials_missing websocket = _FakeWebSocket("/openai/v1/realtime", "model=gpt-4o-realtime-preview") with patch(GET_CREDENTIALS, return_value=None): - relay = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) + served = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), ENABLED) assert websocket.closed is not None assert websocket.closed[0] == 1011 assert "OPENAI_API_KEY" in websocket.closed[1] assert websocket.accepts == [] - assert relay.calls == [] + assert served.relay.calls == [] @pytest.mark.asyncio @@ -172,23 +193,26 @@ async def test_openai_websocket_closes_cleanly_when_provider_credentials_missing async def test_openai_websocket_refused_unless_explicitly_enabled(prefix, general_settings): websocket = _FakeWebSocket(f"/{prefix}/v1/realtime", "model=gpt-4o-realtime-preview") - relay = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), general_settings) + served = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), general_settings) assert "enable_openai_websocket_passthrough" in websocket.error_message() assert websocket.accepts == [None] assert websocket.closed == (1008, _OPENAI_WS_DISABLED_REFUSAL.close_reason) - assert relay.calls == [] + assert served.relay.calls == [] +@pytest.mark.asyncio @pytest.mark.parametrize("general_settings", DISABLED_SETTINGS) -def test_openai_websocket_refusal_is_disabled_for_falsy_settings(general_settings): - assert _openai_websocket_refusal(UserAPIKeyAuth(), general_settings) is _OPENAI_WS_DISABLED_REFUSAL +async def test_openai_websocket_refusal_is_disabled_for_falsy_settings(general_settings): + refusal = await _openai_websocket_refusal(UserAPIKeyAuth(), general_settings, _FakeModelAllowlists(())) + assert refusal is _OPENAI_WS_DISABLED_REFUSAL +@pytest.mark.asyncio @pytest.mark.parametrize("value", [True, "true", "True"]) -def test_openai_websocket_refusal_is_none_for_truthy_settings(value): +async def test_openai_websocket_refusal_is_none_for_truthy_settings(value): settings = MappingProxyType({"enable_openai_websocket_passthrough": value}) - assert _openai_websocket_refusal(UserAPIKeyAuth(), settings) is None + assert await _openai_websocket_refusal(UserAPIKeyAuth(), settings, _FakeModelAllowlists(())) is None @pytest.mark.asyncio @@ -199,51 +223,73 @@ async def test_openai_websocket_refusal_echoes_requested_subprotocol(): subprotocols="realtime, openai-beta.realtime-v1", ) - relay = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), MappingProxyType({})) + served = await _serve(websocket, "v1/realtime", UserAPIKeyAuth(), MappingProxyType({})) assert websocket.accepts == ["realtime"] assert websocket.closed == (1008, _OPENAI_WS_DISABLED_REFUSAL.close_reason) - assert relay.calls == [] + assert served.relay.calls == [] -RESTRICTED_KEYS: Final = ( - UserAPIKeyAuth(models=["gpt-4o"]), - UserAPIKeyAuth(team_models=["gpt-4o-realtime-preview"]), - UserAPIKeyAuth(models=["all-team-models"], team_models=["gpt-4o"]), +RESTRICTED_SCOPES: Final[tuple[Scopes, ...]] = ( + (("gpt-4o",),), + ((), ("gpt-4o-realtime-preview",)), + (("all-team-models",), ("gpt-4o",)), + ((), ("all-proxy-models",), ("gpt-4o",)), + ((), (), (), ("gpt-4o",)), + (("*",), (), (), (), ("gpt-4o",)), ) -UNRESTRICTED_KEYS: Final = ( - UserAPIKeyAuth(), - UserAPIKeyAuth(models=["all-proxy-models"]), - UserAPIKeyAuth(models=["*"]), - UserAPIKeyAuth(models=["all-team-models"], team_models=["all-proxy-models"]), +UNRESTRICTED_SCOPES: Final[tuple[Scopes, ...]] = ( + (), + ((),), + (("all-proxy-models",),), + (("*",),), + (("all-team-models",), ("all-proxy-models",)), + ((), (), (), (), ()), + (("*",), ("all-proxy-models",), ("all-team-models",), (), ()), ) @pytest.mark.asyncio -@pytest.mark.parametrize("user_api_key_dict", RESTRICTED_KEYS) -async def test_openai_websocket_rejects_model_restricted_keys(user_api_key_dict): +@pytest.mark.parametrize("scopes", RESTRICTED_SCOPES) +async def test_openai_websocket_rejects_model_restricted_identities(scopes): websocket = _FakeWebSocket("/openai/v1/realtime", "model=gpt-4o-realtime-preview") + user_api_key_dict = UserAPIKeyAuth(token="hashed-fake", user_id="user-fake", team_id="team-fake") - relay = await _serve(websocket, "v1/realtime", user_api_key_dict, ENABLED) + served = await _serve(websocket, "v1/realtime", user_api_key_dict, ENABLED, scopes) assert "model restrictions" in websocket.error_message() assert websocket.closed == (1008, _OPENAI_WS_MODEL_RESTRICTED_REFUSAL.close_reason) - assert relay.calls == [] - - -@pytest.mark.parametrize("user_api_key_dict", RESTRICTED_KEYS) -def test_openai_websocket_refusal_prefers_disabled_over_model_restriction(user_api_key_dict): - assert _openai_websocket_refusal(user_api_key_dict, MappingProxyType({})) is _OPENAI_WS_DISABLED_REFUSAL + assert served.relay.calls == [] + assert served.allowlists.calls == [user_api_key_dict] @pytest.mark.asyncio -@pytest.mark.parametrize("user_api_key_dict", UNRESTRICTED_KEYS) -async def test_openai_websocket_allows_unrestricted_keys(user_api_key_dict): +@pytest.mark.parametrize("scopes", RESTRICTED_SCOPES) +async def test_openai_websocket_disabled_refusal_skips_allowlist_lookups(scopes): + allowlists = _FakeModelAllowlists(scopes) + + refusal = await _openai_websocket_refusal(UserAPIKeyAuth(), MappingProxyType({}), allowlists) + + assert refusal is _OPENAI_WS_DISABLED_REFUSAL + assert allowlists.calls == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("scopes", UNRESTRICTED_SCOPES) +async def test_openai_websocket_allows_unrestricted_identities(scopes): websocket = _FakeWebSocket("/openai/v1/responses", "") with patch(GET_CREDENTIALS, return_value="sk-provider"): - relay = await _serve(websocket, "v1/responses", user_api_key_dict, ENABLED) + served = await _serve(websocket, "v1/responses", UserAPIKeyAuth(), ENABLED, scopes) - assert len(relay.calls) == 1 + assert len(served.relay.calls) == 1 assert websocket.sent == [] assert websocket.closed is None + + +@pytest.mark.asyncio +async def test_proxy_model_allowlists_reads_the_key_scope_without_a_database(): + with patch("litellm.proxy.proxy_server.prisma_client", None): + scopes = await _proxy_model_allowlists()(UserAPIKeyAuth(models=["gpt-4o"])) + + assert tuple(tuple(scope) for scope in scopes) == (("gpt-4o",),) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index d91928a203e..34216f7e1b9 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -11568,14 +11568,10 @@ async def test_key_window_spend_row_is_enqueued_with_the_actual_cost(): reset_at = datetime.now(timezone.utc) + timedelta(days=10) key_obj = MagicMock() - key_obj.budget_limits = [ - {"budget_duration": "30d", "max_budget": 100.0, "reset_at": reset_at.isoformat()} - ] + key_obj.budget_limits = [{"budget_duration": "30d", "max_budget": 100.0, "reset_at": reset_at.isoformat()}] with _window_spend_enqueue_env({"hashed-token": key_obj}) as queue: - await increment_spend_counters( - token="hashed-token", team_id=None, user_id=None, response_cost=0.25 - ) + await increment_spend_counters(token="hashed-token", team_id=None, user_id=None, response_cost=0.25) enqueued = await _drain(queue) assert len(enqueued) == 1 @@ -11594,14 +11590,10 @@ async def test_team_window_spend_row_is_enqueued(): reset_at = datetime.now(timezone.utc) + timedelta(days=3) team_obj = MagicMock() - team_obj.budget_limits = [ - {"budget_duration": "7d", "max_budget": 50.0, "reset_at": reset_at.isoformat()} - ] + team_obj.budget_limits = [{"budget_duration": "7d", "max_budget": 50.0, "reset_at": reset_at.isoformat()}] with _window_spend_enqueue_env({"team_id:team-1": team_obj}) as queue: - await increment_spend_counters( - token=None, team_id="team-1", user_id=None, response_cost=1.5 - ) + await increment_spend_counters(token=None, team_id="team-1", user_id=None, response_cost=1.5) enqueued = await _drain(queue) assert len(enqueued) == 1 @@ -11620,9 +11612,7 @@ async def test_window_spend_row_is_enqueued_even_when_the_counter_was_reserved() reset_at = datetime.now(timezone.utc) + timedelta(days=10) key_obj = MagicMock() - key_obj.budget_limits = [ - {"budget_duration": "30d", "max_budget": 100.0, "reset_at": reset_at.isoformat()} - ] + key_obj.budget_limits = [{"budget_duration": "30d", "max_budget": 100.0, "reset_at": reset_at.isoformat()}] reservation = { "entries": [ {"counter_key": "spend:key:hashed-token", "reserved": 1.0}, @@ -11660,9 +11650,7 @@ async def test_sliding_window_without_reset_at_is_not_enqueued(): key_obj.budget_limits = [{"budget_duration": "30d", "max_budget": 100.0}] with _window_spend_enqueue_env({"hashed-token": key_obj}) as queue: - await increment_spend_counters( - token="hashed-token", team_id=None, user_id=None, response_cost=0.25 - ) + await increment_spend_counters(token="hashed-token", team_id=None, user_id=None, response_cost=0.25) enqueued = await _drain(queue) assert enqueued == [] @@ -11680,9 +11668,7 @@ async def test_each_configured_window_gets_its_own_row_enqueue(): ] with _window_spend_enqueue_env({"hashed-token": key_obj}) as queue: - await increment_spend_counters( - token="hashed-token", team_id=None, user_id=None, response_cost=0.25 - ) + await increment_spend_counters(token="hashed-token", team_id=None, user_id=None, response_cost=0.25) enqueued = await _drain(queue) assert sorted(item["window_duration"] for item in enqueued) == ["1d", "30d"] @@ -11697,9 +11683,7 @@ async def test_no_window_spend_row_enqueued_without_budget_limits(): key_obj.budget_limits = None with _window_spend_enqueue_env({"hashed-token": key_obj}) as queue: - await increment_spend_counters( - token="hashed-token", team_id=None, user_id=None, response_cost=0.25 - ) + await increment_spend_counters(token="hashed-token", team_id=None, user_id=None, response_cost=0.25) enqueued = await _drain(queue) assert enqueued == [] @@ -11713,9 +11697,7 @@ async def test_window_spend_row_carries_the_request_start_time(): reset_at = datetime.now(timezone.utc) + timedelta(days=10) key_obj = MagicMock() - key_obj.budget_limits = [ - {"budget_duration": "30d", "max_budget": 100.0, "reset_at": reset_at.isoformat()} - ] + key_obj.budget_limits = [{"budget_duration": "30d", "max_budget": 100.0, "reset_at": reset_at.isoformat()}] with _window_spend_enqueue_env({"hashed-token": key_obj}) as queue: await increment_spend_counters( @@ -11736,9 +11718,7 @@ async def test_team_window_spend_row_carries_the_request_start_time(): reset_at = datetime.now(timezone.utc) + timedelta(days=3) team_obj = MagicMock() - team_obj.budget_limits = [ - {"budget_duration": "7d", "max_budget": 50.0, "reset_at": reset_at.isoformat()} - ] + team_obj.budget_limits = [{"budget_duration": "7d", "max_budget": 50.0, "reset_at": reset_at.isoformat()}] with _window_spend_enqueue_env({"team_id:team-1": team_obj}) as queue: await increment_spend_counters( @@ -12050,7 +12030,6 @@ async def test_init_guardrails_in_db_snapshots_and_reconciles_under_guardrail_re assert not GUARDRAIL_RECONCILE_LOCK.locked() - @pytest.mark.asyncio async def test_init_prompts_in_db_reloads_rows_patched_on_another_worker(monkeypatch): from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY @@ -12094,7 +12073,9 @@ async def test_init_prompts_in_db_reloads_rows_patched_on_another_worker(monkeyp await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) assert served_content() == "Begin every reply with AHOY" - prisma_client.db.litellm_prompttable.find_many = AsyncMock(return_value=[db_row("Begin every reply with HOWDY")]) + prisma_client.db.litellm_prompttable.find_many = AsyncMock( + return_value=[db_row("Begin every reply with HOWDY")] + ) await ProxyConfig()._init_prompts_in_db(prisma_client=prisma_client) assert served_content() == "Begin every reply with HOWDY" @@ -12547,3 +12528,40 @@ def test_disabling_docs_does_not_disable_other_routes(monkeypatch): assert client.get("/redoc").status_code == 404 assert client.get("/health/liveliness").status_code == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "db_general_settings, expected", + [ + ({"enable_openai_websocket_passthrough": True}, True), + ({"enable_openai_websocket_passthrough": False}, False), + ({}, None), + ], +) +async def test_update_general_settings_propagates_openai_websocket_passthrough(db_general_settings, expected): + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + + with patch("litellm.proxy.proxy_server.general_settings", {"enable_openai_websocket_passthrough": True}): + await proxy_config._update_general_settings(db_general_settings=db_general_settings) + + import litellm.proxy.proxy_server as ps + + assert ps.general_settings["enable_openai_websocket_passthrough"] is expected + + +@pytest.mark.asyncio +async def test_update_general_settings_keeps_yaml_openai_websocket_passthrough(): + from litellm.proxy.proxy_server import ProxyConfig + + proxy_config = ProxyConfig() + proxy_config._yaml_general_settings_keys = {"enable_openai_websocket_passthrough"} + + with patch("litellm.proxy.proxy_server.general_settings", {"enable_openai_websocket_passthrough": False}): + await proxy_config._update_general_settings(db_general_settings={"enable_openai_websocket_passthrough": True}) + + import litellm.proxy.proxy_server as ps + + assert ps.general_settings["enable_openai_websocket_passthrough"] is False From 853fed824e841ce3e3952f137b6a3d577ced07d4 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Fri, 4 Sep 2026 18:40:35 -0700 Subject: [PATCH 162/410] fix(bedrock): stop sending toolConfig tool definitions to guardrails on passthrough converse (#39281) Bedrock passthrough Converse routes flattened every non-empty string under toolConfig.tools into the guardrail INPUT texts, so tool names, tool descriptions and JSON-schema strings (object, property names, titles, type names, enum values) each arrived as a separate guardrail item. A request whose only prompt was one benign user message could be blocked outright because a denied term appeared in an app-authored tool definition. Tool definitions are now excluded from the extracted texts, matching every other guardrail translation handler, which carries tool definitions in the structured tools input rather than in texts. Caller content stays scanned: message text, toolUse.input, toolResult content and json, and additionalModelRequestFields are unchanged. Resolves LIT-5797 --- .../guardrail_translation/handler.py | 27 ++++-- .../guardrail_translation/test_handler.py | 97 ++++++++++++++----- 2 files changed, 90 insertions(+), 34 deletions(-) diff --git a/litellm/llms/bedrock/passthrough/guardrail_translation/handler.py b/litellm/llms/bedrock/passthrough/guardrail_translation/handler.py index 9d35a87855e..b87f6196e51 100644 --- a/litellm/llms/bedrock/passthrough/guardrail_translation/handler.py +++ b/litellm/llms/bedrock/passthrough/guardrail_translation/handler.py @@ -89,11 +89,24 @@ def _extract_converse_texts( top-level ``text`` blocks this scans the arbitrary-JSON fields a caller can hide prompt content in -- ``toolUse.input`` and ``toolResult.content[].json`` (alongside ``toolResult.content[].text``) -- - as well as the request-level fields still forwarded to Bedrock that a caller - can route blocked content through: ``toolConfig.tools`` (tool names, - descriptions and input schemas) and ``additionalModelRequestFields``. Tool - message blocks are skipped when tool messages are excluded, but tool - definitions are always scanned to match the chat-completions guardrail path. + as well as ``additionalModelRequestFields``, a free-form model-parameter bag + with no schema that a caller can route blocked content through. + + ``toolConfig.tools`` is deliberately NOT scanned. Tool definitions are + app-authored config, so their names, descriptions and JSON-schema strings + ("object", property names, titles, type names, enum values) would each reach + the guardrail as a separate INPUT item, producing false positives and + inflating guardrail usage for a request whose only prompt is one user + message. No other guardrail translation handler puts tool definitions in + ``texts``; the chat and messages handlers carry them in the structured + ``tools`` input instead, which this handler does not populate because a + Bedrock ``toolSpec`` is not the OpenAI tool shape those consumers expect. + + ``additionalModelRequestFields`` is treated differently on purpose. Bedrock + gives ``toolConfig.tools`` a fixed schema whose contents are tool metadata by + contract, while ``additionalModelRequestFields`` is free-form and defined by + the target model, so what it carries cannot be classified without knowing + that model. Scanning it stays the fail-closed default. """ holders: Final[list[_StringHolder]] = [] @@ -121,10 +134,6 @@ def _extract_converse_texts( _collect_block_text(inner, holders) _collect_strings(inner.get("json"), holders) - tool_config: Final = body.get("toolConfig") - if isinstance(tool_config, dict): - _collect_strings(tool_config.get("tools"), holders) - _collect_strings(body.get("additionalModelRequestFields"), holders) texts: Final = [container[key] for container, key in holders] diff --git a/tests/test_litellm/llms/bedrock/passthrough/guardrail_translation/test_handler.py b/tests/test_litellm/llms/bedrock/passthrough/guardrail_translation/test_handler.py index 744ed50dbcb..dee8366ce2d 100644 --- a/tests/test_litellm/llms/bedrock/passthrough/guardrail_translation/test_handler.py +++ b/tests/test_litellm/llms/bedrock/passthrough/guardrail_translation/test_handler.py @@ -170,22 +170,27 @@ class TestExtractConverseTexts: texts, _ = _extract_converse_texts(body, skip_system=False, skip_tool=False) assert texts == [] - def test_extracts_tool_config_description_and_schema(self): + def test_tool_config_definitions_not_extracted(self): + """Tool definitions are app-authored config, so nothing under + toolConfig.tools reaches the guardrail as input content.""" body = { - "messages": [{"role": "user", "content": [{"text": "hi"}]}], + "messages": [ + {"role": "user", "content": [{"text": "How much lag is there in my data?"}]} + ], "toolConfig": { "tools": [ { "toolSpec": { "name": "lookup", - "description": "blocked tool description", + "description": "tool description", "inputSchema": { "json": { "type": "object", "properties": { - "q": { + "agent_name": { "type": "string", - "description": "blocked schema description", + "title": "Agent Name", + "enum": ["alpha", "beta", "gamma"], } }, } @@ -196,20 +201,56 @@ class TestExtractConverseTexts: }, } texts, _ = _extract_converse_texts(body, skip_system=False, skip_tool=False) - assert "blocked tool description" in texts - assert "blocked schema description" in texts + assert texts == ["How much lag is there in my data?"] - def test_tool_config_scanned_even_when_tool_messages_skipped(self): + def test_every_tool_definition_excluded_not_just_the_first(self): + """A per-tool scan that only skipped tools[0] would still leak the rest.""" body = { "messages": [{"role": "user", "content": [{"text": "hi"}]}], "toolConfig": { "tools": [ - {"toolSpec": {"name": "fn", "description": "blocked description"}} + {"toolSpec": {"name": "first", "description": "first description"}}, + {"toolSpec": {"name": "second", "description": "second description"}}, + {"toolSpec": {"name": "third", "description": "third description"}}, + ] + }, + } + texts, _ = _extract_converse_texts(body, skip_system=False, skip_tool=False) + assert texts == ["hi"] + + def test_tool_config_definitions_not_extracted_when_tool_messages_skipped(self): + body = { + "messages": [{"role": "user", "content": [{"text": "hi"}]}], + "toolConfig": { + "tools": [ + {"toolSpec": {"name": "fn", "description": "tool description"}} ] }, } texts, _ = _extract_converse_texts(body, skip_system=False, skip_tool=True) - assert "blocked description" in texts + assert texts == ["hi"] + + def test_tool_use_input_still_extracted_alongside_tool_config(self): + """Only tool DEFINITIONS are excluded; caller content inside a toolUse + block is still scanned.""" + body = { + "messages": [ + { + "role": "user", + "content": [ + {"text": "hi"}, + {"toolUse": {"toolUseId": "t1", "name": "fn", "input": {"q": "user secret"}}}, + ], + } + ], + "toolConfig": { + "tools": [ + {"toolSpec": {"name": "fn", "description": "tool description"}} + ] + }, + } + texts, _ = _extract_converse_texts(body, skip_system=False, skip_tool=False) + assert texts == ["hi", "user secret"] def test_extracts_additional_model_request_fields(self): body = { @@ -437,9 +478,9 @@ class TestBedrockPassthroughGuardrailHandlerInput: assert "blocked content" in sent_texts @pytest.mark.asyncio - async def test_tool_config_description_scanned_and_masked(self): - """Blocked text hidden in toolConfig.tools[].toolSpec.description is still - forwarded to Bedrock, so the guardrail must see it and mask it in place.""" + async def test_tool_config_definitions_not_sent_and_left_untouched(self): + """Tool definitions never reach the guardrail, and the body forwarded to + Bedrock keeps them byte for byte.""" handler = BedrockPassthroughGuardrailHandler() data = _converse_data() data["data"]["toolConfig"] = { @@ -453,36 +494,42 @@ class TestBedrockPassthroughGuardrailHandlerInput: } ] } - guardrail = _make_guardrail( - {"texts": ["You are helpful.", "Hello world", "lookup", "[REDACTED]", "object"]} - ) + original_tool_config = copy.deepcopy(data["data"]["toolConfig"]) + guardrail = _make_guardrail({"texts": ["[REDACTED]", "[REDACTED]"]}) result = await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) sent_texts = guardrail.apply_guardrail.call_args.kwargs["inputs"]["texts"] - assert "email john@example.com" in sent_texts - tool_spec = result["data"]["toolConfig"]["tools"][0]["toolSpec"] - assert tool_spec["description"] == "[REDACTED]" + assert sent_texts == ["You are helpful.", "Hello world"] + assert result["data"]["toolConfig"] == original_tool_config @pytest.mark.asyncio - async def test_tool_config_description_blocking_propagates(self): - """A blocking guardrail must reject content hidden in a tool description.""" + async def test_blocking_guardrail_not_triggered_by_tool_description(self): + """LIT-5797: a request whose only prompt is a benign user message must not + be blocked because a denied term appears in a tool definition.""" handler = BedrockPassthroughGuardrailHandler() data = _converse_data() data["data"]["toolConfig"] = { "tools": [{"toolSpec": {"name": "fn", "description": "blocked content"}}] } + + async def _block_on_denied_term(**kwargs): + texts = kwargs["inputs"]["texts"] + if any("blocked content" in text for text in texts): + raise GuardrailBlocked("Blocked") + return {"texts": texts} + guardrail = MagicMock() guardrail.guardrail_name = "block-guard" guardrail.skip_system_message_in_guardrail = False guardrail.skip_tool_message_in_guardrail = False - guardrail.apply_guardrail = AsyncMock(side_effect=GuardrailBlocked("Blocked")) + guardrail.apply_guardrail = AsyncMock(side_effect=_block_on_denied_term) - with pytest.raises(GuardrailBlocked): - await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) + result = await handler.process_input_messages(data=data, guardrail_to_apply=guardrail) sent_texts = guardrail.apply_guardrail.call_args.kwargs["inputs"]["texts"] - assert "blocked content" in sent_texts + assert "blocked content" not in sent_texts + assert result["data"]["toolConfig"]["tools"][0]["toolSpec"]["description"] == "blocked content" @pytest.mark.asyncio async def test_additional_model_request_fields_scanned_and_masked(self): From 2c3c7dd1a60f80595350671c7f654dfb2c16933d Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 18:41:47 -0700 Subject: [PATCH 163/410] feat(shadow_eval): judge tool-call turns instead of dropping or erroring on them (#39818) * fix(shadow_eval): tell a tool-call shadow reply apart from an empty one Both arrive at the attempt row as the same 'shadow router returned an empty response', because _chat_final_text returns empty for a tool-final turn by design and for a reply that genuinely carried no text. Those are different things: an arm that chose a tool where the real model wrote prose is a divergence a text judge cannot score, and the sampling side already drops the real arm's tool-final turns for exactly that reason, so the shadow side reads as a fault where the real side reads as a filter. A job that is almost all 'empty response' gives no way to tell a tool-happy arm from a broken one. The error now names which of the two happened, and carries the finish_reason and the routed model so the row says what the arm was doing. Every varying part sits behind the first semicolon: operators read these by grouping on the error text, and interpolating the model into the leading sentence would make each row its own group. The outcome stays 'error'. Whether a tool-call reply should instead be its own non-judged outcome, excluded from the loss rate the way the real arm's tool-final turns already are, needs the four aggregation predicates that spell judged as outcome != 'error' rewritten, and a decision on how to surface the new bucket. That is a separate change. * fix(shadow_eval): read the tool name of a custom tool call A custom tool call carries its name under custom.name with no function key, so every one of them reported as tool=unnamed. * feat(shadow_eval): judge tool calls instead of dropping the turn A turn where either arm called a tool was discarded before it could be compared: the real arm's at sampling, the shadow arm's as an error row. On agentic traffic that is most of the traffic, so a job set to sample 10% was sampling 10% of the prose-only slice. Tool calls now serialize to text on every surface and are judged like any other response, and the judge is told a tool call is not a defect so it scores the choice rather than the shape. * feat(shadow_eval): show the judge what tools were available Both arms were offered the same tools, but the judge only ever saw the chosen call in isolation, with no way to tell whether a better tool existed or the arguments matched what the tool expects. Threads the request's tool definitions (name and description only) into the judge prompt, capped and omitted entirely on turns that offered none. * fix(shadow_eval): read a custom tool definition's name from custom, not function A chat-completions custom tool definition nests name and description under custom, mirroring how a custom tool call nests them (openai.types.chat. ChatCompletionCustomToolParam). Reading only function rendered every one as unnamed, telling the judge nothing about what it was. --- litellm/integrations/shadow_eval_logger.py | 163 ++++++++-- .../integrations/test_shadow_eval_logger.py | 287 +++++++++++++++++- 2 files changed, 416 insertions(+), 34 deletions(-) diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index 27da785331a..b28d21ba1ca 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -165,28 +165,91 @@ def _chat_request_from_responses( ) -def _chat_final_text(response_obj: object) -> str: - """The assistant's text, or empty when the turn carries tool calls: only text-final - turns produce a judgeable A/B comparison.""" +def _chat_choice(response_obj: object) -> object | None: + """The response's first choice, from a payload mapping or a duck-typed ModelResponse.""" try: - message: Final = ( - response_obj["choices"][0]["message"] - if isinstance(response_obj, Mapping) - else response_obj.choices[0].message # pyright: ignore[reportAttributeAccessIssue] # duck-typed ModelResponse - ) + if isinstance(response_obj, Mapping): + return response_obj["choices"][0] + return response_obj.choices[0] # pyright: ignore[reportAttributeAccessIssue] # duck-typed ModelResponse except (AttributeError, KeyError, IndexError, TypeError): + return None + + +def _field_reader(obj: object) -> Callable[[str], object]: + return obj.get if isinstance(obj, Mapping) else lambda key: getattr(obj, key, None) + + +def _chat_message_reader(response_obj: object) -> Callable[[str], object] | None: + """Field access over the assistant message of a chat response, or None for a payload + with no readable message.""" + choice: Final = _chat_choice(response_obj) + if choice is None: + return None + message: Final = _field_reader(choice)("message") + return _field_reader(message) if message is not None else None + + +def _chat_final_text(response_obj: object) -> str: + """The turn's judgeable text: prose, or every tool call serialized alongside it as + `[tool call] name(arguments)` when the assistant chose to act instead of, or as well + as, answering directly. A tool call is a real turn, not a gap, so this is what both + the real arm's sampling decision and the shadow arm's reply compare against.""" + read: Final = _chat_message_reader(response_obj) + if read is None: return "" - read: Final = message.get if isinstance(message, Mapping) else lambda key: getattr(message, key, None) - if read("tool_calls") or read("function_call"): - return "" - return extract_text_from_content(read("content")) + prose: Final = extract_text_from_content(read("content")) + if not (read("tool_calls") or read("function_call")): + return prose + serialized: Final = _serialize_tool_calls(read) + return f"{prose} {serialized}".strip() if prose else serialized + + +def _chat_finish_reason(response_obj: object) -> str: + choice: Final = _chat_choice(response_obj) + raw: Final = _field_reader(choice)("finish_reason") if choice is not None else None + return str(raw) if raw else "unknown" + + +_RESPONSES_TOOL_CALL_TYPES: Final = frozenset(("function_call", "custom_tool_call")) + + +def _tool_calls_list(read: Callable[[str], object]) -> tuple[object, ...]: + calls: Final = read("tool_calls") + listed: Final = tuple(calls) if isinstance(calls, Sequence) and not isinstance(calls, str) else () + single: Final = read("function_call") + return listed if listed else ((single,) if single is not None else ()) + + +def _tool_call_invocation(call: object) -> str: + """One tool call as `name(arguments)`. Custom tool calls name themselves and carry their + arguments under `custom` rather than `function`.""" + read_call: Final = _field_reader(call) + payload: Final = read_call("function") or read_call("custom") or call + read_payload: Final = _field_reader(payload) + name: Final = read_payload("name") + arguments: Final = read_payload("arguments") or read_payload("input") or "" + return f"{name or 'unnamed'}({arguments})" + + +def _serialize_tool_calls(read: Callable[[str], object]) -> str: + """Every tool call in a reply as text a judge built for prose can still read.""" + return ", ".join(f"[tool call] {_tool_call_invocation(call)}" for call in _tool_calls_list(read)) + + +def _shadow_empty_reply_error(response_obj: object, routed_model: str) -> str: + """Why a shadow reply yielded no judgeable text at all: no prose, and no tool call to + serialize either. The stable sentence comes first and every varying part after the + semicolon, so grouping rows by error still yields one row per cause.""" + detail: Final = f"finish_reason={_chat_finish_reason(response_obj)}, model={routed_model or 'unknown'}" + return f"shadow router returned an empty response; {detail}" def _responses_final_text(response_obj: object) -> str: - """The turn's aggregated output text, or empty when the turn carries tool calls. A - dict-shaped payload is validated into the owner type first, because ``output_text`` - is a derived property rather than a serialized field, so it never exists on a dict; - a dict the owner type rejects is unjudgeable and skipped.""" + """The turn's judgeable text: the aggregated output plus any tool call serialized + alongside it, the same way the chat surface renders one. A dict-shaped payload is + validated into the owner type first, because ``output_text`` is a derived property + rather than a serialized field, so it never exists on a dict; a dict the owner type + rejects is unjudgeable and skipped.""" from litellm.types.llms.openai import ResponsesAPIResponse try: @@ -199,11 +262,16 @@ def _responses_final_text(response_obj: object) -> str: if not isinstance(output, Sequence): return "" items: Final = tuple(item.model_dump() if isinstance(item, BaseModel) else item for item in output) - if any( - not isinstance(item, Mapping) or item.get("type") in ("function_call", "custom_tool_call") for item in items - ): + if any(not isinstance(item, Mapping) for item in items): return "" - return str(getattr(response, "output_text", "") or "") + calls: Final = tuple( + item for item in items if isinstance(item, Mapping) and item.get("type") in _RESPONSES_TOOL_CALL_TYPES + ) + prose: Final = str(getattr(response, "output_text", "") or "") + if not calls: + return prose + serialized: Final = ", ".join(f"[tool call] {_tool_call_invocation(call)}" for call in calls) + return f"{prose} {serialized}".strip() if prose else serialized class _SurfaceOps: @@ -273,8 +341,8 @@ def _judgeable_sample( response_obj: object, ) -> tuple[tuple[Mapping[str, object], ...], Mapping[str, object], str] | None: """The normalized chat conversation, the forwardable generation params, and the - judgeable final text; None when this request's shapes cannot be sampled (tool-final - turn, empty text, or a shape the owner transformations reject).""" + judgeable final text; None when this request's shapes cannot be sampled (no text and no + tool call to serialize, or a shape the owner transformations reject).""" try: request: Final = ops.chat_request(kwargs, model_parameters) items: Final = _MESSAGE_ITEMS_ADAPTER.validate_python(request.get("messages")) @@ -307,6 +375,11 @@ PAIRWISE_JUDGE_SYSTEM_PROMPT: Final = """You are an impartial quality judge comp The responses are labeled A and B in random order. You do not know which system produced which. +A response may be prose, or a tool call shown as `[tool call] name(arguments)` if the +assistant chose to act instead of answering directly. A tool call is not a defect: judge +whether calling that tool was the right response to the conversation, the same as you +would judge prose. + Criteria: correctness, completeness, clarity, conciseness. Return ONLY valid JSON in this exact format, no other text: @@ -376,14 +449,37 @@ def _unmask_preference(raw_preference: str, real_is_a: bool) -> str: return "tie" -def _judge_user_prompt(conversation: str, response_a: str, response_b: str) -> str: +_MAX_JUDGE_TOOL_DEFS_CHARS: Final = 2_000 + + +def _tool_definitions_text(tools: object) -> str: + """The tools available to both arms, name and description only: enough for the judge + to tell whether the chosen tool, and not some other one, was the right call, without + forwarding parameter schemas it does not need to score that.""" + if not isinstance(tools, Sequence) or isinstance(tools, str): + return "" + entries: Final = tuple( + _field_reader(t)("function") or _field_reader(t)("custom") or t for t in tools if not isinstance(t, str) + ) + lines: Final = tuple( + f"- {_field_reader(e)('name') or 'unnamed'}: {_field_reader(e)('description') or 'no description'}" + for e in entries + ) + if not lines: + return "" + return ("Tools available to both responses:\n" + "\n".join(lines))[:_MAX_JUDGE_TOOL_DEFS_CHARS] + + +def _judge_user_prompt(conversation: str, response_a: str, response_b: str, tool_definitions: str = "") -> str: """The judge prompt under one total character budget: each response is capped, and - the conversation tail gets whatever budget the responses left over.""" + the conversation tail gets whatever budget the responses and tool definitions left + over.""" a: Final = response_a[:_MAX_JUDGE_RESPONSE_CHARS] b: Final = response_b[:_MAX_JUDGE_RESPONSE_CHARS] - conversation_budget: Final = _MAX_JUDGE_PROMPT_CHARS - len(a) - len(b) + prefix: Final = f"{tool_definitions}\n\n" if tool_definitions else "" + conversation_budget: Final = _MAX_JUDGE_PROMPT_CHARS - len(a) - len(b) - len(prefix) return ( - f"Conversation:\n{conversation[-conversation_budget:]}\n\n" + f"{prefix}Conversation:\n{conversation[-conversation_budget:]}\n\n" f"Response A:\n{a}\n\n" f"Response B:\n{b}\n\n" "Which response is better?" @@ -942,6 +1038,7 @@ class ShadowEvalLogger(CustomLogger): messages=messages, real_text=real_text, shadow_text=shadow.text, + tools=shadow_params.get("tools"), parent_metadata=parent_metadata, ) if isinstance(verdict, _CallFailure): @@ -1080,15 +1177,18 @@ class ShadowEvalLogger(CustomLogger): classifier_cost=_decision_classifier_cost(shadow_metadata), ) text: Final = _chat_final_text(response) + routed_model: Final = str( + getattr(response, "model", None) or _routing_decision(shadow_metadata).get("routed_model") or "" + ) if not text: return _CallFailure( - "shadow router returned an empty response", + _shadow_empty_reply_error(response, routed_model), cost=_call_cost(response), classifier_cost=_decision_classifier_cost(shadow_metadata), ) return _ShadowResponse( text=text, - model=str(getattr(response, "model", None) or _routing_decision(shadow_metadata).get("routed_model") or ""), + model=routed_model, tier=_routed_tier(shadow_metadata), cost=_call_cost(response), classifier_cost=_decision_classifier_cost(shadow_metadata), @@ -1100,9 +1200,12 @@ class ShadowEvalLogger(CustomLogger): messages: Sequence[Mapping[str, object]], real_text: str, shadow_text: str, + tools: object, parent_metadata: Mapping[str, object], ) -> "_JudgeVerdict | _CallFailure": - """Blind pairwise judge with A/B labels randomized to cancel position bias.""" + """Blind pairwise judge with A/B labels randomized to cancel position bias. Both + arms were offered the same tools, so the judge is shown their definitions too: a + tool call is only assessable against what else was available to call instead.""" real_is_a: Final = random.random() < 0.5 response_a: Final = real_text if real_is_a else shadow_text response_b: Final = shadow_text if real_is_a else real_text @@ -1117,7 +1220,7 @@ class ShadowEvalLogger(CustomLogger): {"role": "system", "content": PAIRWISE_JUDGE_SYSTEM_PROMPT}, # mutable-ok: SDK message { "role": "user", - "content": _judge_user_prompt(conversation, response_a, response_b), + "content": _judge_user_prompt(conversation, response_a, response_b, _tool_definitions_text(tools)), }, # mutable-ok: SDK message ] try: diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 5628d69de26..f273a285d49 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -24,7 +24,13 @@ from litellm.integrations.shadow_eval_logger import ( _unmask_preference, ) from litellm.types.guardrails import GuardrailEventHooks -from litellm.types.utils import SHADOW_EVAL_JUDGE_CALL_ORIGIN, SHADOW_EVAL_ROUTER_CALL_ORIGIN, ModelResponse +from litellm.types.utils import ( + SHADOW_EVAL_JUDGE_CALL_ORIGIN, + SHADOW_EVAL_ROUTER_CALL_ORIGIN, + ChatCompletionCustomToolCallPayload, + ChatCompletionMessageCustomToolCall, + ModelResponse, +) def _job(**overrides) -> ActiveShadowEvalJob: @@ -120,6 +126,39 @@ def _router( return router +def _shadow_reply_router(message, finish_reason="stop", routed_model="cheap-model"): + """A router whose shadow arm answers with a caller-supplied message, so a reply that + yields no judgeable text can be posed as the two different things it can be: an arm + that chose a tool, or an arm that returned nothing.""" + router = MagicMock() + router.model_group_alias = {} + router.get_model_list = MagicMock(return_value=[{"litellm_params": {"model": "openai/gpt-4o-mini"}}]) + + async def acompletion(**kwargs): + if kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != SHADOW_EVAL_ROUTER_CALL_ORIGIN: + return {"choices": [{"message": {"content": '{"preference": "A", "confidence": 0.9}'}}]} + kwargs["metadata"]["routing_decision"] = {"tier_label": "SIMPLE", "routed_model": routed_model} + return {"choices": [{"message": message, "finish_reason": finish_reason}]} + + router.acompletion = MagicMock(side_effect=acompletion) + return router + + +TOOL_CALL_MESSAGE = { + "content": None, + "tool_calls": [{"id": "c1", "type": "function", "function": {"name": "Read", "arguments": "{}"}}], +} + +CUSTOM_TOOL_CALL_MESSAGE = { + "content": None, + "tool_calls": [ + ChatCompletionMessageCustomToolCall( + id="c2", custom=ChatCompletionCustomToolCallPayload(name="exec_sql", input="select 1") + ) + ], +} + + def _spend_counter(store=None): """In-memory stand-in for the proxy's cross-pod spend counter: reads take the max of the counter and the caller's fallback, exactly like get_current_spend does for a key @@ -368,7 +407,13 @@ class TestSurfaceNormalization: ], ids=["tool-final-chat-turn", "tool-final-responses-turn"], ) - async def test_unjudgeable_turns_are_skipped_without_consuming_budget(self, response_mutation, kwargs_mutation): + async def test_a_tool_final_turn_is_sampled_and_serialized_for_the_judge( + self, response_mutation, kwargs_mutation + ): + """A turn where the real model called a tool used to be dropped before sampling, on + every surface. On agentic traffic that is most of the traffic, so a job set to + sample 10% was really sampling 10% of the prose-only slice and calling it 10% of + the key. The turn is sampled like any other and the call is serialized as text.""" from litellm.types.llms.openai import ResponsesAPIResponse hook_kwargs = _success_kwargs(**({"call_type": "acompletion"} | kwargs_mutation)) @@ -406,6 +451,38 @@ class TestSurfaceNormalization: prisma, router = await self._drive(hook_kwargs, response) + judge_prompt = next( + call.kwargs["messages"][-1]["content"] + for call in router.acompletion.call_args_list + if call.kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != SHADOW_EVAL_ROUTER_CALL_ORIGIN + ) + assert "[tool call] f({})" in judge_prompt + prisma.db.litellm_shadowevalattempt.create.assert_called_once() + + @pytest.mark.parametrize( + "response_mutation,kwargs_mutation", + [ + ("chat-no-content", {}), + ("responses-no-output", {"call_type": "aresponses"}), + ], + ids=["empty-chat-turn", "empty-responses-turn"], + ) + async def test_turns_with_nothing_to_compare_are_skipped_without_consuming_budget( + self, response_mutation, kwargs_mutation + ): + """No prose and no tool call leaves the judge nothing to score, so the turn is + still skipped rather than billed.""" + from litellm.types.llms.openai import ResponsesAPIResponse + + hook_kwargs = _success_kwargs(**({"call_type": "acompletion"} | kwargs_mutation)) + if response_mutation == "chat-no-content": + response = {"choices": [{"message": {"content": ""}}]} + else: + hook_kwargs["messages"] = "do the thing" + response = ResponsesAPIResponse.model_validate(RESPONSES_API_RESPONSE | {"output": []}) + + prisma, router = await self._drive(hook_kwargs, response) + router.acompletion.assert_not_called() prisma.db.litellm_shadowevalattempt.create.assert_not_called() @@ -1134,6 +1211,206 @@ class TestShadowPipeline: assert row["shadow_cost"] == 0.007 assert logger._test_counter["spend:shadow_eval:job-1"] == 0.007 + async def _no_text_error(self, router) -> str: + prisma = _prisma() + await _logger(router=router, prisma=prisma)._run_shadow_eval( + job=_job(), + request_id="req-1", + messages=({"role": "user", "content": "hi"},), + real_text="real answer", + real_model="claude-opus", + real_cost=0.0, + real_classifier_cost=0.0, + real_cache_hit=False, + control_tier=None, + shadow_params={}, + parent_metadata={}, + ) + row = prisma.db.litellm_shadowevalattempt.create.call_args.kwargs["data"] + assert row["outcome"] == "error" + return row["error"] + + async def _judged_shadow_row(self, router: MagicMock, shadow_params: dict | None = None) -> dict: + prisma = _prisma() + await _logger(router=router, prisma=prisma)._run_shadow_eval( + job=_job(), + request_id="req-1", + messages=({"role": "user", "content": "hi"},), + real_text="real answer", + real_model="claude-opus", + real_cost=0.0, + real_classifier_cost=0.0, + real_cache_hit=False, + control_tier=None, + shadow_params=shadow_params or {}, + parent_metadata={}, + ) + return prisma.db.litellm_shadowevalattempt.create.call_args.kwargs["data"] + + async def test_a_tool_call_shadow_reply_is_judged_rather_than_discarded(self): + """An arm that calls a tool where the real model wrote prose has answered, it just + answered by acting. Dropping that turn threw away the comparison the job exists to + make, and on agentic traffic it threw away most of them, so the tool call is + serialized into text and judged like any other response.""" + row = await self._judged_shadow_row(_shadow_reply_router(TOOL_CALL_MESSAGE, finish_reason="tool_calls")) + + assert row["outcome"] != "error" + assert row["error"] is None + assert row["confidence"] == 0.9 + + async def test_a_tool_call_reaches_the_judge_as_readable_text(self): + """The judge only ever sees strings, so a tool call has to arrive as its name and + arguments. A serialization that dropped either would ask the judge to score a + response it cannot tell apart from any other tool call.""" + router = _shadow_reply_router(TOOL_CALL_MESSAGE, finish_reason="tool_calls") + await self._judged_shadow_row(router) + + judge_prompt = next( + call.kwargs["messages"][-1]["content"] + for call in router.acompletion.call_args_list + if call.kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != SHADOW_EVAL_ROUTER_CALL_ORIGIN + ) + + assert "[tool call] Read({})" in judge_prompt + + async def test_the_judge_sees_what_tools_were_available(self): + """Scoring whether a tool call was the right response needs to know what else the + arm could have called instead. Without the tool list, the judge can score the + arguments but not whether Read, specifically, was the correct choice.""" + router = _shadow_reply_router(TOOL_CALL_MESSAGE, finish_reason="tool_calls") + tools = [ + {"type": "function", "function": {"name": "Read", "description": "read a file from disk"}}, + {"type": "function", "function": {"name": "Bash", "description": "run a shell command"}}, + ] + await self._judged_shadow_row(router, shadow_params={"tools": tools}) + + judge_prompt = next( + call.kwargs["messages"][-1]["content"] + for call in router.acompletion.call_args_list + if call.kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != SHADOW_EVAL_ROUTER_CALL_ORIGIN + ) + + assert "Read: read a file from disk" in judge_prompt + assert "Bash: run a shell command" in judge_prompt + + async def test_a_custom_tool_definition_is_named_for_the_judge(self): + """A custom tool definition nests name and description under `custom`, not + `function`, so reading only `function` renders every one of them as unnamed and + tells the judge nothing about what the arm could have called.""" + from openai.types.chat import ChatCompletionCustomToolParam + + router = _shadow_reply_router(TOOL_CALL_MESSAGE, finish_reason="tool_calls") + tools = [ + ChatCompletionCustomToolParam( + type="custom", + custom={"name": "exec_sql", "description": "run a read-only sql query"}, + ) + ] + await self._judged_shadow_row(router, shadow_params={"tools": tools}) + + judge_prompt = next( + call.kwargs["messages"][-1]["content"] + for call in router.acompletion.call_args_list + if call.kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != SHADOW_EVAL_ROUTER_CALL_ORIGIN + ) + + assert "exec_sql: run a read-only sql query" in judge_prompt + assert "unnamed" not in judge_prompt + + @pytest.mark.parametrize("shadow_params", [{}, {"tools": []}], ids=["omitted", "empty-list"]) + async def test_no_tool_definitions_section_when_the_turn_offered_no_tools(self, shadow_params): + """Padding every judge prompt with an empty tools section wastes budget on the + turns, still the majority, that never offered one, whether tools was left out of + the request entirely or sent as an empty list.""" + router = _shadow_reply_router({"content": "hello"}, finish_reason="stop") + await self._judged_shadow_row(router, shadow_params=shadow_params) + + judge_prompt = next( + call.kwargs["messages"][-1]["content"] + for call in router.acompletion.call_args_list + if call.kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != SHADOW_EVAL_ROUTER_CALL_ORIGIN + ) + + assert "Tools available" not in judge_prompt + + async def test_a_custom_tool_call_serializes_its_name_and_input(self): + """Custom tool calls carry no `function` key: name and arguments live under + `custom`, so reading only `function` serializes every one of them as unnamed.""" + router = _shadow_reply_router(CUSTOM_TOOL_CALL_MESSAGE, finish_reason="tool_calls") + await self._judged_shadow_row(router) + + judge_prompt = next( + call.kwargs["messages"][-1]["content"] + for call in router.acompletion.call_args_list + if call.kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != SHADOW_EVAL_ROUTER_CALL_ORIGIN + ) + + assert "[tool call] exec_sql(select 1)" in judge_prompt + + async def test_the_judge_is_told_a_tool_call_is_not_a_defect(self): + """The judge scores on completeness and clarity. Handed a tool call with no + instruction, it marks it down for not reading like an answer, which would bias + every verdict against a tool-calling arm on exactly the traffic that calls tools.""" + router = _shadow_reply_router(TOOL_CALL_MESSAGE, finish_reason="tool_calls") + await self._judged_shadow_row(router) + + system_prompt = next( + call.kwargs["messages"][0]["content"] + for call in router.acompletion.call_args_list + if call.kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != SHADOW_EVAL_ROUTER_CALL_ORIGIN + ) + + assert "tool call" in system_prompt + assert "not a defect" in system_prompt + + async def test_prose_written_alongside_a_tool_call_survives_into_the_verdict(self): + """Some providers write a sentence before acting. Serializing only the call would + hide half of what the arm actually said from the judge.""" + router = _shadow_reply_router( + {"content": "Let me look that up.", "tool_calls": TOOL_CALL_MESSAGE["tool_calls"]}, + finish_reason="tool_calls", + ) + await self._judged_shadow_row(router) + + judge_prompt = next( + call.kwargs["messages"][-1]["content"] + for call in router.acompletion.call_args_list + if call.kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) != SHADOW_EVAL_ROUTER_CALL_ORIGIN + ) + + assert "Let me look that up. [tool call] Read({})" in judge_prompt + + async def test_an_empty_shadow_reply_names_the_finish_reason_and_the_routed_model(self): + """A reply that really carried no text is diagnosable only if the row says what + the arm was doing when it produced none: a truncated turn and a model that answers + with nothing are different faults with different fixes.""" + error = await self._no_text_error( + _shadow_reply_router({"content": ""}, finish_reason="length", routed_model="some-model") + ) + + assert "empty response" in error + assert "finish_reason=length" in error + assert "model=some-model" in error + + async def test_no_text_errors_stay_groupable_across_models_and_finish_reasons(self): + """Operators read these rows by grouping on the error text, which is how a job's + failures collapse to a handful of causes. Every varying part therefore has to sit + behind the first semicolon, or each row becomes its own group and the count that + made the problem visible stops existing.""" + first = await self._no_text_error( + _shadow_reply_router({"content": None}, finish_reason="length", routed_model="model-a") + ) + second = await self._no_text_error( + _shadow_reply_router( + {"content": ""}, + finish_reason="stop", + routed_model="model-b", + ) + ) + + assert first != second + assert first.split(";")[0] == second.split(";")[0] + async def test_a_pipeline_error_after_the_shadow_call_keeps_its_billed_cost(self, monkeypatch: pytest.MonkeyPatch): """An unexpected error between the billed shadow call and the attempt write must still record the shadow cost, or the per-key dollar gate undercounts forever.""" @@ -1691,11 +1968,13 @@ class TestSamplingFunnel: prisma.db.litellm_shadowevalattempt.create.assert_not_awaited() async def test_an_unjudgeable_sampled_request_counts_unjudgeable(self): + """A tool call still serializes into judgeable text; a turn with neither prose nor + a tool call to serialize is the one case left with nothing to compare.""" prisma = _prisma() logger = _logger(router=_router(), prisma=prisma, jobs=(_job(),)) - tool_final = {"choices": [{"message": {"content": None, "tool_calls": [{"type": "function", "function": {}}]}}]} + empty = {"choices": [{"message": {"content": None}}]} - await logger.async_log_success_event(_success_kwargs(), tool_final, None, None) + await logger.async_log_success_event(_success_kwargs(), empty, None, None) await _drain(logger) assert logger._test_funnel == [("job-1", "unjudgeable")] From d4fd658891dde94b12518541da20feda35b2c6c1 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:47:48 -0700 Subject: [PATCH 164/410] fix(datadog_llm_obs): keep guardrail_cost_by_unit on redacted spans --- litellm/types/utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 238986f86a6..5052cd6ef48 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3096,7 +3096,7 @@ PROMPT_CARRYING_GUARDRAIL_FIELDS: Final[frozenset[str]] = frozenset( # The rest of the record: what the guardrail is, what it decided, how long it took and what it cost. # None of these reproduce the prompt, so a redacted record keeps them and stays explainable. -# `test_every_guardrail_field_is_classified` fails if a field is added to the record without being +# `test_a_redacted_span_carries_every_declared_guardrail_field` fails if a field is added to the record without being # placed in one set or the other, so a new field is dropped from redacted records rather than # shipped unexamined. AUDIT_GUARDRAIL_FIELDS: Final[frozenset[str]] = frozenset( @@ -3120,6 +3120,7 @@ AUDIT_GUARDRAIL_FIELDS: Final[frozenset[str]] = frozenset( "guardrail_action", "guardrail_usage", "guardrail_cost", + "guardrail_cost_by_unit", "guardrail_cost_in_spend", } ) From ee50f2bc448d004c757ab2ddea21ac7e5a83baf4 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:50:47 -0700 Subject: [PATCH 165/410] test(e2e/batches): run the list assertion when a batch completes before cancel The completed-batch early return skipped both the cancel and the list assertion while the lifecycle's covers markers still credited both cells. List does not depend on the batch being cancellable, so it now runs either way; cancel on a completed batch stays a documented vacuous pass --- tests/e2e/batches/COVERAGE.md | 2 +- tests/e2e/batches/test_batches_e2e.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/tests/e2e/batches/COVERAGE.md b/tests/e2e/batches/COVERAGE.md index 2b1f60cbda7..8a7b68511ec 100644 --- a/tests/e2e/batches/COVERAGE.md +++ b/tests/e2e/batches/COVERAGE.md @@ -25,7 +25,7 @@ Bedrock cancel maps to `StopModelInvocationJob` and comes back `cancelling`; the lifecycle asserts it the same way it does for OpenAI (`_CANCEL_ASSERTED_PROVIDERS`). Bedrock has no provider-side list, so list is the proxy's DB-backed managed view: the unified lifecycle lists with the plain `GET /v1/batches` and the batch must appear -there. Both were gated off until LIT-5730, after LIT-4774 landed cancel support. +there. Both were gated off until LIT-5730, after LIT-4774 landed cancel support. A batch that completes inside the 2 s pre-cancel window skips the cancel assertion (a documented vacuous pass for the cancel cell, same as OpenAI); the list assertion runs either way. Bedrock file upload requires a model on the request (`encoded` / `unified` scenarios only); `model_param` and `provider_fallback` are omitted because `POST /bedrock/v1/files` has no model-less passthrough path. diff --git a/tests/e2e/batches/test_batches_e2e.py b/tests/e2e/batches/test_batches_e2e.py index cb9954b8e09..ed7cf656d01 100644 --- a/tests/e2e/batches/test_batches_e2e.py +++ b/tests/e2e/batches/test_batches_e2e.py @@ -293,14 +293,13 @@ def test_batch_lifecycle( f"batch reached {pre_cancel.status!r} before cancel; " "provider likely rejected the input" ) - if pre_cancel.status == "completed": - return - cancelled = cancel_batch(client, batch.id, key=key, provider=provider) - assert cancelled.id == batch.id - assert cancelled.object == "batch" - assert cancelled.status in {"cancelling", "cancelled"}, ( - f"unexpected post-cancel status {cancelled.status!r}" - ) + if pre_cancel.status != "completed": + cancelled = cancel_batch(client, batch.id, key=key, provider=provider) + assert cancelled.id == batch.id + assert cancelled.object == "batch" + assert cancelled.status in {"cancelling", "cancelled"}, ( + f"unexpected post-cancel status {cancelled.status!r}" + ) if cap.can_list: list_result = client.list_batches(key=key, provider=provider) From 567915aeee82953c4127456654f3a63ec9862970 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:51:14 -0700 Subject: [PATCH 166/410] fix(image_handling): inline url-sourced Anthropic document and image blocks in the async walker --- .../prompt_templates/image_handling.py | 51 +++++++++++++++---- .../litellm_core_utils/test_image_handling.py | 11 ++++ ...ations_anthropic_claude3_transformation.py | 47 +++++++++++++++++ ...test_bedrock_chat_mantle_transformation.py | 48 +++++++++++++++++ 4 files changed, 147 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index 4beaabc8b24..55c01b3b1cb 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -157,18 +157,41 @@ def _remote_url(candidate: object) -> str | None: return candidate if isinstance(candidate, str) and candidate.startswith(_REMOTE_URL_PREFIXES) else None -def _parse_remote_part(part: object) -> _RemoteImage | _RemoteFile | None: +_ANTHROPIC_MEDIA_BLOCK_TYPES: Final = frozenset({"document", "image"}) + + +@dataclass(frozen=True, slots=True) +class _RemoteSource: + part: Mapping[str, object] + url: str + + +def _parse_remote_image(fields: Mapping[str, object]) -> _RemoteImage | None: + if fields.get("type") != "image_url": + return None + image_url: Final = fields.get("image_url") + image_url_fields: Final = _as_mapping(image_url) + url: Final = _remote_url(image_url_fields.get("url") if image_url_fields is not None else image_url) + return _RemoteImage(fields, image_url_fields, url) if url is not None else None + + +def _parse_remote_file(fields: Mapping[str, object]) -> _RemoteFile | None: + file: Final = _as_mapping(fields.get("file")) if fields.get("type") == "file" else None + url: Final = _remote_url(file.get("file_id")) if file is not None else None + return _RemoteFile(fields, file, url) if file is not None and url is not None else None + + +def _parse_remote_source(fields: Mapping[str, object]) -> _RemoteSource | None: + source: Final = _as_mapping(fields.get("source")) if fields.get("type") in _ANTHROPIC_MEDIA_BLOCK_TYPES else None + url: Final = _remote_url(source.get("url")) if source is not None and source.get("type") == "url" else None + return _RemoteSource(fields, url) if url is not None else None + + +def _parse_remote_part(part: object) -> _RemoteImage | _RemoteFile | _RemoteSource | None: fields: Final = _as_mapping(part) if fields is None: return None - if fields.get("type") == "image_url": - image_url: Final = fields.get("image_url") - image_url_fields: Final = _as_mapping(image_url) - url: Final = _remote_url(image_url_fields.get("url") if image_url_fields is not None else image_url) - return _RemoteImage(fields, image_url_fields, url) if url is not None else None - file: Final = _as_mapping(fields.get("file")) if fields.get("type") == "file" else None - file_url: Final = _remote_url(file.get("file_id")) if file is not None else None - return _RemoteFile(fields, file, file_url) if file is not None and file_url is not None else None + return _parse_remote_image(fields) or _parse_remote_file(fields) or _parse_remote_source(fields) _PDF_FORMAT: Final = MappingProxyType({"format": "application/pdf"}) @@ -187,12 +210,20 @@ def _inlined_file(file: Mapping[str, object], url: str, data_url: str) -> Mappin return {**kept, **_inferred_format(file, url), "file_data": data_url} # mutable-ok: json-serialized part -def _inline(remote: _RemoteImage | _RemoteFile, data_url: str) -> Mapping[str, object]: +def _base64_source(url: str, data_url: str) -> Mapping[str, str]: + fetched_media_type, data = data_url.removeprefix("data:").split(";base64,", 1) + media_type: Final = "application/pdf" if url.lower().endswith(".pdf") else fetched_media_type + return {"type": "base64", "media_type": media_type, "data": data} # mutable-ok: json-serialized message part + + +def _inline(remote: _RemoteImage | _RemoteFile | _RemoteSource, data_url: str) -> Mapping[str, object]: match remote: case _RemoteImage(part, image_url, _): return {**part, "image_url": _inlined_image_url(image_url, data_url)} # mutable-ok: json-serialized part case _RemoteFile(part, file, url): return {**part, "file": _inlined_file(file, url, data_url)} # mutable-ok: json-serialized message part + case _RemoteSource(part, url): + return {**part, "source": _base64_source(url, data_url)} # mutable-ok: json-serialized message part def _content_parts(message: Mapping[str, object]) -> tuple[object, ...]: diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index ae9016f2f9e..106bf90213b 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -289,6 +289,9 @@ async def test_async_inline_remote_media_inlines_every_remote_part_shape(async_o {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}}, {"type": "file", "file": {"file_id": pdf_url}}, {"type": "file", "file": {"file_id": image_url, "format": "image/png"}}, + {"type": "document", "source": {"type": "url", "url": pdf_url}, "title": "the doc"}, + {"type": "image", "source": {"type": "url", "url": image_url}}, + {"type": "document", "source": {"type": "file", "file_id": "file_abc"}}, ], }, ] @@ -297,6 +300,7 @@ async def test_async_inline_remote_media_inlines_every_remote_part_shape(async_o inlined = await async_inline_remote_media(messages) data_url = async_only_image_fetch.data_url + base64_png = async_only_image_fetch.base64_png assert inlined[0] == {"role": "system", "content": "be terse"} assert inlined[1]["content"] == [ {"type": "text", "text": "what is this?"}, @@ -305,6 +309,13 @@ async def test_async_inline_remote_media_inlines_every_remote_part_shape(async_o {"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo="}}, {"type": "file", "file": {"format": "application/pdf", "file_data": data_url}}, {"type": "file", "file": {"format": "image/png", "file_data": data_url}}, + { + "type": "document", + "source": {"type": "base64", "media_type": "application/pdf", "data": base64_png}, + "title": "the doc", + }, + {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": base64_png}}, + {"type": "document", "source": {"type": "file", "file_id": "file_abc"}}, ] assert sorted(async_only_image_fetch.fetched) == sorted([image_url, pdf_url]) assert messages == snapshot diff --git a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py index e808a087e3d..cbf160c451f 100644 --- a/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/invoke_transformations/test_bedrock_chat_invoke_transformations_anthropic_claude3_transformation.py @@ -763,3 +763,50 @@ async def test_bedrock_invoke_claude_async_completion_inlines_remote_images_off_ assert async_only_image_fetch.fetched == [image_url] assert image_url not in captured["body"] assert async_only_image_fetch.base64_png in captured["body"] + + +async def test_bedrock_invoke_claude_async_completion_inlines_document_url_sources_off_the_event_loop(async_only_image_fetch): + pdf_url = f"http://docs.example/{uuid.uuid4()}.pdf" + captured = {} + + def handle(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "us.anthropic.claude-sonnet-5", + "content": [{"type": "text", "text": "A lease"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + + response = await litellm.acompletion( + model="bedrock/invoke/us.anthropic.claude-sonnet-5", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is this document?"}, + {"type": "document", "source": {"type": "url", "url": pdf_url}}, + ], + } + ], + aws_access_key_id="AKIAEXAMPLE", + aws_secret_access_key="fake-secret", + aws_region_name="us-east-1", + client=client, + ) + + assert response.choices[0].message.content == "A lease" + assert async_only_image_fetch.fetched == [pdf_url] + assert { + "type": "document", + "source": {"type": "base64", "media_type": "application/pdf", "data": async_only_image_fetch.base64_png}, + } in captured["body"]["messages"][0]["content"] diff --git a/tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py b/tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py index 9b491d305c7..a8448f5fa7a 100644 --- a/tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/mantle/test_bedrock_chat_mantle_transformation.py @@ -1,3 +1,4 @@ +import json import uuid import httpx @@ -49,3 +50,50 @@ async def test_bedrock_mantle_claude_async_completion_inlines_remote_images_off_ assert async_only_image_fetch.fetched == [image_url] assert image_url not in captured["body"] assert async_only_image_fetch.base64_png in captured["body"] + + +async def test_bedrock_mantle_claude_async_completion_inlines_document_url_sources_off_the_event_loop(async_only_image_fetch): + pdf_url = f"http://docs.example/{uuid.uuid4()}.pdf" + captured = {} + + def handle(request): + captured["body"] = json.loads(request.content) + return httpx.Response( + 200, + json={ + "id": "msg_1", + "type": "message", + "role": "assistant", + "model": "us.anthropic.claude-sonnet-5", + "content": [{"type": "text", "text": "A lease"}], + "stop_reason": "end_turn", + "usage": {"input_tokens": 1, "output_tokens": 1}, + }, + ) + + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + + response = await litellm.acompletion( + model="bedrock/mantle/us.anthropic.claude-sonnet-5", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "What is this document?"}, + {"type": "document", "source": {"type": "url", "url": pdf_url}}, + ], + } + ], + aws_access_key_id="AKIAEXAMPLE", + aws_secret_access_key="fake-secret", + aws_region_name="us-east-1", + client=client, + ) + + assert response.choices[0].message.content == "A lease" + assert async_only_image_fetch.fetched == [pdf_url] + assert { + "type": "document", + "source": {"type": "base64", "media_type": "application/pdf", "data": async_only_image_fetch.base64_png}, + } in captured["body"]["messages"][0]["content"] From a61bead2874d889bc67ea283cc74cc4df5fbbca9 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:52:17 -0700 Subject: [PATCH 167/410] test(store_model_in_db): accept both 400 shapes in the unknown-model spend log test --- tests/store_model_in_db_tests/test_openai_error_handling.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/store_model_in_db_tests/test_openai_error_handling.py b/tests/store_model_in_db_tests/test_openai_error_handling.py index 9433375c16d..d3f38f93bf3 100644 --- a/tests/store_model_in_db_tests/test_openai_error_handling.py +++ b/tests/store_model_in_db_tests/test_openai_error_handling.py @@ -194,6 +194,7 @@ async def test_chat_completion_bad_model_with_spend_logs(): # Verify the structure of the log entry assert log_entry["request_id"] == litellm_call_id assert log_entry["model"] == "non-existent-model" + assert log_entry["model_group"] in ("", "non-existent-model") assert log_entry["spend"] == 0.0 assert log_entry["total_tokens"] == 0 assert log_entry["prompt_tokens"] == 0 @@ -208,6 +209,7 @@ async def test_chat_completion_bad_model_with_spend_logs(): error_info = log_entry["metadata"]["error_information"] assert "traceback" in error_info assert error_info["error_code"] == "400" + assert error_info["error_class"] in ("ProxyModelNotFoundError", "BadRequestError") assert "non-existent-model" in error_info["error_message"] # Verify request details From 0157808187bc8dec07205b78cd67dc9e2cea40d8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 18:58:09 -0700 Subject: [PATCH 168/410] fix(fireworks_ai): keep file_search on LiteLLM's emulated search for the Responses API --- litellm/llms/fireworks_ai/responses/transformation.py | 3 +++ .../test_fireworks_ai_responses_transformation.py | 9 +++++++++ 2 files changed, 12 insertions(+) diff --git a/litellm/llms/fireworks_ai/responses/transformation.py b/litellm/llms/fireworks_ai/responses/transformation.py index 9265d12e75e..121d3b5d9a9 100644 --- a/litellm/llms/fireworks_ai/responses/transformation.py +++ b/litellm/llms/fireworks_ai/responses/transformation.py @@ -94,3 +94,6 @@ class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig): def supports_native_websocket(self) -> bool: return False + + def supports_native_file_search(self) -> bool: + return False diff --git a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py index e6e92824ee1..f948acb7bf3 100644 --- a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py @@ -19,6 +19,7 @@ from typing_extensions import ReadOnly import litellm from litellm.llms.fireworks_ai.responses.transformation import FireworksAIResponsesAPIConfig +from litellm.responses.file_search.emulated_handler import should_use_emulated_file_search from litellm.types.llms.openai import InputTokensDetails, ResponseAPIUsage, ResponsesAPIResponse from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders @@ -180,6 +181,14 @@ def test_responses_call_sends_developer_items_as_system_messages() -> None: ) +def test_file_search_tools_take_litellm_emulated_search_not_fireworks() -> None: + config: Final = FireworksAIResponsesAPIConfig() + file_search: Final = ({"type": "file_search", "vector_store_ids": ("vs_kb",)},) + function_tool: Final = ({"type": "function", "name": "get_weather", "parameters": {"type": "object"}},) + assert should_use_emulated_file_search(tools=file_search, provider_config=config) + assert not should_use_emulated_file_search(tools=function_tool, provider_config=config) + + def test_responses_call_sends_session_affinity_for_caller_session_id() -> None: client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) with patch(HTTPX_CLIENT_FACTORY, return_value=client): From d8ca43a800d2c814655981e2c1a565a4a029c9e9 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Fri, 4 Sep 2026 18:59:50 -0700 Subject: [PATCH 169/410] feat(complexity_router): let the LLM classifier see request images (#39825) The classifier scores extracted text, so a turn whose complexity lives in its image is invisible to it: a screenshot of a stack trace classifies on its caption, and an image-only turn flattens to empty text and never reaches the classifier at all. classifier_llm_config.vision opts in, off by default, with max_images bounding what one turn can add. Images are still dropped when the classifier model is declared supports_vision false. Anthropic and Responses image parts are rewritten into chat-completions dialect before they reach the classifier call, since /v1/messages hands the pre-routing hook its own dialect untranslated. The local scorer no longer short-circuits heuristic_first or hybrid on a turn carrying forwarded images, because it reads text alone and its confidence describes a request it has only partly seen. --- .../prompt_templates/common_utils.py | 39 +++ .../adapters/transformation.py | 16 +- .../complexity_router/complexity_router.py | 144 +++++++-- .../complexity_router/config.py | 35 +++ .../router_strategy/test_complexity_router.py | 274 ++++++++++++++++++ .../build_complexity_router_config.test.ts | 6 + .../build_complexity_router_config.ts | 11 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 24 ++ 8 files changed, 507 insertions(+), 42 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 17ebde83eee..00b80839dde 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -229,6 +229,45 @@ def _content_parts_contain_image(parts: Sequence[object]) -> bool: return False +def anthropic_image_source_to_openai_url(image_source: Mapping[str, object]) -> str | None: + """Data or remote URL for an Anthropic ``source`` block, in the form chat completions expects.""" + source_type: Final = image_source.get("type") + if source_type == "base64": + media_type: Final = image_source.get("media_type") or "image/jpeg" + image_data: Final = image_source.get("data") or "" + return f"data:{media_type};base64,{image_data}" if image_data else None + if source_type == "url": + url: Final = image_source.get("url") + return url if isinstance(url, str) else "" + return None + + +def _image_part_url(part: Mapping[str, object]) -> str | None: + """The image URL carried by one content part, whichever of the three dialects wrote it.""" + part_type: Final = part.get("type") + if part_type == "image_url": + image_url: Final = part.get("image_url") + if isinstance(image_url, str): + return image_url + return image_url.get("url") if isinstance(image_url, Mapping) else None + if part_type == "input_image": + responses_url: Final = part.get("image_url") + return responses_url if isinstance(responses_url, str) else None + if part_type == "image": + source: Final = part.get("source") + return anthropic_image_source_to_openai_url(source) if isinstance(source, Mapping) else None + return None + + +def as_openai_image_part(part: Mapping[str, object]) -> ChatCompletionImageObject | None: + """One image content part rewritten into chat-completions dialect, or None when it is not one. + + Rebuilt rather than forwarded so no caller-controlled key beyond the URL rides along. + """ + url: Final = _image_part_url(part) + return {"type": "image_url", "image_url": {"url": url}} if url else None + + def request_contains_image_content(messages: Sequence[Mapping[str, object]]) -> bool: """Whether any message carries an image content part, across the dialects that reach pre-routing hooks untranslated: chat-completions ``image_url``, Responses ``input_image``, diff --git a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py index 64f10046109..594fac512e6 100644 --- a/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py @@ -99,6 +99,7 @@ def create_tool_name_mapping( from openai.types.chat.chat_completion_chunk import Choice as OpenAIStreamingChoice from litellm.litellm_core_utils.prompt_templates.common_utils import ( + anthropic_image_source_to_openai_url, parse_tool_call_arguments, reasoning_content_from_thinking_blocks, with_prompt_cache_breakpoint, @@ -1225,20 +1226,7 @@ class LiteLLMAnthropicMessagesAdapter: """ if not isinstance(image_source, dict): return None - - source_type: Final = image_source.get("type") - - if source_type == "base64": - # Base64 image format - media_type: Final = image_source.get("media_type", "image/jpeg") - image_data: Final = image_source.get("data", "") - if image_data: - return f"data:{media_type};base64,{image_data}" - elif source_type == "url": - # URL-referenced image format - return image_source.get("url", "") - - return None + return anthropic_image_source_to_openai_url(image_source) def _tool_result_content(self, raw_content: object) -> ToolResultContent: if isinstance(raw_content, str): diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index ec11c16eea0..98a1eb7ac9e 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -40,7 +40,10 @@ from litellm.litellm_core_utils.core_helpers import ( get_metadata_variable_name_from_kwargs, ) from litellm.litellm_core_utils.internal_call_metadata import forwarded_internal_call_metadata -from litellm.litellm_core_utils.prompt_templates.common_utils import request_contains_image_content +from litellm.litellm_core_utils.prompt_templates.common_utils import ( + as_openai_image_part, + request_contains_image_content, +) from litellm.litellm_core_utils.sensitive_data_masker import mask_credentials_in_payload from litellm.llms.base_llm.base_utils import type_to_response_format_param from litellm.router_strategy.adaptive_router.classifier import classify_prompt @@ -48,7 +51,11 @@ from litellm.router_strategy.complexity_router.tier_predictor import ( TierSuccessPredictor, resolve_tier_artifact, ) -from litellm.types.llms.openai import AllMessageValues +from litellm.types.llms.openai import ( + AllMessageValues, + ChatCompletionImageObject, + ChatCompletionTextObject, +) from litellm.types.utils import ( AUTOROUTER_CLASSIFIER_CALL_ORIGIN, ModelResponse, @@ -435,6 +442,23 @@ def _strip_reminder_blocks(text: str, marker_pairs: tuple[tuple[str, str], ...] return " ".join(kept for a, b in zip(keep_from, keep_to) if (kept := text[a:b].strip())) +def _inline_image_part(part: Mapping[str, object]) -> ChatCompletionImageObject | None: + """One image content part safe to hand the classifier, or None. + + Inline data URIs only. A remote URL is caller-controlled and provider adapters do not uniformly + delegate fetching to the provider: gigachat's file handler downloads any non-data URL with + `client.get` from the proxy host, so forwarding one would let a key scoped to this router aim a + proxy-side request at an internal address, on a call the caller never asked for. The routed + model still receives the original URL exactly as before. + """ + converted: Final = as_openai_image_part(part) + if converted is None: + return None + image_url: Final = converted["image_url"] + url: Final = image_url if isinstance(image_url, str) else image_url.get("url", "") + return converted if url.startswith("data:") else None + + def _human_text(content: object, marker_pairs: tuple[tuple[str, str], ...] = _DEFAULT_REMINDER_MARKERS) -> str: """Message content as the text a human wrote, with complete reminder blocks removed. @@ -1592,6 +1616,10 @@ class ComplexityRouter(CustomLogger): threshold check alone would hand that traffic to the cheapest model without ever consulting the classifier. Scores also go negative when simple indicators fire, so a score threshold would reject exactly the trivial prompts this path exists to serve. + + A turn carrying images the classifier would see is never decided cheaply: the scorer reads + text alone, so its confidence describes a request it has only partly seen, and a trivial + caption beside a screenshot is exactly the misrouting vision classification exists to stop. """ tier, score, signals, cause = self._score_and_classify(prompt, system_prompt) scored: Final = ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause) @@ -1599,6 +1627,7 @@ class ComplexityRouter(CustomLogger): decided_cheaply: Final = ( threshold is not None and bool(signals) + and not self._classifier_image_parts(messages) and self._active_tier_severity(tier) <= self._active_tier_severity(threshold) ) if decided_cheaply: @@ -1623,11 +1652,43 @@ class ComplexityRouter(CustomLogger): tier, score, signals, cause = self._score_and_classify(prompt, system_prompt) scored: Final = ClassificationOutcome(tier=tier, score=score, signals=signals, cause=cause) margin: Final = self.config.hybrid_boundary_margin - decided: Final = margin is not None and bool(signals) and not self._is_near_tier_boundary(score, margin) + decided: Final = ( + margin is not None + and bool(signals) + and not self._classifier_image_parts(messages) + and not self._is_near_tier_boundary(score, margin) + ) if decided: return ClassificationOutcome(tier=tier, score=score, signals=signals, cause="hybrid_short_circuit") return await self._llm_classifier_outcome(prompt, system_prompt, request_kwargs, messages, scored=scored) + def _classifier_image_parts( + self, messages: Sequence[Mapping[str, object]] | None + ) -> tuple[ChatCompletionImageObject, ...]: + """Images from the newest user turn to hand the classifier, capped by max_images. + + Empty unless the operator opted in AND the classifier model is declared vision-capable, so + every other deployment keeps today's text-only payload byte for byte. Only the newest user + turn is read: earlier turns are context the classifier already gets as quoted text, and an + image nested in a tool_result is tool output rather than the ask being classified. + Remote-URL images are left out entirely; `_inline_image_part` carries why. + """ + llm_config: Final = self.config.classifier_llm_config + if llm_config is None or not llm_config.vision.enabled or not self.config.uses_llm_classifier or not messages: + return () + if not self._model_declares_vision_support(llm_config.model): + return () + newest_user_turn: Final = next((msg for msg in reversed(messages) if msg.get("role") == "user"), None) + content: Final = newest_user_turn.get("content") if newest_user_turn is not None else None + if not isinstance(content, list): + return () + return tuple( + islice( + (part for raw in content if isinstance(raw, Mapping) and (part := _inline_image_part(raw)) is not None), + llm_config.vision.max_images, + ) + ) + async def _llm_classifier_outcome( self, prompt: str, @@ -1865,9 +1926,18 @@ class ComplexityRouter(CustomLogger): } turn_off_message_logging: Final = _effective_turn_off_message_logging(request_kwargs) + image_parts: Final = self._classifier_image_parts(messages) + user_content: Final[str | Sequence[ChatCompletionTextObject | ChatCompletionImageObject]] = ( + [ # mutable-ok: SDK request payload content list is built once + {"type": "text", "text": user_payload}, + *image_parts, + ] + if image_parts + else user_payload + ) messages_for_call: Final[list[AllMessageValues]] = [ # mutable-ok: SDK request payload list is built once {"role": "system", "content": classifier_system_prompt}, - {"role": "user", "content": user_payload}, + {"role": "user", "content": user_content}, ] response_format: Final = classifier_response_format classifier_call_params: Mapping[str, str] = EMPTY_MAPPING @@ -2558,31 +2628,53 @@ class ComplexityRouter(CustomLogger): return pinned_model return self.get_model_for_tier(escalated_tier) - def _model_accepts_image_input(self, model_name: str) -> bool: - """Whether a routed model or pool entry can serve an image request. + def _vision_verdicts(self, model_name: str) -> tuple[bool | None, ...]: + """Declared vision support per deployment serving the name: True, False, or None when + nothing declares either way. Resolved through the deployments that would actually serve the name; a name with no deployment on the router is served by the SDK directly and is checked against the model - cost map itself. Only an explicit supports_vision false excludes, a deployment-level - model_info override first and the map otherwise, so unmapped custom names stay routable. + cost map itself. A deployment-level model_info override wins over the map. + + One verdict set, two readings, because the two callers fail in opposite directions. + Routing a user's image asks whether anything RULES IT OUT, so an undeclared model stays + eligible and unmapped custom names keep routing. Handing an image to the classifier asks + whether something RULES IT IN: an undeclared model that turns out to be text-only rejects + every image request, and that rejection is swallowed by the classifier's own fallback, so + the router quietly serves all image traffic from the fallback tier and pays for the failed + call each time. An undeclared model instead keeps today's text-only payload, which is a + visible no-op the operator fixes by declaring supports_vision on the deployment. + """ + from litellm.utils import is_vision_explicitly_disabled, supports_vision + + def model_verdict(model: str) -> bool | None: + if supports_vision(model): + return True + return False if is_vision_explicitly_disabled(model) else None + + def deployment_verdict(deployment: Mapping[str, Any]) -> bool | None: + declared: Final = (deployment.get("model_info") or EMPTY_MAPPING).get("supports_vision") + if declared is not None: + return declared is True + return model_verdict((deployment.get("litellm_params") or EMPTY_MAPPING).get("model") or model_name) + + deployments: Final = self.litellm_router_instance.get_model_list(model_name=model_name) + if not deployments: + return (model_verdict(model_name),) + return tuple(deployment_verdict(deployment) for deployment in deployments) + + def _model_accepts_image_input(self, model_name: str) -> bool: + """Whether a routed model or pool entry can serve an image request. A multi-deployment group must accept on EVERY deployment: the router picks a deployment inside the group after this gate runs, so a mixed group marked eligible could still hand the image to its text-only member and fail with the exact 400 the gate exists to prevent. """ - from litellm.utils import is_vision_explicitly_disabled + return all(verdict is not False for verdict in self._vision_verdicts(model_name)) - def deployment_accepts(deployment: Mapping[str, Any]) -> bool: - declared: Final = (deployment.get("model_info") or EMPTY_MAPPING).get("supports_vision") - if declared is not None: - return declared is True - litellm_model: Final = (deployment.get("litellm_params") or EMPTY_MAPPING).get("model") or model_name - return not is_vision_explicitly_disabled(litellm_model) - - deployments: Final = self.litellm_router_instance.get_model_list(model_name=model_name) - if not deployments: - return not is_vision_explicitly_disabled(model_name) - return all(deployment_accepts(deployment) for deployment in deployments) + def _model_declares_vision_support(self, model_name: str) -> bool: + """Whether every deployment serving the name is declared vision-capable.""" + return all(verdict is True for verdict in self._vision_verdicts(model_name)) def _modality_eligible_models(self) -> frozenset[str]: """Every configured pool entry, plus default_model, that can serve an image request.""" @@ -3374,8 +3466,9 @@ class ComplexityRouter(CustomLogger): has_original_messages: Final = messages is not None and len(messages) > 0 user_message, system_prompt = _extract_current_ask_and_system_prompt(resolved_messages, self._reminder_markers) + classifier_images: Final = self._classifier_image_parts(resolved_messages) - if user_message is None: + if user_message is None and not classifier_images: verbose_router_logger.debug("ComplexityRouter: No user message found, routing to default model") default_model_first: Final = not self.config.plugins and self.config.default_model if default_model_first: @@ -3402,6 +3495,7 @@ class ComplexityRouter(CustomLogger): ), ) + ask: Final = user_message or "" newest_ask: Final = _newest_turn_ask(resolved_messages, self._reminder_markers) escalation_keyword: Final = self._matched_escalation_keyword(newest_ask) if newest_ask is not None else None # Resolved here rather than beside the classifier because the keyword-override path below @@ -3439,7 +3533,7 @@ class ComplexityRouter(CustomLogger): ), ) - override: Final = await self._resolve_keyword_tier_override(user_message, request_kwargs) + override: Final = await self._resolve_keyword_tier_override(ask, request_kwargs) if override is not None: keyword_bumped_tier: Final = ( self._escalate_tier(override.tier) if escalation_keyword is not None else override.tier @@ -3486,9 +3580,7 @@ class ComplexityRouter(CustomLogger): outcome: Final = ( ClassificationOutcome(tier=housekeeping_tier, score=None, signals=("housekeeping",), cause="housekeeping") if housekeeping_tier is not None - else await self.aclassify( - user_message, system_prompt, request_kwargs, resolved_messages, raw_messages=messages - ) + else await self.aclassify(ask, system_prompt, request_kwargs, resolved_messages, raw_messages=messages) ) tier, score, signals = outcome.tier, outcome.score, outcome.signals classified_tier: Final = tier @@ -3558,7 +3650,7 @@ class ComplexityRouter(CustomLogger): # under is not a floor. routed_model = self._soft_floor_pick( tier, - user_message, + ask, request_kwargs, hard_floor=tier if context_original_tier is not None else plan_floor, hard_ceiling=housekeeping_ceiling, diff --git a/litellm/router_strategy/complexity_router/config.py b/litellm/router_strategy/complexity_router/config.py index 5848973a9c7..c483a0b7073 100644 --- a/litellm/router_strategy/complexity_router/config.py +++ b/litellm/router_strategy/complexity_router/config.py @@ -442,12 +442,47 @@ DEFAULT_TIER_MODELS: Final[dict[str, str]] = { } +class ClassifierVisionConfig(BaseModel): + """Whether the LLM classifier sees the images on the request it is classifying. + + Off by default because images cost far more than the text ask they arrive with, and the + classifier runs on every request. A turn whose complexity lives in the image ("what is wrong in + this stack trace screenshot") is invisible to a text-only classifier, which is what this buys. + """ + + enabled: bool = Field( + default=False, + description=( + "Forward image content to the classifier. Requires a classifier model declared " + "supports_vision, on the deployment's model_info or in the model cost map; images stay " + "stripped otherwise, so a classifier that cannot read them is never sent one. Declare " + "model_info.supports_vision on the deployment to enable a model the cost map does not " + "describe. Only inline data: URIs are forwarded. A request whose images are http(s) " + "URLs still classifies on its text alone, because some providers fetch such a URL from " + "the proxy rather than the provider, which would let a caller aim a proxy-side request " + "at an address of their choosing." + ), + ) + max_images: int = Field( + default=1, + ge=1, + description=( + "How many images from the newest user turn to forward, in wire order. Bounds the added " + "cost of a turn that attaches many images. Images on earlier turns are never forwarded." + ), + ) + + class ClassifierLLMConfig(BaseModel): """Configuration for the LLM-based complexity classifier.""" model: str = Field( description="Model name (from the router's model_list) to call for classification", ) + vision: ClassifierVisionConfig = Field( + default_factory=ClassifierVisionConfig, + description="Whether the classifier sees images on the request, and how many", + ) reasoning_effort: REASONING_EFFORT | None = Field( default=None, description=( diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 8de138688f1..52e58304476 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -12429,3 +12429,277 @@ class TestTierHealthFailover: for _ in range(20) ] assert {r.model for r in results} == {"live-c"} + + +ANTHROPIC_IMG_PART = {"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGk="}} +RESPONSES_IMG_PART = {"type": "input_image", "image_url": "data:image/png;base64,aGk="} + + +class TestClassifierVision: + """classifier_llm_config.vision: what the LLM classifier is shown for an image-bearing turn.""" + + TIERS = {"SIMPLE": "t-simple", "MEDIUM": "t-medium", "COMPLEX": "t-complex", "REASONING": "t-reasoning"} + + @staticmethod + def _router(mock_router_instance, *, vision, classifier_declares_vision=True, classifier_type="llm", **extra): + def get_model_list(model_name=None): + if model_name != "clf": + return [{"model_name": model_name, "litellm_params": {"model": "openai/gpt-4o"}}] + declared = classifier_declares_vision + return [ + { + "model_name": "clf", + "litellm_params": {"model": "openai/unmapped-classifier"}, + "model_info": {} if declared is None else {"supports_vision": declared}, + } + ] + + mock_router_instance.get_model_list = get_model_list + classifier_llm_config = {"model": "clf", "circuit_breaker_enabled": False} + return ComplexityRouter( + model_name="vision-classifier-router", + litellm_router_instance=mock_router_instance, + complexity_router_config={ + "classifier_type": classifier_type, + "classifier_llm_config": ( + classifier_llm_config if vision is None else {**classifier_llm_config, "vision": vision} + ), + "tiers": dict(TestClassifierVision.TIERS), + **extra, + }, + ) + + @staticmethod + def _classifier_user_content(mock_router_instance): + return mock_router_instance.acompletion.call_args.kwargs["messages"][-1]["content"] + + @staticmethod + def _turn(*parts): + return [{"role": "user", "content": list(parts)}] + + @pytest.fixture(autouse=True) + def _classifier_answers_complex(self, mock_router_instance): + mock_router_instance.acompletion = AsyncMock(return_value=_llm_response('{"tier": "COMPLEX"}')) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "vision, classifier_declares_vision", + [ + (None, True), + ({"enabled": False}, True), + ({"enabled": True}, False), + ({"enabled": True}, None), + ], + ids=["vision_unset", "vision_disabled", "classifier_declared_text_only", "classifier_undeclared"], + ) + async def test_payload_stays_text_only(self, mock_router_instance, vision, classifier_declares_vision): + """Off, or a classifier not declared vision-capable, keeps the plain-string payload. + + The undeclared case is the polarity. A text-only classifier handed an image rejects the + call, the rejection is swallowed by the classifier's own fallback, and every image request + then serves from the fallback tier while still paying for the failed call. Staying text-only + is instead a visible no-op the operator fixes by declaring supports_vision. + """ + router = self._router( + mock_router_instance, vision=vision, classifier_declares_vision=classifier_declares_vision + ) + await router.async_pre_routing_hook( + model="m", request_kwargs={}, messages=self._turn({"type": "text", "text": "what is this"}, IMG_PART) + ) + content = self._classifier_user_content(mock_router_instance) + assert isinstance(content, str) + assert "what is this" in content + + @pytest.mark.asyncio + async def test_deployment_model_info_enables_a_classifier_the_cost_map_does_not_describe( + self, mock_router_instance + ): + """The escape hatch for an unmapped classifier name, and the reason undeclared can stay off. + + `_router` gives every deployment an `openai/unmapped-*` litellm_params model, so nothing in + the cost map declares it and the verdict comes only from model_info. + """ + router = self._router(mock_router_instance, vision={"enabled": True}, classifier_declares_vision=True) + await router.async_pre_routing_hook( + model="m", request_kwargs={}, messages=self._turn({"type": "text", "text": "what is this"}, IMG_PART) + ) + assert [b["type"] for b in self._classifier_user_content(mock_router_instance)] == ["text", "image_url"] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "part", + [IMG_PART, ANTHROPIC_IMG_PART, RESPONSES_IMG_PART], + ids=["chat_completions", "anthropic_messages", "responses"], + ) + async def test_image_reaches_the_classifier_in_chat_completions_dialect(self, mock_router_instance, part): + """Every surface's dialect arrives as a chat-completions image_url on the classifier call. + + /v1/messages hands the hook an Anthropic image block untranslated, so forwarding verbatim + would send the classifier a content part its own request dialect has no meaning for. + """ + router = self._router(mock_router_instance, vision={"enabled": True}) + await router.async_pre_routing_hook( + model="m", request_kwargs={}, messages=self._turn({"type": "text", "text": "what is this"}, part) + ) + content = self._classifier_user_content(mock_router_instance) + assert [block["type"] for block in content] == ["text", "image_url"] + assert content[1]["image_url"] == {"url": "data:image/png;base64,aGk="} + assert "what is this" in content[0]["text"] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "part", + [ + {"type": "image_url", "image_url": {"url": "http://169.254.169.254/latest/meta-data/"}}, + {"type": "image_url", "image_url": {"url": "https://example.internal/secret.png"}}, + {"type": "input_image", "image_url": "https://example.internal/secret.png"}, + {"type": "image", "source": {"type": "url", "url": "https://example.internal/secret.png"}}, + ], + ids=["metadata_service", "chat_completions", "responses", "anthropic"], + ) + async def test_remote_url_images_are_never_forwarded(self, mock_router_instance, part): + """A caller-supplied URL must not reach an internal call the caller did not ask for. + + Provider adapters do not uniformly delegate fetching: gigachat downloads any non-data URL + from the proxy host, so forwarding one would turn a router-scoped key into a proxy-side GET + at an address of the caller's choosing. + """ + router = self._router(mock_router_instance, vision={"enabled": True}) + await router.async_pre_routing_hook( + model="m", request_kwargs={}, messages=self._turn({"type": "text", "text": "what is this"}, part) + ) + assert isinstance(self._classifier_user_content(mock_router_instance), str) + + @pytest.mark.asyncio + async def test_remote_url_image_only_turn_does_not_reach_the_classifier(self, mock_router_instance): + """With nothing forwardable left, the turn stays unclassifiable rather than sending the URL.""" + router = self._router(mock_router_instance, vision={"enabled": True}) + response = await router.async_pre_routing_hook( + model="m", + request_kwargs={}, + messages=self._turn({"type": "image_url", "image_url": {"url": "https://example.internal/x.png"}}), + ) + assert response.routing_decision["cause"] == "default_fallback" + mock_router_instance.acompletion.assert_not_awaited() + + @pytest.mark.asyncio + async def test_image_only_turn_is_classified_instead_of_falling_back(self, mock_router_instance): + """A turn carrying only an image reaches the classifier rather than the default model. + + It flattens to empty text, so before this it never reached the classifier at all and was + routed as default_fallback on text the request never contained. + """ + router = self._router(mock_router_instance, vision={"enabled": True}) + response = await router.async_pre_routing_hook( + model="m", request_kwargs={}, messages=self._turn(IMG_PART) + ) + assert response.routing_decision["cause"] == "llm_classifier" + assert response.model == "t-complex" + assert [block["type"] for block in self._classifier_user_content(mock_router_instance)] == [ + "text", + "image_url", + ] + + @pytest.mark.asyncio + async def test_image_only_turn_still_falls_back_when_vision_is_off(self, mock_router_instance): + router = self._router(mock_router_instance, vision={"enabled": False}) + response = await router.async_pre_routing_hook( + model="m", request_kwargs={}, messages=self._turn(IMG_PART) + ) + assert response.routing_decision["cause"] == "default_fallback" + mock_router_instance.acompletion.assert_not_awaited() + + @pytest.mark.asyncio + @pytest.mark.parametrize("max_images, expected", [(1, 1), (2, 2), (5, 3)]) + async def test_max_images_caps_what_is_forwarded(self, mock_router_instance, max_images, expected): + router = self._router(mock_router_instance, vision={"enabled": True, "max_images": max_images}) + images = [dict(IMG_PART, image_url={"url": f"data:image/png;base64,{n}"}) for n in ("a", "b", "c")] + await router.async_pre_routing_hook( + model="m", request_kwargs={}, messages=self._turn({"type": "text", "text": "look"}, *images) + ) + content = self._classifier_user_content(mock_router_instance) + forwarded = [block for block in content if block["type"] == "image_url"] + assert len(forwarded) == expected + assert [block["image_url"]["url"] for block in forwarded] == [ + f"data:image/png;base64,{n}" for n in ("a", "b", "c")[:expected] + ] + + @pytest.mark.asyncio + async def test_earlier_turn_images_are_not_forwarded(self, mock_router_instance): + """Only the newest user turn's images ride along, so history cannot inflate every call. + + The two turns carry different images on purpose: identical ones would pass this assertion + whichever turn the helper read. + """ + older = dict(IMG_PART, image_url={"url": "data:image/png;base64,OLDER"}) + newer = dict(IMG_PART, image_url={"url": "data:image/png;base64,NEWER"}) + router = self._router(mock_router_instance, vision={"enabled": True, "max_images": 5}) + await router.async_pre_routing_hook( + model="m", + request_kwargs={}, + messages=[ + {"role": "user", "content": [{"type": "text", "text": "first"}, older]}, + {"role": "assistant", "content": "ok"}, + {"role": "user", "content": [{"type": "text", "text": "second"}, newer]}, + ], + ) + content = self._classifier_user_content(mock_router_instance) + forwarded = [block for block in content if block["type"] == "image_url"] + assert [block["image_url"]["url"] for block in forwarded] == ["data:image/png;base64,NEWER"] + + @pytest.mark.asyncio + async def test_logged_request_body_matches_what_was_sent(self, mock_router_instance): + """proxy_server_request is the logged copy of the classifier call and must not drift.""" + router = self._router(mock_router_instance, vision={"enabled": True}) + await router.async_pre_routing_hook( + model="m", request_kwargs={}, messages=self._turn({"type": "text", "text": "what is this"}, IMG_PART) + ) + call_kwargs = mock_router_instance.acompletion.call_args.kwargs + assert call_kwargs["proxy_server_request"]["body"]["messages"] == call_kwargs["messages"] + + SHORT_CIRCUIT_ARMS = [ + ("heuristic_first", {"heuristic_first_max_tier": "SIMPLE"}, "heuristic_first_short_circuit"), + ("hybrid", {"hybrid_boundary_margin": 0.05}, "hybrid_short_circuit"), + ] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "classifier_type, extra, short_circuit_cause", SHORT_CIRCUIT_ARMS, ids=["heuristic_first", "hybrid"] + ) + async def test_local_scorer_cannot_short_circuit_a_turn_it_cannot_see( + self, mock_router_instance, classifier_type, extra, short_circuit_cause + ): + """The scorer reads text alone, so its confidence is not a verdict on an image turn. + + Both arms are tuned so the scorer WOULD short-circuit on this exact text, which is what + makes the image the only variable; a margin loose enough to leave the score undecided + would pass whether or not the guard exists. + """ + router = self._router( + mock_router_instance, vision={"enabled": True}, classifier_type=classifier_type, **extra + ) + response = await router.async_pre_routing_hook( + model="m", request_kwargs={}, messages=self._turn({"type": "text", "text": "what is this"}, IMG_PART) + ) + assert response.routing_decision["cause"] == "llm_classifier" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "classifier_type, extra, short_circuit_cause", SHORT_CIRCUIT_ARMS, ids=["heuristic_first", "hybrid"] + ) + async def test_local_scorer_still_short_circuits_without_images( + self, mock_router_instance, classifier_type, extra, short_circuit_cause + ): + """The negative class: same router, same text, no image, and the scorer still decides.""" + router = self._router( + mock_router_instance, vision={"enabled": True}, classifier_type=classifier_type, **extra + ) + response = await router.async_pre_routing_hook( + model="m", request_kwargs={}, messages=[{"role": "user", "content": "what is this"}] + ) + assert response.routing_decision["cause"] == short_circuit_cause + mock_router_instance.acompletion.assert_not_awaited() + + def test_max_images_must_be_positive(self): + with pytest.raises(ValidationError): + ClassifierLLMConfig(model="clf", vision={"enabled": True, "max_images": 0}) diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts index edb32c6717f..1b5bb9e72eb 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts @@ -592,6 +592,12 @@ describe("classifier prompt and fallback", () => { timeout_ms: 1, }); }); + + it.each([{}, { system_prompt: "x" }])("normalizeClassifierLlmConfig carries vision through %o", (extra) => { + const base = { model: "m", timeout_ms: 1, ...extra }; + const vision = { enabled: true, max_images: 2 }; + expect(normalizeClassifierLlmConfig({ ...base, vision })).toEqual({ ...base, vision }); + }); }); describe("tier labels", () => { diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts index 1a395c38779..d7974484970 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts @@ -1,4 +1,7 @@ import { KeywordTierRule } from "./KeywordTierRules"; + +type ClassifierLLMConfigWire = ClassifierLLMConfig & { vision?: { enabled?: boolean; max_images?: number } }; + import type { ModelGroup } from "../llm_calls/fetch_models"; import { type CustomTierSet, @@ -61,7 +64,8 @@ export const normalizeClassifierLlmConfig = ({ reasoning_effort, classification_rubric, system_prompt, -}: ClassifierLLMConfig): ClassifierLLMConfig => + vision, +}: ClassifierLLMConfigWire): ClassifierLLMConfigWire => system_prompt?.trim() ? { model, @@ -69,6 +73,7 @@ export const normalizeClassifierLlmConfig = ({ ...(circuit_breaker_enabled !== undefined && { circuit_breaker_enabled }), ...(circuit_breaker_cooldown_seconds !== undefined && { circuit_breaker_cooldown_seconds }), ...(reasoning_effort && { reasoning_effort }), + ...(vision && { vision }), system_prompt, } : { @@ -78,6 +83,7 @@ export const normalizeClassifierLlmConfig = ({ ...(circuit_breaker_cooldown_seconds !== undefined && { circuit_breaker_cooldown_seconds }), ...(reasoning_effort && { reasoning_effort }), ...(classification_rubric && { classification_rubric }), + ...(vision && { vision }), }; interface ScorerKnobInputs { @@ -324,7 +330,7 @@ export const getSemanticConfigError = ({ }; interface CustomTierWireFieldInputs { - classifierLlmConfig: ClassifierLLMConfig | undefined; + classifierLlmConfig: ClassifierLLMConfigWire | undefined; planModeMinTierId: string | undefined; classificationPrompt: string | undefined; classificationExamples: string | undefined; @@ -356,6 +362,7 @@ export const customTierWireFields = ( circuit_breaker_cooldown_seconds: classifierLlmConfig.circuit_breaker_cooldown_seconds, }), ...(classifierLlmConfig.reasoning_effort && { reasoning_effort: classifierLlmConfig.reasoning_effort }), + ...(classifierLlmConfig.vision && { vision: classifierLlmConfig.vision }), }, }), session_affinity: false, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 6f60905d4c1..45e1b1cab0b 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -25305,6 +25305,30 @@ export interface components { * @default 3000 */ timeout_ms: number; + /** @description Whether the classifier sees images on the request, and how many */ + vision?: components["schemas"]["ClassifierVisionConfig"]; + }; + /** + * ClassifierVisionConfig + * @description Whether the LLM classifier sees the images on the request it is classifying. + * + * Off by default because images cost far more than the text ask they arrive with, and the + * classifier runs on every request. A turn whose complexity lives in the image ("what is wrong in + * this stack trace screenshot") is invisible to a text-only classifier, which is what this buys. + */ + ClassifierVisionConfig: { + /** + * Enabled + * @description Forward image content to the classifier. Requires a classifier model declared supports_vision, on the deployment's model_info or in the model cost map; images stay stripped otherwise, so a classifier that cannot read them is never sent one. Declare model_info.supports_vision on the deployment to enable a model the cost map does not describe. Only inline data: URIs are forwarded. A request whose images are http(s) URLs still classifies on its text alone, because some providers fetch such a URL from the proxy rather than the provider, which would let a caller aim a proxy-side request at an address of their choosing. + * @default false + */ + enabled: boolean; + /** + * Max Images + * @description How many images from the newest user turn to forward, in wire order. Bounds the added cost of a turn that attaches many images. Images on earlier turns are never forwarded. + * @default 1 + */ + max_images: number; }; /** * CloudZeroExportRequest From 2a7fc8de01c2dca87de9cfc9514a91fddd7a43c2 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:05:35 -0700 Subject: [PATCH 170/410] fix(proxy): keep the token's team model list in the websocket passthrough gate without a database --- litellm/proxy/auth/auth_checks.py | 2 +- tests/test_litellm/proxy/auth/test_auth_checks.py | 10 ++++++++-- .../proxy/test_openai_ws_passthrough_routes.py | 9 ++++++--- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 98d334ce2cc..120a0bb29ea 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -4164,7 +4164,7 @@ async def enforced_model_allowlists( """One model allowlist per level that ``common_checks`` enforces on a request from this identity.""" key_models: Final = _resolve_key_models_for_auth_check(valid_token=valid_token) if prisma_client is None: - return (key_models,) + return (key_models, tuple(valid_token.team_models or ())) team_object: Final = ( None if valid_token.team_id is None diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 45e10948267..5242a99c54c 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -7392,7 +7392,13 @@ async def test_enforced_model_allowlists_reads_every_level_from_cache(): proxy_logging_obj=proxy_logging_obj, ) without_database = await enforced_model_allowlists( - valid_token=UserAPIKeyAuth(token="hashed-fake", models=["gpt-4o"], user_id="user-fake", team_id="team-fake"), + valid_token=UserAPIKeyAuth( + token="hashed-fake", + models=["gpt-4o"], + team_models=["gpt-4o-mini"], + user_id="user-fake", + team_id="team-fake", + ), prisma_client=None, user_api_key_cache=cache, proxy_logging_obj=proxy_logging_obj, @@ -7406,4 +7412,4 @@ async def test_enforced_model_allowlists_reads_every_level_from_cache(): ["gpt-4.1"], ] assert [list(scope) for scope in personal] == [[], [], [], ["o3"], []] - assert [list(scope) for scope in without_database] == [["gpt-4o"]] + assert [list(scope) for scope in without_database] == [["gpt-4o"], ["gpt-4o-mini"]] diff --git a/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py b/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py index c96e7684e97..7d79192b884 100644 --- a/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py +++ b/tests/test_litellm/proxy/test_openai_ws_passthrough_routes.py @@ -14,6 +14,7 @@ from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( _OPENAI_WS_DISABLED_REFUSAL, _OPENAI_WS_MODEL_RESTRICTED_REFUSAL, + _has_model_restrictions, _openai_websocket_refusal, _proxy_model_allowlists, openai_websocket_proxy_route, @@ -288,8 +289,10 @@ async def test_openai_websocket_allows_unrestricted_identities(scopes): @pytest.mark.asyncio -async def test_proxy_model_allowlists_reads_the_key_scope_without_a_database(): +async def test_proxy_model_allowlists_reads_the_token_scopes_without_a_database(): + token: Final = UserAPIKeyAuth(models=[], team_id="team-fake", team_models=["gpt-4o"]) with patch("litellm.proxy.proxy_server.prisma_client", None): - scopes = await _proxy_model_allowlists()(UserAPIKeyAuth(models=["gpt-4o"])) + scopes = await _proxy_model_allowlists()(token) - assert tuple(tuple(scope) for scope in scopes) == (("gpt-4o",),) + assert tuple(tuple(scope) for scope in scopes) == ((), ("gpt-4o",)) + assert _has_model_restrictions(scopes) From 001b531ae960aad3fa38447508d4ce0d05c118c4 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:11:27 -0700 Subject: [PATCH 171/410] fix(cost): bill realtime reasoning tokens nested in text_tokens once OpenAI and Azure realtime usage reports output_tokens == text_tokens + audio_tokens with reasoning_tokens counted inside text_tokens, so generic_cost_per_token billed the reasoning share twice. When the output token details sum past completion_tokens, the nested reasoning overlap is now subtracted from text_tokens before pricing; shapes where text_tokens already excludes reasoning are unchanged. --- .../litellm_core_utils/llm_cost_calc/utils.py | 19 ++++++- .../llm_cost_calc/test_llm_cost_calc_utils.py | 51 +++++++++++++++++++ tests/test_litellm/test_cost_calculator.py | 42 +++++++++++++++ 3 files changed, 111 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 8e24302b440..1b8980abd35 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -852,6 +852,17 @@ class CompletionTokensDetailsResult(TypedDict): video_tokens: int +def _text_tokens_without_nested_reasoning( + completion_tokens: int, + text_tokens: int, + reasoning_tokens: int, + other_modality_tokens: int, +) -> int: + reported_total: Final = text_tokens + reasoning_tokens + other_modality_tokens + nested_reasoning_tokens: Final = min(reasoning_tokens, text_tokens, max(reported_total - completion_tokens, 0)) + return text_tokens - nested_reasoning_tokens + + def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResult: audio_tokens: Final = ( cast( @@ -860,7 +871,7 @@ def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResu ) or 0 ) - text_tokens: Final = ( + reported_text_tokens: Final = ( cast( int | None, getattr(usage.completion_tokens_details, "text_tokens", None), @@ -882,6 +893,12 @@ def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResu or 0 ) video_tokens: Final = _coerce_token_count(getattr(usage.completion_tokens_details, "video_tokens", 0)) + text_tokens: Final = _text_tokens_without_nested_reasoning( + completion_tokens=usage.completion_tokens, + text_tokens=reported_text_tokens, + reasoning_tokens=reasoning_tokens, + other_modality_tokens=audio_tokens + image_tokens + video_tokens, + ) return CompletionTokensDetailsResult( audio_tokens=audio_tokens, diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 0f8084643ea..f7ca2a89048 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -4723,3 +4723,54 @@ def test_route_image_generation_cost_falls_back_to_requested_size(monkeypatch, r ) assert cost == expected_cost + + +def test_generic_cost_per_token_bills_reasoning_nested_in_text_tokens_once(_local_model_cost_map): + """ + Realtime usage (OpenAI and Azure) reports output_tokens == text_tokens + audio_tokens with + reasoning_tokens already counted inside text_tokens, so reasoning must not be billed on top. + """ + + model = "gpt-realtime-2.1-mini" + usage = Usage( + prompt_tokens=346, + completion_tokens=29, + total_tokens=375, + prompt_tokens_details=PromptTokensDetailsWrapper( + text_tokens=152, image_tokens=194, audio_tokens=0, cached_tokens=128 + ), + completion_tokens_details=CompletionTokensDetailsWrapper( + text_tokens=29, audio_tokens=0, reasoning_tokens=19 + ), + ) + + prompt_cost, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="openai") + breakdown = get_token_type_cost_breakdown(model=model, custom_llm_provider="openai", usage=usage) + + info = litellm.get_model_info(model=model, custom_llm_provider="openai") + assert completion_cost == pytest.approx(29 * info["output_cost_per_token"]) + assert completion_cost - breakdown.reasoning_cost == pytest.approx(10 * info["output_cost_per_token"]) + assert prompt_cost == pytest.approx( + 24 * info["input_cost_per_token"] + + 128 * info["cache_read_input_token_cost"] + + 194 * info["input_cost_per_image_token"] + ) + + +def test_generic_cost_per_token_keeps_billing_reasoning_reported_beside_text_tokens(_local_model_cost_map): + """Providers whose text_tokens exclude reasoning (text + reasoning == completion) stay billed in full.""" + + model = "gpt-realtime-2.1-mini" + usage = Usage( + prompt_tokens=100, + completion_tokens=44, + total_tokens=144, + completion_tokens_details=CompletionTokensDetailsWrapper( + text_tokens=25, audio_tokens=0, reasoning_tokens=19 + ), + ) + + _, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="openai") + + info = litellm.get_model_info(model=model, custom_llm_provider="openai") + assert completion_cost == pytest.approx(44 * info["output_cost_per_token"]) diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 2046695f151..203f0a9e840 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -4492,3 +4492,45 @@ def test_batch_cost_calculator_gpt_6_astra_bills_half_the_standard_rate(_local_m assert prompt_cost == pytest.approx(1000 * 5e-6) assert completion_cost == pytest.approx(500 * 2.5e-5) + + +def test_handle_realtime_stream_cost_calculation_bills_nested_reasoning_tokens_once(_local_model_cost_map): + """Realtime response.done nests reasoning_tokens inside text_tokens, so they are billed once.""" + results: OpenAIRealtimeStreamList = [ + {"type": "session.created", "session": {"model": "gpt-realtime-2.1-mini"}}, + { + "type": "response.done", + "response": { + "usage": { + "total_tokens": 260, + "input_tokens": 237, + "output_tokens": 23, + "input_token_details": { + "text_tokens": 43, + "audio_tokens": 0, + "image_tokens": 194, + "cached_tokens": 0, + "cached_tokens_details": {"text_tokens": 0, "audio_tokens": 0, "image_tokens": 0}, + }, + "output_token_details": {"text_tokens": 23, "audio_tokens": 0, "reasoning_tokens": 18}, + } + }, + }, + ] + combined_usage_object = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results( + results=results, + ) + + total_cost = handle_realtime_stream_cost_calculation( + results=results, + combined_usage_object=combined_usage_object, + custom_llm_provider="azure", + litellm_model_name="azure/gpt-realtime-2.1-mini", + ) + + info = litellm.get_model_info(model="azure/gpt-realtime-2.1-mini", custom_llm_provider="azure") + expected = ( + 43 * info["input_cost_per_token"] + 194 * info["input_cost_per_image_token"] + 23 * info["output_cost_per_token"] + ) + assert total_cost == pytest.approx(expected) + assert total_cost == pytest.approx(0.0002362) From 02cb3daf2656be211c8c4ee3665c94f0686d2ea7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:15:21 -0700 Subject: [PATCH 172/410] fix(cli): return debug failures as values, survive transport errors, size report fences to content --- litellm/proxy/client/cli/commands/debug.py | 109 +++++++++++------- litellm/proxy/client/cli/main.py | 1 - .../proxy/client/cli/test_debug_commands.py | 54 ++++++++- 3 files changed, 121 insertions(+), 43 deletions(-) diff --git a/litellm/proxy/client/cli/commands/debug.py b/litellm/proxy/client/cli/commands/debug.py index b5774822df7..c5f147eb37a 100644 --- a/litellm/proxy/client/cli/commands/debug.py +++ b/litellm/proxy/client/cli/commands/debug.py @@ -8,13 +8,16 @@ single markdown report that can be pasted into a bug report or handed to another import json import os +import re from collections.abc import Mapping, Sequence +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from types import MappingProxyType from typing import Final import click +import requests from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, ValidationError, field_validator from ...http_client import HTTPClient @@ -36,8 +39,9 @@ report was saved to so I can hand it off. If nothing failed, say so. """ -class DebugError(Exception): - """Raised for any user-actionable failure while building the report.""" +@dataclass(frozen=True, slots=True) +class DebugFailure: + message: str class ErrorInformation(BaseModel): @@ -113,10 +117,10 @@ _PAYLOAD: Final[TypeAdapter[RequestResponsePayload | None]] = TypeAdapter(Reques _JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue) _SESSION_PAGE_SIZE: Final = 100 +_TRANSPORT_BODY_CHARS: Final = 500 def detect_claude_session_id(env: Mapping[str, str], claude_dir: Path) -> str | None: - """Explicit env var first, else the transcript Claude Code touched most recently.""" explicit: Final = env.get(SESSION_ID_ENV) if explicit: return explicit @@ -127,39 +131,54 @@ def detect_claude_session_id(env: Mapping[str, str], claude_dir: Path) -> str | return newest.stem -class SpendLogsFetcher: - """Thin typed wrapper over the two spend-log endpoints the report needs.""" +def _transport_failure(uri: str, error: requests.exceptions.RequestException) -> DebugFailure: + body: Final = error.response.text[:_TRANSPORT_BODY_CHARS] if error.response is not None else "" + detail: Final = f"\n{body}" if body else "" + return DebugFailure(f"GET {uri} failed: {error}{detail}") + +class SpendLogsFetcher: def __init__(self, http: HTTPClient) -> None: self._http = http - def session_rows(self, session_id: str) -> tuple[SpendLogRow, ...]: + def session_rows(self, session_id: str) -> tuple[SpendLogRow, ...] | DebugFailure: first: Final = self._page(session_id, 1) - rest: Final = tuple( - row for page in range(2, first.total_pages + 1) for row in self._page(session_id, page).data - ) - rows: Final = first.data + rest + if isinstance(first, DebugFailure): + return first + rest: Final = tuple(self._page(session_id, page) for page in range(2, first.total_pages + 1)) + failed_page: Final = next((page for page in rest if isinstance(page, DebugFailure)), None) + if failed_page is not None: + return failed_page + rows: Final = first.data + tuple(row for page in rest if isinstance(page, SessionLogsPage) for row in page.data) return tuple(sorted(rows, key=lambda r: r.start_time or "")) - def _get(self, uri: str, params: Mapping[str, str | int] | None = None) -> JsonValue: - return _JSON.validate_python(self._http.request("GET", uri, params=params)) # pyright: ignore[reportUnknownMemberType] # HTTPClient.request is untyped + def _get(self, uri: str, params: Mapping[str, str | int] | None = None) -> JsonValue | DebugFailure: + try: + return _JSON.validate_python(self._http.request("GET", uri, params=params)) # pyright: ignore[reportUnknownMemberType] # HTTPClient.request is untyped + except requests.exceptions.RequestException as e: + return _transport_failure(uri, e) - def _page(self, session_id: str, page: int) -> SessionLogsPage: + def _page(self, session_id: str, page: int) -> SessionLogsPage | DebugFailure: + uri: Final = "/spend/logs/session/ui" raw: Final = self._get( - "/spend/logs/session/ui", - MappingProxyType({"session_id": session_id, "page": page, "page_size": _SESSION_PAGE_SIZE}), + uri, MappingProxyType({"session_id": session_id, "page": page, "page_size": _SESSION_PAGE_SIZE}) ) + if isinstance(raw, DebugFailure): + return raw try: return _SESSION_PAGE.validate_python(raw) except ValidationError as e: - raise DebugError(f"Unexpected /spend/logs/session/ui response: {e}") from e + return DebugFailure(f"Unexpected {uri} response: {e}") - def payload(self, request_id: str) -> RequestResponsePayload | None: - raw: Final = self._get(f"/spend/logs/ui/{request_id}") + def payload(self, request_id: str) -> RequestResponsePayload | None | DebugFailure: + uri: Final = f"/spend/logs/ui/{request_id}" + raw: Final = self._get(uri) + if isinstance(raw, DebugFailure): + return raw try: return _PAYLOAD.validate_python(raw) except ValidationError as e: - raise DebugError(f"Unexpected /spend/logs/ui/{request_id} response: {e}") from e + return DebugFailure(f"Unexpected {uri} response: {e}") def _fmt_json(value: JsonValue, max_chars: int) -> str: @@ -169,12 +188,19 @@ def _fmt_json(value: JsonValue, max_chars: int) -> str: return f"{text[:max_chars]}\n... (truncated, {len(text) - max_chars} more chars)" +def _fenced(text: str, info: str = "") -> tuple[str, str, str]: + longest_run: Final = max((len(run) for run in re.findall(r"`+", text)), default=0) + fence: Final = "`" * max(3, longest_run + 1) + return (f"{fence}{info}", text, fence) + + def _row_section(row: SpendLogRow, index: int, payload: RequestResponsePayload | None, max_chars: int) -> str: err: Final = row.error error_lines: Final = ( ( f"- error: `{err.error_code or '?'}` {err.error_class or ''}".rstrip(), - f"\n```\n{err.error_message or ''}\n```", + "", + *_fenced(err.error_message or ""), ) if err is not None and row.failed else () @@ -184,16 +210,12 @@ def _row_section(row: SpendLogRow, index: int, payload: RequestResponsePayload | "", "
request body", "", - "```json", - _fmt_json(payload.proxy_server_request, max_chars), - "```", + *_fenced(_fmt_json(payload.proxy_server_request, max_chars), "json"), "
", "", "
response", "", - "```json", - _fmt_json(payload.response, max_chars), - "```", + *_fenced(_fmt_json(payload.response, max_chars), "json"), "
", ) if payload is not None @@ -246,17 +268,23 @@ def build_report( base_url: str, recent_bodies: int, max_chars: int, -) -> str: +) -> str | DebugFailure: rows: Final = fetcher.session_rows(session_id) + if isinstance(rows, DebugFailure): + return rows if not rows: - raise DebugError( + return DebugFailure( f"No spend logs found for session {session_id!r} on {base_url}. " "Is Claude Code routed through this proxy (`lite up`), and does your key have log access?" ) wanted: Final = frozenset(r.request_id for r in rows if r.failed) | frozenset( r.request_id for r in rows[-recent_bodies:] if recent_bodies > 0 ) - payloads: Final = MappingProxyType({rid: fetcher.payload(rid) for rid in wanted}) + fetched: Final = MappingProxyType({rid: fetcher.payload(rid) for rid in sorted(wanted)}) + failed_payload: Final = next((p for p in fetched.values() if isinstance(p, DebugFailure)), None) + if failed_payload is not None: + return failed_payload + payloads: Final = MappingProxyType({rid: p for rid, p in fetched.items() if not isinstance(p, DebugFailure)}) return render_report(session_id=session_id, base_url=base_url, rows=rows, payloads=payloads, max_chars=max_chars) @@ -318,19 +346,18 @@ def debug_claude( values: Final = cli_context_values(ctx) base_url: Final = values["base_url"] fetcher: Final = SpendLogsFetcher(HTTPClient(base_url, values["api_key"])) - try: - report: Final = build_report( - fetcher=fetcher, - session_id=resolved, - base_url=base_url, - recent_bodies=recent_bodies, - max_chars=max_body_chars, - ) - except DebugError as e: - raise click.ClickException(str(e)) from e - click.echo(report) + outcome: Final = build_report( + fetcher=fetcher, + session_id=resolved, + base_url=base_url, + recent_bodies=recent_bodies, + max_chars=max_body_chars, + ) + if isinstance(outcome, DebugFailure): + raise click.ClickException(outcome.message) + click.echo(outcome) if not no_save: - path: Final = write_report(report, resolved, REPORT_DIR) + path: Final = write_report(outcome, resolved, REPORT_DIR) click.echo(f"Saved to {path}", err=True) diff --git a/litellm/proxy/client/cli/main.py b/litellm/proxy/client/cli/main.py index b78d542085a..eae1b0f5bc9 100644 --- a/litellm/proxy/client/cli/main.py +++ b/litellm/proxy/client/cli/main.py @@ -144,7 +144,6 @@ cli.add_command(encryption) cli.add_command(chat) # Add the http command group cli.add_command(http) -# Add the debug command group (session debug reports for coding agents) cli.add_command(debug) # Add the keys command group cli.add_command(keys) diff --git a/tests/test_litellm/proxy/client/cli/test_debug_commands.py b/tests/test_litellm/proxy/client/cli/test_debug_commands.py index 42ce3aaf4d2..1853d0c3468 100644 --- a/tests/test_litellm/proxy/client/cli/test_debug_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_debug_commands.py @@ -3,6 +3,7 @@ import os import time import pytest +import requests import responses from click.testing import CliRunner @@ -38,7 +39,6 @@ FAILED_ROW = { "spend": 0.0, "prompt_tokens": 0, "completion_tokens": 0, - # query_raw hands metadata back as a JSON string on some paths "metadata": json.dumps( { "status": "failure", @@ -169,3 +169,55 @@ def test_install_slash_command_writes_runnable_command_file(tmp_path): result = CliRunner().invoke(cli, ["debug", "install-claude-command"]) assert result.exit_code == 0, result.output assert "/debug-lite" in result.output + + +@responses.activate +def test_rejected_key_is_a_clear_error_not_a_traceback(): + responses.get( + f"{PROXY}/spend/logs/session/ui", + status=401, + json={"error": {"message": "Authentication Error, Invalid proxy server token passed", "code": "401"}}, + ) + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION]) + + assert isinstance(result.exception, SystemExit), result.exception + assert result.exit_code == 1 + assert "401" in result.output + assert "Invalid proxy server token passed" in result.output + + +@responses.activate +def test_unreachable_proxy_is_a_clear_error_not_a_traceback(): + responses.get(f"{PROXY}/spend/logs/session/ui", body=requests.ConnectionError("Connection refused")) + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION]) + + assert isinstance(result.exception, SystemExit), result.exception + assert result.exit_code == 1 + assert "Connection refused" in result.output + + +@responses.activate +def test_non_json_proxy_response_is_a_clear_error_not_a_traceback(): + responses.get(f"{PROXY}/spend/logs/session/ui", body="502 Bad Gateway") + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION]) + + assert isinstance(result.exception, SystemExit), result.exception + assert result.exit_code == 1 + assert "/spend/logs/session/ui failed" in result.output + + +@responses.activate +def test_logged_content_with_code_fences_stays_inside_its_fence(): + fenced_error_row = { + **FAILED_ROW, + "metadata": { + "status": "failure", + "error_information": {"error_code": "400", "error_message": "bad\n```\nrequest"}, + }, + } + _mock_proxy([fenced_error_row], {"req-failed": {"proxy_server_request": None, "response": "x\n````\ny"}}) + result = CliRunner().invoke(cli, ["debug", "claude", "--session-id", SESSION, "--no-save"]) + + assert result.exit_code == 0, result.output + assert "````\nbad\n```\nrequest\n````\n" in result.output + assert "`````json\nx\n````\ny\n`````\n" in result.output From 5bd4da0389f42d1b0f32f27c2061d17523f7cdab Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:16:51 -0700 Subject: [PATCH 173/410] test(health): score the liveliness probe on the median of five warm polls --- .../proxy/health_endpoints/test_health_endpoints.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py index c9cd4e0d9a1..e9e58347337 100644 --- a/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py +++ b/tests/test_litellm/proxy/health_endpoints/test_health_endpoints.py @@ -1205,8 +1205,9 @@ def test_health_liveliness_endpoint(proxy_client): assert response.status_code == 200, f"Expected 200 OK, got {response.status_code}: {response.text}" assert response.json() == "I'm alive!", f"Expected 'I'm alive!' message, got: {response.json()}" - fastest_ms: Final = min(duration_ms for duration_ms, _ in polls) - assert fastest_ms < 100, f"Fastest of {len(polls)} health checks took {fastest_ms:.2f}ms, expected < 100ms" + durations_ms: Final = tuple(sorted(duration_ms for duration_ms, _ in polls)) + median_ms: Final = durations_ms[len(durations_ms) // 2] + assert median_ms < 100, f"Median of {len(polls)} health checks took {median_ms:.2f}ms, expected < 100ms" def test_health_liveness_endpoint(proxy_client): From f846388bb1174b5cdb89a7f019825ad83b6d6e81 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:24:30 -0700 Subject: [PATCH 174/410] fix(proxy): treat a missing user row as unrestricted in the websocket passthrough gate --- litellm/proxy/auth/auth_checks.py | 23 ++++++++++++++--- .../proxy/auth/test_auth_checks.py | 25 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 120a0bb29ea..b0e22153401 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -4155,6 +4155,24 @@ async def _granted_model_lists( ) +async def _user_object_or_none( + valid_token: UserAPIKeyAuth, + prisma_client: PrismaClient, + user_api_key_cache: UserApiKeyCache, + proxy_logging_obj: ProxyLogging, +) -> LiteLLM_UserTable | None: + try: + return await get_user_object( + user_id=valid_token.user_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + user_id_upsert=False, + proxy_logging_obj=proxy_logging_obj, + ) + except ValueError: + return None + + async def enforced_model_allowlists( valid_token: UserAPIKeyAuth, prisma_client: PrismaClient | None, @@ -4178,11 +4196,10 @@ async def enforced_model_allowlists( user_object: Final = ( None if team_object is not None - else await get_user_object( - user_id=valid_token.user_id, + else await _user_object_or_none( + valid_token=valid_token, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, - user_id_upsert=False, proxy_logging_obj=proxy_logging_obj, ) ) diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 5242a99c54c..7351c981838 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -7336,6 +7336,31 @@ class _UntouchedPrisma: raise AssertionError(f"database reached through {name}") +class _MissingUserPrisma: + class db: + class litellm_usertable: + @staticmethod + async def find_unique(where: dict[str, str], include: dict[str, bool]) -> None: + return None + + +@pytest.mark.asyncio +async def test_enforced_model_allowlists_treats_a_missing_user_row_as_unrestricted(): + from litellm.proxy.auth.auth_checks import enforced_model_allowlists + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + from litellm.proxy.utils import ProxyLogging + + cache = UserApiKeyCache() + scopes = await enforced_model_allowlists( + valid_token=UserAPIKeyAuth(token="hashed-fake", user_id="default_user_id"), + prisma_client=_MissingUserPrisma(), + user_api_key_cache=cache, + proxy_logging_obj=ProxyLogging(user_api_key_cache=cache), + ) + + assert [list(scope) for scope in scopes] == [[], [], [], [], []] + + @pytest.mark.asyncio async def test_enforced_model_allowlists_reads_every_level_from_cache(): from litellm.proxy._types import ( From 6ee33df952ef9102f14960ed04c46e8f49900d66 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:25:12 -0700 Subject: [PATCH 175/410] fix(realtime): relay the upstream websocket close to the client instead of hanging When the provider closes the realtime websocket (for example Vertex Live refusing the session with 1008 "Publisher model ... was not found"), the proxy swallowed the close and kept waiting on the client, so the client sat on an open socket with nothing coming back and the session was logged as a $0 success The backend relay now returns the upstream close, and bidirectional_forward sends the client an OpenAI-style error event naming the upstream code and reason, then closes the client socket with the same code (or 1011 when the upstream code is one a server may not send). A session the upstream refused before sending any frame is logged through the failure handlers instead of as a success --- litellm/litellm_core_utils/realtime_errors.py | 8 + .../litellm_core_utils/realtime_streaming.py | 186 ++++++++++++------ .../test_realtime_errors.py | 10 + .../test_realtime_streaming.py | 169 +++++++++++++++- 4 files changed, 303 insertions(+), 70 deletions(-) diff --git a/litellm/litellm_core_utils/realtime_errors.py b/litellm/litellm_core_utils/realtime_errors.py index e1b957f4325..3c064728a66 100644 --- a/litellm/litellm_core_utils/realtime_errors.py +++ b/litellm/litellm_core_utils/realtime_errors.py @@ -29,3 +29,11 @@ def websocket_close_reason(message: str, fallback: str) -> str: if len(encoded) <= WEBSOCKET_CLOSE_REASON_MAX_BYTES: return message return encoded[:WEBSOCKET_CLOSE_REASON_MAX_BYTES].decode("utf-8", errors="ignore") + + +def client_close_code(upstream_code: int) -> int: + from websockets.frames import EXTERNAL_CLOSE_CODES, CloseCode + + if upstream_code in EXTERNAL_CLOSE_CODES or 3000 <= upstream_code < 5000: + return upstream_code + return int(CloseCode.INTERNAL_ERROR) diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index 8479e108d17..746343026ed 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -1,7 +1,9 @@ import asyncio import json -from collections.abc import Mapping, Sequence -from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict, cast +import traceback +from collections.abc import Coroutine, Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol, TypedDict, cast from typing_extensions import ReadOnly @@ -19,9 +21,11 @@ from litellm.types.llms.openai import ( from litellm.types.realtime import ALL_DELTA_TYPES from .litellm_logging import Logging as LiteLLMLogging +from .realtime_errors import client_close_code, realtime_error_event, websocket_close_reason if TYPE_CHECKING: from websockets.asyncio.client import ClientConnection + from websockets.exceptions import ConnectionClosed from litellm.types.guardrails import GuardrailEventHooks @@ -30,8 +34,22 @@ else: CLIENT_CONNECTION_CLASS = Any -class _ClientWebSocketExceptions(Protocol): - ConnectionClosed: type[Exception] +@dataclass(frozen=True, slots=True) +class BackendClose: + code: int + reason: str + + @property + def message(self) -> str: + if not self.reason: + return f"upstream websocket closed with code {self.code}" + return f"upstream websocket closed with code {self.code}: {self.reason}" + + +def backend_close_from(error: "ConnectionClosed") -> BackendClose: + if error.rcvd is None: + return BackendClose(code=1006, reason=str(error)) + return BackendClose(code=error.rcvd.code, reason=error.rcvd.reason) class _ASGIScope(TypedDict, total=False): @@ -69,10 +87,13 @@ class _ScopedWebSocket(Protocol): class _ClientWebSocket(_ScopedWebSocket, Protocol): - exceptions: _ClientWebSocketExceptions - async def send_text(self, data: str) -> None: ... async def receive_text(self) -> str: ... + async def close(self, code: int = 1000, reason: str | None = None) -> None: ... + + +class _LoggingWorker(Protocol): + def ensure_initialized_and_enqueue(self, async_coroutine: Coroutine[object, object, None]) -> None: ... def _decode_json_object(payload: str) -> Mapping[str, object]: @@ -108,11 +129,14 @@ class RealTimeStreaming: backend_uses_beta_protocol: bool | None = None, force_transcription_model: str | None = None, event_normalizer: RealtimeEventNormalizer | None = None, + logging_worker: _LoggingWorker = GLOBAL_LOGGING_WORKER, ): self.websocket: _ClientWebSocket = websocket self.backend_ws = backend_ws self.logging_obj = logging_obj + self._logging_worker = logging_worker self.messages: list[OpenAIRealtimeEvents] = [] + self._backend_sent_frames: bool = False self.input_message: dict = {} self.input_messages: list[dict[str, str]] = [] self.session_tools: list[dict] = [] @@ -388,7 +412,7 @@ class RealTimeStreaming: # Route through the bounded logging worker (per-coroutine timeout + # concurrency cap) instead of a bare create_task, so a slow callback # can't leave suspended tasks pinning each call's response in memory. - GLOBAL_LOGGING_WORKER.ensure_initialized_and_enqueue( + self._logging_worker.ensure_initialized_and_enqueue( self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True) ) @@ -1035,60 +1059,84 @@ class RealTimeStreaming: return True return False - async def backend_to_client_send_messages(self): + async def _relay_backend_messages(self) -> NoReturn: + while True: + try: + raw_response = await self.backend_ws.recv(decode=False) + except TypeError: + raw_response = await self.backend_ws.recv() + self._backend_sent_frames = True + + if isinstance(raw_response, bytes): + try: + raw_response = raw_response.decode("utf-8") + except UnicodeDecodeError: + verbose_logger.warning("Received non-UTF-8 binary frame from backend, skipping.") + continue + + if self.provider_config: + try: + await self._handle_provider_config_message(raw_response) + except Exception as e: + verbose_logger.exception("Error processing backend message, skipping: %s", e) + continue + else: + event = self._parse_backend_event(raw_response) + if event is None: + await self.websocket.send_text(raw_response) + continue + + if self._should_drop_event_from_client(event): + continue + + if await self._handle_raw_backend_message(event, raw_response): + continue + + event = self._normalize_event_for_ga_client(event) + self.store_message(event) + + if not self._client_wants_beta: + await self.websocket.send_text(json.dumps(event)) + continue + + translated = self._translate_event_to_beta(event) + if translated is None: + continue + await self.websocket.send_text(json.dumps(translated)) + + async def backend_to_client_send_messages(self) -> BackendClose: import websockets try: - while True: - try: - raw_response = await self.backend_ws.recv(decode=False) - except TypeError: - raw_response = await self.backend_ws.recv() - - if isinstance(raw_response, bytes): - try: - raw_response = raw_response.decode("utf-8") - except UnicodeDecodeError: - verbose_logger.warning("Received non-UTF-8 binary frame from backend, skipping.") - continue - - if self.provider_config: - try: - await self._handle_provider_config_message(raw_response) - except Exception as e: - verbose_logger.exception("Error processing backend message, skipping: %s", e) - continue - else: - event = self._parse_backend_event(raw_response) - if event is None: - await self.websocket.send_text(raw_response) - continue - - if self._should_drop_event_from_client(event): - continue - - if await self._handle_raw_backend_message(event, raw_response): - continue - - event = self._normalize_event_for_ga_client(event) - self.store_message(event) - - if not self._client_wants_beta: - await self.websocket.send_text(json.dumps(event)) - continue - - translated = self._translate_event_to_beta(event) - if translated is None: - continue - await self.websocket.send_text(json.dumps(translated)) - + await self._relay_backend_messages() except websockets.exceptions.ConnectionClosed as e: verbose_logger.exception("Connection closed in backend to client send messages - %s", e) - except Exception as e: - verbose_logger.exception("Error in backend to client send messages: %s", e) - finally: + close: Final = backend_close_from(e) + self._flush_unbilled_transcription_usage() + if self._backend_refused_session(close): + await self.log_backend_refusal(e) + else: + await self.log_messages() + return close + except asyncio.CancelledError: self._flush_unbilled_transcription_usage() await self.log_messages() + raise + except Exception as e: + verbose_logger.exception("Error in backend to client send messages: %s", e) + self._flush_unbilled_transcription_usage() + await self.log_messages() + return BackendClose(code=1011, reason="proxy failed while relaying the upstream websocket") + + def _backend_refused_session(self, close: BackendClose) -> bool: + return close.code != 1000 and not self._backend_sent_frames and not self.messages + + async def log_backend_refusal(self, error: Exception) -> None: + if not self.logging_obj: + return + self._logging_worker.ensure_initialized_and_enqueue( + self.logging_obj.dispatch_failure_handlers(error, traceback.format_exc(), prefer_async_handlers=True) + ) @staticmethod def _detect_beta_header(websocket: _ScopedWebSocket) -> bool: @@ -1484,20 +1532,28 @@ class RealTimeStreaming: except Exception as e: verbose_logger.debug("Error in client ack messages: %s", e) - async def bidirectional_forward(self): + async def bidirectional_forward(self) -> None: forward_task: Final = asyncio.create_task(self.backend_to_client_send_messages()) + client_task: Final = asyncio.create_task(self.client_ack_messages()) try: - await self.client_ack_messages() - except self.websocket.exceptions.ConnectionClosed: - verbose_logger.debug("Connection closed") - forward_task.cancel() + await asyncio.wait((forward_task, client_task), return_when=asyncio.FIRST_COMPLETED) + if not client_task.done(): + await self._close_client(forward_task.result()) finally: - if not forward_task.done(): - forward_task.cancel() - try: - await forward_task - except asyncio.CancelledError: - pass + forward_task.cancel() + client_task.cancel() + await asyncio.gather(forward_task, client_task, return_exceptions=True) + + async def _close_client(self, close: BackendClose) -> None: + try: + if close.code != 1000: + await self.websocket.send_text(realtime_error_event(close.message, error_type="server_error")) + await self.websocket.close( + code=client_close_code(close.code), + reason=websocket_close_reason(close.reason, fallback=close.message), + ) + except Exception as e: # noqa: BLE001 # the client may already be gone; the session is over either way + verbose_logger.debug("Could not relay the upstream close to the client: %s", e) def client_sent_openai_beta_realtime_header(websocket: _ScopedWebSocket) -> bool: diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_errors.py b/tests/test_litellm/litellm_core_utils/test_realtime_errors.py index 494d16b0b9b..1d2cf905f4e 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_errors.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_errors.py @@ -1,8 +1,10 @@ import json +import pytest from litellm.litellm_core_utils.realtime_errors import ( WEBSOCKET_CLOSE_REASON_MAX_BYTES, + client_close_code, realtime_error_event, websocket_close_reason, ) @@ -42,3 +44,11 @@ def test_websocket_close_reason_truncates_multibyte_message_by_bytes(): assert len(reason.encode("utf-8")) <= WEBSOCKET_CLOSE_REASON_MAX_BYTES assert reason == "あ" * (WEBSOCKET_CLOSE_REASON_MAX_BYTES // 3) assert "�" not in reason + + +@pytest.mark.parametrize( + ("upstream_code", "expected"), + [(1000, 1000), (1008, 1008), (1011, 1011), (4001, 4001), (1005, 1011), (1006, 1011), (1015, 1011), (2999, 1011)], +) +def test_client_close_code_only_forwards_codes_a_server_may_send(upstream_code, expected): + assert client_close_code(upstream_code) == expected diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py index 52e88db753a..1e0456079e9 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py @@ -1,8 +1,13 @@ +import asyncio import json +from collections.abc import Coroutine +from dataclasses import dataclass +from typing import Final from unittest.mock import AsyncMock, MagicMock, patch import pytest from websockets.exceptions import ConnectionClosed +from websockets.frames import Close import litellm @@ -2941,13 +2946,11 @@ async def test_log_messages_routes_async_logging_through_bounded_worker(): realtime turn leaves a suspended task pinning its response in memory -> an unbounded leak. Regression for that fix.""" logging_obj = MagicMock() - streaming = RealTimeStreaming(MagicMock(), MagicMock(), logging_obj) + mock_worker = MagicMock() + streaming = RealTimeStreaming(MagicMock(), MagicMock(), logging_obj, logging_worker=mock_worker) streaming.messages = [{"type": "session.created"}] - with ( - patch("litellm.litellm_core_utils.realtime_streaming.GLOBAL_LOGGING_WORKER") as mock_worker, - patch("litellm.litellm_core_utils.realtime_streaming.asyncio.create_task") as mock_create_task, - ): + with patch("litellm.litellm_core_utils.realtime_streaming.asyncio.create_task") as mock_create_task: await streaming.log_messages() mock_worker.ensure_initialized_and_enqueue.assert_called_once() @@ -3111,3 +3114,159 @@ async def test_session_close_flush_noop_without_unbilled_usage(): isinstance(message, dict) and message.get("type") == "conversation.item.input_audio_transcription.completed" for message in streaming.messages ) + + + +_UPSTREAM_REFUSAL: Final = "Publisher model `publishers/google/models/gemini-live-2.5-flash` was not found" + + +class _InlineLoggingWorker: + def __init__(self) -> None: + self.enqueued: tuple[Coroutine[object, object, None], ...] = () + + def ensure_initialized_and_enqueue(self, async_coroutine: Coroutine[object, object, None]) -> None: + self.enqueued = (*self.enqueued, async_coroutine) + + async def drain(self) -> None: + for coroutine in self.enqueued: + await coroutine + + +class _RecordingLogging: + def __init__(self) -> None: + self.logged_sessions: tuple[tuple[dict, ...], ...] = () + self.logged_failures: tuple[Exception, ...] = () + + async def dispatch_success_handlers(self, result: list[dict], prefer_async_handlers: bool = False) -> None: + self.logged_sessions = (*self.logged_sessions, tuple(result)) + + async def dispatch_failure_handlers( + self, exception: Exception, traceback_exception: str, prefer_async_handlers: bool = False + ) -> None: + self.logged_failures = (*self.logged_failures, exception) + + +@dataclass(frozen=True, slots=True) +class _RelaySession: + streaming: RealTimeStreaming + logging: _RecordingLogging + worker: _InlineLoggingWorker + + async def run(self) -> None: + await asyncio.wait_for(self.streaming.bidirectional_forward(), timeout=2) + await self.worker.drain() + + +async def _wait_forever() -> str: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + +def _client_ws_that_never_sends() -> MagicMock: + client_ws: Final = MagicMock() + client_ws.headers = {} + client_ws.receive_text = AsyncMock(side_effect=_wait_forever) + client_ws.send_text = AsyncMock() + client_ws.close = AsyncMock() + return client_ws + + +def _backend_ws_closing_with(*frames: bytes | Exception) -> MagicMock: + backend_ws: Final = MagicMock() + backend_ws.recv = AsyncMock(side_effect=list(frames)) + return backend_ws + + +def _relay_session(client_ws: MagicMock, backend_ws: MagicMock) -> _RelaySession: + logging: Final = _RecordingLogging() + worker: Final = _InlineLoggingWorker() + streaming: Final = RealTimeStreaming( + client_ws, backend_ws, logging, model="gpt-realtime", logging_worker=worker + ) + return _RelaySession(streaming=streaming, logging=logging, worker=worker) + + +def _error_events_sent_to(client_ws: MagicMock) -> list[dict]: + events: Final = (json.loads(call.args[0]) for call in client_ws.send_text.await_args_list) + return [event for event in events if event.get("type") == "error"] + + +@pytest.mark.asyncio +async def test_bidirectional_forward_relays_upstream_policy_close_to_client(): + client_ws: Final = _client_ws_that_never_sends() + upstream_close: Final = ConnectionClosed(Close(1008, _UPSTREAM_REFUSAL), None) + session: Final = _relay_session(client_ws, _backend_ws_closing_with(upstream_close)) + + await session.run() + + (error_event,) = _error_events_sent_to(client_ws) + assert error_event["error"]["type"] == "server_error" + assert "1008" in error_event["error"]["message"] + assert _UPSTREAM_REFUSAL in error_event["error"]["message"] + client_ws.close.assert_awaited_once_with(code=1008, reason=_UPSTREAM_REFUSAL) + + +@pytest.mark.asyncio +async def test_bidirectional_forward_maps_abnormal_upstream_close_to_internal_error(): + client_ws: Final = _client_ws_that_never_sends() + session: Final = _relay_session(client_ws, _backend_ws_closing_with(ConnectionClosed(None, None))) + + await session.run() + + (error_event,) = _error_events_sent_to(client_ws) + assert "1006" in error_event["error"]["message"] + client_ws.close.assert_awaited_once() + assert client_ws.close.await_args.kwargs["code"] == 1011 + + +@pytest.mark.asyncio +async def test_bidirectional_forward_relays_normal_upstream_close_without_error_event(): + client_ws: Final = _client_ws_that_never_sends() + session: Final = _relay_session(client_ws, _backend_ws_closing_with(ConnectionClosed(Close(1000, ""), None))) + + await session.run() + + assert _error_events_sent_to(client_ws) == [] + client_ws.close.assert_awaited_once() + assert client_ws.close.await_args.kwargs["code"] == 1000 + + +@pytest.mark.asyncio +async def test_upstream_refusal_before_any_frame_logs_a_failure_not_a_success(): + upstream_close: Final = ConnectionClosed(Close(1008, _UPSTREAM_REFUSAL), None) + session: Final = _relay_session(_client_ws_that_never_sends(), _backend_ws_closing_with(upstream_close)) + + await session.run() + + assert session.logging.logged_failures == (upstream_close,) + assert session.logging.logged_sessions == () + + +@pytest.mark.asyncio +async def test_upstream_close_after_relayed_events_still_logs_the_session_as_success(): + client_ws: Final = _client_ws_that_never_sends() + session_created: Final = json.dumps({"type": "session.created", "session": {"id": "sess_1"}}).encode() + upstream_close: Final = ConnectionClosed(Close(1008, _UPSTREAM_REFUSAL), None) + session: Final = _relay_session(client_ws, _backend_ws_closing_with(session_created, upstream_close)) + + await session.run() + + (logged_session,) = session.logging.logged_sessions + assert [event["type"] for event in logged_session] == ["session.created"] + assert session.logging.logged_failures == () + client_ws.close.assert_awaited_once_with(code=1008, reason=_UPSTREAM_REFUSAL) + + +@pytest.mark.asyncio +async def test_client_hanging_up_first_ends_the_session_without_a_relayed_close(): + client_ws: Final = _client_ws_that_never_sends() + client_ws.receive_text = AsyncMock(side_effect=RuntimeError("client went away")) + backend_ws: Final = MagicMock() + backend_ws.recv = AsyncMock(side_effect=_wait_forever) + session: Final = _relay_session(client_ws, backend_ws) + + await session.run() + + assert session.logging.logged_sessions == ((),) + assert session.logging.logged_failures == () + client_ws.close.assert_not_awaited() From 85d45fbb4b6f9299af75b6891af61664176ad69f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:33:30 -0700 Subject: [PATCH 176/410] fix(realtime): relay the upstream close even when a client message hit the closed socket first When the upstream closes while the proxy is forwarding a client message, the client loop ends before the backend relay sees the close, and the relay skipped closing the client because it read the client loop's exit as the client hanging up. The client loop now reports why it stopped, so a close observed on the backend send still reaches the client with the error event and the upstream close code --- .../litellm_core_utils/realtime_streaming.py | 19 ++++++++-- .../test_realtime_streaming.py | 37 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index 746343026ed..530391c7b57 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -3,6 +3,7 @@ import json import traceback from collections.abc import Coroutine, Mapping, Sequence from dataclasses import dataclass +from enum import Enum, auto from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol, TypedDict, cast from typing_extensions import ReadOnly @@ -46,6 +47,11 @@ class BackendClose: return f"upstream websocket closed with code {self.code}: {self.reason}" +class ClientLoopExit(Enum): + CLIENT_DISCONNECTED = auto() + BACKEND_CLOSED = auto() + + def backend_close_from(error: "ConnectionClosed") -> BackendClose: if error.rcvd is None: return BackendClose(code=1006, reason=str(error)) @@ -1291,7 +1297,9 @@ class RealTimeStreaming: item["content"] = new_content return item - async def client_ack_messages(self): + async def client_ack_messages(self) -> ClientLoopExit: + import websockets + client_event: _ClientEventFrame try: while True: @@ -1529,16 +1537,21 @@ class RealTimeStreaming: if guardrail_turn_detection_injected and sent: self._guardrail_turn_detection_update_sent = True + except websockets.exceptions.ConnectionClosed as e: + verbose_logger.debug("Backend closed while forwarding a client message: %s", e) + return ClientLoopExit.BACKEND_CLOSED except Exception as e: verbose_logger.debug("Error in client ack messages: %s", e) + return ClientLoopExit.CLIENT_DISCONNECTED async def bidirectional_forward(self) -> None: forward_task: Final = asyncio.create_task(self.backend_to_client_send_messages()) client_task: Final = asyncio.create_task(self.client_ack_messages()) try: await asyncio.wait((forward_task, client_task), return_when=asyncio.FIRST_COMPLETED) - if not client_task.done(): - await self._close_client(forward_task.result()) + if client_task.done() and client_task.result() is ClientLoopExit.CLIENT_DISCONNECTED: + return + await self._close_client(await forward_task) finally: forward_task.cancel() client_task.cancel() diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py index 1e0456079e9..41b7557f6b2 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py @@ -3134,9 +3134,13 @@ class _InlineLoggingWorker: class _RecordingLogging: def __init__(self) -> None: + self.model_call_details: dict[str, object] = {} self.logged_sessions: tuple[tuple[dict, ...], ...] = () self.logged_failures: tuple[Exception, ...] = () + def pre_call(self, input: str | dict, api_key: str) -> None: + return None + async def dispatch_success_handlers(self, result: list[dict], prefer_async_handlers: bool = False) -> None: self.logged_sessions = (*self.logged_sessions, tuple(result)) @@ -3257,6 +3261,39 @@ async def test_upstream_close_after_relayed_events_still_logs_the_session_as_suc client_ws.close.assert_awaited_once_with(code=1008, reason=_UPSTREAM_REFUSAL) +@pytest.mark.asyncio +async def test_upstream_closing_while_a_client_message_is_forwarded_still_reaches_the_client(): + upstream_close: Final = ConnectionClosed(Close(1008, _UPSTREAM_REFUSAL), None) + backend_closed: Final = asyncio.Event() + client_messages: Final = iter((json.dumps({"type": "response.create"}),)) + + async def receive_text() -> str: + message = next(client_messages, None) + return message if message is not None else await _wait_forever() + + async def send_to_backend(_message: str) -> None: + backend_closed.set() + raise upstream_close + + async def recv_from_backend() -> bytes: + await backend_closed.wait() + raise upstream_close + + client_ws: Final = _client_ws_that_never_sends() + client_ws.receive_text = receive_text + backend_ws: Final = MagicMock() + backend_ws.send = send_to_backend + backend_ws.recv = recv_from_backend + session: Final = _relay_session(client_ws, backend_ws) + + await session.run() + + (error_event,) = _error_events_sent_to(client_ws) + assert _UPSTREAM_REFUSAL in error_event["error"]["message"] + client_ws.close.assert_awaited_once_with(code=1008, reason=_UPSTREAM_REFUSAL) + assert session.logging.logged_failures == (upstream_close,) + + @pytest.mark.asyncio async def test_client_hanging_up_first_ends_the_session_without_a_relayed_close(): client_ws: Final = _client_ws_that_never_sends() From a0b2e7fca6c0dd2bd22e81906eea41d2e2c87426 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:35:08 -0700 Subject: [PATCH 177/410] fix(proxy): only a provably missing user row counts as unrestricted in the websocket passthrough gate --- litellm/proxy/auth/auth_checks.py | 15 +++++++++--- .../proxy/auth/test_auth_checks.py | 24 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index b0e22153401..dc693317de0 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2352,6 +2352,13 @@ async def _backfill_null_user_email( return updated_row +class UserNotFoundError(ValueError): + """The user row is provably absent, as opposed to merely unreadable, so a caller that reads a missing row as no user-level limits can key on it without also swallowing a database that would not answer.""" + + def __init__(self, user_id: str) -> None: + super().__init__(f"User doesn't exist in db. 'user_id'={user_id}. Create user via `/user/new` call.") + + @log_db_metrics async def get_user_object( user_id: str | None, @@ -2457,7 +2464,7 @@ async def get_user_object( value=None, last_db_access_time=last_db_access_time, ) - raise Exception + raise UserNotFoundError(user_id=user_id) if response.organization_memberships is not None and len(response.organization_memberships) > 0: # dump each organization membership to type LiteLLM_OrganizationMembershipTable @@ -2493,7 +2500,9 @@ async def get_user_object( ) return _response - except Exception as e: # if user not in db + except UserNotFoundError: + raise + except Exception as e: _log_budget_lookup_failure("user", e) raise ValueError( f"User doesn't exist in db. 'user_id'={user_id}. Create user via `/user/new` call. Got error - {e}" @@ -4169,7 +4178,7 @@ async def _user_object_or_none( user_id_upsert=False, proxy_logging_obj=proxy_logging_obj, ) - except ValueError: + except UserNotFoundError: return None diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index 7351c981838..2284a05b2e9 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -7361,6 +7361,30 @@ async def test_enforced_model_allowlists_treats_a_missing_user_row_as_unrestrict assert [list(scope) for scope in scopes] == [[], [], [], [], []] +class _UnreachableUserPrisma: + class db: + class litellm_usertable: + @staticmethod + async def find_unique(where: dict[str, str], include: dict[str, bool]) -> None: + raise RuntimeError("database gone") + + +@pytest.mark.asyncio +async def test_enforced_model_allowlists_surfaces_a_failed_user_lookup(): + from litellm.proxy.auth.auth_checks import enforced_model_allowlists + from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache + from litellm.proxy.utils import ProxyLogging + + cache = UserApiKeyCache() + with pytest.raises(ValueError, match="database gone"): + await enforced_model_allowlists( + valid_token=UserAPIKeyAuth(token="hashed-fake", user_id="user-fake"), + prisma_client=_UnreachableUserPrisma(), + user_api_key_cache=cache, + proxy_logging_obj=ProxyLogging(user_api_key_cache=cache), + ) + + @pytest.mark.asyncio async def test_enforced_model_allowlists_reads_every_level_from_cache(): from litellm.proxy._types import ( From bde3f6ae4605d356ac298ce470396c1b89ec535d Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 4 Sep 2026 19:35:43 -0700 Subject: [PATCH 178/410] feat(ui): show guardrail usage units and cost on the Guardrails Monitor The overview table gains Usage Units and Cost columns plus a Guardrail Cost card, and the detail page gains a Usage & Cost section that breaks units and cost down by counter, team and key. Units the cost map could not price are called out next to the cost they are left out of. Both pages now read /guardrails/usage/* through $api.useQuery so the rows are typed from schema.d.ts; the hand-written PerformanceRow and the untyped fetch helpers are gone. fetchClient resolves fetch per request so integration tests that stub the global see typed-client calls too. Refs LIT-5652 --- .../_components/GuardrailDetail.test.tsx | 59 +++-- .../_components/GuardrailDetail.tsx | 12 +- .../GuardrailUsageBreakdown.test.tsx | 114 ++++++++++ .../_components/GuardrailUsageBreakdown.tsx | 159 ++++++++++++++ .../GuardrailsMonitorView.test.tsx | 20 +- .../_components/GuardrailsOverview.test.tsx | 204 +++++++++++++----- .../_components/GuardrailsOverview.tsx | 121 ++++++++--- .../page.integration.test.tsx | 27 ++- .../guardrails/useGuardrailsUsage.test.ts | 81 +++++++ .../hooks/guardrails/useGuardrailsUsage.ts | 38 ++++ .../GuardrailsMonitor/MetricCard.tsx | 2 +- .../components/GuardrailsMonitor/mockData.ts | 33 --- .../GuardrailsMonitor/usageUnits.test.ts | 55 +++++ .../GuardrailsMonitor/usageUnits.ts | 21 ++ .../src/components/networking.tsx | 57 ----- ui/litellm-dashboard/src/lib/http/api.ts | 12 +- 16 files changed, 789 insertions(+), 226 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.test.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.ts create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx index c7567aa80bb..bbcb8138d52 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx @@ -2,12 +2,16 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import userEvent from "@testing-library/user-event"; import { render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; import { GuardrailDetail } from "./GuardrailDetail"; -const mockGetGuardrailsUsageDetail = vi.fn(); +const mockUseGuardrailsUsageDetail = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage", () => ({ + useGuardrailsUsageDetail: (...args: unknown[]) => mockUseGuardrailsUsageDetail(...args), +})); + const mockGetGuardrailsUsageLogs = vi.fn(); vi.mock("@/components/networking", () => ({ - getGuardrailsUsageDetail: (...args: unknown[]) => mockGetGuardrailsUsageDetail(...args), getGuardrailsUsageLogs: (...args: unknown[]) => mockGetGuardrailsUsageLogs(...args), })); @@ -19,7 +23,8 @@ vi.mock("./EvaluationSettingsModal", () => ({ EvaluationSettingsModal: ({ open }: { open: boolean }) => (open ?
: null), })); -const detail = { +const detail: GuardrailUsageDetail = { + guardrail_id: "pii-detector", guardrail_name: "pii-detector", description: "Blocks personally identifiable information", status: "warning", @@ -29,12 +34,25 @@ const detail = { failRate: 20, avgScore: 0.4, avgLatency: 180, + trend: "stable", + time_series: [], + usage_units: { sensitiveInformationPolicyUnits: 4 }, + usage_units_daily: [], + usage_units_by_team: { "": { sensitiveInformationPolicyUnits: 4 } }, + usage_units_by_key: { "hash-1": { sensitiveInformationPolicyUnits: 4 } }, + cost: 0.0004, + cost_by_unit: { sensitiveInformationPolicyUnits: 0.0004 }, + cost_by_team: { "": 0.0004 }, + cost_by_key: { "hash-1": 0.0004 }, + untracked_usage_units: {}, }; +const loaded = (data: GuardrailUsageDetail | undefined) => ({ data, isLoading: false, error: null }); + const defaultProps = { guardrailId: "pii-detector", onBack: vi.fn(), - accessToken: "test-token", + accessToken: "test-token" as string | null, startDate: "2026-07-01", endDate: "2026-07-24", }; @@ -49,19 +67,19 @@ function renderDetail(props: Partial = {}) { describe("GuardrailDetail", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetGuardrailsUsageDetail.mockResolvedValue(detail); + mockUseGuardrailsUsageDetail.mockReturnValue(loaded(detail)); mockGetGuardrailsUsageLogs.mockResolvedValue({ logs: [], total: 0 }); }); it("should show a busy indicator while the detail request is in flight", () => { - mockGetGuardrailsUsageDetail.mockReturnValue(new Promise(() => {})); + mockUseGuardrailsUsageDetail.mockReturnValue({ data: undefined, isLoading: true, error: null }); renderDetail(); expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument(); expect(screen.queryByText("pii-detector")).not.toBeInTheDocument(); }); it("should show an error message and a way back when the detail request fails", async () => { - mockGetGuardrailsUsageDetail.mockRejectedValue(new Error("boom")); + mockUseGuardrailsUsageDetail.mockReturnValue({ data: undefined, isLoading: false, error: new Error("boom") }); renderDetail(); expect(await screen.findByText("Failed to load guardrail details.")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /back to overview/i })).toBeInTheDocument(); @@ -69,14 +87,13 @@ describe("GuardrailDetail", () => { it("should request the detail and the logs for the guardrail and date range", async () => { renderDetail(); - await waitFor(() => - expect(mockGetGuardrailsUsageDetail).toHaveBeenCalledWith( - "test-token", - "pii-detector", - "2026-07-01", - "2026-07-24", - ), - ); + expect(mockUseGuardrailsUsageDetail).toHaveBeenCalledWith({ + accessToken: "test-token", + guardrailId: "pii-detector", + startDate: "2026-07-01", + endDate: "2026-07-24", + }); + await waitFor(() => expect(mockGetGuardrailsUsageLogs).toHaveBeenCalled()); expect(mockGetGuardrailsUsageLogs).toHaveBeenCalledWith( "test-token", expect.objectContaining({ guardrailId: "pii-detector", startDate: "2026-07-01", endDate: "2026-07-24" }), @@ -100,11 +117,18 @@ describe("GuardrailDetail", () => { }); it("should show a placeholder when no latency has been recorded", async () => { - mockGetGuardrailsUsageDetail.mockResolvedValue({ ...detail, avgLatency: null }); + mockUseGuardrailsUsageDetail.mockReturnValue(loaded({ ...detail, avgLatency: null })); renderDetail(); expect(await screen.findByText("No data")).toBeInTheDocument(); }); + it("should show the usage and cost breakdown for the guardrail on the overview tab", async () => { + renderDetail(); + const section = await screen.findByRole("region", { name: "Usage and cost" }); + expect(section).toHaveTextContent("$0.0004"); + expect(section).toHaveTextContent("Sensitive Information Policy"); + }); + it("should call onBack when 'Back to Overview' is clicked", async () => { const user = userEvent.setup(); const onBack = vi.fn(); @@ -138,8 +162,9 @@ describe("GuardrailDetail", () => { }); it("should not request anything without an access token", () => { + mockUseGuardrailsUsageDetail.mockReturnValue(loaded(undefined)); renderDetail({ accessToken: null }); - expect(mockGetGuardrailsUsageDetail).not.toHaveBeenCalled(); + expect(mockUseGuardrailsUsageDetail).toHaveBeenCalledWith(expect.objectContaining({ accessToken: null })); expect(mockGetGuardrailsUsageLogs).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx index 253477ffeac..1e82f1fee85 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx @@ -1,13 +1,15 @@ import { useQuery } from "@tanstack/react-query"; import { ArrowLeft, Settings, Shield, TriangleAlert } from "lucide-react"; import React, { useMemo, useState } from "react"; -import { getGuardrailsUsageDetail, getGuardrailsUsageLogs } from "@/components/networking"; +import { getGuardrailsUsageLogs } from "@/components/networking"; +import { useGuardrailsUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; import { StatusBadge, type StatusTone } from "@/components/shared/table_cells/status_badge"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { EvaluationSettingsModal } from "./EvaluationSettingsModal"; +import { GuardrailUsageBreakdown } from "./GuardrailUsageBreakdown"; import { LogViewer } from "@/components/GuardrailsMonitor/LogViewer"; import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; import type { LogEntry } from "@/components/GuardrailsMonitor/mockData"; @@ -36,11 +38,7 @@ export function GuardrailDetail({ guardrailId, onBack, accessToken = null, start data: detailData, isLoading: detailLoading, error: detailError, - } = useQuery({ - queryKey: ["guardrails-usage-detail", guardrailId, startDate, endDate], - queryFn: () => getGuardrailsUsageDetail(accessToken!, guardrailId, startDate, endDate), - enabled: !!accessToken && !!guardrailId, - }); + } = useGuardrailsUsageDetail({ accessToken, guardrailId, startDate, endDate }); const { data: logsData, isLoading: logsLoading } = useQuery({ queryKey: ["guardrails-usage-logs", guardrailId, logsPage, logsPageSize], queryFn: () => @@ -194,6 +192,8 @@ export function GuardrailDetail({ guardrailId, onBack, accessToken = null, start />
+ {detailData && } + {logViewer("all")} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx new file mode 100644 index 00000000000..3b24185f0b6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx @@ -0,0 +1,114 @@ +import { render, screen, within } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; +import { GuardrailUsageBreakdown } from "./GuardrailUsageBreakdown"; + +const detail: GuardrailUsageDetail = { + guardrail_id: "bedrock-pii-mask", + guardrail_name: "bedrock-pii-mask", + type: "pii", + provider: "Bedrock", + requestsEvaluated: 5, + failRate: 0, + avgScore: null, + avgLatency: 120, + status: "healthy", + trend: "stable", + description: null, + time_series: [], + usage_units: { contentPolicyUnits: 1000, sensitiveInformationPolicyUnits: 300, someFutureCounter: 7 }, + usage_units_daily: [], + usage_units_by_team: { + "team-a": { contentPolicyUnits: 900, sensitiveInformationPolicyUnits: 300 }, + "": { contentPolicyUnits: 100, someFutureCounter: 7 }, + }, + usage_units_by_key: { + "hash-1": { contentPolicyUnits: 1000, sensitiveInformationPolicyUnits: 300 }, + "hash-2": { someFutureCounter: 7 }, + }, + cost: 0.18, + cost_by_unit: { contentPolicyUnits: 0.15, sensitiveInformationPolicyUnits: 0.03, someFutureCounter: null }, + cost_by_team: { "team-a": 0.165, "": 0.015 }, + cost_by_key: { "hash-1": 0.18, "hash-2": null }, + untracked_usage_units: { someFutureCounter: 7 }, +}; + +const rowNamed = (name: string) => screen.getByRole("row", { name: new RegExp(name) }); + +describe("GuardrailUsageBreakdown", () => { + it("totals the units and the cost, and says how many units the cost leaves out", () => { + render(); + + const cost = screen.getByRole("group", { name: "Cost" }); + expect(cost).toHaveTextContent("$0.1800"); + expect(cost).toHaveTextContent("7 units unpriced"); + + const units = screen.getByRole("group", { name: "Usage Units" }); + expect(units).toHaveTextContent("1,307"); + expect(units).toHaveTextContent("3 counters"); + }); + + it("lists each counter with its units, cost and unpriced share", () => { + render(); + + const content = rowNamed("Content Policy"); + expect(within(content).getByText("1,000")).toBeInTheDocument(); + expect(within(content).getByText("$0.1500")).toBeInTheDocument(); + expect(within(content).getByText("—")).toBeInTheDocument(); + + const future = rowNamed("Some Future Counter"); + expect(within(future).getByText("7", { selector: ".text-warning" })).toBeInTheDocument(); + expect(within(future).getByText("—")).toBeInTheDocument(); + }); + + it("breaks units and cost down by team and by key, naming the rows without one", () => { + render(); + + expect(screen.getByRole("heading", { name: "By team" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "By key" })).toBeInTheDocument(); + const teamA = rowNamed("team-a"); + expect(within(teamA).getByText("1,200")).toBeInTheDocument(); + expect(within(teamA).getByText("$0.1650")).toBeInTheDocument(); + + const noTeam = rowNamed("No team"); + expect(within(noTeam).getByText("107")).toBeInTheDocument(); + expect(within(noTeam).getByText("$0.0150")).toBeInTheDocument(); + + const unpricedKey = rowNamed("hash-2"); + expect(within(unpricedKey).getByText("7")).toBeInTheDocument(); + expect(within(unpricedKey).getByText("—")).toBeInTheDocument(); + }); + + it("orders teams and keys by units, largest first", () => { + render(); + + const rows = screen.getAllByRole("row").map((row) => row.textContent ?? ""); + expect(rows.findIndex((text) => text.includes("team-a"))).toBeLessThan( + rows.findIndex((text) => text.includes("No team")), + ); + expect(rows.findIndex((text) => text.includes("hash-1"))).toBeLessThan( + rows.findIndex((text) => text.includes("hash-2")), + ); + }); + + it("says so when the window has no billable units instead of rendering empty tables", () => { + render( + , + ); + + expect(screen.getByText("No billable usage units were recorded in this period.")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx new file mode 100644 index 00000000000..1eaab86c506 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx @@ -0,0 +1,159 @@ +import type { ColumnDef } from "@tanstack/react-table"; +import { CircleDollarSign } from "lucide-react"; +import React from "react"; +import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; +import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; +import { counterLabel, formatCost, totalUnits, unpricedSummary } from "@/components/GuardrailsMonitor/usageUnits"; +import { DataTable } from "@/components/shared/DataTable"; +import { IdCell } from "@/components/shared/table_cells/id_cell"; +import { MoneyCell } from "@/components/shared/table_cells/money_cell"; + +interface CounterRow { + counter: string; + units: number; + cost: number | null; + unpriced: number; +} + +interface GroupRow { + id: string; + units: number; + cost: number | null; +} + +const counterRows = (detail: GuardrailUsageDetail): CounterRow[] => + Object.entries(detail.usage_units).map(([counter, units]) => ({ + counter, + units, + cost: detail.cost_by_unit[counter] ?? null, + unpriced: detail.untracked_usage_units[counter] ?? 0, + })); + +const groupRows = ( + unitsByGroup: GuardrailUsageDetail["usage_units_by_team"], + costByGroup: GuardrailUsageDetail["cost_by_team"], +): GroupRow[] => + Object.entries(unitsByGroup) + .map(([id, units]) => ({ id, units: totalUnits(units), cost: costByGroup[id] ?? null })) + .sort((a, b) => b.units - a.units); + +const counterColumns: ColumnDef[] = [ + { header: "Counter", accessorKey: "counter", cell: ({ row }) => counterLabel(row.original.counter) }, + { + header: "Units", + accessorKey: "units", + meta: { numeric: true }, + cell: ({ row }) => row.original.units.toLocaleString(), + }, + { + header: "Cost", + accessorKey: "cost", + meta: { numeric: true }, + cell: ({ row }) => , + }, + { + header: "Unpriced Units", + accessorKey: "unpriced", + meta: { numeric: true }, + cell: ({ row }) => + row.original.unpriced > 0 ? ( + {row.original.unpriced.toLocaleString()} + ) : ( + + ), + }, +]; + +const groupColumns = (label: string, emptyLabel: string): ColumnDef[] => [ + { + header: label, + accessorKey: "id", + cell: ({ row }) => + row.original.id ? ( + + ) : ( + {emptyLabel} + ), + }, + { + header: "Units", + accessorKey: "units", + meta: { numeric: true }, + cell: ({ row }) => row.original.units.toLocaleString(), + }, + { + header: "Cost", + accessorKey: "cost", + meta: { numeric: true }, + cell: ({ row }) => , + }, +]; + +const teamColumns = groupColumns("Team", "No team"); +const keyColumns = groupColumns("Key", "No key"); + +const TableHeading = ({ title }: { title: string }) => ( +
{title}
+); + +export function GuardrailUsageBreakdown({ detail }: { detail: GuardrailUsageDetail }) { + const counters = counterRows(detail); + const unpriced = unpricedSummary(detail.untracked_usage_units); + + return ( +
+
+
Usage & Cost
+

+ Billable units the provider reported for this guardrail and what LiteLLM priced them at +

+
+ + {counters.length === 0 ? ( +

No billable usage units were recorded in this period.

+ ) : ( + <> +
+ } + subtitle={unpriced ?? undefined} + /> + +
+ + row.counter} + size="compact" + toolbar={() => } + /> + +
+ row.id || "no-team"} + size="compact" + toolbar={() => } + /> + row.id || "no-key"} + size="compact" + toolbar={() => } + /> +
+ + )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx index 9f27daab6b3..83b3a8f5d58 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx @@ -2,14 +2,15 @@ import { render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { describe, expect, it, vi } from "vitest"; import GuardrailsMonitorView from "./GuardrailsMonitorView"; -import * as networking from "@/components/networking"; vi.mock("@/components/networking", () => ({ - getGuardrailsUsageOverview: vi.fn(), formatDate: vi.fn((d: Date) => d.toISOString().slice(0, 10)), })); -const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview); +const mockUseGuardrailsUsageOverview = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage", () => ({ + useGuardrailsUsageOverview: (...args: unknown[]) => mockUseGuardrailsUsageOverview(...args), +})); function wrapper({ children }: { children: React.ReactNode }) { const queryClient = new QueryClient({ @@ -22,23 +23,20 @@ function wrapper({ children }: { children: React.ReactNode }) { describe("GuardrailsMonitorView", () => { it("should render overview and fetch guardrails usage when accessToken is provided", async () => { - mockGetGuardrailsUsageOverview.mockResolvedValue({ - rows: [], - chart: [], - totalRequests: 0, - totalBlocked: 0, - passRate: 100, - }); + mockUseGuardrailsUsageOverview.mockReturnValue({ data: undefined, isLoading: true, error: null }); render(, { wrapper }); expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument(); await waitFor(() => { - expect(mockGetGuardrailsUsageOverview).toHaveBeenCalled(); + expect(mockUseGuardrailsUsageOverview).toHaveBeenCalledWith( + expect.objectContaining({ accessToken: "test-token", startDate: expect.any(String) }), + ); }); }); it("should render without crashing when accessToken is null", async () => { + mockUseGuardrailsUsageOverview.mockReturnValue({ data: undefined, isLoading: false, error: null }); render(, { wrapper }); expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx index c62505cc74f..3a56667b156 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx @@ -1,12 +1,15 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import * as networking from "@/components/networking"; +import type { + GuardrailUsageOverview, + GuardrailUsageOverviewRow, +} from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; import { GuardrailsOverview } from "./GuardrailsOverview"; -vi.mock("@/components/networking", () => ({ - getGuardrailsUsageOverview: vi.fn(), +const useGuardrailsUsageOverviewMock = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage", () => ({ + useGuardrailsUsageOverview: (...args: unknown[]) => useGuardrailsUsageOverviewMock(...args), })); vi.mock("./ScoreChart", () => ({ @@ -17,16 +20,63 @@ vi.mock("./EvaluationSettingsModal", () => ({ EvaluationSettingsModal: ({ open }: { open: boolean }) => (open ?
Evaluation settings modal
: null), })); -const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview); +const row = (overrides: Partial): GuardrailUsageOverviewRow => ({ + id: "guardrail", + name: "Guardrail", + type: "content_filter", + provider: "LiteLLM", + requestsEvaluated: 0, + failRate: 0, + avgScore: null, + avgLatency: null, + status: "healthy", + trend: "stable", + usageUnits: {}, + cost: null, + untrackedUsageUnits: {}, + ...overrides, +}); -function wrapper({ children }: { children: React.ReactNode }) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); - return {children}; -} +const overview: GuardrailUsageOverview = { + rows: [ + row({ + id: "guardrail-low", + name: "Low Failure Guardrail", + requestsEvaluated: 1200, + failRate: 2.5, + avgLatency: 45, + trend: "down", + }), + row({ + id: "guardrail-high", + name: "High Failure Guardrail", + provider: "Bedrock", + requestsEvaluated: 300, + failRate: 18, + status: "warning", + trend: "up", + usageUnits: { contentPolicyUnits: 1000, sensitiveInformationPolicyUnits: 250 }, + cost: 0.15, + untrackedUsageUnits: { sensitiveInformationPolicyUnits: 250 }, + }), + row({ + id: "guardrail-free", + name: "Free Bedrock Guardrail", + provider: "Bedrock", + requestsEvaluated: 10, + failRate: 0, + usageUnits: { contentPolicyUnits: 40 }, + cost: 0, + }), + ], + chart: [], + totalRequests: 1510, + totalBlocked: 84, + passRate: 94.4, + totalUsageUnits: { contentPolicyUnits: 1040, sensitiveInformationPolicyUnits: 250 }, + totalCost: 0.15, + totalUntrackedUsageUnits: { sensitiveInformationPolicyUnits: 250 }, +}; function renderOverview(onSelectGuardrail = vi.fn()) { return render( @@ -36,41 +86,24 @@ function renderOverview(onSelectGuardrail = vi.fn()) { endDate="2026-08-12" onSelectGuardrail={onSelectGuardrail} />, - { wrapper }, ); } +const rowNamed = (name: string) => screen.getByRole("row", { name: new RegExp(name) }); + describe("GuardrailsOverview", () => { beforeEach(() => { vi.clearAllMocks(); - mockGetGuardrailsUsageOverview.mockResolvedValue({ - rows: [ - { - id: "guardrail-low", - name: "Low Failure Guardrail", - type: "content_filter", - provider: "LiteLLM", - requestsEvaluated: 1200, - failRate: 2.5, - avgLatency: 45, - status: "healthy", - trend: "down", - }, - { - id: "guardrail-high", - name: "High Failure Guardrail", - type: "content_filter", - provider: "Bedrock", - requestsEvaluated: 300, - failRate: 18, - status: "warning", - trend: "up", - }, - ], - chart: [], - totalRequests: 1500, - totalBlocked: 84, - passRate: 94.4, + useGuardrailsUsageOverviewMock.mockReturnValue({ data: overview, isLoading: false, error: null }); + }); + + it("asks for the usage overview of the selected window", () => { + renderOverview(); + + expect(useGuardrailsUsageOverviewMock).toHaveBeenCalledWith({ + accessToken: "test-token", + startDate: "2026-08-01", + endDate: "2026-08-12", }); }); @@ -78,15 +111,7 @@ describe("GuardrailsOverview", () => { const onSelectGuardrail = vi.fn(); const user = userEvent.setup(); - render( - , - { wrapper }, - ); + renderOverview(onSelectGuardrail); expect(await screen.findByRole("columnheader", { name: "Guardrail" })).toBeInTheDocument(); expect(screen.getByRole("columnheader", { name: /Requests/ })).toBeInTheDocument(); @@ -105,6 +130,46 @@ describe("GuardrailsOverview", () => { expect(onSelectGuardrail).toHaveBeenCalledWith("guardrail-low"); }); + it("shows each guardrail's usage units and cost, marking the units cost leaves out", async () => { + renderOverview(); + + expect(await screen.findByRole("columnheader", { name: "Usage Units" })).toBeInTheDocument(); + expect(screen.getByRole("columnheader", { name: /Cost/ })).toBeInTheDocument(); + + const priced = rowNamed("High Failure Guardrail"); + expect(within(priced).getByText("1,250")).toBeInTheDocument(); + expect(within(priced).getByText("$0.1500")).toBeInTheDocument(); + expect(within(priced).getByLabelText("250 units unpriced")).toBeInTheDocument(); + + const free = rowNamed("Free Bedrock Guardrail"); + expect(within(free).getByText("40")).toBeInTheDocument(); + expect(within(free).getByText("$0.0000")).toBeInTheDocument(); + expect(within(free).queryByLabelText(/unpriced/)).not.toBeInTheDocument(); + + const unmetered = rowNamed("Low Failure Guardrail"); + expect(within(unmetered).getAllByText("—")).toHaveLength(2); + }); + + it("breaks the usage units down per counter on hover", async () => { + const user = userEvent.setup(); + renderOverview(); + + await user.hover(within(rowNamed("High Failure Guardrail")).getByText("1,250")); + + expect(await screen.findByText("Content Policy: 1,000")).toBeInTheDocument(); + expect(screen.getByText("Sensitive Information Policy: 250")).toBeInTheDocument(); + }); + + it("sorts by cost when its header is clicked", async () => { + const user = userEvent.setup(); + renderOverview(); + + await user.click(await screen.findByRole("button", { name: /Cost/ })); + + await waitFor(() => expect(screen.getAllByRole("row")[1]).toHaveTextContent("Low Failure Guardrail")); + expect(screen.getAllByRole("row")[3]).toHaveTextContent("High Failure Guardrail"); + }); + it("renders the page header and the export action", async () => { renderOverview(); @@ -117,15 +182,36 @@ describe("GuardrailsOverview", () => { it("renders every summary metric card", async () => { renderOverview(); - expect(await screen.findByText("1,500")).toBeInTheDocument(); + expect(await screen.findByText("1,510")).toBeInTheDocument(); expect(screen.getByText("Total Evaluations")).toBeInTheDocument(); expect(screen.getByText("Blocked Requests")).toBeInTheDocument(); expect(screen.getByText("84")).toBeInTheDocument(); expect(screen.getByText("Pass Rate")).toBeInTheDocument(); expect(screen.getByText("94.4%")).toBeInTheDocument(); - expect(screen.getByText("23ms")).toBeInTheDocument(); + expect(screen.getByText("15ms")).toBeInTheDocument(); expect(screen.getByText("Active Guardrails")).toBeInTheDocument(); - expect(screen.getByText("2")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("totals guardrail cost across the window and says how many units it leaves out", async () => { + renderOverview(); + + const card = await screen.findByRole("group", { name: "Guardrail Cost" }); + expect(card).toHaveTextContent("$0.1500"); + expect(card).toHaveTextContent("250 units unpriced"); + }); + + it("shows a dash for guardrail cost when nothing in the window was priced", async () => { + useGuardrailsUsageOverviewMock.mockReturnValue({ + data: { ...overview, totalCost: null, totalUntrackedUsageUnits: {} }, + isLoading: false, + error: null, + }); + renderOverview(); + + const card = await screen.findByRole("group", { name: "Guardrail Cost" }); + expect(card).toHaveTextContent("—"); + expect(card).not.toHaveTextContent("unpriced"); }); it("renders the table toolbar heading and its description", async () => { @@ -147,14 +233,18 @@ describe("GuardrailsOverview", () => { }); it("marks the overview busy while the usage request is in flight", async () => { - mockGetGuardrailsUsageOverview.mockReturnValue(new Promise(() => {})); + useGuardrailsUsageOverviewMock.mockReturnValue({ data: undefined, isLoading: true, error: null }); renderOverview(); await waitFor(() => expect(document.querySelector('[aria-busy="true"]')).toBeInTheDocument()); }); it("shows a failure message when the usage request rejects", async () => { - mockGetGuardrailsUsageOverview.mockRejectedValue(new Error("network down")); + useGuardrailsUsageOverviewMock.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error("network down"), + }); renderOverview(); expect(await screen.findByText("Failed to load data. Try again.")).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx index 5bc9eb16cee..67630fef13a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx @@ -1,10 +1,14 @@ -import { useQuery } from "@tanstack/react-query"; import type { ColumnDef, OnChangeFn, SortingState } from "@tanstack/react-table"; -import { Download, HeartPulse, Settings, TrendingUp, TriangleAlert } from "lucide-react"; +import { CircleDollarSign, Download, HeartPulse, Settings, TrendingUp, TriangleAlert } from "lucide-react"; import React, { useMemo, useState } from "react"; import { DataTable, DataTableSortHeader } from "@/components/shared/DataTable"; -import { getGuardrailsUsageOverview } from "@/components/networking"; -import { type PerformanceRow } from "@/components/GuardrailsMonitor/mockData"; +import { MoneyCell } from "@/components/shared/table_cells/money_cell"; +import { CellTooltip } from "@/components/shared/table_cells/cell_tooltip"; +import { + type GuardrailUsageOverviewRow, + useGuardrailsUsageOverview, +} from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; +import { counterLabel, formatCost, totalUnits, unpricedSummary } from "@/components/GuardrailsMonitor/usageUnits"; import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/shared/PageHeader"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; @@ -20,7 +24,7 @@ interface GuardrailsOverviewProps { dateRangeControl?: React.ReactNode; } -type SortKey = "failRate" | "requestsEvaluated" | "avgLatency" | "falsePositiveRate" | "falseNegativeRate"; +type SortKey = "failRate" | "requestsEvaluated" | "avgLatency" | "cost"; const providerColors: Record = { Bedrock: "bg-warning/15 text-warning border-warning/20", @@ -30,14 +34,48 @@ const providerColors: Record = { Custom: "bg-muted text-muted-foreground border-border", }; -function computeMetricsFromRows(data: PerformanceRow[]) { - const totalRequests = data.reduce((sum, r) => sum + r.requestsEvaluated, 0); - const totalBlocked = data.reduce((sum, r) => sum + Math.round((r.requestsEvaluated * r.failRate) / 100), 0); - const passRate = totalRequests > 0 ? ((1 - totalBlocked / totalRequests) * 100).toFixed(1) : "0"; - const withLat = data.filter((r) => r.avgLatency != null); - const avgLatency = - withLat.length > 0 ? Math.round(withLat.reduce((sum, r) => sum + (r.avgLatency ?? 0), 0) / withLat.length) : 0; - return { totalRequests, totalBlocked, passRate, avgLatency, count: data.length }; +const EMPTY_METRICS = { + totalRequests: 0, + totalBlocked: 0, + passRate: "0", + avgLatency: 0, + count: 0, + totalCost: null as number | null, + unpriced: null as string | null, +}; + +function UsageUnitsCell({ units }: { units: GuardrailUsageOverviewRow["usageUnits"] }) { + const counters = Object.entries(units); + if (counters.length === 0) return ; + return ( + + {counters.map(([counter, n]) => ( +
  • + {counterLabel(counter)}: {n.toLocaleString()} +
  • + ))} + + } + trigger={{totalUnits(units).toLocaleString()}} + /> + ); +} + +function CostCell({ row }: { row: GuardrailUsageOverviewRow }) { + const unpriced = unpricedSummary(row.untrackedUsageUnits); + return ( + + {unpriced && ( + } + /> + )} + + + ); } export function GuardrailsOverview({ @@ -55,26 +93,22 @@ export function GuardrailsOverview({ data: guardrailsData, isLoading: guardrailsLoading, error: guardrailsError, - } = useQuery({ - queryKey: ["guardrails-usage-overview", startDate, endDate], - queryFn: () => getGuardrailsUsageOverview(accessToken!, startDate, endDate), - enabled: !!accessToken, - }); + } = useGuardrailsUsageOverview({ accessToken, startDate, endDate }); - const activeData: PerformanceRow[] = guardrailsData?.rows ?? []; + const activeData: GuardrailUsageOverviewRow[] = useMemo(() => guardrailsData?.rows ?? [], [guardrailsData]); const metrics = useMemo(() => { - if (guardrailsData) { - return { - totalRequests: guardrailsData.totalRequests ?? 0, - totalBlocked: guardrailsData.totalBlocked ?? 0, - passRate: String(guardrailsData.passRate ?? 0), - avgLatency: activeData.length - ? Math.round(activeData.reduce((s, r) => s + (r.avgLatency ?? 0), 0) / activeData.length) - : 0, - count: activeData.length, - }; - } - return computeMetricsFromRows(activeData); + if (!guardrailsData) return EMPTY_METRICS; + return { + totalRequests: guardrailsData.totalRequests, + totalBlocked: guardrailsData.totalBlocked, + passRate: String(guardrailsData.passRate), + avgLatency: activeData.length + ? Math.round(activeData.reduce((s, r) => s + (r.avgLatency ?? 0), 0) / activeData.length) + : 0, + count: activeData.length, + totalCost: guardrailsData.totalCost, + unpriced: unpricedSummary(guardrailsData.totalUntrackedUsageUnits), + }; }, [guardrailsData, activeData]); const chartData = guardrailsData?.chart; const sorted = useMemo(() => { @@ -88,7 +122,7 @@ export function GuardrailsOverview({ const isLoading = guardrailsLoading; const error = guardrailsError; - const columns: ColumnDef[] = [ + const columns: ColumnDef[] = [ { header: "Guardrail", accessorKey: "name", @@ -166,6 +200,20 @@ export function GuardrailsOverview({ ), }, + { + header: "Usage Units", + accessorKey: "usageUnits", + enableSorting: false, + meta: { numeric: true }, + cell: ({ row }) => , + }, + { + header: ({ column }) => , + accessorKey: "cost", + meta: { numeric: true }, + sortDescFirst: false, + cell: ({ row }) => , + }, { header: "Status", accessorKey: "status", @@ -187,7 +235,7 @@ export function GuardrailsOverview({ }, ]; - const sortableKeys: SortKey[] = ["failRate", "requestsEvaluated", "avgLatency"]; + const sortableKeys: SortKey[] = ["failRate", "requestsEvaluated", "avgLatency", "cost"]; const sorting = useMemo(() => [{ id: sortBy, desc: sortDir === "desc" }], [sortBy, sortDir]); const handleSortingChange: OnChangeFn = (updater) => { const nextSorting = typeof updater === "function" ? updater(sorting) : updater; @@ -236,6 +284,13 @@ export function GuardrailsOverview({ metrics.avgLatency > 150 ? "text-destructive" : metrics.avgLatency > 50 ? "text-warning" : "text-success" } /> + } + subtitle={metrics.unpriced ?? undefined} + />
    diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.integration.test.tsx index fb521c0b8a3..ce8fda0ea69 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.integration.test.tsx @@ -11,7 +11,23 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ const fetchMock = vi.fn(); -const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url)); +const requestUrl = (input: RequestInfo | URL) => (input instanceof Request ? input.url : String(input)); + +const requestedUrls = () => fetchMock.mock.calls.map(([input]) => requestUrl(input)); + +const emptyOverview = { + rows: [], + chart: [], + totalRequests: 0, + totalBlocked: 0, + passRate: 100, + totalUsageUnits: {}, + totalCost: null, + totalUntrackedUsageUnits: {}, +}; + +const jsonResponse = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { "Content-Type": "application/json" } }); const renderAs = (userRole: string) => { useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole }); @@ -25,12 +41,9 @@ describe("Guardrails Monitor page access by role", () => { beforeEach(() => { testQueryClient.clear(); vi.clearAllMocks(); - fetchMock.mockResolvedValue({ - ok: true, - status: 200, - statusText: "OK", - json: async () => ({ rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 }), - }); + fetchMock.mockImplementation(async (input: RequestInfo | URL) => + jsonResponse(requestUrl(input).includes("/guardrails/usage/overview") ? emptyOverview : []), + ); vi.stubGlobal("fetch", fetchMock); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.test.ts new file mode 100644 index 00000000000..f0b2709484d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.test.ts @@ -0,0 +1,81 @@ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useGuardrailsUsageDetail, useGuardrailsUsageOverview } from "./useGuardrailsUsage"; + +const useQueryMock = vi.fn(); +vi.mock("@/lib/http/api", () => ({ + $api: { useQuery: (...args: unknown[]) => useQueryMock(...args) }, +})); + +const lastCall = () => { + const calls = useQueryMock.mock.calls; + return calls[calls.length - 1] as [string, string, unknown, { enabled: boolean }]; +}; + +describe("useGuardrailsUsageOverview", () => { + beforeEach(() => { + vi.clearAllMocks(); + useQueryMock.mockReturnValue({ data: undefined }); + }); + + it("queries GET /guardrails/usage/overview with the window as query params", () => { + renderHook(() => useGuardrailsUsageOverview({ accessToken: "sk", startDate: "2026-09-01", endDate: "2026-09-04" })); + + expect(lastCall().slice(0, 3)).toEqual([ + "get", + "/guardrails/usage/overview", + { params: { query: { start_date: "2026-09-01", end_date: "2026-09-04" } } }, + ]); + expect(lastCall()[3].enabled).toBe(true); + }); + + it("omits blank dates so the proxy applies its default window", () => { + renderHook(() => useGuardrailsUsageOverview({ accessToken: "sk", startDate: "", endDate: "" })); + + expect(lastCall()[2]).toEqual({ params: { query: { start_date: undefined, end_date: undefined } } }); + }); + + it("stays disabled without an access token", () => { + renderHook(() => useGuardrailsUsageOverview({ accessToken: null, startDate: "2026-09-01", endDate: "2026-09-04" })); + + expect(lastCall()[3].enabled).toBe(false); + }); +}); + +describe("useGuardrailsUsageDetail", () => { + beforeEach(() => { + vi.clearAllMocks(); + useQueryMock.mockReturnValue({ data: undefined }); + }); + + it("queries GET /guardrails/usage/detail/{guardrail_id} with the id as a path param", () => { + renderHook(() => + useGuardrailsUsageDetail({ + accessToken: "sk", + guardrailId: "bedrock-pii-mask", + startDate: "2026-09-01", + endDate: "2026-09-04", + }), + ); + + expect(lastCall().slice(0, 3)).toEqual([ + "get", + "/guardrails/usage/detail/{guardrail_id}", + { + params: { + path: { guardrail_id: "bedrock-pii-mask" }, + query: { start_date: "2026-09-01", end_date: "2026-09-04" }, + }, + }, + ]); + expect(lastCall()[3].enabled).toBe(true); + }); + + it("stays disabled without a guardrail id", () => { + renderHook(() => + useGuardrailsUsageDetail({ accessToken: "sk", guardrailId: "", startDate: "2026-09-01", endDate: "2026-09-04" }), + ); + + expect(lastCall()[3].enabled).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.ts new file mode 100644 index 00000000000..dc7f58fbc8f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.ts @@ -0,0 +1,38 @@ +import { $api } from "@/lib/http/api"; +import type { components } from "@/lib/http/schema"; + +export type GuardrailUsageOverview = components["schemas"]["UsageOverviewResponse"]; +export type GuardrailUsageOverviewRow = components["schemas"]["UsageOverviewRow"]; +export type GuardrailUsageDetail = components["schemas"]["UsageDetailResponse"]; + +export interface GuardrailsUsageWindow { + accessToken: string | null; + startDate: string; + endDate: string; +} + +const dateQuery = (startDate: string, endDate: string) => ({ + start_date: startDate || undefined, + end_date: endDate || undefined, +}); + +export const useGuardrailsUsageOverview = ({ accessToken, startDate, endDate }: GuardrailsUsageWindow) => + $api.useQuery( + "get", + "/guardrails/usage/overview", + { params: { query: dateQuery(startDate, endDate) } }, + { enabled: Boolean(accessToken) }, + ); + +export const useGuardrailsUsageDetail = ({ + accessToken, + guardrailId, + startDate, + endDate, +}: GuardrailsUsageWindow & { guardrailId: string }) => + $api.useQuery( + "get", + "/guardrails/usage/detail/{guardrail_id}", + { params: { path: { guardrail_id: guardrailId }, query: dateQuery(startDate, endDate) } }, + { enabled: Boolean(accessToken && guardrailId) }, + ); diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx index d5d249e4799..c0b5e0a50d1 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx @@ -10,7 +10,7 @@ interface MetricCardProps { export function MetricCard({ label, value, valueColor = "text-foreground", icon, subtitle }: MetricCardProps) { return ( -
    +
    {label} {icon && {icon}} diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/mockData.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/mockData.ts index 7d99ebe7c44..2b42f7907f1 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/mockData.ts +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/mockData.ts @@ -2,39 +2,6 @@ * Types for Guardrails Monitor dashboard (data from usage API). */ -export interface PerformanceRow { - id: string; - name: string; - type: string; - provider: string; - requestsEvaluated: number; - failRate: number; - avgScore?: number; - avgLatency?: number; - p95Latency?: number; - falsePositiveRate?: number; - falseNegativeRate?: number; - status: "healthy" | "warning" | "critical"; - trend: "up" | "down" | "stable"; -} - -export interface GuardrailDetailRecord { - name: string; - type: string; - provider: string; - requestsEvaluated: number; - failRate: number; - avgScore?: number; - avgLatency?: number; - p95Latency?: number; - falsePositiveRate?: number; - falsePositiveCount?: number; - falseNegativeRate?: number; - falseNegativeCount?: number; - status: string; - description: string; -} - export interface LogEntry { id: string; timestamp: string; diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts new file mode 100644 index 00000000000..29362cc3701 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { counterLabel, formatCost, totalUnits, unpricedSummary } from "./usageUnits"; + +describe("formatCost", () => { + it("renders a dash when nothing was priced", () => { + expect(formatCost(null)).toBe("—"); + expect(formatCost(undefined)).toBe("—"); + }); + + it("keeps an explicit zero as a real price rather than a dash", () => { + expect(formatCost(0)).toBe("$0.0000"); + }); + + it("shows four decimals for the sub-cent amounts guardrail units cost", () => { + expect(formatCost(0.0003)).toBe("$0.0003"); + expect(formatCost(12.5)).toBe("$12.5000"); + }); + + it("flags amounts below the displayed precision instead of rounding them to zero", () => { + expect(formatCost(0.00001)).toBe("< $0.0001"); + }); +}); + +describe("totalUnits", () => { + it("sums every counter", () => { + expect(totalUnits({ contentPolicyUnits: 3, sensitiveInformationPolicyUnits: 4 })).toBe(7); + }); + + it("is zero for no counters", () => { + expect(totalUnits({})).toBe(0); + }); +}); + +describe("counterLabel", () => { + it("turns a Bedrock counter name into words without the Units suffix", () => { + expect(counterLabel("sensitiveInformationPolicyUnits")).toBe("Sensitive Information Policy"); + expect(counterLabel("contentPolicyUnits")).toBe("Content Policy"); + }); + + it("leaves a name it cannot split alone apart from capitalising it", () => { + expect(counterLabel("units")).toBe("Units"); + }); +}); + +describe("unpricedSummary", () => { + it("is null when every unit was priced", () => { + expect(unpricedSummary({})).toBeNull(); + expect(unpricedSummary({ contentPolicyUnits: 0 })).toBeNull(); + }); + + it("counts unpriced units across counters with a pluralised label", () => { + expect(unpricedSummary({ contentPolicyUnits: 1200, someFutureCounter: 34 })).toBe("1,234 units unpriced"); + expect(unpricedSummary({ someFutureCounter: 1 })).toBe("1 unit unpriced"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts new file mode 100644 index 00000000000..f3a5e9d7140 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts @@ -0,0 +1,21 @@ +import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils"; + +export type UsageUnits = Readonly>; + +export const formatCost = (cost: number | null | undefined): string => { + if (cost == null) return "—"; + return cost === 0 ? `$${formatNumberWithCommas(0, 4)}` : getSpendString(cost, 4); +}; + +export const totalUnits = (units: UsageUnits): number => Object.values(units).reduce((sum, n) => sum + n, 0); + +export const counterLabel = (counter: string): string => + counter + .replace(/Units$/, "") + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/^./, (c) => c.toUpperCase()); + +export const unpricedSummary = (untracked: UsageUnits): string | null => { + const total = totalUnits(untracked); + return total > 0 ? `${total.toLocaleString()} ${total === 1 ? "unit" : "units"} unpriced` : null; +}; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index e06d457cfa9..ccf4bd748e5 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -3956,63 +3956,6 @@ export const rejectGuardrailSubmission = async ( }; // Guardrails / Policies usage (dashboard) -export const getGuardrailsUsageOverview = async (accessToken: string, startDate?: string, endDate?: string) => { - try { - let url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/usage/overview` : `/guardrails/usage/overview`; - const params = new URLSearchParams(); - if (startDate) params.append("start_date", startDate); - if (endDate) params.append("end_date", endDate); - if (params.toString()) url += `?${params.toString()}`; - const response = await fetch(url, { - method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(deriveErrorMessage(errorData)); - } - return response.json(); - } catch (error) { - console.error("Failed to get guardrails usage overview:", error); - throw error; - } -}; - -export const getGuardrailsUsageDetail = async ( - accessToken: string, - guardrailId: string, - startDate?: string, - endDate?: string, -) => { - try { - let url = proxyBaseUrl - ? `${proxyBaseUrl}/guardrails/usage/detail/${encodeURIComponent(guardrailId)}` - : `/guardrails/usage/detail/${encodeURIComponent(guardrailId)}`; - const params = new URLSearchParams(); - if (startDate) params.append("start_date", startDate); - if (endDate) params.append("end_date", endDate); - if (params.toString()) url += `?${params.toString()}`; - const response = await fetch(url, { - method: "GET", - headers: { - [globalLitellmHeaderName]: `Bearer ${accessToken}`, - "Content-Type": "application/json", - }, - }); - if (!response.ok) { - const errorData = await response.json(); - throw new Error(deriveErrorMessage(errorData)); - } - return response.json(); - } catch (error) { - console.error("Failed to get guardrails usage detail:", error); - throw error; - } -}; - export const getGuardrailsUsageLogs = async ( accessToken: string, options: { diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts index 508a27db78d..904e4e4f3ab 100644 --- a/ui/litellm-dashboard/src/lib/http/api.ts +++ b/ui/litellm-dashboard/src/lib/http/api.ts @@ -43,11 +43,15 @@ const middleware: Middleware = { * * The base URL is injected, not fixed at import: every request is built against * whatever registerBaseUrlGetter supplies at call time (a split-origin proxy or - * worker URL), falling back to the current origin. The middleware injects the - * auth header and maps non-2xx responses to ApiError so query functions can just - * read `.data`. + * worker URL), falling back to the current origin. `fetch` is looked up per + * request for the same reason, so a test that stubs the global sees these calls + * too. The middleware injects the auth header and maps non-2xx responses to + * ApiError so query functions can just read `.data`. */ -export const fetchClient = createFetchClient({ Request: BaseAwareRequest }); +export const fetchClient = createFetchClient({ + Request: BaseAwareRequest, + fetch: (request) => globalThis.fetch(request), +}); fetchClient.use(middleware); /** From 5a7e938f371e787d1e60af1eddea70bc10978813 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:40:46 -0700 Subject: [PATCH 179/410] test(cost): type the cost map fixture parameter on the new tests --- .../llm_cost_calc/test_llm_cost_calc_utils.py | 4 ++-- tests/test_litellm/test_cost_calculator.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index f7ca2a89048..19464dfd2a0 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -4725,7 +4725,7 @@ def test_route_image_generation_cost_falls_back_to_requested_size(monkeypatch, r assert cost == expected_cost -def test_generic_cost_per_token_bills_reasoning_nested_in_text_tokens_once(_local_model_cost_map): +def test_generic_cost_per_token_bills_reasoning_nested_in_text_tokens_once(_local_model_cost_map: None) -> None: """ Realtime usage (OpenAI and Azure) reports output_tokens == text_tokens + audio_tokens with reasoning_tokens already counted inside text_tokens, so reasoning must not be billed on top. @@ -4757,7 +4757,7 @@ def test_generic_cost_per_token_bills_reasoning_nested_in_text_tokens_once(_loca ) -def test_generic_cost_per_token_keeps_billing_reasoning_reported_beside_text_tokens(_local_model_cost_map): +def test_generic_cost_per_token_keeps_billing_reasoning_reported_beside_text_tokens(_local_model_cost_map: None) -> None: """Providers whose text_tokens exclude reasoning (text + reasoning == completion) stay billed in full.""" model = "gpt-realtime-2.1-mini" diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 203f0a9e840..3eb051982e4 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -4494,7 +4494,7 @@ def test_batch_cost_calculator_gpt_6_astra_bills_half_the_standard_rate(_local_m assert completion_cost == pytest.approx(500 * 2.5e-5) -def test_handle_realtime_stream_cost_calculation_bills_nested_reasoning_tokens_once(_local_model_cost_map): +def test_handle_realtime_stream_cost_calculation_bills_nested_reasoning_tokens_once(_local_model_cost_map: None) -> None: """Realtime response.done nests reasoning_tokens inside text_tokens, so they are billed once.""" results: OpenAIRealtimeStreamList = [ {"type": "session.created", "session": {"model": "gpt-realtime-2.1-mini"}}, From a90328aa8c620935f78bbfcb0a6b49f1858fee97 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:42:37 -0700 Subject: [PATCH 180/410] refactor(typing): drop the dead self guard in Predibase init and use a plain list factory --- litellm/llms/predibase/chat/transformation.py | 2 +- litellm/router_strategy/adaptive_router/signals.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/llms/predibase/chat/transformation.py b/litellm/llms/predibase/chat/transformation.py index 0ebac5185d7..2a63c489395 100644 --- a/litellm/llms/predibase/chat/transformation.py +++ b/litellm/llms/predibase/chat/transformation.py @@ -83,7 +83,7 @@ class PredibaseConfig(BaseConfig): ("watermark", watermark), ) for key, value in locals_: - if key != "self" and value is not None: + if value is not None: setattr(self.__class__, key, value) @classmethod diff --git a/litellm/router_strategy/adaptive_router/signals.py b/litellm/router_strategy/adaptive_router/signals.py index 7b69714aad9..c28613b54eb 100644 --- a/litellm/router_strategy/adaptive_router/signals.py +++ b/litellm/router_strategy/adaptive_router/signals.py @@ -93,7 +93,7 @@ class Turn: user_content: str | None = None assistant_content: str | None = None tool_calls: list[dict[str, Any]] = field(default_factory=list) - tool_results: Sequence[Mapping[str, object]] = field(default_factory=list[Mapping[str, object]]) + tool_results: Sequence[Mapping[str, object]] = field(default_factory=list) response_status: int | None = None From 296cd8c1f5bb9945f7afa30ba38ac2f39bc4a530 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:45:42 -0700 Subject: [PATCH 181/410] fix(cli): read CLAUDE_CODE_SESSION_ID and skip subagent transcripts when detecting the Claude Code session --- litellm/proxy/client/cli/commands/debug.py | 7 ++++-- .../proxy/client/cli/test_debug_commands.py | 22 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/litellm/proxy/client/cli/commands/debug.py b/litellm/proxy/client/cli/commands/debug.py index c5f147eb37a..4e3914143d8 100644 --- a/litellm/proxy/client/cli/commands/debug.py +++ b/litellm/proxy/client/cli/commands/debug.py @@ -25,7 +25,7 @@ from ._cli_context import cli_context_values CLAUDE_DIR: Final = Path.home() / ".claude" REPORT_DIR: Final = Path.home() / ".litellm" / "debug" -SESSION_ID_ENV: Final = "CLAUDE_SESSION_ID" +SESSION_ID_ENV: Final = "CLAUDE_CODE_SESSION_ID" SLASH_COMMAND_NAME: Final = "debug-lite" SLASH_COMMAND_BODY: Final = """--- description: Pull the LiteLLM debug report (spend, request, response, error) for this Claude Code session @@ -118,13 +118,16 @@ _JSON: Final[TypeAdapter[JsonValue]] = TypeAdapter(JsonValue) _SESSION_PAGE_SIZE: Final = 100 _TRANSPORT_BODY_CHARS: Final = 500 +_SESSION_TRANSCRIPT_STEM: Final = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}") def detect_claude_session_id(env: Mapping[str, str], claude_dir: Path) -> str | None: explicit: Final = env.get(SESSION_ID_ENV) if explicit: return explicit - transcripts: Final = tuple(claude_dir.glob("projects/*/*.jsonl")) + transcripts: Final = tuple( + path for path in claude_dir.glob("projects/*/*.jsonl") if _SESSION_TRANSCRIPT_STEM.fullmatch(path.stem) + ) if not transcripts: return None newest: Final = max(transcripts, key=lambda p: p.stat().st_mtime) diff --git a/tests/test_litellm/proxy/client/cli/test_debug_commands.py b/tests/test_litellm/proxy/client/cli/test_debug_commands.py index 1853d0c3468..419fd821cfb 100644 --- a/tests/test_litellm/proxy/client/cli/test_debug_commands.py +++ b/tests/test_litellm/proxy/client/cli/test_debug_commands.py @@ -136,25 +136,33 @@ def test_no_rows_is_a_clear_error(): def test_no_session_id_anywhere_is_a_clear_error(monkeypatch): - monkeypatch.delenv("CLAUDE_SESSION_ID", raising=False) + monkeypatch.delenv("CLAUDE_CODE_SESSION_ID", raising=False) result = CliRunner().invoke(cli, ["debug", "claude"]) assert result.exit_code != 0 assert "Could not find a Claude Code session" in result.output -def test_detect_session_id_prefers_env_then_newest_transcript(tmp_path): +OLD_SESSION = "0f3c2b1a-1111-4222-8333-444455556666" +NEW_SESSION = "2d79c54d-4644-4708-b03e-95395ef9ecbd" + + +def test_detect_session_id_prefers_env_then_newest_session_transcript(tmp_path): project = tmp_path / "projects" / "-Users-me-repo" project.mkdir(parents=True) - old = project / "old-session.jsonl" - new = project / "new-session.jsonl" + old = project / f"{OLD_SESSION}.jsonl" + new = project / f"{NEW_SESSION}.jsonl" + subagent = project / "agent-a1b2c3d4.jsonl" old.write_text("{}") new.write_text("{}") + subagent.write_text("{}") now = time.time() os.utime(old, (now - 100, now - 100)) - os.utime(new, (now, now)) + os.utime(new, (now - 50, now - 50)) + os.utime(subagent, (now, now)) - assert detect_claude_session_id({}, tmp_path) == "new-session" - assert detect_claude_session_id({"CLAUDE_SESSION_ID": "from-env"}, tmp_path) == "from-env" + assert detect_claude_session_id({}, tmp_path) == NEW_SESSION + assert detect_claude_session_id({"CLAUDE_CODE_SESSION_ID": "from-env"}, tmp_path) == "from-env" + assert detect_claude_session_id({"CLAUDE_SESSION_ID": "stale-name"}, tmp_path) == NEW_SESSION assert detect_claude_session_id({}, tmp_path / "missing") is None From 1d375d8ada91eb6f6aeceb8af8bc649a031a5012 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 4 Sep 2026 19:46:14 -0700 Subject: [PATCH 182/410] fix(guardrails): flag unpriced units per team and key, sort unknown cost last The detail endpoint now returns untracked_usage_units_by_team and untracked_usage_units_by_key next to the cost breakdowns, and the By team and By key tables show them in an Unpriced Units column, so a row that pairs its total units with a partial cost says how many units that cost leaves out. The overview comparator no longer treats a missing cost as zero: guardrails with no known cost sort last in both directions instead of mixing in with genuinely free ones. Refs LIT-5652 --- litellm/proxy/_lazy_openapi_snapshot.json | 24 ++++++++++- litellm/proxy/guardrails/usage_endpoints.py | 20 ++++++++-- .../proxy/guardrails/test_usage_endpoints.py | 8 ++++ .../_components/GuardrailDetail.test.tsx | 2 + .../GuardrailUsageBreakdown.test.tsx | 11 ++++- .../_components/GuardrailUsageBreakdown.tsx | 40 ++++++++++++------- .../_components/GuardrailsOverview.test.tsx | 16 ++++++-- .../_components/GuardrailsOverview.tsx | 9 +++-- ui/litellm-dashboard/src/lib/http/schema.d.ts | 12 ++++++ 9 files changed, 114 insertions(+), 28 deletions(-) diff --git a/litellm/proxy/_lazy_openapi_snapshot.json b/litellm/proxy/_lazy_openapi_snapshot.json index c24eea968f8..ddf6a59bea7 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.json +++ b/litellm/proxy/_lazy_openapi_snapshot.json @@ -13218,6 +13218,26 @@ "title": "Untracked Usage Units", "type": "object" }, + "untracked_usage_units_by_key": { + "additionalProperties": { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + "title": "Untracked Usage Units By Key", + "type": "object" + }, + "untracked_usage_units_by_team": { + "additionalProperties": { + "additionalProperties": { + "type": "integer" + }, + "type": "object" + }, + "title": "Untracked Usage Units By Team", + "type": "object" + }, "usage_units": { "additionalProperties": { "type": "integer" @@ -13274,7 +13294,9 @@ "cost_by_unit", "cost_by_team", "cost_by_key", - "untracked_usage_units" + "untracked_usage_units", + "untracked_usage_units_by_team", + "untracked_usage_units_by_key" ], "title": "UsageDetailResponse", "type": "object" diff --git a/litellm/proxy/guardrails/usage_endpoints.py b/litellm/proxy/guardrails/usage_endpoints.py index 0390a2b5013..d3bd09f7d27 100644 --- a/litellm/proxy/guardrails/usage_endpoints.py +++ b/litellm/proxy/guardrails/usage_endpoints.py @@ -156,6 +156,14 @@ def _counter_name(row: "prisma_models.LiteLLM_DailyGuardrailUsageUnits") -> str: return row.usage_unit +def _team_of(row: "prisma_models.LiteLLM_DailyGuardrailUsageUnits") -> str: + return row.team_id + + +def _key_of(row: "prisma_models.LiteLLM_DailyGuardrailUsageUnits") -> str: + return row.api_key + + def _row_untracked_units(row: "prisma_models.LiteLLM_DailyGuardrailUsageUnits") -> int: """A row written before the cost column carries NULL cost and is untracked in full.""" return int(row.units) if row.cost is None else int(row.untracked_units) @@ -308,6 +316,8 @@ class UsageDetailResponse(BaseModel): cost_by_team: Mapping[str, float | None] cost_by_key: Mapping[str, float | None] untracked_usage_units: Mapping[str, int] + untracked_usage_units_by_team: Mapping[str, Mapping[str, int]] + untracked_usage_units_by_key: Mapping[str, Mapping[str, int]] class UsageLogEntry(BaseModel): @@ -705,13 +715,15 @@ async def guardrails_usage_detail( time_series=time_series, usage_units=_sum_counter_units(units_rows), usage_units_daily=units_daily, - usage_units_by_team=_by(units_rows, lambda r: r.team_id, _sum_counter_units), - usage_units_by_key=_by(units_rows, lambda r: r.api_key, _sum_counter_units), + usage_units_by_team=_by(units_rows, _team_of, _sum_counter_units), + usage_units_by_key=_by(units_rows, _key_of, _sum_counter_units), cost=_sum_tracked_cost(units_rows), cost_by_unit=_by(units_rows, _counter_name, _sum_tracked_cost), - cost_by_team=_by(units_rows, lambda r: r.team_id, _sum_tracked_cost), - cost_by_key=_by(units_rows, lambda r: r.api_key, _sum_tracked_cost), + cost_by_team=_by(units_rows, _team_of, _sum_tracked_cost), + cost_by_key=_by(units_rows, _key_of, _sum_tracked_cost), untracked_usage_units=_sum_untracked_units(units_rows), + untracked_usage_units_by_team=_by(units_rows, _team_of, _sum_untracked_units), + untracked_usage_units_by_key=_by(units_rows, _key_of, _sum_untracked_units), ) diff --git a/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py b/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py index ebb2be6edc2..1ff33c76035 100644 --- a/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py @@ -430,6 +430,13 @@ async def test_detail_breaks_cost_down_by_unit_day_team_and_key(): assert resp.cost_by_team.keys() == resp.usage_units_by_team.keys() assert resp.cost_by_key.keys() == resp.usage_units_by_key.keys() assert resp.untracked_usage_units == {"contentPolicyUnits": 50, "topicPolicyUnits": 10} + assert resp.untracked_usage_units_by_team == {"team-a": {"topicPolicyUnits": 10}, "": {"contentPolicyUnits": 50}} + assert resp.untracked_usage_units_by_key == { + "hash-1": {"topicPolicyUnits": 10}, + "hash-2": {"contentPolicyUnits": 50}, + } + assert resp.untracked_usage_units_by_team.keys() == resp.usage_units_by_team.keys() + assert resp.untracked_usage_units_by_key.keys() == resp.usage_units_by_key.keys() @pytest.mark.asyncio @@ -451,6 +458,7 @@ async def test_detail_degrades_units_to_empty_when_units_table_is_missing(): ) assert (resp.cost, resp.cost_by_unit, resp.cost_by_team, resp.cost_by_key) == (None, {}, {}, {}) assert resp.untracked_usage_units == {} + assert (resp.untracked_usage_units_by_team, resp.untracked_usage_units_by_key) == ({}, {}) # ---- logs ------------------------------------------------------------------- diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx index bbcb8138d52..3d00e29245d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx @@ -45,6 +45,8 @@ const detail: GuardrailUsageDetail = { cost_by_team: { "": 0.0004 }, cost_by_key: { "hash-1": 0.0004 }, untracked_usage_units: {}, + untracked_usage_units_by_team: {}, + untracked_usage_units_by_key: {}, }; const loaded = (data: GuardrailUsageDetail | undefined) => ({ data, isLoading: false, error: null }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx index 3b24185f0b6..7929b97b000 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx @@ -31,6 +31,8 @@ const detail: GuardrailUsageDetail = { cost_by_team: { "team-a": 0.165, "": 0.015 }, cost_by_key: { "hash-1": 0.18, "hash-2": null }, untracked_usage_units: { someFutureCounter: 7 }, + untracked_usage_units_by_team: { "team-a": {}, "": { someFutureCounter: 7 } }, + untracked_usage_units_by_key: { "hash-1": {}, "hash-2": { someFutureCounter: 7 } }, }; const rowNamed = (name: string) => screen.getByRole("row", { name: new RegExp(name) }); @@ -61,7 +63,7 @@ describe("GuardrailUsageBreakdown", () => { expect(within(future).getByText("—")).toBeInTheDocument(); }); - it("breaks units and cost down by team and by key, naming the rows without one", () => { + it("breaks units and cost down by team and by key, flagging the unpriced share of each row", () => { render(); expect(screen.getByRole("heading", { name: "By team" })).toBeInTheDocument(); @@ -69,14 +71,17 @@ describe("GuardrailUsageBreakdown", () => { const teamA = rowNamed("team-a"); expect(within(teamA).getByText("1,200")).toBeInTheDocument(); expect(within(teamA).getByText("$0.1650")).toBeInTheDocument(); + expect(within(teamA).getByText("—")).toBeInTheDocument(); + expect(within(teamA).queryByText("7")).not.toBeInTheDocument(); const noTeam = rowNamed("No team"); expect(within(noTeam).getByText("107")).toBeInTheDocument(); expect(within(noTeam).getByText("$0.0150")).toBeInTheDocument(); + expect(within(noTeam).getByText("7", { selector: ".text-warning" })).toBeInTheDocument(); const unpricedKey = rowNamed("hash-2"); - expect(within(unpricedKey).getByText("7")).toBeInTheDocument(); expect(within(unpricedKey).getByText("—")).toBeInTheDocument(); + expect(within(unpricedKey).getByText("7", { selector: ".text-warning" })).toBeInTheDocument(); }); it("orders teams and keys by units, largest first", () => { @@ -104,6 +109,8 @@ describe("GuardrailUsageBreakdown", () => { cost_by_team: {}, cost_by_key: {}, untracked_usage_units: {}, + untracked_usage_units_by_team: {}, + untracked_usage_units_by_key: {}, }} />, ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx index 1eaab86c506..6e8b725aa2e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx @@ -19,6 +19,7 @@ interface GroupRow { id: string; units: number; cost: number | null; + unpriced: number; } const counterRows = (detail: GuardrailUsageDetail): CounterRow[] => @@ -32,11 +33,31 @@ const counterRows = (detail: GuardrailUsageDetail): CounterRow[] => const groupRows = ( unitsByGroup: GuardrailUsageDetail["usage_units_by_team"], costByGroup: GuardrailUsageDetail["cost_by_team"], + untrackedByGroup: GuardrailUsageDetail["untracked_usage_units_by_team"], ): GroupRow[] => Object.entries(unitsByGroup) - .map(([id, units]) => ({ id, units: totalUnits(units), cost: costByGroup[id] ?? null })) + .map(([id, units]) => ({ + id, + units: totalUnits(units), + cost: costByGroup[id] ?? null, + unpriced: totalUnits(untrackedByGroup[id] ?? {}), + })) .sort((a, b) => b.units - a.units); +const UnpricedUnitsCell = ({ unpriced }: { unpriced: number }) => + unpriced > 0 ? ( + {unpriced.toLocaleString()} + ) : ( + + ); + +const unpricedColumn = (): ColumnDef => ({ + header: "Unpriced Units", + accessorKey: "unpriced", + meta: { numeric: true }, + cell: ({ row }) => , +}); + const counterColumns: ColumnDef[] = [ { header: "Counter", accessorKey: "counter", cell: ({ row }) => counterLabel(row.original.counter) }, { @@ -51,17 +72,7 @@ const counterColumns: ColumnDef[] = [ meta: { numeric: true }, cell: ({ row }) => , }, - { - header: "Unpriced Units", - accessorKey: "unpriced", - meta: { numeric: true }, - cell: ({ row }) => - row.original.unpriced > 0 ? ( - {row.original.unpriced.toLocaleString()} - ) : ( - - ), - }, + unpricedColumn(), ]; const groupColumns = (label: string, emptyLabel: string): ColumnDef[] => [ @@ -87,6 +98,7 @@ const groupColumns = (label: string, emptyLabel: string): ColumnDef[] meta: { numeric: true }, cell: ({ row }) => , }, + unpricedColumn(), ]; const teamColumns = groupColumns("Team", "No team"); @@ -139,14 +151,14 @@ export function GuardrailUsageBreakdown({ detail }: { detail: GuardrailUsageDeta
    row.id || "no-team"} size="compact" toolbar={() => } /> row.id || "no-key"} size="compact" toolbar={() => } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx index 3a56667b156..c52645def70 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx @@ -160,14 +160,24 @@ describe("GuardrailsOverview", () => { expect(screen.getByText("Sensitive Information Policy: 250")).toBeInTheDocument(); }); - it("sorts by cost when its header is clicked", async () => { + it("sorts by cost when its header is clicked, keeping guardrails with no known cost last either way", async () => { const user = userEvent.setup(); renderOverview(); + const rowNames = () => + screen + .getAllByRole("row") + .slice(1) + .map((r) => r.textContent ?? ""); await user.click(await screen.findByRole("button", { name: /Cost/ })); + await waitFor(() => expect(rowNames()[0]).toContain("Free Bedrock Guardrail")); + expect(rowNames()[1]).toContain("High Failure Guardrail"); + expect(rowNames()[2]).toContain("Low Failure Guardrail"); - await waitFor(() => expect(screen.getAllByRole("row")[1]).toHaveTextContent("Low Failure Guardrail")); - expect(screen.getAllByRole("row")[3]).toHaveTextContent("High Failure Guardrail"); + await user.click(screen.getByRole("button", { name: /Cost/ })); + await waitFor(() => expect(rowNames()[0]).toContain("High Failure Guardrail")); + expect(rowNames()[1]).toContain("Free Bedrock Guardrail"); + expect(rowNames()[2]).toContain("Low Failure Guardrail"); }); it("renders the page header and the export action", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx index 67630fef13a..0bbda6015b4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx @@ -112,11 +112,12 @@ export function GuardrailsOverview({ }, [guardrailsData, activeData]); const chartData = guardrailsData?.chart; const sorted = useMemo(() => { + const mult = sortDir === "desc" ? -1 : 1; return [...activeData].sort((a, b) => { - const mult = sortDir === "desc" ? -1 : 1; - const aVal = a[sortBy] ?? 0; - const bVal = b[sortBy] ?? 0; - return (Number(aVal) - Number(bVal)) * mult; + const aVal = a[sortBy]; + const bVal = b[sortBy]; + if (aVal == null || bVal == null) return Number(aVal == null) - Number(bVal == null); + return (aVal - bVal) * mult; }); }, [activeData, sortBy, sortDir]); const isLoading = guardrailsLoading; diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 427e5deb555..c1f65299c52 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -38461,6 +38461,18 @@ export interface components { untracked_usage_units: { [key: string]: number; }; + /** Untracked Usage Units By Key */ + untracked_usage_units_by_key: { + [key: string]: { + [key: string]: number; + }; + }; + /** Untracked Usage Units By Team */ + untracked_usage_units_by_team: { + [key: string]: { + [key: string]: number; + }; + }; /** Usage Units */ usage_units: { [key: string]: number; From c27f1e348dd2f6191177e4b1016388bce1b161f3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:47:31 -0700 Subject: [PATCH 183/410] fix(ui): name the object arguments at two new call sites to bring the inline-object lint budget back under its ceiling --- ui/litellm-dashboard/eslint-budgets.json | 2 +- .../src/components/add_model/ClassificationMethodConfig.tsx | 5 +++-- .../add_model/build_complexity_router_config.test.ts | 5 +++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/ui/litellm-dashboard/eslint-budgets.json b/ui/litellm-dashboard/eslint-budgets.json index 3f9163d9028..b3c77e287fc 100644 --- a/ui/litellm-dashboard/eslint-budgets.json +++ b/ui/litellm-dashboard/eslint-budgets.json @@ -3,7 +3,7 @@ "no-console": { "max": 12, "target": 0 }, "complexity": { "max": 140, "target": 80 }, "max-depth": { "max": 70, "target": 30 }, - "local/no-large-inline-object-arg": { "max": 555, "target": 300 }, + "local/no-large-inline-object-arg": { "max": 554, "target": 300 }, "local/no-long-condition-chain": { "max": 265, "target": 120 }, "testing-library/no-container": { "max": 133, "target": 50 }, "testing-library/no-node-access": { "max": 716, "target": 500 }, diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index cc66103fc86..188ef7f8cb5 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -315,12 +315,13 @@ const ClassificationMethodConfig: React.FC = ({ timeout_ms: value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS, classification_rubric: selectedRubric, }; - onChange({ + const nextValue: ComplexityRouterConfigValue = { ...value, ...(selectedRubric && { classifier_llm_config: rubricConfig }), classification_prompt: classificationPrompt, classification_examples: classificationExamples, - }); + }; + onChange(nextValue); }; const handleClassifierModelChange = (model: string) => { diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts index 1b5bb9e72eb..81f05a94a61 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts @@ -1170,12 +1170,13 @@ describe("buildComplexityRouterConfig stall escalation", () => { }); it("emits the toggle and both knobs when it is on", () => { - const config = buildComplexityRouterConfig({ + const params: BuildComplexityRouterConfigParams = { ...baseParams, stallEscalationEnabled: true, stallEscalationWindow: 8, stallEscalationRepeatThreshold: 4, - }); + }; + const config = buildComplexityRouterConfig(params); expect(config.stall_escalation_enabled).toBe(true); expect(config.stall_escalation_window).toBe(8); expect(config.stall_escalation_repeat_threshold).toBe(4); From da9dbdba961ce2981f9d82669ab9e3eb3a9d90a0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:52:48 -0700 Subject: [PATCH 184/410] fix(realtime): treat any client receive failure as a client hangup client_ack_messages classified a websockets ConnectionClosed raised by the client socket as the backend closing, so bidirectional_forward kept waiting on the upstream instead of ending the session. Starlette clients raise WebSocketDisconnect, but the realtime test client in tests/llm_translation/realtime raises websockets.exceptions.ConnectionClosed, which hung test_openai_realtime_simple.py until the run was killed. Only the receive_text call now maps every exception to CLIENT_DISCONNECTED; the loop body keeps ConnectionClosed as BACKEND_CLOSED, since the backend socket is the only websockets socket touched there. --- litellm/litellm_core_utils/realtime_streaming.py | 11 ++++++++++- .../litellm_core_utils/test_realtime_streaming.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index 530391c7b57..bb7fbd81146 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -1297,13 +1297,22 @@ class RealTimeStreaming: item["content"] = new_content return item + async def _receive_client_message(self) -> str | None: + try: + return await self.websocket.receive_text() + except Exception as e: # noqa: BLE001 # whatever the client socket raises, the client is gone + verbose_logger.debug("Client disconnected: %s", e) + return None + async def client_ack_messages(self) -> ClientLoopExit: import websockets client_event: _ClientEventFrame try: while True: - message = await self.websocket.receive_text() + message = await self._receive_client_message() + if message is None: + return ClientLoopExit.CLIENT_DISCONNECTED ## GUARDRAIL: intercept conversation.item.create for text-based injection. guardrail_turn_detection_injected = False diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py index 41b7557f6b2..00addb613c2 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py @@ -3307,3 +3307,18 @@ async def test_client_hanging_up_first_ends_the_session_without_a_relayed_close( assert session.logging.logged_sessions == ((),) assert session.logging.logged_failures == () client_ws.close.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_client_hanging_up_with_a_websockets_close_is_not_mistaken_for_the_backend_closing(): + client_ws: Final = _client_ws_that_never_sends() + client_ws.receive_text = AsyncMock(side_effect=ConnectionClosed(None, None)) + backend_ws: Final = MagicMock() + backend_ws.recv = AsyncMock(side_effect=_wait_forever) + session: Final = _relay_session(client_ws, backend_ws) + + await session.run() + + assert session.logging.logged_sessions == ((),) + assert session.logging.logged_failures == () + client_ws.close.assert_not_awaited() From 41c8969f0aee5769f5feded4bc5e7ff8723db469 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:54:40 -0700 Subject: [PATCH 185/410] test(router): drive tag routing tests through acompletion until both deployments are seen --- .../test_router_tag_routing.py | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/tests/test_litellm/router_strategy/test_router_tag_routing.py b/tests/test_litellm/router_strategy/test_router_tag_routing.py index 27f871ed39f..59a59c7e16d 100644 --- a/tests/test_litellm/router_strategy/test_router_tag_routing.py +++ b/tests/test_litellm/router_strategy/test_router_tag_routing.py @@ -2,25 +2,25 @@ # This tests litellm router -import pytest - import logging from typing import Final +import pytest import litellm from litellm._logging import verbose_logger -from litellm.router_strategy.tag_based_routing import get_deployments_for_tag -async def _eligible_deployment_ids(router: litellm.Router, model: str, tags: list[str]) -> set[str]: - eligible: Final = await get_deployments_for_tag( - llm_router_instance=router, - model=model, - healthy_deployments=router.get_model_list(model_name=model) or [], - request_kwargs={"metadata": {"tags": tags}}, +async def _routed_model_ids( + router: litellm.Router, tags: list[str], remaining: frozenset[str], attempts: int = 100 +) -> frozenset[str]: + if not remaining or attempts == 0: + return frozenset() + response: Final = await router.acompletion( + model="gpt-4", messages=[{"role": "user", "content": "hi"}], metadata={"tags": tags}, mock_response="hi" ) - return {deployment["model_info"]["id"] for deployment in eligible} + seen: Final = frozenset({response._hidden_params["model_id"]}) + return seen | await _routed_model_ids(router, tags, remaining - seen, attempts - 1) @pytest.mark.asyncio() @@ -862,9 +862,10 @@ async def test_negation_regex_pattern_treated_as_literal(): # The regex-like string matches no deployment tag literally, so all # candidates survive and both model IDs are reachable. - eligible_ids: Final = await _eligible_deployment_ids(router, "gpt-4", ["!provider:(anthropic|openai)"]) + expected: Final = frozenset({"anthropic-model", "openai-model"}) + routed_ids: Final = await _routed_model_ids(router, ["!provider:(anthropic|openai)"], expected) - assert eligible_ids == {"anthropic-model", "openai-model"} + assert routed_ids == expected @pytest.mark.asyncio() @@ -1285,9 +1286,10 @@ async def test_chain_enable_tag_filtering_false_overrides_router_level_true(): enable_tag_filtering=True, ) - eligible_ids: Final = await _eligible_deployment_ids(router, "gpt-4", ["teamA"]) + expected: Final = frozenset({"team-a-deployment", "team-b-deployment"}) + routed_ids: Final = await _routed_model_ids(router, ["teamA"], expected) - assert eligible_ids == {"team-a-deployment", "team-b-deployment"} + assert routed_ids == expected @pytest.mark.asyncio() From f73e6838000d7bca1af751fcbcf83fc59d663a9b Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Fri, 4 Sep 2026 20:26:13 -0700 Subject: [PATCH 186/410] chore(ui): drop the fetch lookup note from the fetchClient docblock --- ui/litellm-dashboard/src/lib/http/api.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts index 904e4e4f3ab..9aa6bddf704 100644 --- a/ui/litellm-dashboard/src/lib/http/api.ts +++ b/ui/litellm-dashboard/src/lib/http/api.ts @@ -43,10 +43,9 @@ const middleware: Middleware = { * * The base URL is injected, not fixed at import: every request is built against * whatever registerBaseUrlGetter supplies at call time (a split-origin proxy or - * worker URL), falling back to the current origin. `fetch` is looked up per - * request for the same reason, so a test that stubs the global sees these calls - * too. The middleware injects the auth header and maps non-2xx responses to - * ApiError so query functions can just read `.data`. + * worker URL), falling back to the current origin. The middleware injects the + * auth header and maps non-2xx responses to ApiError so query functions can just + * read `.data`. */ export const fetchClient = createFetchClient({ Request: BaseAwareRequest, From 6385c7b3c53617fb480f5c8875a9b45538174ddf Mon Sep 17 00:00:00 2001 From: moe-berri Date: Fri, 4 Sep 2026 20:28:17 -0700 Subject: [PATCH 187/410] fix(auto-router compression): close three review findings on the per-hop policy Suppression state moves out of request metadata into a request-scoped ContextVar. refresh_proxy_server_request_body_snapshot copies metadata into proxy_server_request.body, which deployments persist to spend logs, so the marker naming each suppressed guardrail was readable by the caller whose request produced it. Recovering it was enough to replay {token}:{name} for any CustomGuardrail and switch off a PII or content-filter guardrail, since the check never verified the named guardrail was a compression one. Nothing is read from metadata now, so there is no marker to forge and the per-process token is no longer needed. Routing-side compression reads the live messages instead of a pre-guardrail copy. arm_pre_call runs before the pre-call hook, so its snapshot held the prompt as it was before any masking guardrail rewrote it, and messages_for_routing handed that to a compression guardrail which POSTs it to an external service. Masked content left the proxy anyway. The cost is one combination: when the model hop compressed and the hops differ, routing now classifies on the compressed text, since no uncompressed copy survives that a masking guardrail has already seen. policy_for_model no longer falls back to a marker scoped to tags the request does not carry, which applied an 'eu' policy to a 'us' request on config order alone. Each fix carries a regression test; all three fail when the fix is reverted. --- litellm/constants.py | 1 - litellm/integrations/custom_guardrail.py | 36 +- .../guardrails/auto_router_compression.py | 101 +++--- .../integrations/test_custom_guardrail.py | 335 +++++------------- .../test_auto_router_compression.py | 147 ++++---- tests/test_litellm/test_router.py | 25 +- 6 files changed, 236 insertions(+), 409 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index 25fdaec20de..43fefaae048 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -217,7 +217,6 @@ PRE_CALL_EXECUTED_GUARDRAILS_KEY: Final = "_pre_call_executed_guardrails" # Metadata key listing compression guardrails an auto router's own compression # policy suppresses for this request. See litellm.proxy.guardrails.auto_router_compression. -AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: Final = "_auto_router_suppressed_compression_guardrails" # Generic fallback for unknown models DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET: Final = int( diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 1ec08641706..f511d128dfc 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -45,7 +45,6 @@ dc: Final = DualCache() from litellm.constants import ( - AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY, GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS, PRE_CALL_EXECUTED_GUARDRAILS_KEY, ) @@ -941,33 +940,22 @@ class CustomGuardrail(CustomLogger): """ return False - def auto_router_suppression_marker(self) -> str | None: - """The value `arm_pre_call` must write to suppress this guardrail. + def _suppressed_by_auto_router_compression(self) -> bool: + """True when an auto router's own compression policy suppresses this guardrail. - Carries the per-process token for the same reason `_pre_call_marker` does: a - caller controls request metadata, so a bare guardrail name there would let any - request switch off a PII, content-filter, or compression guardrail for itself. - The token is never sent to the caller, so the marker cannot be forged. + Reads request-scoped state set by `arm_pre_call`, never request metadata. The + caller controls metadata, and metadata reaches spend logs the caller can read, + so a suppression list carried there would be one a request could replay to + switch off a PII or content-filter guardrail for itself. """ name: Final = self.guardrail_name if not name: - return None - return f"{_PRE_CALL_EXECUTED_TOKEN}:{name}" - - def _suppressed_by_auto_router_compression(self, data: Mapping[str, object]) -> bool: - """True when an auto router's own compression policy suppresses this guardrail.""" - marker: Final = self.auto_router_suppression_marker() - if marker is None: return False - for meta_key in ("metadata", "litellm_metadata"): - meta = data.get(meta_key) - if isinstance(meta, Mapping): - # arm_pre_call writes a tuple; it arrives as a list once the metadata - # has been round-tripped through JSON. - suppressed = meta.get(AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY) - if isinstance(suppressed, (list, tuple)) and marker in suppressed: - return True - return False + from litellm.proxy.guardrails.auto_router_compression import ( + suppressed_compression_guardrails, + ) + + return name in suppressed_compression_guardrails() def should_run_guardrail( self, @@ -977,7 +965,7 @@ class CustomGuardrail(CustomLogger): """ Returns True if the guardrail should be run on the event_type """ - if self._suppressed_by_auto_router_compression(data): + if self._suppressed_by_auto_router_compression(): return False requested_guardrails: Final = self.get_guardrail_from_metadata(data) diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py index 3b0804e40e2..2a4a0c82022 100644 --- a/litellm/proxy/guardrails/auto_router_compression.py +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -15,11 +15,9 @@ each hop sees. import contextvars from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass -from types import MappingProxyType from typing import TYPE_CHECKING, Final from litellm._logging import verbose_proxy_logger -from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY from litellm.litellm_core_utils.core_helpers import get_or_create_metadata_bucket from litellm.router_utils.auto_router_model_naming import AUTO_ROUTER_MODEL_PREFIX from litellm.types.utils import GenericGuardrailAPIInputs @@ -31,16 +29,22 @@ if TYPE_CHECKING: COMPRESSION_GUARDRAIL_PROVIDERS: Final = frozenset({"headroom", "compresr"}) _NO_COMPRESSION: Final = "none" -# The pre-compression messages, so a routing decision that does not share the model -# call's compression still classifies on the original text. Deliberately a ContextVar -# rather than a metadata key: `refresh_proxy_server_request_body_snapshot` copies -# metadata into `proxy_server_request.body`, which deployments persist to spend logs, -# and this holds the prompt as it was before any masking guardrail rewrote it. -_routing_messages_snapshot: Final[contextvars.ContextVar[tuple[Mapping[str, object], ...] | None]] = ( - contextvars.ContextVar("litellm_auto_router_routing_messages_snapshot", default=None) +# Compression guardrails this request's auto router has switched off. Deliberately a +# ContextVar rather than a metadata key: `refresh_proxy_server_request_body_snapshot` +# copies metadata into `proxy_server_request.body`, which deployments persist to spend +# logs. A suppression list that reaches a log the caller can read is a list the caller +# can replay, which would let any request switch off a PII or content-filter guardrail. +# Nothing here is caller-supplied, so there is no marker to forge in the first place. +_suppressed_compression_guardrails: Final[contextvars.ContextVar[frozenset[str]]] = contextvars.ContextVar( + "litellm_auto_router_suppressed_compression_guardrails", default=frozenset() ) +def suppressed_compression_guardrails() -> frozenset[str]: + """Names of the compression guardrails this request's auto router suppresses.""" + return _suppressed_compression_guardrails.get() + + @dataclass(frozen=True, slots=True) class AutoRouterCompressionPolicy: """An auto router's compression choice for each hop. ``None`` means no compression.""" @@ -96,7 +100,11 @@ def policy_for_model( tag_matched: Final = tuple( params for params in markers if (tags := params.get("tags")) and requested.issuperset(frozenset(tags)) ) - for params in (*tag_matched, *markers): + # Only untagged markers may serve as the fallback. A marker scoped to tags this + # request does not carry describes a different slice of traffic, so falling back + # to it would apply, say, an "eu" policy to a "us" request purely on config order. + untagged: Final = tuple(params for params in markers if not params.get("tags")) + for params in (*tag_matched, *untagged): policy = policy_from_litellm_params(params) if policy is not None: return policy @@ -135,12 +143,10 @@ async def arm_pre_call( ) -> None: """Apply an auto router's compression policy, if any, before guardrails run. - Suppresses every other compression guardrail, re-enables the model-side - guardrail the policy names (if any) even when it isn't ``default_on``, and - snapshots the pre-compression messages so the routing decision can read them - independently of whatever the model-side guardrail does to `data`. + Suppresses every other compression guardrail and re-enables the model-side + guardrail the policy names (if any) even when it isn't ``default_on``. """ - _routing_messages_snapshot.set(None) + _suppressed_compression_guardrails.set(frozenset()) if llm_router is None: return @@ -162,18 +168,16 @@ async def arm_pre_call( if policy is None: return - _, metadata = get_or_create_metadata_bucket(data) - # Markers carry a per-process token so a caller cannot suppress a guardrail by - # naming it in its own request metadata. - suppressed: Final = tuple( - marker - for guardrail in _active_compression_guardrails() - if guardrail.guardrail_name != policy.model and (marker := guardrail.auto_router_suppression_marker()) + _suppressed_compression_guardrails.set( + frozenset( + name + for guardrail in _active_compression_guardrails() + if (name := guardrail.guardrail_name) and name != policy.model + ) ) - if suppressed: - metadata[AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] = suppressed if policy.model is not None: + _, metadata = get_or_create_metadata_bucket(data) requested: Final = metadata.get("guardrails") existing: Final = tuple(requested) if isinstance(requested, (list, tuple)) else () if policy.model not in existing: @@ -181,20 +185,6 @@ async def arm_pre_call( # isinstance(..., list) and extends it, and would drop a tuple on the floor. metadata["guardrails"] = [*existing, policy.model] # mutable-ok: this key's contract is a list - from litellm.litellm_core_utils.prompt_templates.factory import resolve_structured_messages - - raw_messages: Final = data.get("messages") - snapshot: Final = resolve_structured_messages( - messages=raw_messages if isinstance(raw_messages, list) else None, - request_kwargs=data, - ) - if snapshot is not None: - _routing_messages_snapshot.set(tuple(MappingProxyType(dict(message)) for message in snapshot)) - - -def _snapshot_messages() -> tuple[Mapping[str, object], ...] | None: - return _routing_messages_snapshot.get() - def _as_routing_messages( messages: Iterable[Mapping[str, object]], @@ -213,23 +203,22 @@ async def messages_for_routing( """Messages to use for a routing decision, per `policy.routing`. Returns None when the caller should route on whatever messages it already has. - The model call is untouched either way: model-side compression, if any, already - ran as an ordinary pre-call guardrail before the router was reached, so when the - two hops differ the routing decision reads the pre-compression snapshot rather - than what that guardrail left behind. + + Always reads the live messages, never a pre-guardrail copy of them. The routing + hop compresses through a real guardrail, which POSTs the text to an external + compression service, so it must see what every other guardrail has already done + to the request. Routing on a snapshot taken before the pre-call hook would send + a masking guardrail's own input straight back out of the proxy. + + The consequence, when the model hop compressed and the two hops differ: the + messages in hand are that guardrail's output, and there is no un-compressed copy + left to route on. The routing decision reads the compressed text in that one + combination rather than leaking the original. """ - if policy is None: + if policy is None or policy.routing is None: return None - snapshot: Final = _snapshot_messages() - original: Final = snapshot if snapshot is not None else messages - - if policy.routing is None: - # Explicitly no compression for routing. When the model side compressed, the - # messages in hand are its output, so fall back to the untouched snapshot. - return _as_routing_messages(snapshot) if policy.model is not None and snapshot is not None else None - - if not original: + if not messages: return None from litellm.proxy.common_utils.registry_read_through import ( @@ -241,20 +230,20 @@ async def messages_for_routing( verbose_proxy_logger.warning( "AutoRouter compression: guardrail '%s' not found; routing on uncompressed messages", policy.routing ) - return _as_routing_messages(original) + return _as_routing_messages(messages) inputs: Final[GenericGuardrailAPIInputs] = { - "structured_messages": _as_routing_messages(original) # pyright: ignore[reportAssignmentType] # plain dicts, not AllMessageValues; see headroom.py's own use of this shape + "structured_messages": _as_routing_messages(messages) # pyright: ignore[reportAssignmentType] # plain dicts, not AllMessageValues; see headroom.py's own use of this shape } model: Final = request_kwargs.get("model") # A throwaway request_data: apply_guardrail writes its stats onto this dict, not the # real request's metadata, so routing-side compression never double-counts against # extract_compression_saved_tokens's model-savings accounting. - stats_sink: Final = {"messages": original, "model": model} # mutable-ok: apply_guardrail writes its stats here + stats_sink: Final = {"messages": messages, "model": model} # mutable-ok: apply_guardrail writes its stats here result: Final = await guardrail.apply_guardrail( inputs=inputs, request_data=stats_sink, input_type="request", ) compressed: Final = result.get("structured_messages") - return compressed if isinstance(compressed, list) else _as_routing_messages(original) + return compressed if isinstance(compressed, list) else _as_routing_messages(messages) diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index f590903cb74..c4a702d453c 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -13,7 +13,6 @@ from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailTracingDetai class TestCustomGuardrailDeploymentHook: - @pytest.mark.asyncio async def test_async_pre_call_deployment_hook_no_guardrails(self): """Test that method returns kwargs unchanged when no guardrails are present""" @@ -26,18 +25,14 @@ class TestCustomGuardrailDeploymentHook: "guardrails": None, } - result = await custom_guardrail.async_pre_call_deployment_hook( - kwargs=kwargs, call_type=CallTypes.completion - ) + result = await custom_guardrail.async_pre_call_deployment_hook(kwargs=kwargs, call_type=CallTypes.completion) assert result == kwargs # Test with guardrails as non-list kwargs["guardrails"] = "not_a_list" - result = await custom_guardrail.async_pre_call_deployment_hook( - kwargs=kwargs, call_type=CallTypes.completion - ) + result = await custom_guardrail.async_pre_call_deployment_hook(kwargs=kwargs, call_type=CallTypes.completion) assert result == kwargs @@ -64,9 +59,7 @@ class TestCustomGuardrailDeploymentHook: "user_api_key_request_route": "test_route", } - result = await custom_guardrail.async_pre_call_deployment_hook( - kwargs=kwargs, call_type=CallTypes.completion - ) + result = await custom_guardrail.async_pre_call_deployment_hook(kwargs=kwargs, call_type=CallTypes.completion) # Verify async_pre_call_hook was called with correct parameters custom_guardrail.async_pre_call_hook.assert_called_once() @@ -99,9 +92,7 @@ class TestCustomGuardrailDeploymentHook: super().__init__(guardrail_name="g1", default_on=True) self.pre_call_count = 0 - async def async_pre_call_hook( - self, user_api_key_dict, cache, data, call_type - ): + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): self.pre_call_count += 1 return data @@ -114,9 +105,7 @@ class TestCustomGuardrailDeploymentHook: } guardrail.mark_pre_call_hook_ran(kwargs) - await guardrail.async_pre_call_deployment_hook( - kwargs=kwargs, call_type=CallTypes.completion - ) + await guardrail.async_pre_call_deployment_hook(kwargs=kwargs, call_type=CallTypes.completion) assert guardrail.pre_call_count == 0 @@ -130,9 +119,7 @@ class TestCustomGuardrailDeploymentHook: super().__init__(guardrail_name="g1", default_on=True) self.pre_call_count = 0 - async def async_pre_call_hook( - self, user_api_key_dict, cache, data, call_type - ): + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): self.pre_call_count += 1 return data @@ -144,9 +131,7 @@ class TestCustomGuardrailDeploymentHook: "metadata": {}, } - await guardrail.async_pre_call_deployment_hook( - kwargs=kwargs, call_type=CallTypes.completion - ) + await guardrail.async_pre_call_deployment_hook(kwargs=kwargs, call_type=CallTypes.completion) assert guardrail.pre_call_count == 1 @@ -175,9 +160,7 @@ class TestCustomGuardrailDeploymentHook: super().__init__(guardrail_name="g1", default_on=True) self.pre_call_count = 0 - async def async_pre_call_hook( - self, user_api_key_dict, cache, data, call_type - ): + async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): self.pre_call_count += 1 return data @@ -189,15 +172,12 @@ class TestCustomGuardrailDeploymentHook: "metadata": {PRE_CALL_EXECUTED_GUARDRAILS_KEY: ["g1"]}, } - await guardrail.async_pre_call_deployment_hook( - kwargs=kwargs, call_type=CallTypes.completion - ) + await guardrail.async_pre_call_deployment_hook(kwargs=kwargs, call_type=CallTypes.completion) assert guardrail.pre_call_count == 1 class TestCustomGuardrailShouldRunGuardrail: - def test_should_run_guardrail_with_litellm_metadata(self): """Test that should_run_guardrail works with litellm_metadata pattern""" from litellm.types.guardrails import GuardrailEventHooks @@ -214,9 +194,7 @@ class TestCustomGuardrailShouldRunGuardrail: "litellm_metadata": {"guardrails": ["test_guardrail"]}, } - result = custom_guardrail.should_run_guardrail( - data=data, event_type=GuardrailEventHooks.pre_call - ) + result = custom_guardrail.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) assert result is True @@ -236,9 +214,7 @@ class TestCustomGuardrailShouldRunGuardrail: "metadata": {"guardrails": ["test_guardrail"]}, } - result = custom_guardrail.should_run_guardrail( - data=data, event_type=GuardrailEventHooks.pre_call - ) + result = custom_guardrail.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) assert result is True @@ -255,9 +231,7 @@ class TestCustomGuardrailShouldRunGuardrail: # Test with guardrails at root level data = {"model": "gpt-3.5-turbo", "guardrails": ["test_guardrail"]} - result = custom_guardrail.should_run_guardrail( - data=data, event_type=GuardrailEventHooks.pre_call - ) + result = custom_guardrail.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) assert result is True @@ -277,9 +251,7 @@ class TestCustomGuardrailShouldRunGuardrail: "litellm_metadata": {"guardrails": ["different_guardrail"]}, } - result = custom_guardrail.should_run_guardrail( - data=data, event_type=GuardrailEventHooks.pre_call - ) + result = custom_guardrail.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) assert result is False @@ -298,9 +270,7 @@ class TestCustomGuardrailShouldRunGuardrail: "model": "gpt-3.5-turbo", "messages": [{"role": "user", "content": "test"}], } - result = custom_guardrail.should_run_guardrail( - data=data, event_type=GuardrailEventHooks.pre_call - ) + result = custom_guardrail.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) assert result is True, "Global guardrail should run when default_on=True" # Test 2: User-injected disable at root level is IGNORED @@ -312,9 +282,7 @@ class TestCustomGuardrailShouldRunGuardrail: result = custom_guardrail.should_run_guardrail( data=data_with_disable_root, event_type=GuardrailEventHooks.pre_call ) - assert ( - result is True - ), "User-injected disable_global_guardrails should be ignored" + assert result is True, "User-injected disable_global_guardrails should be ignored" # Test 3: User-injected disable in metadata is IGNORED data_with_disable_metadata = { @@ -345,12 +313,8 @@ class TestCustomGuardrailShouldRunGuardrail: "metadata": {"user_api_key_metadata": {"disable_global_guardrails": True}}, "litellm_metadata": {"request_tags": ["user-supplied"]}, } - result = custom_guardrail.should_run_guardrail( - data=data_cross_key, event_type=GuardrailEventHooks.pre_call - ) - assert ( - result is False - ), "Admin config in metadata must not be shadowed by user-supplied litellm_metadata" + result = custom_guardrail.should_run_guardrail(data=data_cross_key, event_type=GuardrailEventHooks.pre_call) + assert result is False, "Admin config in metadata must not be shadowed by user-supplied litellm_metadata" # Test 6: After the pre-call strip runs, user-injected # user_api_key_metadata in the non-authoritative metadata key is gone. @@ -361,12 +325,8 @@ class TestCustomGuardrailShouldRunGuardrail: "metadata": {"user_api_key_metadata": {"disable_global_guardrails": True}}, "litellm_metadata": {}, # post-strip: attacker payload removed } - result = custom_guardrail.should_run_guardrail( - data=data_post_strip, event_type=GuardrailEventHooks.pre_call - ) - assert ( - result is False - ), "Admin config in metadata must be respected when other metadata key is empty" + result = custom_guardrail.should_run_guardrail(data=data_post_strip, event_type=GuardrailEventHooks.pre_call) + assert result is False, "Admin config in metadata must be respected when other metadata key is empty" def test_should_run_guardrail_key_disable_global_not_overruled_by_team_guardrail_list( self, @@ -432,12 +392,7 @@ class TestCustomGuardrailShouldRunGuardrail: "messages": [{"role": "user", "content": "test"}], "opted_out_global_guardrails": ["global_guardrail"], } - assert ( - custom_guardrail.should_run_guardrail( - data=data_root, event_type=GuardrailEventHooks.pre_call - ) - is True - ) + assert custom_guardrail.should_run_guardrail(data=data_root, event_type=GuardrailEventHooks.pre_call) is True # Test 2: User-injected opt-out in metadata is IGNORED data_metadata = { @@ -446,10 +401,7 @@ class TestCustomGuardrailShouldRunGuardrail: "metadata": {"opted_out_global_guardrails": ["global_guardrail"]}, } assert ( - custom_guardrail.should_run_guardrail( - data=data_metadata, event_type=GuardrailEventHooks.pre_call - ) - is True + custom_guardrail.should_run_guardrail(data=data_metadata, event_type=GuardrailEventHooks.pre_call) is True ) # Test 4: a different guardrail in the opt-out list → still runs @@ -458,12 +410,7 @@ class TestCustomGuardrailShouldRunGuardrail: "messages": [{"role": "user", "content": "test"}], "metadata": {"opted_out_global_guardrails": ["some_other_guardrail"]}, } - assert ( - custom_guardrail.should_run_guardrail( - data=data_other, event_type=GuardrailEventHooks.pre_call - ) - is True - ) + assert custom_guardrail.should_run_guardrail(data=data_other, event_type=GuardrailEventHooks.pre_call) is True # Test 5: empty opt-out list → still runs data_empty = { @@ -471,12 +418,7 @@ class TestCustomGuardrailShouldRunGuardrail: "messages": [{"role": "user", "content": "test"}], "metadata": {"opted_out_global_guardrails": []}, } - assert ( - custom_guardrail.should_run_guardrail( - data=data_empty, event_type=GuardrailEventHooks.pre_call - ) - is True - ) + assert custom_guardrail.should_run_guardrail(data=data_empty, event_type=GuardrailEventHooks.pre_call) is True # Test 6: malformed value (bool instead of list) → safely ignored, guardrail runs data_malformed = { @@ -485,10 +427,7 @@ class TestCustomGuardrailShouldRunGuardrail: "metadata": {"opted_out_global_guardrails": True}, } assert ( - custom_guardrail.should_run_guardrail( - data=data_malformed, event_type=GuardrailEventHooks.pre_call - ) - is True + custom_guardrail.should_run_guardrail(data=data_malformed, event_type=GuardrailEventHooks.pre_call) is True ) def test_should_run_guardrail_opt_out_does_not_affect_non_global(self): @@ -511,17 +450,12 @@ class TestCustomGuardrailShouldRunGuardrail: "guardrails": ["opt_in_guardrail"], }, } - assert ( - non_global.should_run_guardrail( - data=data, event_type=GuardrailEventHooks.pre_call - ) - is True - ) + assert non_global.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) is True def test_should_run_guardrail_suppressed_by_auto_router_compression(self): """An auto router's own compression policy can suppress an otherwise-eligible guardrail, even one that is default_on and explicitly requested.""" - from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY + from litellm.proxy.guardrails import auto_router_compression from litellm.types.guardrails import GuardrailEventHooks always_on = CustomGuardrail( @@ -529,22 +463,17 @@ class TestCustomGuardrailShouldRunGuardrail: default_on=True, event_hook=GuardrailEventHooks.pre_call, ) - data = { - "model": "smart-router", - "metadata": { - AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: [ - always_on.auto_router_suppression_marker() - ], - }, - } + token = auto_router_compression._suppressed_compression_guardrails.set(frozenset({"headroom-default"})) + try: + assert ( + always_on.should_run_guardrail(data={"model": "smart-router"}, event_type=GuardrailEventHooks.pre_call) + is False + ) + finally: + auto_router_compression._suppressed_compression_guardrails.reset(token) - assert ( - always_on.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) - is False - ) - - def test_should_run_guardrail_suppression_list_does_not_affect_other_names(self): - from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY + def test_should_run_guardrail_suppression_does_not_affect_other_names(self): + from litellm.proxy.guardrails import auto_router_compression from litellm.types.guardrails import GuardrailEventHooks always_on = CustomGuardrail( @@ -552,25 +481,20 @@ class TestCustomGuardrailShouldRunGuardrail: default_on=True, event_hook=GuardrailEventHooks.pre_call, ) - other = CustomGuardrail(guardrail_name="some-other-guardrail", default_on=True) - data = { - "model": "smart-router", - "metadata": { - AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: [ - other.auto_router_suppression_marker() - ], - }, - } + token = auto_router_compression._suppressed_compression_guardrails.set(frozenset({"some-other-guardrail"})) + try: + assert ( + always_on.should_run_guardrail(data={"model": "smart-router"}, event_type=GuardrailEventHooks.pre_call) + is True + ) + finally: + auto_router_compression._suppressed_compression_guardrails.reset(token) - assert ( - always_on.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) - is True - ) - - def test_should_run_guardrail_ignores_a_forged_suppression_marker(self): - """A caller controls request metadata, so a bare guardrail name there must not - switch off an always-on guardrail: only the per-process marker counts.""" - from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY + def test_request_metadata_can_never_suppress_a_guardrail(self): + """Regression (security): suppression state is request-scoped and server-set, + never read from metadata. Metadata reaches spend logs the caller can read, so + anything honored from there is something a later request could replay to switch + off a PII or content-filter guardrail for itself.""" from litellm.types.guardrails import GuardrailEventHooks always_on = CustomGuardrail( @@ -581,17 +505,14 @@ class TestCustomGuardrailShouldRunGuardrail: forged = { "model": "smart-router", "metadata": { - AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: [ + "_auto_router_suppressed_compression_guardrails": [ "headroom-default", - "forged-token:headroom-default", + "any-token:headroom-default", ], }, } - assert ( - always_on.should_run_guardrail(data=forged, event_type=GuardrailEventHooks.pre_call) - is True - ) + assert always_on.should_run_guardrail(data=forged, event_type=GuardrailEventHooks.pre_call) is True class TestApplyGuardrailCheck: @@ -630,35 +551,33 @@ class TestApplyGuardrailCheck: child_with_override = ChildGuardrailWithOverride() # Test: CustomGuardrail itself has apply_guardrail in its __dict__ - assert ( - "apply_guardrail" in type(CustomGuardrail()).__dict__ - ), "CustomGuardrail should have apply_guardrail in its own __dict__" + assert "apply_guardrail" in type(CustomGuardrail()).__dict__, ( + "CustomGuardrail should have apply_guardrail in its own __dict__" + ) # Test: ParentGuardrail inherits but doesn't override, so it should NOT be in __dict__ - assert ( - "apply_guardrail" not in type(parent_instance).__dict__ - ), "ParentGuardrail should NOT have apply_guardrail in its own __dict__ (only inherited)" + assert "apply_guardrail" not in type(parent_instance).__dict__, ( + "ParentGuardrail should NOT have apply_guardrail in its own __dict__ (only inherited)" + ) # Test: ChildGuardrailWithoutOverride only inherits, should NOT be in __dict__ - assert ( - "apply_guardrail" not in type(child_without_override).__dict__ - ), "ChildGuardrailWithoutOverride should NOT have apply_guardrail in its own __dict__ (only inherited)" + assert "apply_guardrail" not in type(child_without_override).__dict__, ( + "ChildGuardrailWithoutOverride should NOT have apply_guardrail in its own __dict__ (only inherited)" + ) # Test: ChildGuardrailWithOverride overrides the method, SHOULD be in __dict__ - assert ( - "apply_guardrail" in type(child_with_override).__dict__ - ), "ChildGuardrailWithOverride SHOULD have apply_guardrail in its own __dict__ (overridden)" + assert "apply_guardrail" in type(child_with_override).__dict__, ( + "ChildGuardrailWithOverride SHOULD have apply_guardrail in its own __dict__ (overridden)" + ) # Verify that all instances still have the method via inheritance (hasattr) - assert hasattr( - parent_instance, "apply_guardrail" - ), "All instances should have apply_guardrail via inheritance" - assert hasattr( - child_without_override, "apply_guardrail" - ), "All instances should have apply_guardrail via inheritance" - assert hasattr( - child_with_override, "apply_guardrail" - ), "All instances should have apply_guardrail via inheritance" + assert hasattr(parent_instance, "apply_guardrail"), "All instances should have apply_guardrail via inheritance" + assert hasattr(child_without_override, "apply_guardrail"), ( + "All instances should have apply_guardrail via inheritance" + ) + assert hasattr(child_with_override, "apply_guardrail"), ( + "All instances should have apply_guardrail via inheritance" + ) class TestGuardrailLoggingAggregation: @@ -685,11 +604,7 @@ class TestGuardrailLoggingAggregation: def test_appends_to_existing_metadata_list(self): request_data = { - "metadata": { - "standard_logging_guardrail_information": [ - {"guardrail_name": "existing_guardrail"} - ] - } + "metadata": {"standard_logging_guardrail_information": [{"guardrail_name": "existing_guardrail"}]} } self._invoke_add_log(request_data) @@ -701,11 +616,7 @@ class TestGuardrailLoggingAggregation: assert info[1]["guardrail_name"] == "test_guardrail" def test_converts_existing_metadata_dict_to_list(self): - request_data = { - "metadata": { - "standard_logging_guardrail_information": {"guardrail_name": "legacy"} - } - } + request_data = {"metadata": {"standard_logging_guardrail_information": {"guardrail_name": "legacy"}}} self._invoke_add_log(request_data) @@ -717,18 +628,12 @@ class TestGuardrailLoggingAggregation: def test_appends_to_litellm_metadata(self): request_data = { - "litellm_metadata": { - "standard_logging_guardrail_information": [ - {"guardrail_name": "litellm_existing"} - ] - } + "litellm_metadata": {"standard_logging_guardrail_information": [{"guardrail_name": "litellm_existing"}]} } self._invoke_add_log(request_data) - info = request_data["litellm_metadata"][ - "standard_logging_guardrail_information" - ] + info = request_data["litellm_metadata"]["standard_logging_guardrail_information"] assert isinstance(info, list) assert len(info) == 2 assert info[1]["guardrail_name"] == "test_guardrail" @@ -745,12 +650,10 @@ class TestGuardrailLoggingAggregation: self._invoke_add_log(request_data) - assert ( - "standard_logging_guardrail_information" not in request_data["metadata"] - ), "entry landed in the caller's metadata, where the spend log does not read it" - info = request_data["litellm_metadata"][ - "standard_logging_guardrail_information" - ] + assert "standard_logging_guardrail_information" not in request_data["metadata"], ( + "entry landed in the caller's metadata, where the spend log does not read it" + ) + info = request_data["litellm_metadata"]["standard_logging_guardrail_information"] assert len(info) == 1 assert info[0]["guardrail_name"] == "test_guardrail" @@ -768,9 +671,7 @@ class TestGuardrailLoggingAggregation: } self._invoke_add_log(request_data) - add_guardrail_to_applied_guardrails_header( - request_data=request_data, guardrail_name="test_guardrail" - ) + add_guardrail_to_applied_guardrails_header(request_data=request_data, guardrail_name="test_guardrail") buckets = { key @@ -816,9 +717,7 @@ class TestGuardrailOtelSpanEmission: assert len(captured) == 1 emitted = captured[0] - recorded = request_data["metadata"]["standard_logging_guardrail_information"][ - -1 - ] + recorded = request_data["metadata"]["standard_logging_guardrail_information"][-1] assert emitted is recorded assert emitted["guardrail_name"] == "emit_guard" assert emitted["start_time"] == 1.0 @@ -828,9 +727,7 @@ class TestGuardrailOtelSpanEmission: def _boom(_entry): raise RuntimeError("otel exporter down") - monkeypatch.setattr( - "litellm.integrations.otel.logger.emit_guardrail_span", _boom - ) + monkeypatch.setattr("litellm.integrations.otel.logger.emit_guardrail_span", _boom) request_data = {"metadata": {}} self._record(self._make_guardrail(), request_data) @@ -927,9 +824,7 @@ class TestGuardrailSensitiveFieldStripping: duration=1.0, ) - logged_response = request_data["metadata"][ - "standard_logging_guardrail_information" - ][0]["guardrail_response"] + logged_response = request_data["metadata"]["standard_logging_guardrail_information"][0]["guardrail_response"] assert "secret_fields" not in logged_response assert "sk-live-SHOULD-NOT-APPEAR" not in json.dumps(logged_response) @@ -942,9 +837,7 @@ class TestGuardrailSensitiveFieldStripping: guardrail_json_response=[ { "result": "ok", - "secret_fields": { - "raw_headers": {"authorization": "Bearer sk-secret"} - }, + "secret_fields": {"raw_headers": {"authorization": "Bearer sk-secret"}}, }, {"result": "also_ok"}, ], @@ -998,9 +891,7 @@ class TestGuardrailResponseCredentialMasking: duration=1.0, ) - logged = request_data["metadata"]["standard_logging_guardrail_information"][0][ - "guardrail_response" - ] + logged = request_data["metadata"]["standard_logging_guardrail_information"][0]["guardrail_response"] masked_key = logged["metadata_snapshot"]["callback_vars"]["langsmith_api_key"] assert masked_key != plaintext_key @@ -1009,10 +900,7 @@ class TestGuardrailResponseCredentialMasking: assert logged["model"] == "gpt-4o-mini" assert logged["messages"] == [{"role": "user", "content": "hi"}] - assert ( - logged["metadata_snapshot"]["callback_vars"]["langsmith_project"] - == "proj-name" - ) + assert logged["metadata_snapshot"]["callback_vars"]["langsmith_project"] == "proj-name" def test_nested_user_api_key_auth_metadata_is_masked(self): import json @@ -1071,9 +959,7 @@ class TestGuardrailResponseCredentialMasking: request_data: dict = {"metadata": {}} guardrail.add_standard_logging_guardrail_information_to_request_data( - guardrail_json_response={ - "filters": [{"regex": r"\d{3}-\d{2}-\d{4}", "action": "BLOCKED"}] - }, + guardrail_json_response={"filters": [{"regex": r"\d{3}-\d{2}-\d{4}", "action": "BLOCKED"}]}, request_data=request_data, guardrail_status="success", ) @@ -1096,9 +982,7 @@ class TestGuardrailResponseCredentialMasking: guardrail_status="success", ) - logged = request_data["metadata"]["standard_logging_guardrail_information"][0][ - "guardrail_response" - ] + logged = request_data["metadata"]["standard_logging_guardrail_information"][0]["guardrail_response"] assert logged["flagged"] is True assert logged["score"] == 0.94 assert logged["tokens_used"] == 42 @@ -1110,18 +994,14 @@ class TestGuardrailResponseCredentialMasking: plaintext = "lsv2_pt_abcdef1234567890" guardrail.add_standard_logging_guardrail_information_to_request_data( - guardrail_json_response={ - "metadata_snapshot": { - "callback_vars": {"langsmith_api_key": plaintext} - } - }, + guardrail_json_response={"metadata_snapshot": {"callback_vars": {"langsmith_api_key": plaintext}}}, request_data=request_data, guardrail_status="success", ) - masked = request_data["metadata"]["standard_logging_guardrail_information"][0][ - "guardrail_response" - ]["metadata_snapshot"]["callback_vars"]["langsmith_api_key"] + masked = request_data["metadata"]["standard_logging_guardrail_information"][0]["guardrail_response"][ + "metadata_snapshot" + ]["callback_vars"]["langsmith_api_key"] assert masked != plaintext assert masked.startswith(plaintext[:4]) assert masked.endswith(plaintext[-4:]) @@ -1615,9 +1495,7 @@ class TestEventTypeLogging: guardrail = TestGuardrail() request_data = {"metadata": {}} - await guardrail.apply_guardrail( - inputs={"texts": ["x"]}, request_data=request_data - ) + await guardrail.apply_guardrail(inputs={"texts": ["x"]}, request_data=request_data) logged_info = request_data["metadata"]["standard_logging_guardrail_information"] assert len(logged_info) == 1, ( @@ -1659,9 +1537,7 @@ class TestEventTypeLogging: request_data = {"metadata": {}} with pytest.raises(ValueError, match="blocked"): - await guardrail.apply_guardrail( - inputs={"texts": ["x"]}, request_data=request_data - ) + await guardrail.apply_guardrail(inputs={"texts": ["x"]}, request_data=request_data) logged_info = request_data["metadata"]["standard_logging_guardrail_information"] assert len(logged_info) == 1 @@ -1790,9 +1666,7 @@ class TestTracingFieldsPopulation: guardrail_json_response="blocked", request_data=request_data, guardrail_status="guardrail_intervened", - tracing_detail=GuardrailTracingDetail( - policy_template="EU AI Act Article 5" - ), + tracing_detail=GuardrailTracingDetail(policy_template="EU AI Act Article 5"), ) slg_list = request_data["metadata"]["standard_logging_guardrail_information"] @@ -1834,13 +1708,7 @@ class TestCustomGuardrailSpendLogMatchRedaction: cg = CustomGuardrail(guardrail_name="test-rail") raw = { "assessments": [ - { - "sensitiveInformationPolicy": { - "piiEntities": [ - {"type": "NAME", "match": "GG", "action": "BLOCKED"} - ] - } - } + {"sensitiveInformationPolicy": {"piiEntities": [{"type": "NAME", "match": "GG", "action": "BLOCKED"}]}} ] } request_data: dict = {"metadata": {}} @@ -1851,17 +1719,10 @@ class TestCustomGuardrailSpendLogMatchRedaction: ) slg = request_data["metadata"]["standard_logging_guardrail_information"][0] assert ( - slg["guardrail_response"]["assessments"][0]["sensitiveInformationPolicy"][ - "piiEntities" - ][0]["match"] + slg["guardrail_response"]["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0]["match"] == "[REDACTED]" ) - assert ( - raw["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0][ - "match" - ] - == "GG" - ) + assert raw["assessments"][0]["sensitiveInformationPolicy"]["piiEntities"][0]["match"] == "GG" def test_add_standard_logging_redacts_regex_field(self): cg = CustomGuardrail(guardrail_name="test-rail") diff --git a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py index de667e8ed48..0b47e56cb02 100644 --- a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py +++ b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py @@ -4,15 +4,16 @@ Unit tests for litellm.proxy.guardrails.auto_router_compression. Covers: - policy_from_litellm_params: absent keys mean no policy; the "none" sentinel normalizes to explicit no-compression within an active policy; is_same -- policy_for_model: finds the auto-router marker deployment for an alias, and - picks the tag-scoped marker the request's tags actually match +- policy_for_model: finds the auto-router marker deployment for an alias, picks the + tag-scoped marker the request's tags actually match, and never falls back to a + marker scoped to tags the request does not carry - arm_pre_call: no-op without a policy; suppresses active compression guardrails - with a forgery-proof marker; arms the model-side guardrail even when it isn't - default_on; keeps the pre-compression snapshot out of persisted metadata -- messages_for_routing: no-op without a policy; routes on the pre-compression - snapshot when the two hops differ; compresses via the named guardrail's - apply_guardrail; never writes stats onto the caller's own request_kwargs - (regression for double-counted compression savings) + through request-scoped state rather than metadata, which reaches spend logs a + caller can read; arms the model-side guardrail even when it isn't default_on +- messages_for_routing: no-op without a policy; compresses the live messages every + earlier guardrail has already rewritten, never a pre-guardrail copy of them; + never writes stats onto the caller's own request_kwargs (regression for + double-counted compression savings) """ import json @@ -20,7 +21,6 @@ from typing import Any import pytest -from litellm.constants import AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy.guardrails import auto_router_compression from litellm.proxy.guardrails.auto_router_compression import ( @@ -136,6 +136,26 @@ class TestPolicyForModel: ) assert policy == AutoRouterCompressionPolicy(routing="headroom-a", model=None) + def test_a_marker_scoped_to_other_tags_is_never_the_fallback(self): + """Regression: an "eu" marker describes a different slice of traffic, so a "us" + request must not fall back to its policy just because it is configured first.""" + router = _FakeRouter( + [ + _marker({"auto_router_routing_compression": "headroom-eu"}, tags=["eu"]), + _marker({"auto_router_routing_compression": "headroom-default"}), + ] + ) + policy = policy_for_model(llm_router=router, model_alias="smart-router", team_id=None, request_tags=("us",)) + assert policy == AutoRouterCompressionPolicy(routing="headroom-default", model=None) + + def test_no_untagged_fallback_means_no_policy(self): + """With only tag-scoped markers and none matching, there is no policy to apply: + inheriting an unrelated slice's compression is worse than inheriting nothing.""" + router = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-eu"}, tags=["eu"])]) + assert ( + policy_for_model(llm_router=router, model_alias="smart-router", team_id=None, request_tags=("us",)) is None + ) + def test_tag_scoped_marker_takes_precedence_over_untagged(self): """Regression: when multiple markers exist, the tag-scoped one the request actually matches should be used, not the first untagged one.""" @@ -220,25 +240,30 @@ class TestArmPreCall: ) data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} await arm_pre_call(data=data, llm_router=router) - suppressed = data["metadata"][AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY] - assert tuple(suppressed) == (always_on.auto_router_suppression_marker(),) - # The bare name alone must never suppress: that is what a caller could forge. - assert "always-on-compression" not in suppressed + assert auto_router_compression.suppressed_compression_guardrails() == frozenset({"always-on-compression"}) + # Suppression state must never ride along in metadata: that reaches spend + # logs the caller can read, and anything there is replayable. + assert "always-on-compression" not in json.dumps(data.get("metadata", {})) assert always_on.should_run_guardrail(data=data, event_type=GuardrailEventHooks.pre_call) is False finally: litellm.logging_callback_manager.remove_callback_from_all_lists(always_on) @pytest.mark.asyncio - async def test_a_caller_cannot_suppress_a_guardrail_by_naming_it_in_metadata(self): - """Regression: request metadata is caller-controlled, so a bare guardrail name - there must not switch off a PII, content-filter, or compression guardrail.""" + async def test_suppression_state_never_enters_request_metadata(self): + """Regression (security): a suppression list written to metadata is copied into + proxy_server_request.body and persisted to spend logs, so a caller could read it + back and replay it to switch off a PII or content-filter guardrail.""" guardrail = _RecordingCompressionGuardrail(guardrail_name="always-on-compression") - forged = { - "model": "smart-router", - "metadata": {AUTO_ROUTER_SUPPRESSED_COMPRESSION_GUARDRAILS_KEY: ["always-on-compression"]}, - } + import litellm - assert guardrail._suppressed_by_auto_router_compression(forged) is False + litellm.logging_callback_manager.add_litellm_callback(guardrail) + try: + router = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-a"})]) + data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} + await arm_pre_call(data=data, llm_router=router) + assert "suppress" not in json.dumps(data).lower() + finally: + litellm.logging_callback_manager.remove_callback_from_all_lists(guardrail) @pytest.mark.asyncio async def test_model_side_guardrail_is_requested_even_when_not_default_on(self): @@ -259,52 +284,20 @@ class TestArmPreCall: assert data["metadata"]["guardrails"] == ["headroom-b"] @pytest.mark.asyncio - async def test_snapshot_never_lands_in_persisted_metadata(self): - """Regression: refresh_proxy_server_request_body_snapshot copies metadata into - proxy_server_request.body, which deployments persist to spend logs. The - pre-compression snapshot holds the prompt before any masking guardrail ran, so - it must live outside anything that gets serialized.""" + async def test_arm_pre_call_keeps_no_copy_of_the_prompt(self): + """Regression (security): arm_pre_call runs before the pre-call guardrails, so + any copy of the messages it retained would be the pre-masking text. Routing-side + compression POSTs its input to an external service, so that copy must not exist.""" router = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-a"})]) - original_messages = [{"role": "user", "content": "my ssn is 123-45-6789"}] - data = {"model": "smart-router", "messages": original_messages} + data = {"model": "smart-router", "messages": [{"role": "user", "content": "my ssn is 123-45-6789"}]} await arm_pre_call(data=data, llm_router=router) - assert "123-45-6789" not in json.dumps(data["metadata"]) - assert [dict(m) for m in auto_router_compression._snapshot_messages()] == original_messages - - @pytest.mark.asyncio - async def test_snapshot_is_a_copy_not_the_live_message_list(self): - router = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-a"})]) - original_messages = [{"role": "user", "content": "hi"}] - - await arm_pre_call(data={"model": "smart-router", "messages": original_messages}, llm_router=router) - original_messages[0]["content"] = "mutated after the snapshot" - - assert [dict(m) for m in auto_router_compression._snapshot_messages()] == [{"role": "user", "content": "hi"}] - - @pytest.mark.asyncio - async def test_a_request_without_a_policy_clears_a_previous_snapshot(self): - router_with = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-a"})]) - await arm_pre_call( - data={"model": "smart-router", "messages": [{"role": "user", "content": "first"}]}, llm_router=router_with - ) - - router_without = _FakeRouter([{"model_name": "plain", "litellm_params": {"model": "openai/gpt-4o-mini"}}]) - await arm_pre_call( - data={"model": "plain", "messages": [{"role": "user", "content": "second"}]}, llm_router=router_without - ) - - assert auto_router_compression._snapshot_messages() is None + assert "123-45-6789" not in json.dumps(data.get("metadata", {})) + assert not hasattr(auto_router_compression, "_routing_messages_snapshot") class TestMessagesForRouting: - @pytest.fixture(autouse=True) - def _clear_snapshot(self): - auto_router_compression._routing_messages_snapshot.set(None) - yield - auto_router_compression._routing_messages_snapshot.set(None) - @pytest.mark.asyncio async def test_no_policy_returns_none(self): assert await messages_for_routing(policy=None, messages=[], request_kwargs={}) is None @@ -316,18 +309,15 @@ class TestMessagesForRouting: assert await messages_for_routing(policy=policy, messages=[], request_kwargs={}) is None @pytest.mark.asyncio - async def test_routing_none_with_model_compression_routes_on_the_snapshot(self): - """Regression: with routing explicitly off and the model side compressed, the - messages in hand are the model-side guardrail's output. Routing asked for no - compression, so it must read the pre-compression snapshot instead.""" - original = [{"role": "user", "content": "the full original conversation"}] - auto_router_compression._routing_messages_snapshot.set(tuple(dict(m) for m in original)) + async def test_routing_none_never_reaches_for_a_pre_guardrail_copy(self): + """Routing asked for no compression while the model hop compressed, so the + messages in hand are that guardrail's output and no uncompressed copy survives. + Routing reads them as-is: the alternative is keeping a pre-guardrail copy, which + is the text a masking guardrail exists to remove.""" policy = AutoRouterCompressionPolicy(routing=None, model="headroom-a") model_compressed = [{"role": "user", "content": "[COMPRESSED] the full original conversation"}] - result = await messages_for_routing(policy=policy, messages=model_compressed, request_kwargs={}) - - assert result == original + assert await messages_for_routing(policy=policy, messages=model_compressed, request_kwargs={}) is None @pytest.mark.asyncio async def test_unknown_guardrail_name_routes_on_the_uncompressed_messages(self): @@ -344,15 +334,18 @@ class TestMessagesForRouting: assert result == [{"role": "user", "content": "[COMPRESSED] hello world"}] @pytest.mark.asyncio - async def test_uses_the_snapshot_when_present(self, registered_guardrail): + async def test_routing_compresses_what_the_other_guardrails_left_behind(self, registered_guardrail): + """Regression (security): routing-side compression POSTs its input to an external + service, so it must read the live messages every earlier guardrail has already + rewritten. Routing on a pre-guardrail copy would send a masking guardrail's own + input straight back out of the proxy.""" policy = AutoRouterCompressionPolicy(routing="fake-compress", model="headroom-b") - auto_router_compression._routing_messages_snapshot.set(({"role": "user", "content": "original"},)) - # `messages` here stands in for whatever the model-side guardrail already - # rewrote `data["messages"]` to -- routing must ignore it and compress the - # pristine snapshot instead. - already_rewritten = [{"role": "user", "content": "rewritten by another guardrail"}] - result = await messages_for_routing(policy=policy, messages=already_rewritten, request_kwargs={}) - assert result == [{"role": "user", "content": "[COMPRESSED] original"}] + masked = [{"role": "user", "content": "my ssn is [REDACTED]"}] + + result = await messages_for_routing(policy=policy, messages=masked, request_kwargs={}) + + assert result == [{"role": "user", "content": "[COMPRESSED] my ssn is [REDACTED]"}] + assert registered_guardrail.request_data_seen[0]["messages"] == masked @pytest.mark.asyncio async def test_guardrail_receives_a_throwaway_request_data_not_the_real_request_kwargs(self, registered_guardrail): diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 4c5813911ac..b4de20e9f0a 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -10106,32 +10106,29 @@ class TestAutoRouterCompressionDecoupling: assert registered_guardrail.call_count == 0 @pytest.mark.asyncio - async def test_routing_none_routes_on_the_original_not_the_model_compressed_messages( + async def test_routing_none_classifies_on_the_live_messages_not_a_pre_guardrail_copy( self, registered_guardrail ): - """Regression: with routing explicitly off and the model side compressed, the - messages the router holds are the model-side guardrail's output. Routing asked - for no compression, so it has to classify on the pre-compression snapshot.""" - from litellm.proxy.guardrails import auto_router_compression + """Routing asked for no compression while the model hop compressed, so the only + messages left are that guardrail's output and the strategy classifies on them. + Keeping a pre-compression copy to classify on instead is what this deliberately + gives up: that copy is taken before the pre-call guardrails run, so it still + holds whatever a masking guardrail exists to strip, and routing-side compression + POSTs its input to an external service.""" router, strategy = self._router( { "auto_router_routing_compression": "none", "auto_router_model_compression": "fake-compress", } ) - original_messages = self._messages() - auto_router_compression._routing_messages_snapshot.set(tuple(dict(m) for m in original_messages)) model_compressed = [{"role": "user", "content": "[COMPRESSED] What is the capital of France?"}] - try: - await router.async_pre_routing_hook( - model="smart-router", request_kwargs={"metadata": {}}, messages=model_compressed - ) - finally: - auto_router_compression._routing_messages_snapshot.set(None) + await router.async_pre_routing_hook( + model="smart-router", request_kwargs={"metadata": {}}, messages=model_compressed + ) - assert strategy.received_messages == original_messages + assert strategy.received_messages == model_compressed assert registered_guardrail.call_count == 0 @pytest.mark.asyncio From e7dd524a3c8662312364d31db1f19324e8f8ac13 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:33:44 +0000 Subject: [PATCH 188/410] feat(otel): stamp litellm.request.route on the LLM call span (#39698) * feat(otel): stamp litellm.request.route on the LLM call span Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(otel): drop redundant comment on REQUEST_ROUTE Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(otel): Final-annotate route test locals, drop field comment Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(otel): read litellm.request.route off the server span The LLM call span took the auth-normalized literal path from logging metadata, which disagrees with the SERVER span wherever FastAPI matched a template: on /engines/{model:path}/chat/completions the LLM span spelled the model name while http.route carried the template, so the two spans grouped into different buckets and the PR's premise did not hold. Read the value off the span that already holds it. The request's root SERVER span is anchored per request for parenting, and its attributes stay readable after it ends, so request_root_http_route() answers from the async close callback with the same http.route the SERVER span exports: the route template on a normal route, the literal path where the passthrough hook rewrote it, and the mount point on an MCP call. Nothing has to re-derive any of that, so the two spans cannot drift apart. The route the proxy recorded at auth stays as the backstop for a deployment whose FastAPI instrumentation never mounted, where there is no server span to disagree with. Off the proxy the attribute is omitted rather than empty. --------- Co-authored-by: shivam Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Yucheng He --- litellm/integrations/otel/README.md | 9 ++ litellm/integrations/otel/logger.py | 2 + litellm/integrations/otel/mappers/genai.py | 1 + litellm/integrations/otel/model/metadata.py | 2 + litellm/integrations/otel/model/payloads.py | 3 + litellm/integrations/otel/model/semconv.py | 1 + litellm/integrations/otel/plumbing/context.py | 22 +++++ .../integrations/otel/test_otel_v2_logger.py | 56 +++++++++++ .../integrations/otel/test_otel_v2_mount.py | 99 +++++++++++++++++++ .../otel/test_otel_v2_sources_of_truth.py | 33 +++++++ 10 files changed, 228 insertions(+) diff --git a/litellm/integrations/otel/README.md b/litellm/integrations/otel/README.md index 338afe04e5e..023caf06d12 100644 --- a/litellm/integrations/otel/README.md +++ b/litellm/integrations/otel/README.md @@ -35,6 +35,15 @@ span orphaned into its own trace). The anchor — a contextvar inherited by thos child tasks — gives a stable parent in both cases. DB/service spans keep ambient parenting so an auth DB lookup still nests under `auth`. +The anchor is also what `litellm.request.route` is read from: `request_root_http_route` +returns the server span's own `http.route`, so the LLM call span cannot disagree with +its parent about which endpoint served the request. That means the route template on a +normal route and the literal path on a passthrough prefix, because the passthrough hook +rewrote the attribute; an MCP call anchors the same server span, so it reports the +`/mcp` mount point. Attributes stay readable after a span ends, so the async close +callback reads the same value. Where no server span was anchored at all, the route the +proxy recorded at auth (`metadata.user_api_key_request_route`) is the backstop. + **Which service calls become spans (`spans.span_role_for_service`).** LiteLLM's service-logging layer instruments many internal functions, but only some are traceable units of work: diff --git a/litellm/integrations/otel/logger.py b/litellm/integrations/otel/logger.py index a550dca6cc8..5519896a961 100644 --- a/litellm/integrations/otel/logger.py +++ b/litellm/integrations/otel/logger.py @@ -49,6 +49,7 @@ from litellm.integrations.otel.model.utils import to_ns from litellm.integrations.otel.plumbing.context import ( is_recordable_span, mcp_message_transport_span, + request_root_http_route, request_root_span, resolve_mcp_span_context, resolve_parent_context, @@ -541,6 +542,7 @@ class OpenTelemetryV2(CustomLogger): payload, capture_content=self.config.capture_span_content, time_to_first_chunk_seconds=call.time_to_first_chunk_seconds, + request_route=request_root_http_route(), ) end_time_ns: Final = to_ns(end_time) if carrier is not None and carrier.span is not None: diff --git a/litellm/integrations/otel/mappers/genai.py b/litellm/integrations/otel/mappers/genai.py index 3ac92b04c27..33457f5de16 100644 --- a/litellm/integrations/otel/mappers/genai.py +++ b/litellm/integrations/otel/mappers/genai.py @@ -89,6 +89,7 @@ class GenAIMapper: f"{LiteLLM.COST_PREFIX}margin_percent": lambda d: d.cost.margin_percent, f"{LiteLLM.COST_PREFIX}margin_total_amount": lambda d: d.cost.margin_total_amount, LiteLLM.REQUEST_STREAMING: lambda d: d.is_streaming, + LiteLLM.REQUEST_ROUTE: lambda d: d.request_route, } _TOOL_ATTRS: dict[str, Callable[[ToolDefinition], AttrValue | None]] = { diff --git a/litellm/integrations/otel/model/metadata.py b/litellm/integrations/otel/model/metadata.py index 062b2ca20b4..ee116aca46b 100644 --- a/litellm/integrations/otel/model/metadata.py +++ b/litellm/integrations/otel/model/metadata.py @@ -64,6 +64,7 @@ class RequestIdentity: # completes (routing has picked a deployment), so it's absent from the # auth-time seed and filled only from the payload. provider_model: str | None = None + request_route: str | None = None metadata: Mapping[str, str] = field(default_factory=dict) @classmethod @@ -87,6 +88,7 @@ class RequestIdentity: key_hash=as_str(raw_meta.get("user_api_key_hash")), end_user=as_str(payload.get("end_user")) or as_str(raw_meta.get("user_api_key_end_user_id")), provider_model=resolve_provider_model(payload), + request_route=as_str(raw_meta.get("user_api_key_request_route")), metadata=metadata, ) diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index e8ed269f6cb..d0959a6c2e9 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -386,6 +386,7 @@ class LLMCallSpanData: # keeps routes the convention folds into one operation distinguishable. output_type: GenAIOutputType | None = None call_type: str | None = None + request_route: str | None = None @classmethod def from_standard_logging_payload( @@ -393,6 +394,7 @@ class LLMCallSpanData: payload: StandardLoggingPayload, capture_content: bool = False, time_to_first_chunk_seconds: float | None = None, + request_route: str | None = None, ) -> LLMCallSpanData: params: Final = cast(Mapping[str, object], payload.get("model_parameters") or {}) # The single parse of the request's metadata — the request-vs-provider @@ -433,6 +435,7 @@ class LLMCallSpanData: time_to_first_chunk_seconds=time_to_first_chunk_seconds, output_type=resolve_output_type(call_type), call_type=call_type or None, + request_route=request_route or context.identity.request_route, ) diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py index f7a6280f95b..af5327cbd41 100644 --- a/litellm/integrations/otel/model/semconv.py +++ b/litellm/integrations/otel/model/semconv.py @@ -295,6 +295,7 @@ class LiteLLM: # ``litellm_params.model``), distinct from the user-facing ``gen_ai.request.model``. PROVIDER_MODEL: Final = "litellm.provider.model" REQUEST_STREAMING: Final = "litellm.request.streaming" + REQUEST_ROUTE: Final = "litellm.request.route" TOOLS_DECLARED: Final = "litellm.request.tools.declared" GUARDRAIL_NAME: Final = "litellm.guardrail.name" GUARDRAIL_MODE: Final = "litellm.guardrail.mode" diff --git a/litellm/integrations/otel/plumbing/context.py b/litellm/integrations/otel/plumbing/context.py index 159a84b121f..aa7cc8e2afd 100644 --- a/litellm/integrations/otel/plumbing/context.py +++ b/litellm/integrations/otel/plumbing/context.py @@ -6,6 +6,7 @@ from typing import Final from opentelemetry import baggage from opentelemetry.context import Context, get_current +from opentelemetry.sdk.trace import ReadableSpan from opentelemetry.trace import ( Link, NonRecordingSpan, @@ -18,6 +19,8 @@ from opentelemetry.trace.propagation.tracecontext import ( TraceContextTextMapPropagator, ) +from litellm.integrations.otel.model.semconv import HTTP + _PROPAGATOR: Final = TraceContextTextMapPropagator() # The request's root span — the FastAPI-owned SERVER span — captured ONCE when the @@ -55,6 +58,25 @@ def request_root_span() -> "Span | None": return span if is_recordable_span(span) else None +def request_root_http_route() -> str | None: + """``http.route`` exactly as the request's root SERVER span reports it. + + Read off the span rather than re-derived, so the LLM call span cannot disagree + with its own parent about which endpoint served the request: the template the + instrumentation matched, or the literal path where + ``mount._passthrough_span_name_hook`` rewrote it, are already in the attribute. + An MCP call anchors that same server span, so it reports the ``/mcp`` mount + point the instrumentation matched. Attributes stay readable after a span ends, + so this answers just as well from the async logging callback. + + None when no server span is anchored, which is the SDK path and any deployment + where the FastAPI instrumentation did not mount. + """ + span: Final = request_root_span() + route: Final = span.attributes.get(HTTP.ROUTE) if isinstance(span, ReadableSpan) and span.attributes else None + return route if isinstance(route, str) and route else None + + # The W3C trace-context carrier (``traceparent``/``tracestate``/``baggage``) the # MCP client propagated in the current request's ``params._meta``. The MCP gateway # sets it per message so the MCP span can record the client's span as a span diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py index 4973bda29e0..b735abaf7bf 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_logger.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_logger.py @@ -188,6 +188,62 @@ def test_streaming_span_carries_time_to_first_chunk(): assert span.attributes[GenAI.RESPONSE_TIME_TO_FIRST_CHUNK] == pytest.approx(0.75) +def test_llm_call_span_reports_the_server_spans_route(): + """``litellm.request.route`` is the anchored server span's own ``http.route``, + so an operator can group LLM spans by endpoint without joining to the parent.""" + logger, exporter = _logger() + root = logger.tracer.start_span("POST /engines/{model:path}/chat/completions") + root.set_attribute("http.route", "/engines/{model:path}/chat/completions") + set_request_root_span(root) + + _emit_llm(logger, ambient=root) + root.end() + + llm_span = next(s for s in exporter.get_finished_spans() if s.kind is SpanKind.CLIENT) + assert llm_span.attributes[LiteLLM.REQUEST_ROUTE] == "/engines/{model:path}/chat/completions" + + +def test_llm_call_span_omits_the_route_without_a_server_span(): + """An SDK call has no server span, so the key is absent rather than empty.""" + logger, exporter = _logger() + _emit_llm(logger) + (span,) = exporter.get_finished_spans() + assert LiteLLM.REQUEST_ROUTE not in span.attributes + + +def test_failed_llm_call_span_reports_the_server_spans_route(): + """The failure leg builds the same span data, so an errored call is still + attributable to the endpoint it came in on.""" + logger, exporter = _logger() + root = logger.tracer.start_span("POST /v1/responses/{response_id}") + root.set_attribute("http.route", "/v1/responses/{response_id}") + set_request_root_span(root) + + _emit_llm(logger, ambient=root, fail=True) + root.end() + + llm_span = next(s for s in exporter.get_finished_spans() if s.kind is SpanKind.CLIENT) + assert llm_span.attributes[LiteLLM.REQUEST_ROUTE] == "/v1/responses/{response_id}" + + +def test_deferred_llm_call_span_reports_the_server_spans_route(): + """``pre_call`` driven from a thread pool sees no recordable parent, so the span + is created in the close callback instead. That branch has to carry the route + too, and it can: the worker context still holds the anchor.""" + logger, exporter = _logger() + root = logger.tracer.start_span("POST /v1/messages") + root.set_attribute("http.route", "/v1/messages") + set_request_root_span(root) + + # no ``ambient``: pre_call runs with no recordable span active, which is what + # defers creation to the close callback + _emit_llm(logger) + root.end() + + llm_span = next(s for s in exporter.get_finished_spans() if s.kind is SpanKind.CLIENT) + assert llm_span.attributes[LiteLLM.REQUEST_ROUTE] == "/v1/messages" + + def test_non_streaming_span_has_no_time_to_first_chunk(): logger, exporter = _logger() kwargs = { diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_mount.py b/tests/test_litellm/integrations/otel/test_otel_v2_mount.py index 0cd71db4ae1..e2007486a40 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_mount.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_mount.py @@ -5,6 +5,8 @@ surface and the server-span + shared-provider behavior it produces. """ +from datetime import datetime, timezone + import pytest @@ -18,6 +20,7 @@ from opentelemetry.sdk.trace.export import SimpleSpanProcessor # noqa: E402 from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402 InMemorySpanExporter, ) +from opentelemetry import trace # noqa: E402 from opentelemetry.trace import SpanKind # noqa: E402 from litellm.integrations.otel.model.config import ( # noqa: E402 @@ -30,6 +33,23 @@ from litellm.integrations.otel.mount import ( # noqa: E402 _passthrough_span_name_hook, instrument_fastapi_app, ) +from litellm.integrations.otel.plumbing.context import ( # noqa: E402 + request_root_http_route, + set_request_root_span, +) + + +@pytest.fixture(autouse=True) +def _reset_request_root_span(): + """Clear the root-span anchor around every test. Production gets a fresh + contextvar copy per request task; the test process shares one context.""" + from litellm.integrations.otel.plumbing import context as _otel_context + + _otel_context._request_root_span.set(None) + _otel_context._mcp_message_transport_span.set(None) + yield + _otel_context._request_root_span.set(None) + _otel_context._mcp_message_transport_span.set(None) @pytest.fixture(autouse=True) @@ -128,6 +148,85 @@ def test_passthrough_hook_ignores_non_recording_span(): assert span.name is None +def test_llm_span_route_is_read_off_the_server_span(monkeypatch): + """``request_root_http_route`` answers with the SERVER span's own ``http.route``. + + Driven through ``instrument_fastapi_app`` and the same + ``create_litellm_proxy_request_started_span`` call the proxy makes per request, + so breaking either the mount or the anchor capture fails this.""" + monkeypatch.setenv("LITELLM_OTEL_V2", "1") + is_otel_v2_enabled.cache_clear() + app = fastapi.FastAPI() + seen = {} + + def _anchor_then_read(key): + logger.create_litellm_proxy_request_started_span(start_time=datetime.now(timezone.utc), headers=None) + seen[key] = request_root_http_route() + + @app.post("/engines/{model:path}/chat/completions") + async def engines(model: str): + _anchor_then_read("templated") + return {} + + @app.post("/openai/{endpoint:path}") + async def openai_passthrough(endpoint: str): + _anchor_then_read("passthrough") + return {} + + logger = OpenTelemetryV2(config=OpenTelemetryV2Config(exporter="in_memory")) + exporter = InMemorySpanExporter() + logger._tracer_provider.add_span_processor(SimpleSpanProcessor(exporter)) + # instrument_fastapi_app passes no provider, so it binds to the OTel global the + # way the proxy does once proxy_startup_event publishes one. set_tracer_provider + # is a once-per-process door, so place it directly and let monkeypatch undo it. + monkeypatch.setattr(trace, "_TRACER_PROVIDER", logger._tracer_provider) + instrument_fastapi_app(app) + + client = TestClient(app) + client.post("/engines/gpt-4o-mini/chat/completions") + client.post("/openai/v1/responses/resp_abc123") + + routes = { + (s.attributes or {})["http.route"] for s in exporter.get_finished_spans() if s.kind is SpanKind.SERVER + } + # a parameterized route keeps its template; the passthrough hook rewrote the + # catch-all to the literal path, and both spans have to follow their own span + assert routes == {"/engines/{model:path}/chat/completions", "/openai/v1/responses/resp_abc123"} + assert seen["templated"] == "/engines/{model:path}/chat/completions" + assert seen["passthrough"] == "/openai/v1/responses/resp_abc123" + + +def test_server_span_route_survives_the_span_ending(): + """The LLM span closes in an async callback that can run after the server span + has ended, so the attribute has to still be readable then.""" + from opentelemetry.sdk.trace import TracerProvider + + span = TracerProvider().get_tracer("t").start_span("POST /v1/responses/{response_id}") + span.set_attribute("http.route", "/v1/responses/{response_id}") + set_request_root_span(span) + span.end() + + assert request_root_http_route() == "/v1/responses/{response_id}" + + +def test_no_server_span_means_no_route(): + """An SDK call has no anchored server span, so the attribute is omitted rather + than reported as empty.""" + assert request_root_http_route() is None + + +def test_blank_route_on_the_server_span_is_omitted(): + """An excluded or unmatched path leaves the server span without a usable route. + Report nothing rather than a span attribute whose value is the empty string.""" + from opentelemetry.sdk.trace import TracerProvider + + span = TracerProvider().get_tracer("t").start_span("GET") + span.set_attribute("http.route", "") + set_request_root_span(span) + + assert request_root_http_route() is None + + def test_known_passthrough_prefixes_present(): """Guard the prefix set against accidental edits.""" assert {"openai", "anthropic", "vertex_ai", "bedrock"} <= PASSTHROUGH_PREFIXES diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py index 99d706a9c44..addadf8e598 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py @@ -722,6 +722,39 @@ def test_request_identity_falls_back_to_legacy_team_keys(): assert ident.team_alias == "legacy" +def test_llm_span_carries_proxy_request_route(): + """The LLM span records the proxy route the request arrived on, so it can be + filtered by endpoint (``/v1/responses`` vs ``/v1/chat/completions``) without + joining back to the root SERVER span's ``http.route``. The value is that + span's ``http.route`` verbatim, so a parameterized route reports the template + the SERVER span reports and not the path the caller happened to send.""" + data: Final = LLMCallSpanData.from_standard_logging_payload( + _sample_payload(metadata={"user_api_key_request_route": "/v1/responses/resp_abc123"}), + request_route="/v1/responses/{response_id}", + ) + attrs: Final = GenAIMapper().map(data) + + assert attrs[LiteLLM.REQUEST_ROUTE] == "/v1/responses/{response_id}" + + +def test_llm_span_falls_back_to_the_logged_route_without_a_server_span(): + """The route the proxy recorded at auth is the backstop for a deployment whose + FastAPI instrumentation never mounted: there is no server span to disagree with + there, and an endpoint name is worth more than an absent attribute.""" + data: Final = LLMCallSpanData.from_standard_logging_payload( + _sample_payload(metadata={"user_api_key_request_route": "/v1/responses"}) + ) + + assert GenAIMapper().map(data)[LiteLLM.REQUEST_ROUTE] == "/v1/responses" + + +def test_llm_span_omits_request_route_off_the_proxy(): + """An SDK call has no inbound route, so the key is absent rather than empty.""" + attrs: Final = GenAIMapper().map(LLMCallSpanData.from_standard_logging_payload(_sample_payload(metadata={}))) + + assert LiteLLM.REQUEST_ROUTE not in attrs + + def test_guardrail_span_data_block_carries_verdict_and_error(): from litellm.integrations.otel.model.payloads import GuardrailSpanData From 92e449d4d0c486f24c7a09bfce988a6be5b1dd70 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:35:21 -0700 Subject: [PATCH 189/410] test(cost): cover reasoning nested in text_tokens beside audio output --- .../llm_cost_calc/test_llm_cost_calc_utils.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index 19464dfd2a0..29874c5cca7 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -4774,3 +4774,22 @@ def test_generic_cost_per_token_keeps_billing_reasoning_reported_beside_text_tok info = litellm.get_model_info(model=model, custom_llm_provider="openai") assert completion_cost == pytest.approx(44 * info["output_cost_per_token"]) + + +def test_generic_cost_per_token_bills_nested_reasoning_once_beside_audio_output(_local_model_cost_map: None) -> None: + """Audio-output realtime usage nests reasoning inside text_tokens next to audio_tokens; text is billed net of it.""" + + model = "gpt-realtime-2.1-mini" + usage = Usage( + prompt_tokens=120, + completion_tokens=100, + total_tokens=220, + completion_tokens_details=CompletionTokensDetailsWrapper(text_tokens=30, audio_tokens=70, reasoning_tokens=20), + ) + + _, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="openai") + breakdown = get_token_type_cost_breakdown(model=model, custom_llm_provider="openai", usage=usage) + + info = litellm.get_model_info(model=model, custom_llm_provider="openai") + assert breakdown.reasoning_cost == pytest.approx(20 * info["output_cost_per_token"]) + assert completion_cost == pytest.approx(30 * info["output_cost_per_token"] + 70 * info["output_cost_per_audio_token"]) From 93fa9892389c6206f6b7fe99b3032096abd10d88 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:42:41 -0700 Subject: [PATCH 190/410] fix(router): fall back to the deployment voice without a conditional dict spread --- litellm/router.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/router.py b/litellm/router.py index 3b7f2d79823..c211e385d95 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -4518,7 +4518,7 @@ class Router: **{ **data, "input": input, - **({"voice": voice} if voice is not None else {}), + "voice": data.get("voice") if voice is None else voice, "client": model_client, **kwargs, } From 8b6ea728452d10c7c1b36759bee1e74b39ea24ea Mon Sep 17 00:00:00 2001 From: tin-berri Date: Fri, 4 Sep 2026 20:50:46 -0700 Subject: [PATCH 191/410] feat(shadow_eval): scope a job to model groups, ANDed with its key, team, and user targets (#39828) A shadow eval job could only be scoped by identity, so "this user's traffic on model X across every key they own" was not expressible and a models field on the start body was silently dropped. The job now carries a models list that every target is narrowed to, matched on the requested model group with model_group_alias resolved on both sides. An unresolvable name is a 400 at start. Empty means every model, which is what every existing row reads as. The dashboard start form gains an "Only on models" picker and the job headline shows the scope. --- .../migration.sql | 1 + .../litellm_proxy_extras/schema.prisma | 1 + litellm/integrations/shadow_eval_logger.py | 28 ++++++- .../auto_router_endpoints.py | 30 ++++++- litellm/proxy/schema.prisma | 1 + .../auto_router_endpoints.py | 40 ++++++++- schema.prisma | 1 + .../integrations/test_shadow_eval_logger.py | 76 +++++++++++++++++ .../test_auto_router_endpoints.py | 83 +++++++++++++++++++ .../_components/ShadowEvalSection.test.tsx | 31 +++++++ .../_components/ShadowEvalSection.tsx | 12 ++- .../_components/ShadowEvalStartForm.tsx | 31 ++++++- ui/litellm-dashboard/src/lib/http/schema.d.ts | 17 +++- 13 files changed, 343 insertions(+), 9 deletions(-) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_shadow_eval_model_scope/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_shadow_eval_model_scope/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_shadow_eval_model_scope/migration.sql new file mode 100644 index 00000000000..937f0de2569 --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260904000000_shadow_eval_model_scope/migration.sql @@ -0,0 +1 @@ +ALTER TABLE "LiteLLM_ShadowEvalJob" ADD COLUMN IF NOT EXISTS "models" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index e638ad68b6f..1c43668f227 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -1536,6 +1536,7 @@ model LiteLLM_ShadowEvalJob { target_id String // hashed virtual key, team_id, or user_id whose traffic this leg shadows router_name String // first (often only) auto-router under evaluation; router_names is the full set router_names String[] @default([]) // all routers this job runs as shadow arms; empty on legacy rows, whose set is (router_name) + models String[] @default([]) // model groups the sampled traffic is narrowed to; empty samples every model direction String @default("forward") // forward | reverse baseline_model String? // reverse only: the fixed model the router is judged against judge_model String diff --git a/litellm/integrations/shadow_eval_logger.py b/litellm/integrations/shadow_eval_logger.py index b28d21ba1ca..59403874eb0 100644 --- a/litellm/integrations/shadow_eval_logger.py +++ b/litellm/integrations/shadow_eval_logger.py @@ -37,6 +37,7 @@ from litellm.litellm_core_utils.llm_judge import ( ) from litellm.litellm_core_utils.redact_messages import should_redact_message_logging from litellm.llms.base_llm.base_utils import type_to_response_format_param +from litellm.router_utils.common_utils import resolve_model_group_alias from litellm.types.management_endpoints.auto_router_endpoints import ShadowEvalDirection from litellm.types.utils import SHADOW_EVAL_JUDGE_CALL_ORIGIN, SHADOW_EVAL_ROUTER_CALL_ORIGIN @@ -650,6 +651,7 @@ class ActiveShadowEvalJob(BaseModel): id: str router_name: str router_names: tuple[str, ...] = () + models: frozenset[str] = frozenset() direction: ShadowEvalDirection = "forward" baseline_model: str | None = None shadow_percentage: float @@ -692,6 +694,21 @@ class ActiveShadowEvalJob(BaseModel): return self.baseline_model or arm_router +def _canonical_group(router: "Router | None", model_group: str) -> str: + """A model group in the one spelling both a job's scope and a request's model compare + under: an alias resolves to its target so the two never fail to match on spelling.""" + return ( + resolve_model_group_alias(router.model_group_alias, model_group) if router is not None else None + ) or model_group + + +def _scope_admits(router: "Router | None", job: "ActiveShadowEvalJob", model_group: str) -> bool: + """Whether the request's group is in the job's model scope. Both sides resolve through + the router's alias map at match time, so a re-pointed alias applies to the next request + rather than after the jobs cache rolls.""" + return not job.models or any(_canonical_group(router, name) == model_group for name in job.models) + + def _as_active_job(record: object, attempts: int, spend: float) -> ActiveShadowEvalJob | None: """The sampling path's view of one job row, or None for a row it cannot sample: an unknown direction, or a reverse job with no baseline model to duplicate against. @@ -714,7 +731,8 @@ class ShadowEvalLogger(CustomLogger): A job targets a virtual key, a team, or a user; a request qualifies for a job when any of its resolved identities (key hash, team id, user id) matches the job's target, so team and user jobs cover JWT-authenticated traffic, which carries no - key hash at all.""" + key hash at all. A job scoped to model groups further requires the request's + requested group to be one of them.""" def __init__( self, @@ -801,19 +819,24 @@ class ShadowEvalLogger(CustomLogger): active_jobs: Sequence[ActiveShadowEvalJob], request_metadata: Mapping[str, object], request_id: str, + model_group: str, ) -> tuple[ActiveShadowEvalJob, ...]: """The jobs that sample this request. A key can hold one job per direction, and a request routed by one job's router while bypassing the other's qualifies for both; each is separately budgeted, so both fire. An admitting job that loses the sampling - dice is counted, so results can weigh judged rows against the traffic they stand for.""" + dice is counted, so results can weigh judged rows against the traffic they stand for. + A request outside a job's direction or model scope is not that job's traffic and + goes uncounted, so the funnel stays a fraction of the traffic the job admits.""" eligible: list[ActiveShadowEvalJob] = [] # mutable-ok: bucketed per-job admission now: Final = datetime.now(timezone.utc) + router: Final = self._router_provider() for job in active_jobs: if ( now >= job.ends_at or job.attempts + self._job_starts.get(job.id, 0) >= job.max_turns or (job.max_budget is not None and job.spend >= job.max_budget) or not _direction_admits(request_metadata, job) + or not _scope_admits(router, job, model_group) ): continue if not _sample_hits(request_id, job.id, job.shadow_percentage): @@ -868,6 +891,7 @@ class ShadowEvalLogger(CustomLogger): tuple(job for target in targets for job in active_jobs.get(target, ())), request_metadata, request_id, + _canonical_group(self._router_provider(), str(payload.get("model_group") or "")), ) if not eligible: return diff --git a/litellm/proxy/management_endpoints/auto_router_endpoints.py b/litellm/proxy/management_endpoints/auto_router_endpoints.py index 21e652114bc..2a7813bc140 100644 --- a/litellm/proxy/management_endpoints/auto_router_endpoints.py +++ b/litellm/proxy/management_endpoints/auto_router_endpoints.py @@ -789,6 +789,26 @@ def _for_teams(team_ids: Sequence[str | None]) -> str: return f" for team {', '.join(named)}" if named else "" +def _validate_model_scope(llm_router: "Router | None", models: Sequence[str]) -> None: + """Reject a scope naming a model no request on this proxy could carry, at start rather + than as a job that silently samples nothing. The question is "could any caller ask for + this name", not "does it resolve for the job's teams": a user target's traffic can arrive + on any team's key, so a team-public name is a legitimate scope for it, and an auto-router + is one too (a forward job on router A scoped to router B samples what B serves today). + Nothing here is ever dispatched to.""" + unreachable: Final = tuple( + model + for model in models + if judge_target(llm_router, model).via == "nothing" + and (llm_router is None or model not in llm_router.team_public_model_names) + ) + if unreachable: + raise HTTPException( + status_code=400, + detail="models not served by this proxy: " + ", ".join(f"'{model}'" for model in unreachable), + ) + + _JUDGED_ROLES: Final[frozenset[StrategyRouterDependencyRole]] = frozenset({"tier", "default"}) @@ -1080,6 +1100,7 @@ class _LegRow(BaseModel): target_id: str router_name: str router_names: tuple[str, ...] = () + models: tuple[str, ...] = () direction: ShadowEvalDirection baseline_model: str | None = None judge_model: str @@ -1150,6 +1171,7 @@ def _group_response( for leg in sorted(legs, key=lambda leg: (leg.target_type, leg.target_id)) ), router_names=first.arm_router_names, + models=first.models, direction=first.direction, baseline_model=first.baseline_model, judge_model=first.judge_model, @@ -1322,7 +1344,10 @@ async def start_shadow_eval( A target is a virtual key, a team, or a user. Team and user targets match on the identity every request resolves to at auth time, so they cover JWT-authenticated traffic, which presents no virtual key; a user target samples that user's traffic - across all their teams, whether it arrives on a JWT or a key they own. + across all their teams, whether it arrives on a JWT or a key they own. models narrows + every target to requests for those model groups, so a user plus one model samples that + user's traffic on that model across every key they own; it is forward-only, since a + reverse job already samples exactly the traffic its own router served. A forward job answers whether the targets should adopt router_name: it samples the requests the router did not serve and duplicates them through it. A reverse job @@ -1411,6 +1436,7 @@ async def start_shadow_eval( if data.baseline_model is not None: _validate_plain_model(llm_router, data.baseline_model, "baseline_model", team_ids) _validate_judge_is_not_a_candidate(llm_router, data, team_ids) + _validate_model_scope(llm_router, data.models) requested_targets: Final[tuple[tuple[ShadowEvalTargetType, str], ...]] = ( *(("key", key) for key in data.api_key_ids), @@ -1456,6 +1482,7 @@ async def start_shadow_eval( # a pre-router_names pod samples router_name alone, so it must be a real arm "router_name": data.router_names[0], "router_names": list(data.router_names), # mutable-ok: Prisma payload + "models": list(data.models), # mutable-ok: Prisma payload "direction": data.direction, "baseline_model": data.baseline_model, "judge_model": data.judge_model, @@ -1517,6 +1544,7 @@ async def start_shadow_eval( for target_type, target_id in sorted(requested_targets) ), router_names=data.router_names, + models=data.models, direction=data.direction, baseline_model=data.baseline_model, judge_model=data.judge_model, diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index e638ad68b6f..1c43668f227 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -1536,6 +1536,7 @@ model LiteLLM_ShadowEvalJob { target_id String // hashed virtual key, team_id, or user_id whose traffic this leg shadows router_name String // first (often only) auto-router under evaluation; router_names is the full set router_names String[] @default([]) // all routers this job runs as shadow arms; empty on legacy rows, whose set is (router_name) + models String[] @default([]) // model groups the sampled traffic is narrowed to; empty samples every model direction String @default("forward") // forward | reverse baseline_model String? // reverse only: the fixed model the router is judged against judge_model String diff --git a/litellm/types/management_endpoints/auto_router_endpoints.py b/litellm/types/management_endpoints/auto_router_endpoints.py index 88869a1edfb..50c3515cf01 100644 --- a/litellm/types/management_endpoints/auto_router_endpoints.py +++ b/litellm/types/management_endpoints/auto_router_endpoints.py @@ -292,6 +292,18 @@ class StartShadowEvalRequest(BaseModel): "to across all their teams: JWT requests carrying their subject claim and virtual keys they own" ), ) + models: tuple[str, ...] = Field( + default=(), + max_length=100, + description=( + "Model groups to narrow the sampled traffic to, matched on the group the caller " + "requested and resolved through model_group_alias, so an alias and its target are one " + "name. Empty samples every model the targets use. This ANDs with the targets: a job " + "over a user and one model samples that user's requests on that model across every key " + "they own, and none of their other traffic. Forward jobs only: a reverse job samples " + "exactly the traffic its own router served, which no other model group can name" + ), + ) router_name: str | None = Field( default=None, description=( @@ -372,12 +384,20 @@ class StartShadowEvalRequest(BaseModel): def _round_percentage(cls, value: float) -> float: return round(value, 2) - @field_validator("api_key_ids", "team_ids", "user_ids") + @field_validator("api_key_ids", "team_ids", "user_ids", "models") @classmethod def _dedupe_targets(cls, value: tuple[str, ...]) -> tuple[str, ...]: - """A target named twice would collide with itself on the one-active-per-(target, direction) index.""" + """A target named twice would collide with itself on the one-active-per-(target, direction) + index; a model named twice is one scope entry.""" return tuple(dict.fromkeys(value)) + @field_validator("models") + @classmethod + def _models_are_names(cls, value: tuple[str, ...]) -> tuple[str, ...]: + if not all(name.strip() for name in value): + raise ValueError("models must be non-empty model group names") + return value + @model_validator(mode="after") def _at_least_one_target_at_most_hundred(self) -> "StartShadowEvalRequest": total: Final = len(self.api_key_ids) + len(self.team_ids) + len(self.user_ids) @@ -387,6 +407,18 @@ class StartShadowEvalRequest(BaseModel): raise ValueError("at most 100 targets per job across api_key_ids, team_ids, and user_ids") return self + @model_validator(mode="after") + def _model_scope_is_forward_only(self) -> "StartShadowEvalRequest": + """A reverse job admits exactly the requests its own router served, so every one of + them names that router and nothing else; any other scope would sample nothing and + the router itself is a no-op. Both readings are rejected rather than shipped as a + job that silently never samples.""" + if self.models and self.direction == "reverse": + raise ValueError( + "models is only meaningful for a forward job; a reverse job samples its own router's traffic" + ) + return self + @model_validator(mode="after") def _baseline_model_matches_direction(self) -> "StartShadowEvalRequest": if self.direction == "reverse" and self.baseline_model is None: @@ -599,6 +631,10 @@ class ShadowEvalJobResponse(BaseModel): "traffic and judge every arm against the same real responses" ), ) + models: tuple[str, ...] = Field( + default=(), + description="Model groups the sampled traffic is narrowed to; empty means every model the targets use", + ) direction: ShadowEvalDirection = "forward" baseline_model: str | None = None judge_model: str diff --git a/schema.prisma b/schema.prisma index e638ad68b6f..1c43668f227 100644 --- a/schema.prisma +++ b/schema.prisma @@ -1536,6 +1536,7 @@ model LiteLLM_ShadowEvalJob { target_id String // hashed virtual key, team_id, or user_id whose traffic this leg shadows router_name String // first (often only) auto-router under evaluation; router_names is the full set router_names String[] @default([]) // all routers this job runs as shadow arms; empty on legacy rows, whose set is (router_name) + models String[] @default([]) // model groups the sampled traffic is narrowed to; empty samples every model direction String @default("forward") // forward | reverse baseline_model String? // reverse only: the fixed model the router is judged against judge_model String diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index f273a285d49..ebfa1d0eb2f 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -72,6 +72,7 @@ def _job_record(job: ActiveShadowEvalJob, target_type="key", target_id="key-hash target_id=target_id, router_name=job.router_name, router_names=job.router_names, + models=sorted(job.models), direction=job.direction, baseline_model=job.baseline_model, shadow_percentage=job.shadow_percentage, @@ -205,6 +206,7 @@ def _success_kwargs( request_metadata=None, call_type="acompletion", model="claude-opus", + model_group="opus-group", response_cost=None, cache_hit=None, ): @@ -213,6 +215,7 @@ def _success_kwargs( "id": request_id, "call_type": call_type, "model": model, + "model_group": model_group, "metadata": {"user_api_key_hash": api_key_hash}, "model_parameters": {"temperature": 0.5, "stream": True}, "response_cost": response_cost, @@ -1007,6 +1010,79 @@ class TestTargetMatching: assert logger._job_starts == {"key-job": 1, "team-job": 1} +@pytest.mark.asyncio +class TestModelScope: + """A job scoped to model groups samples a target's request only when the group the + caller asked for is one of them; an out-of-scope request is not the job's traffic at + all, so it records no funnel event, exactly like a direction mismatch.""" + + @pytest.mark.parametrize( + "requested,sampled", + [("sonnet-group", True), ("opus-group", False), ("", False)], + ids=["in-scope-group-samples", "other-group-skips", "unknown-group-fails-closed"], + ) + async def test_scope_admits_only_the_named_groups_and_counts_nothing_else(self, requested, sampled): + prisma = _prisma() + logger = _logger(router=_router(), prisma=prisma, jobs=(_job(models=frozenset({"sonnet-group", "haiku-group"})),)) + + await logger.async_log_success_event(_success_kwargs(model_group=requested), RESPONSE, None, None) + await _drain(logger) + + assert prisma.db.litellm_shadowevalattempt.create.await_count == (1 if sampled else 0) + assert logger._test_funnel == [] + + async def test_an_unscoped_job_samples_every_group(self): + prisma = _prisma() + logger = _logger(router=_router(), prisma=prisma, jobs=(_job(),)) + + await logger.async_log_success_event(_success_kwargs(model_group="anything"), RESPONSE, None, None) + await _drain(logger) + + prisma.db.litellm_shadowevalattempt.create.assert_awaited_once() + + @pytest.mark.parametrize( + "scoped_to,requested", + [("sonnet-group", "fast"), ("fast", "sonnet-group")], + ids=["job-names-the-target-request-uses-the-alias", "job-names-the-alias-request-uses-the-target"], + ) + async def test_an_alias_and_its_target_are_one_group_on_both_sides(self, scoped_to, requested): + """Both the job's scope and the request's group resolve through the router's alias + map at match time, so re-pointing an alias follows config rather than freezing at + job start.""" + router = _router() + router.model_group_alias = {"fast": "sonnet-group"} + prisma = _prisma(jobs=[_job_record(_job(models=frozenset({scoped_to})))]) + logger = _logger(router=router, prisma=prisma) + + await logger.async_log_success_event(_success_kwargs(model_group=requested), RESPONSE, None, None) + await _drain(logger) + + prisma.db.litellm_shadowevalattempt.create.assert_awaited_once() + assert prisma.db.litellm_shadowevaljob.find_many.await_count == 1 + + async def test_a_repointed_alias_applies_to_the_next_request_without_a_cache_refill(self): + router = _router() + router.model_group_alias = {"fast": "sonnet-group"} + prisma = _prisma(jobs=[_job_record(_job(models=frozenset({"fast"})))]) + logger = _logger(router=router, prisma=prisma) + await logger.async_log_success_event(_success_kwargs(model_group="sonnet-group"), RESPONSE, None, None) + await _drain(logger) + assert prisma.db.litellm_shadowevalattempt.create.await_count == 1 + + router.model_group_alias = {"fast": "haiku-group"} + await logger.async_log_success_event( + _success_kwargs(request_id="req-2", model_group="sonnet-group"), RESPONSE, None, None + ) + await logger.async_log_success_event( + _success_kwargs(request_id="req-3", model_group="haiku-group"), RESPONSE, None, None + ) + await _drain(logger) + + rows = [call.kwargs["data"]["request_id"] for call in prisma.db.litellm_shadowevalattempt.create.call_args_list] + assert rows == ["req-1", "req-3"] + assert prisma.db.litellm_shadowevaljob.find_many.await_count == 1 + + @pytest.mark.asyncio class TestActiveJobsCache: async def test_cache_miss_reads_db_once_then_serves_from_cache(self): diff --git a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py index c525af84511..21f8d985f22 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_auto_router_endpoints.py @@ -882,6 +882,7 @@ def _leg_record(**overrides: object) -> MagicMock: "target_id": "key-hash", "router_name": "my-router", "router_names": (), + "models": (), "direction": "forward", "baseline_model": None, "judge_model": "anthropic/claude-sonnet-5", @@ -1033,6 +1034,7 @@ def _shadow_prisma( "target_id", "router_name", "router_names", + "models", "direction", "baseline_model", "judge_model", @@ -1533,6 +1535,87 @@ async def test_start_shadow_eval_forward_leaves_the_baseline_column_empty(monkey assert rows[0]["baseline_model"] is None +@pytest.mark.asyncio +async def test_start_shadow_eval_writes_the_model_scope_on_every_leg_and_echoes_it(monkeypatch: pytest.MonkeyPatch): + """A model scope is job config, so every leg carries the same copy and both the start + response and a later list read report it; an auto-router is a legitimate scope (a + forward job on one router may sample what another router serves today).""" + import litellm.proxy.proxy_server as proxy_server + + _configure_anthropic_sdk_judge(monkeypatch) + prisma = _shadow_prisma() + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + monkeypatch.setattr(proxy_server, "llm_router", _shadow_router()) + + response = await start_shadow_eval( + _start_request(api_key_ids=("key-hash", "key-hash-2"), models=("cheap", "sonnet-router")), ADMIN + ) + + rows = prisma.db.litellm_shadowevaljob.create_many.call_args.kwargs["data"] + assert [row["models"] for row in rows] == [["cheap", "sonnet-router"], ["cheap", "sonnet-router"]] + assert response.models == ("cheap", "sonnet-router") + + listed = _shadow_prisma(legs=[_leg_record(models=("cheap",)), _leg_record(id="leg-0", group_id="job-0")]) + monkeypatch.setattr(proxy_server, "prisma_client", listed) + jobs = await list_shadow_eval_jobs(VIEWER, target_type=None, target_id=None, limit=50) + assert {job.job_id: job.models for job in jobs} == {"job-1": ("cheap",), "job-0": ()} + + +@pytest.mark.asyncio +async def test_start_shadow_eval_accepts_a_team_public_scope_for_a_user_target(monkeypatch: pytest.MonkeyPatch): + """A user's traffic can arrive on any team's key, so a name only one team can ask for + is a legitimate scope for a user target even though it resolves for nobody unscoped.""" + import litellm.proxy.proxy_server as proxy_server + + _configure_anthropic_sdk_judge(monkeypatch) + prisma = _shadow_prisma(known_users={"dev-alice": "alice@example.com"}) + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + monkeypatch.setattr(proxy_server, "llm_router", _shadow_router()) + + response = await start_shadow_eval( + _start_request(api_key_ids=(), user_ids=("dev-alice",), models=("house-judge",)), ADMIN + ) + + assert response.models == ("house-judge",) + assert prisma.db.litellm_shadowevaljob.create_many.call_args.kwargs["data"][0]["models"] == ["house-judge"] + + +@pytest.mark.asyncio +async def test_start_shadow_eval_rejects_a_model_scope_this_proxy_does_not_serve(monkeypatch: pytest.MonkeyPatch): + """A typo'd model name would otherwise start a job that samples nothing. Only the + unresolvable names are reported, so the caller fixes them in one round.""" + import litellm.proxy.proxy_server as proxy_server + + _configure_anthropic_sdk_judge(monkeypatch) + prisma = _shadow_prisma() + monkeypatch.setattr(proxy_server, "prisma_client", prisma) + monkeypatch.setattr(proxy_server, "llm_router", _shadow_router()) + + with pytest.raises(HTTPException) as exc: + await start_shadow_eval(_start_request(models=("cheap", "no-such-model-zzz")), ADMIN) + assert exc.value.status_code == 400 + assert "'no-such-model-zzz'" in exc.value.detail + assert "'cheap'" not in exc.value.detail + prisma.db.litellm_shadowevaljob.create_many.assert_not_called() + + +def test_start_request_dedupes_the_model_scope_and_rejects_blank_names(): + assert _start_request(models=("cheap", "mid", "cheap")).models == ("cheap", "mid") + assert _start_request().models == () + with pytest.raises(ValidationError, match="non-empty model group names"): + _start_request(models=("cheap", " ")) + + +def test_start_request_rejects_a_model_scope_on_a_reverse_job(): + """Reverse admission is the router's own traffic, whose requested group is always the + router, so a plain-model scope would sample nothing and the router itself is a no-op.""" + with pytest.raises(ValidationError, match="only meaningful for a forward job"): + _start_request(direction="reverse", baseline_model="cheap", models=("mid",)) + with pytest.raises(ValidationError, match="only meaningful for a forward job"): + _start_request(direction="reverse", baseline_model="cheap", models=("my-router",)) + assert _start_request(direction="reverse", baseline_model="cheap").models == () + + @pytest.mark.asyncio async def test_start_shadow_eval_rejects_keys_this_proxy_does_not_know(monkeypatch: pytest.MonkeyPatch): """A typo'd api_key_id would otherwise create a leg no traffic can ever match. Every diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx index a1de608d0bb..64363da9933 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.test.tsx @@ -104,6 +104,7 @@ const job = (overrides: Partial = {}): ShadowEvalJob => ({ status: "running", router_name: "claude-auto", router_names: ["claude-auto"], + models: [], direction: "forward", baseline_model: null, judge_model: "anthropic/claude-sonnet-5", @@ -450,6 +451,7 @@ describe("ShadowEvalSection", () => { api_key_ids: ["hash-alpha", "hash-beta"], team_ids: [], user_ids: [], + models: [], router_names: ["gpt-auto"], direction: "forward", shadow_percentage: 10, @@ -479,6 +481,7 @@ describe("ShadowEvalSection", () => { api_key_ids: [], team_ids: ["team-eng"], user_ids: [], + models: [], router_names: ["gpt-auto"], direction: "forward", shadow_percentage: 10, @@ -489,15 +492,41 @@ describe("ShadowEvalSection", () => { expect(start.mutate).toHaveBeenCalledWith(expectedBody); }); + it("narrows a job to the picked model groups and shows the scope on the job headline", async () => { + const user = userEvent.setup(); + const { start } = mockHooks({}); + render(); + + await user.click(screen.getByPlaceholderText("Search teams by alias")); + const teamList = await screen.findByTestId("paginated-multi-select-list"); + await user.click(within(teamList).getByText("engineering")); + await chooseSelectOption(user, screen.getByPlaceholderText("Every model the targets use"), "prod-claude"); + await chooseSelectOption(user, screen.getByPlaceholderText("Select up to 4 auto-routers"), "gpt-auto"); + await user.click(screen.getByPlaceholderText("Select a judge model")); + await user.click(await screen.findByRole("option", { name: /anthropic\/claude-sonnet-5/ })); + await user.click(screen.getByText("Start shadow eval")); + + expect(start.mutate).toHaveBeenCalledWith( + expect.objectContaining({ team_ids: ["team-eng"], models: ["prod-claude"] }), + ); + + const scoped = job({ models: ["prod-claude", "prod-haiku"] }); + mockHooks({ jobs: [scoped], detailsById: { "job-1": scoped } }); + render(); + expect(screen.getByText("prod-claude, prod-haiku")).toBeInTheDocument(); + }); + it("requires a baseline model in reverse mode and submits it, while forward mode never shows the picker", async () => { const user = userEvent.setup(); const { start } = mockHooks({}); render(); expect(screen.queryByPlaceholderText("Select a baseline model")).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText("Every model the targets use")).toBeInTheDocument(); await user.click(screen.getByText("Adoption check: key's traffic vs the router")); await user.click(await screen.findByText("Regression check: router's picks vs a baseline")); + expect(screen.queryByPlaceholderText("Every model the targets use")).not.toBeInTheDocument(); await user.click(screen.getByPlaceholderText("Search keys by alias")); const keyList = await screen.findByTestId("paginated-multi-select-list"); await user.click(within(keyList).getByText("prod-alpha")); @@ -516,6 +545,7 @@ describe("ShadowEvalSection", () => { api_key_ids: ["hash-alpha"], team_ids: [], user_ids: [], + models: [], router_names: ["gpt-auto"], direction: "reverse", baseline_model: "prod-claude", @@ -551,6 +581,7 @@ describe("ShadowEvalSection", () => { api_key_ids: ["hash-alpha"], team_ids: [], user_ids: [], + models: [], router_names: ["gpt-auto", "claude-auto"], direction: "forward", shadow_percentage: 10, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx index c66d74074c2..ea11879d971 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalSection.tsx @@ -87,17 +87,25 @@ const targetStatus = (job: ShadowEvalJob, target: ShadowEvalJobTarget): string = const jobRouters = (job: ShadowEvalJob): string => (job.router_names ?? [job.router_name]).join(", "); +const jobModelScope = (job: ShadowEvalJob): React.ReactNode => + job.models && job.models.length > 0 ? ( + <> + {" "} + on {job.models.join(", ")} + + ) : null; + const jobHeadline = (job: ShadowEvalJob): React.ReactNode => job.direction === "reverse" ? ( <> Comparing {jobRouters(job)} to{" "} {job.baseline_model} on {job.shadow_percentage}% of{" "} - {shadowedTargetsLabel(job)} traffic + {shadowedTargetsLabel(job)} traffic{jobModelScope(job)} ) : ( <> Shadowing {job.shadow_percentage}% of {shadowedTargetsLabel(job)}{" "} - traffic via {jobRouters(job)} + traffic{jobModelScope(job)} via {jobRouters(job)} ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx index f96910a4ad6..2eb5fa9c945 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/ShadowEvalStartForm.tsx @@ -23,6 +23,7 @@ import { useStartShadowEval, type ShadowEvalJob } from "./useShadowEval"; type ShadowEvalDirection = ShadowEvalJob["direction"]; const MAX_ROUTERS = 4; +const MAX_MODELS = 100; const RECOMMENDED_JUDGE_MODELS = ["anthropic/claude-sonnet-5", "openai/gpt-4o", "gemini/gemini-2.5-pro"] as const; @@ -206,6 +207,7 @@ interface StartFormValidityInputs { apiKeyIds: string[]; teamIds: string[]; userIds: string[]; + models: string[]; routerNames: string[]; direction: ShadowEvalDirection; baselineModel: string; @@ -224,7 +226,8 @@ const startFormValidity = (inputs: StartFormValidityInputs) => { const routerCountValid = inputs.routerNames.length >= 1 && inputs.routerNames.length <= MAX_ROUTERS; const routersMatchDirection = inputs.direction === "forward" || inputs.routerNames.length === 1; const routersValid = routerCountValid && routersMatchDirection; - const modelsPicked = routersValid && inputs.judgeModel !== "" && baselinePicked; + const scopeValid = routersValid && (inputs.direction === "reverse" || inputs.models.length <= MAX_MODELS); + const modelsPicked = scopeValid && inputs.judgeModel !== "" && baselinePicked; const filled = targetsPicked && modelsPicked; const boundsValid = percentageValid && maxBudgetValid; const valid = Boolean(inputs.accessToken) && filled && boundsValid; @@ -235,6 +238,7 @@ interface StartBodyInputs { apiKeyIds: string[]; teamIds: string[]; userIds: string[]; + models: string[]; routerNames: string[]; direction: ShadowEvalDirection; baselineModel: string; @@ -248,6 +252,7 @@ const buildStartBody = (inputs: StartBodyInputs) => ({ api_key_ids: inputs.apiKeyIds, team_ids: inputs.teamIds, user_ids: inputs.userIds, + models: inputs.direction === "forward" ? inputs.models : [], router_names: inputs.routerNames, direction: inputs.direction, ...(inputs.direction === "reverse" ? { baseline_model: inputs.baselineModel } : {}), @@ -262,6 +267,7 @@ export const StartForm: React.FC = () => { const [apiKeyIds, setApiKeyIds] = useState([]); const [teamIds, setTeamIds] = useState([]); const [userIds, setUserIds] = useState([]); + const [models, setModels] = useState([]); const [routerNames, setRouterNames] = useState([]); const [direction, setDirection] = useState("forward"); const [baselineModel, setBaselineModel] = useState(""); @@ -272,6 +278,11 @@ export const StartForm: React.FC = () => { const { data: autoRouters } = useAutoRouters(); const judgeModelOptions = useJudgeModelOptions(); const baselineModelOptions = useBaselineModelOptions(); + const configuredGroups = usePlainModelGroups(); + const modelOptions = useMemo( + () => [...configuredGroups].toSorted((a, b) => a.localeCompare(b)).map((name) => ({ label: name, value: name })), + [configuredGroups], + ); const start = useStartShadowEval(); const routerOptions = useMemo(() => { @@ -286,6 +297,7 @@ export const StartForm: React.FC = () => { apiKeyIds, teamIds, userIds, + models, routerNames, direction, baselineModel, @@ -299,6 +311,7 @@ export const StartForm: React.FC = () => { apiKeyIds, teamIds, userIds, + models, routerNames, direction, baselineModel, @@ -344,6 +357,22 @@ export const StartForm: React.FC = () => { + {direction === "forward" && ( + + + {models.length > MAX_MODELS ? ( +

    Pick at most {MAX_MODELS} models

    + ) : ( +

    Narrows every target above to requests for these models

    + )} +
    + )} Date: Fri, 4 Sep 2026 20:54:40 -0700 Subject: [PATCH 192/410] fix(image_handling): keep DNS, signing, and Vertex Gemini fetches off the event loop The SSRF check in async_safe_get resolved DNS on the event loop and a blocked address was retried three times; validate_url now runs in a thread and an SSRFError fails the fetch on the first attempt in both fetchers. The shared HTTP handler signed the request and ran pre_call logging on the loop after an async transform; both now run in a thread. Vertex AI Gemini still fetched http:// images and https images without an inferrable mime type with the sync converter inside its async body builder; the walker takes a should_inline predicate and Vertex AI inlines exactly those URLs, leaving https images with a known mime type and Files API refs to Google. When one download fails the other in-flight downloads for that request are now cancelled instead of finishing in the background --- .../prompt_templates/image_handling.py | 73 ++++++++-- litellm/litellm_core_utils/url_utils.py | 3 +- litellm/llms/custom_httpx/llm_http_handler.py | 2 +- .../llms/vertex_ai/gemini/transformation.py | 26 +++- .../litellm_core_utils/test_image_handling.py | 135 ++++++++++++++++-- .../litellm_core_utils/test_url_utils.py | 35 +++++ .../custom_httpx/test_llm_http_handler.py | 28 +++- .../vertex_ai/gemini/test_transformation.py | 51 +++++++ 8 files changed, 318 insertions(+), 35 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index 55c01b3b1cb..dcad7776b58 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -4,7 +4,7 @@ Helper functions to handle images passed in messages import asyncio import base64 -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass from types import MappingProxyType from typing import Final @@ -15,7 +15,7 @@ import litellm from litellm import verbose_logger from litellm.caching.caching import InMemoryCache from litellm.constants import MAX_IMAGE_URL_DOWNLOAD_SIZE_MB -from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get +from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get, safe_get from litellm.types.llms.openai import AllMessageValues MAX_IMGS_IN_MEMORY: Final = 10 @@ -99,6 +99,8 @@ async def async_convert_url_to_base64(url: str) -> str: return _process_image_response(response, url) except litellm.ImageFetchError: raise + except SSRFError as e: + raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL. {e} url={url}") from e except Exception: pass raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL after 3 attempts. url={url}") @@ -125,6 +127,8 @@ def convert_url_to_base64(url: str) -> str: return _process_image_response(response, url) except litellm.ImageFetchError: raise + except SSRFError as e: + raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL. {e} url={url}") from e except Exception as e: verbose_logger.exception(e) raise litellm.ImageFetchError( @@ -163,9 +167,23 @@ _ANTHROPIC_MEDIA_BLOCK_TYPES: Final = frozenset({"document", "image"}) @dataclass(frozen=True, slots=True) class _RemoteSource: part: Mapping[str, object] + source: Mapping[str, object] url: str +@dataclass(frozen=True, slots=True) +class RemoteMedia: + url: str + fields: Mapping[str, object] + + +_NO_FIELDS: Final[Mapping[str, object]] = MappingProxyType({}) + + +def inline_every_remote_url(_media: RemoteMedia) -> bool: + return True + + def _parse_remote_image(fields: Mapping[str, object]) -> _RemoteImage | None: if fields.get("type") != "image_url": return None @@ -184,7 +202,7 @@ def _parse_remote_file(fields: Mapping[str, object]) -> _RemoteFile | None: def _parse_remote_source(fields: Mapping[str, object]) -> _RemoteSource | None: source: Final = _as_mapping(fields.get("source")) if fields.get("type") in _ANTHROPIC_MEDIA_BLOCK_TYPES else None url: Final = _remote_url(source.get("url")) if source is not None and source.get("type") == "url" else None - return _RemoteSource(fields, url) if url is not None else None + return _RemoteSource(fields, source, url) if source is not None and url is not None else None def _parse_remote_part(part: object) -> _RemoteImage | _RemoteFile | _RemoteSource | None: @@ -194,6 +212,16 @@ def _parse_remote_part(part: object) -> _RemoteImage | _RemoteFile | _RemoteSour return _parse_remote_image(fields) or _parse_remote_file(fields) or _parse_remote_source(fields) +def _remote_media(remote: _RemoteImage | _RemoteFile | _RemoteSource) -> RemoteMedia: + match remote: + case _RemoteImage(_, image_url, url): + return RemoteMedia(url, image_url if image_url is not None else _NO_FIELDS) + case _RemoteFile(_, file, url): + return RemoteMedia(url, file) + case _RemoteSource(_, source, url): + return RemoteMedia(url, source) + + _PDF_FORMAT: Final = MappingProxyType({"format": "application/pdf"}) @@ -222,7 +250,7 @@ def _inline(remote: _RemoteImage | _RemoteFile | _RemoteSource, data_url: str) - return {**part, "image_url": _inlined_image_url(image_url, data_url)} # mutable-ok: json-serialized part case _RemoteFile(part, file, url): return {**part, "file": _inlined_file(file, url, data_url)} # mutable-ok: json-serialized message part - case _RemoteSource(part, url): + case _RemoteSource(part, _, url): return {**part, "source": _base64_source(url, data_url)} # mutable-ok: json-serialized message part @@ -231,18 +259,22 @@ def _content_parts(message: Mapping[str, object]) -> tuple[object, ...]: return tuple(content) if isinstance(content, list) else () # pyright: ignore[reportUnknownVariableType, reportUnknownArgumentType] # parts are parsed one by one -def _inline_part(part: object, data_urls: Mapping[str, str]) -> object: +def _inline_part(part: object, data_urls: Mapping[str, str], should_inline: Callable[[RemoteMedia], bool]) -> object: remote: Final = _parse_remote_part(part) - data_url: Final = data_urls.get(remote.url) if remote is not None else None - return _inline(remote, data_url) if remote is not None and data_url is not None else part + if remote is None or not should_inline(_remote_media(remote)): + return part + data_url: Final = data_urls.get(remote.url) + return _inline(remote, data_url) if data_url is not None else part -def _inline_message(message: AllMessageValues, data_urls: Mapping[str, str]) -> AllMessageValues: +def _inline_message( + message: AllMessageValues, data_urls: Mapping[str, str], should_inline: Callable[[RemoteMedia], bool] +) -> AllMessageValues: parts: Final = _content_parts(message) if not parts: return message inlined_parts: Final = [ # mutable-ok: content must stay a list for the transforms' isinstance checks - _inline_part(part, data_urls) for part in parts + _inline_part(part, data_urls, should_inline) for part in parts ] inlined_message: Final = {**message, "content": inlined_parts} # mutable-ok: json-serialized message return inlined_message # pyright: ignore[reportReturnType] # the same message with its remote parts inlined @@ -253,21 +285,34 @@ async def _fetch_data_url(url: str, in_flight: asyncio.Semaphore) -> str: return await async_convert_url_to_base64(url) +async def _fetch_data_urls(remote_urls: tuple[str, ...]) -> tuple[str, ...]: + in_flight: Final = asyncio.Semaphore(MAX_CONCURRENT_REMOTE_MEDIA_FETCHES) + fetches: Final = tuple(asyncio.create_task(_fetch_data_url(url, in_flight)) for url in remote_urls) + try: + return tuple(await asyncio.gather(*fetches)) + except BaseException: + for fetch in fetches: + fetch.cancel() + await asyncio.gather(*fetches, return_exceptions=True) + raise + + async def async_inline_remote_media( messages: list[AllMessageValues], # mutable-ok: every transform_request takes list[AllMessageValues] - skip_url_prefixes: tuple[str, ...] = (), + should_inline: Callable[[RemoteMedia], bool] = inline_every_remote_url, ) -> list[AllMessageValues]: # mutable-ok: every transform_request takes list[AllMessageValues] remote_urls: Final = tuple( dict.fromkeys( remote.url for message in messages for part in _content_parts(message) - if (remote := _parse_remote_part(part)) is not None and not remote.url.startswith(skip_url_prefixes) + if (remote := _parse_remote_part(part)) is not None and should_inline(_remote_media(remote)) ) ) if not remote_urls: return messages - in_flight: Final = asyncio.Semaphore(MAX_CONCURRENT_REMOTE_MEDIA_FETCHES) - data_urls: Final = await asyncio.gather(*(_fetch_data_url(url, in_flight) for url in remote_urls)) + data_urls: Final = await _fetch_data_urls(remote_urls) inlined: Final = MappingProxyType(dict(zip(remote_urls, data_urls, strict=True))) - return [_inline_message(message, inlined) for message in messages] # mutable-ok: transform_request takes a list + return [ # mutable-ok: transform_request takes a list + _inline_message(message, inlined, should_inline) for message in messages + ] diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index 1e43117933d..fa070a648f5 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -19,6 +19,7 @@ Admins can opt out via two ``litellm`` globals (wired from proxy config): check but still resolve DNS and still rewrite HTTP to the resolved IP. """ +import asyncio import socket from ipaddress import ip_address, ip_network from typing import Any, Final, Protocol @@ -471,7 +472,7 @@ async def async_safe_get(client: Any, url: str, **kwargs: Any) -> httpx.Response kwargs.pop("follow_redirects", None) headers_view: Final[_CallerHeadersView] = {"headers": kwargs.pop("headers", {})} for _ in range(_MAX_REDIRECTS): - validated_url, original_host = validate_url(url) + validated_url, original_host = await asyncio.to_thread(validate_url, url) response = await fetcher.get( validated_url, headers={**headers_view["headers"], "Host": original_host}, diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index 54c21f8d5b3..d57aee025d5 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -640,7 +640,7 @@ class BaseLLMHTTPHandler: headers=request_headers, ), ) - return await dispatch_async(*sign_and_log(transformed)) + return await dispatch_async(*await asyncio.to_thread(sign_and_log, transformed)) return transform_then_dispatch() diff --git a/litellm/llms/vertex_ai/gemini/transformation.py b/litellm/llms/vertex_ai/gemini/transformation.py index 6d100143e52..13e2238fdf6 100644 --- a/litellm/llms/vertex_ai/gemini/transformation.py +++ b/litellm/llms/vertex_ai/gemini/transformation.py @@ -7,6 +7,7 @@ Why separate file? Make it easy to see how transformation works import json import os import re +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final, Literal, cast from urllib.parse import quote @@ -27,7 +28,7 @@ from litellm.litellm_core_utils.prompt_templates.factory import ( convert_to_gemini_tool_call_result, response_schema_prompt, ) -from litellm.litellm_core_utils.prompt_templates.image_handling import async_inline_remote_media +from litellm.litellm_core_utils.prompt_templates.image_handling import RemoteMedia, async_inline_remote_media from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.vertex_ai.common_utils import pop_vertex_request_labels from litellm.types.files import ( @@ -1309,6 +1310,23 @@ def sync_transform_request_body( ) +def _explicit_mime_type(fields: Mapping[str, object]) -> str | None: + hint: Final = fields.get("format") or fields.get("mime_type") or fields.get("content_type") + return hint if isinstance(hint, str) else None + + +def _ai_studio_inlines(media: RemoteMedia) -> bool: + return not media.url.startswith(GEMINI_FILES_API_URI_PREFIX) + + +def _vertex_inlines(media: RemoteMedia) -> bool: + if media.url.startswith(GEMINI_FILES_API_URI_PREFIX): + return False + return media.url.startswith("http://") or ( + _explicit_mime_type(media.fields) is None and _get_image_mime_type_from_url(media.url) is None + ) + + async def async_transform_request_body( gemini_api_key: str | None, messages: list[AllMessageValues], @@ -1350,10 +1368,8 @@ async def async_transform_request_body( vertex_auth_header=vertex_auth_header, ) - inlined_messages: Final = ( - await async_inline_remote_media(messages, skip_url_prefixes=(GEMINI_FILES_API_URI_PREFIX,)) - if custom_llm_provider == "gemini" - else messages + inlined_messages: Final = await async_inline_remote_media( + messages, should_inline=_ai_studio_inlines if custom_llm_provider == "gemini" else _vertex_inlines ) if _openai_messages_may_need_sync_gcs_metadata_fetch(inlined_messages): diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index 106bf90213b..e7b28d0d3a2 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -1,5 +1,6 @@ import asyncio import copy +import time import uuid from unittest.mock import patch @@ -11,10 +12,12 @@ from litellm import constants from litellm.litellm_core_utils.prompt_templates import image_handling from litellm.litellm_core_utils.prompt_templates.image_handling import ( MAX_CONCURRENT_REMOTE_MEDIA_FETCHES, + RemoteMedia, async_convert_url_to_base64, async_inline_remote_media, convert_url_to_base64, ) +from litellm.litellm_core_utils.url_utils import SSRFError @pytest.fixture(autouse=True) @@ -321,34 +324,142 @@ async def test_async_inline_remote_media_inlines_every_remote_part_shape(async_o assert messages == snapshot -async def test_async_inline_remote_media_leaves_skipped_url_prefixes_untouched(async_only_image_fetch): - skipped_prefix = "https://generativelanguage.googleapis.com/v1beta/files/" - skipped_file = f"{skipped_prefix}{uuid.uuid4().hex}" - skipped_image = f"{skipped_prefix}{uuid.uuid4().hex}" - fetched_image = f"https://img.example/{uuid.uuid4()}.png" +async def test_async_inline_remote_media_inlines_only_the_parts_the_predicate_accepts(async_only_image_fetch): + files_api_prefix = "https://generativelanguage.googleapis.com/v1beta/files/" + files_api_pdf = f"{files_api_prefix}{uuid.uuid4().hex}" + hinted_image = f"https://img.example/{uuid.uuid4()}.png" + plain_image = f"https://img.example/{uuid.uuid4()}.png" + hinted_document = f"https://docs.example/{uuid.uuid4()}.pdf" + seen = [] + + def inline_unhinted_outside_files_api(media: RemoteMedia) -> bool: + seen.append(media) + return not media.url.startswith(files_api_prefix) and "format" not in media.fields + messages = [ { "role": "user", "content": [ - {"type": "file", "file": {"file_id": skipped_file, "format": "application/pdf"}}, - {"type": "image_url", "image_url": {"url": skipped_image}}, - {"type": "image_url", "image_url": fetched_image}, + {"type": "file", "file": {"file_id": files_api_pdf}}, + {"type": "image_url", "image_url": {"url": hinted_image, "format": "image/png"}}, + {"type": "image_url", "image_url": {"url": plain_image}}, + {"type": "image_url", "image_url": plain_image}, + {"type": "document", "source": {"type": "url", "url": hinted_document, "format": "application/pdf"}}, ], } ] snapshot = copy.deepcopy(messages) - inlined = await async_inline_remote_media(messages, skip_url_prefixes=(skipped_prefix,)) + inlined = await async_inline_remote_media(messages, should_inline=inline_unhinted_outside_files_api) assert inlined[0]["content"] == [ - {"type": "file", "file": {"file_id": skipped_file, "format": "application/pdf"}}, - {"type": "image_url", "image_url": {"url": skipped_image}}, + {"type": "file", "file": {"file_id": files_api_pdf}}, + {"type": "image_url", "image_url": {"url": hinted_image, "format": "image/png"}}, + {"type": "image_url", "image_url": {"url": async_only_image_fetch.data_url}}, {"type": "image_url", "image_url": async_only_image_fetch.data_url}, + {"type": "document", "source": {"type": "url", "url": hinted_document, "format": "application/pdf"}}, + ] + assert async_only_image_fetch.fetched == [plain_image] + assert [(media.url, dict(media.fields)) for media in seen[:5]] == [ + (files_api_pdf, {"file_id": files_api_pdf}), + (hinted_image, {"url": hinted_image, "format": "image/png"}), + (plain_image, {"url": plain_image}), + (plain_image, {}), + (hinted_document, {"type": "url", "url": hinted_document, "format": "application/pdf"}), ] - assert async_only_image_fetch.fetched == [fetched_image] assert messages == snapshot +async def test_async_inline_remote_media_inlines_a_shared_url_only_where_the_predicate_accepts_it( + async_only_image_fetch, +): + shared = f"https://img.example/{uuid.uuid4()}.png" + messages = [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": shared, "format": "image/png"}}, + {"type": "image_url", "image_url": {"url": shared}}, + ], + } + ] + + inlined = await async_inline_remote_media(messages, should_inline=lambda media: "format" not in media.fields) + + assert inlined[0]["content"] == [ + {"type": "image_url", "image_url": {"url": shared, "format": "image/png"}}, + {"type": "image_url", "image_url": {"url": async_only_image_fetch.data_url}}, + ] + assert async_only_image_fetch.fetched == [shared] + + +async def test_async_inline_remote_media_cancels_the_other_fetches_when_one_fails(monkeypatch): + missing = f"http://img.example/{uuid.uuid4()}-missing.png" + slow = f"http://img.example/{uuid.uuid4()}-slow.png" + slow_fetch_outcomes = [] + + async def serve(client, url, **kwargs): + if url == missing: + return Response(404, request=Request("GET", url)) + try: + await asyncio.sleep(5) + except asyncio.CancelledError: + slow_fetch_outcomes.append("cancelled") + raise + slow_fetch_outcomes.append("finished") + return Response(200, content=b"\x89PNG", headers={"content-type": "image/png"}, request=Request("GET", url)) + + monkeypatch.setattr(image_handling, "async_safe_get", serve) + messages = [ + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": missing}}, + {"type": "image_url", "image_url": {"url": slow}}, + ], + } + ] + started = time.perf_counter() + + with pytest.raises(litellm.ImageFetchError, match="Status code: 404"): + await async_inline_remote_media(messages) + + assert slow_fetch_outcomes == ["cancelled"] + assert time.perf_counter() - started < 1 + + +async def test_async_convert_url_to_base64_reports_a_blocked_url_without_retrying(monkeypatch): + attempts = [] + + async def block(client, url, **kwargs): + attempts.append(url) + raise SSRFError("URL targets a blocked address (10.0.0.8)") + + monkeypatch.setattr(image_handling, "async_safe_get", block) + url = f"http://internal.example/{uuid.uuid4()}.png" + + with pytest.raises(litellm.ImageFetchError, match=r"blocked address \(10\.0\.0\.8\)"): + await async_convert_url_to_base64(url) + + assert attempts == [url] + + +def test_convert_url_to_base64_reports_a_blocked_url_without_retrying(monkeypatch): + attempts = [] + + def block(client, url, **kwargs): + attempts.append(url) + raise SSRFError("URL targets a blocked address (10.0.0.8)") + + monkeypatch.setattr(image_handling, "safe_get", block) + url = f"http://internal.example/{uuid.uuid4()}.png" + + with pytest.raises(litellm.ImageFetchError, match=r"blocked address \(10\.0\.0\.8\)"): + convert_url_to_base64(url) + + assert attempts == [url] + + async def test_async_inline_remote_media_caps_in_flight_fetches_per_request(monkeypatch): in_flight = {"now": 0, "peak": 0} diff --git a/tests/test_litellm/litellm_core_utils/test_url_utils.py b/tests/test_litellm/litellm_core_utils/test_url_utils.py index aaaa43a0dc4..fccdc1a2a0a 100644 --- a/tests/test_litellm/litellm_core_utils/test_url_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_url_utils.py @@ -1,5 +1,9 @@ +import asyncio import socket +import threading +import time +import httpx import pytest import litellm @@ -535,3 +539,34 @@ def test_assert_same_origin_error_message_does_not_leak_hostnames(): detail = str(exc.value) assert "attacker.example.com" not in detail assert "api.internal-corp.example" not in detail + + +async def test_async_safe_get_resolves_dns_off_the_event_loop(monkeypatch): + loop_thread = threading.current_thread() + resolver_threads = [] + + def slow_getaddrinfo(host, port, *args, **kwargs): + resolver_threads.append(threading.current_thread()) + time.sleep(0.4) + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", port or 443))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", slow_getaddrinfo) + + class FakeClient: + async def get(self, url, **kwargs): + return httpx.Response(200, request=httpx.Request("GET", url)) + + ticks = [time.perf_counter()] + + async def heartbeat(): + while True: + await asyncio.sleep(0.01) + ticks.append(time.perf_counter()) + + beating = asyncio.create_task(heartbeat()) + response = await url_utils.async_safe_get(FakeClient(), "https://img.example/a.png") + beating.cancel() + + assert response.status_code == 200 + assert resolver_threads and all(thread is not loop_thread for thread in resolver_threads) + assert max(b - a for a, b in zip(ticks, ticks[1:])) < 0.2 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 e86e671b939..f1614654ffb 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 @@ -1,6 +1,7 @@ import asyncio import json import logging +import threading import time from unittest.mock import AsyncMock, Mock, patch @@ -3192,6 +3193,7 @@ class _TransformRecordingConfig(BaseConfig): def __init__(self, transform_async: bool): self.transform_async = transform_async self.transform_calls = [] + self.sign_threads = [] @property def uses_async_transform_request(self) -> bool: @@ -3216,6 +3218,12 @@ class _TransformRecordingConfig(BaseConfig): self.transform_calls.append("async") return {"transformed_by": "async"} + def sign_request( + self, headers, optional_params, request_data, api_base, api_key=None, model=None, stream=None, fake_stream=None + ): + self.sign_threads.append(threading.current_thread()) + return headers, None + def transform_response( self, model, @@ -3237,7 +3245,7 @@ class _TransformRecordingConfig(BaseConfig): return BaseLLMException(status_code=status_code, message=error_message, headers=headers) -def _start_async_completion(config): +def _start_async_completion(config, logging_obj=None): captured = {} def handle(request): @@ -3253,7 +3261,7 @@ def _start_async_completion(config): custom_llm_provider="openai", model_response=ModelResponse(), encoding=None, - logging_obj=Mock(dynamic_success_callbacks=None, model_call_details={}), + logging_obj=logging_obj if logging_obj is not None else Mock(dynamic_success_callbacks=None, model_call_details={}), optional_params={}, timeout=10.0, litellm_params={}, @@ -3277,6 +3285,22 @@ async def test_completion_awaits_async_transform_request_when_config_opts_in(): assert response.choices[0].message.content == "async" +async def test_completion_signs_and_logs_off_the_event_loop_after_the_async_transform(): + config = _TransformRecordingConfig(transform_async=True) + loop_thread = threading.current_thread() + pre_call_threads = [] + logging_obj = Mock(dynamic_success_callbacks=None, model_call_details={}) + logging_obj.pre_call.side_effect = lambda **kwargs: pre_call_threads.append(threading.current_thread()) + + pending, captured = _start_async_completion(config, logging_obj) + response = await pending + + assert response.choices[0].message.content == "async" + assert captured["body"] == {"transformed_by": "async"} + assert config.sign_threads and all(thread is not loop_thread for thread in config.sign_threads) + assert pre_call_threads and all(thread is not loop_thread for thread in pre_call_threads) + + async def test_completion_keeps_sync_transform_request_before_returning_by_default(): config = _TransformRecordingConfig(transform_async=False) diff --git a/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py b/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py index d31254746d4..f135acd094f 100644 --- a/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py +++ b/tests/test_litellm/llms/vertex_ai/gemini/test_transformation.py @@ -1,6 +1,8 @@ import json import uuid +from unittest.mock import Mock + import httpx import pytest @@ -424,3 +426,52 @@ async def test_gemini_ai_studio_async_completion_passes_files_api_uris_through_u {"mime_type": "application/pdf", "file_uri": files_api_pdf}, {"mime_type": "image/png", "file_uri": files_api_image}, ] + + +async def test_vertex_ai_async_transform_inlines_only_the_urls_gemini_cannot_fetch_itself(async_only_image_fetch): + plain_http_png = f"http://img.example/{uuid.uuid4()}.png" + extensionless_https = f"https://cdn.example/files/{uuid.uuid4().hex}" + https_png = f"https://img.example/{uuid.uuid4()}.png" + hinted_extensionless = f"https://cdn.example/files/{uuid.uuid4().hex}" + files_api_pdf = f"https://generativelanguage.googleapis.com/v1beta/files/{uuid.uuid4().hex}" + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": "Describe these"}, + {"type": "image_url", "image_url": {"url": plain_http_png}}, + {"type": "image_url", "image_url": {"url": extensionless_https}}, + {"type": "image_url", "image_url": {"url": https_png}}, + {"type": "image_url", "image_url": {"url": hinted_extensionless, "mime_type": "image/webp"}}, + {"type": "file", "file": {"file_id": files_api_pdf, "format": "application/pdf"}}, + ], + } + ] + + body = await transformation.async_transform_request_body( + gemini_api_key=None, + messages=messages, + api_base=None, + model="gemini-3.8-flash", + client=None, + timeout=None, + extra_headers=None, + optional_params={}, + logging_obj=Mock(), + custom_llm_provider="vertex_ai", + litellm_params={}, + vertex_project="qa-project", + vertex_location="us-central1", + vertex_auth_header=None, + ) + + inlined = {"inline_data": {"mime_type": "image/png", "data": async_only_image_fetch.base64_png}} + assert body["contents"][0]["parts"] == [ + {"text": "Describe these"}, + inlined, + inlined, + {"file_data": {"mime_type": "image/png", "file_uri": https_png}}, + {"file_data": {"mime_type": "image/webp", "file_uri": hinted_extensionless}}, + {"file_data": {"mime_type": "application/pdf", "file_uri": files_api_pdf}}, + ] + assert sorted(async_only_image_fetch.fetched) == sorted([plain_http_png, extensionless_https]) From 14f8677bfcdc160d3b3b424dc84a9c1727734939 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:57:51 -0700 Subject: [PATCH 193/410] fix(realtime): mark realtime sessions async so failure hooks fire once The relay's failure dispatch runs the async handler and then the legacy sync failure_handler for the proxy's callable callbacks. The realtime logging object carried no async marker, so failure_handler treated the session as a sync SDK call and fired every CustomLogger's sync failure hook on top of the async one: Langfuse recorded two ERROR observations per refused session, and OpenTelemetry, MLflow, Braintrust, Literal AI, DeepEval and New Relic implement the same sync hook. Plant the _arealtime marker in litellm_params the way aanthropic_messages and agenerate_content already do, so both dispatchers classify the session async. --- litellm/litellm_core_utils/litellm_logging.py | 1 + litellm/realtime_api/main.py | 4 +-- .../test_litellm_logging.py | 30 +++++++++++++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index df579f6df5b..15585c64efb 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1820,6 +1820,7 @@ class Logging(LiteLLMLoggingBaseClass): and litellm_params.get(CallTypes.aanthropic_messages.value, False) is not True and litellm_params.get(CallTypes.agenerate_content.value, False) is not True and litellm_params.get(CallTypes.agenerate_content_stream.value, False) is not True + and litellm_params.get(CallTypes.arealtime.value, False) is not True ) def _is_assembled_stream_success(self, result=None) -> bool: diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index 3862aec445f..9de91dfcaa5 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -27,7 +27,7 @@ from litellm.types.realtime import ( RealtimeTranscriptionSessionRequest, ) from litellm.types.router import GenericLiteLLMParams -from litellm.types.utils import LlmProviders +from litellm.types.utils import CallTypes, LlmProviders from litellm.utils import ProviderConfigManager from ..litellm_core_utils.get_litellm_params import get_litellm_params @@ -355,7 +355,7 @@ async def _arealtime( user: Final = kwargs.get("user", None) litellm_params: Final = GenericLiteLLMParams(**kwargs) - litellm_params_dict: Final = get_litellm_params(**kwargs) + litellm_params_dict: Final = {**get_litellm_params(**kwargs), CallTypes.arealtime.value: True} model, _custom_llm_provider, dynamic_api_key, dynamic_api_base = get_llm_provider( model=model, diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index f1de7390b5b..af75691eb10 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -995,6 +995,35 @@ async def test_anthropic_messages_marks_litellm_params_async(): litellm.callbacks = original_callbacks +@pytest.mark.asyncio +async def test_arealtime_marks_litellm_params_async(monkeypatch): + """LIT-6973: ``_arealtime`` must plant ``_arealtime`` in ``litellm_params`` so + ``_is_sync_litellm_request`` classifies the session async and a failed session + reaches a CustomLogger's failure hook once, through the async path only, even + though the sync ``failure_handler`` still runs ahead of the async one.""" + captured = {} + async_logged = asyncio.Event() + + class CaptureLogger(CustomLogger): + async def async_log_failure_event(self, kwargs, response_obj, start_time, end_time): + captured["litellm_params"] = kwargs.get("litellm_params", {}) + async_logged.set() + + logger = CaptureLogger() + logger.log_failure_event = MagicMock() + monkeypatch.setattr(litellm, "callbacks", [logger]) + monkeypatch.setattr(litellm, "failure_callback", []) + monkeypatch.setattr(litellm, "_async_failure_callback", []) + monkeypatch.setattr(litellm, "success_callback", []) + monkeypatch.setattr(litellm, "_async_success_callback", []) + with pytest.raises(ValueError, match="Unsupported model"): + await litellm._arealtime(model="anthropic/claude-x", websocket=MagicMock()) + await asyncio.wait_for(async_logged.wait(), timeout=10) + logger.log_failure_event.assert_not_called() + assert captured["litellm_params"].get("_arealtime") is True + assert LitellmLogging._is_sync_litellm_request(captured["litellm_params"]) is False + + @pytest.mark.asyncio async def test_agenerate_content_marks_litellm_params_async(): """LIT-4475: the async ``agenerate_content`` entrypoint must plant @@ -1180,6 +1209,7 @@ def test_is_sync_litellm_request(): assert LitellmLogging._is_sync_litellm_request({}) is True assert LitellmLogging._is_sync_litellm_request({"acompletion": True}) is False assert LitellmLogging._is_sync_litellm_request({"allm_passthrough_route": True}) is False + assert LitellmLogging._is_sync_litellm_request({"_arealtime": True}) is False assert LitellmLogging._is_sync_litellm_request({"aanthropic_messages": True}) is False assert LitellmLogging._is_sync_litellm_request({"agenerate_content": True}) is False assert LitellmLogging._is_sync_litellm_request({"agenerate_content_stream": True}) is False From 412c36bb8e0663fd27e6c635d94f35d7407eabd3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:24:36 -0700 Subject: [PATCH 194/410] fix(realtime): detect an upstream refusal from received frames, not the session log The refusal predicate also required the session log to be empty, but that log is not limited to upstream frames. With gemini_live_defer_setup the handler stores a synthetic session.created before the relay starts, and the transcription usage flush appends a usage event before the check runs, so an upstream policy close with no received frames was still logged as a $0 success. Key the check off the received-frames flag only --- .../litellm_core_utils/realtime_streaming.py | 2 +- .../test_realtime_streaming.py | 39 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index bb7fbd81146..984934daaac 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -1135,7 +1135,7 @@ class RealTimeStreaming: return BackendClose(code=1011, reason="proxy failed while relaying the upstream websocket") def _backend_refused_session(self, close: BackendClose) -> bool: - return close.code != 1000 and not self._backend_sent_frames and not self.messages + return close.code != 1000 and not self._backend_sent_frames async def log_backend_refusal(self, error: Exception) -> None: if not self.logging_obj: diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py index 00addb613c2..09757c35570 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py @@ -3031,12 +3031,12 @@ async def test_session_close_flushes_unbilled_transcription_usage(): messages before log_messages runs, and never forwarded to the client.""" from typing import Final - from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage + from litellm.types.realtime import RealtimeInputAudioTranscriptionUsage, RealtimeResponseTypedDict client_ws: Final = MagicMock() client_ws.send_text = AsyncMock() backend_ws: Final = MagicMock() - backend_ws.recv = AsyncMock(side_effect=ConnectionClosed(None, None)) + backend_ws.recv = AsyncMock(side_effect=[b'{"serverContent": {}}', ConnectionClosed(None, None)]) logging_obj: Final = MagicMock() logging_obj.async_success_handler = AsyncMock() logging_obj.success_handler = MagicMock() @@ -3048,7 +3048,24 @@ async def test_session_close_flushes_unbilled_transcription_usage(): "total_tokens": 171, "input_token_details": {"text_tokens": 0, "audio_tokens": 153}, } + transcript_frame: Final[RealtimeResponseTypedDict] = { + "response": { + "type": "conversation.item.input_audio_transcription.completed", + "event_id": "event_1", + "transcript": "ahoy", + "item_id": "item_1", + "content_index": 0, + }, + "current_output_item_id": None, + "current_response_id": None, + "current_delta_chunks": None, + "current_conversation_id": None, + "current_item_chunks": None, + "current_delta_type": None, + "session_configuration_request": None, + } provider_config: Final = MagicMock() + provider_config.transform_realtime_response = MagicMock(return_value=transcript_frame) provider_config.unbilled_usage_on_session_close = MagicMock(return_value=usage) streaming: Final = RealTimeStreaming( @@ -3080,7 +3097,9 @@ async def test_session_close_flushes_unbilled_transcription_usage(): ) assert len(flushed) == 1 assert flushed[0] in logged_snapshots[0] - assert not client_ws.send_text.called + forwarded: Final = tuple(json.loads(call.args[0]) for call in client_ws.send_text.await_args_list) + assert [event.get("transcript") for event in forwarded] == ["ahoy"] + assert all("usage" not in event for event in forwarded) @pytest.mark.asyncio @@ -3246,6 +3265,20 @@ async def test_upstream_refusal_before_any_frame_logs_a_failure_not_a_success(): assert session.logging.logged_sessions == () +@pytest.mark.asyncio +async def test_upstream_refusal_after_a_synthetic_session_created_still_logs_a_failure(): + """LIT-6973: deferred Gemini Live setup stores a synthetic ``session.created`` before + the relay starts. It is not an upstream frame, so a refusal after it is still a refusal.""" + upstream_close: Final = ConnectionClosed(Close(1008, _UPSTREAM_REFUSAL), None) + session: Final = _relay_session(_client_ws_that_never_sends(), _backend_ws_closing_with(upstream_close)) + session.streaming.store_message(json.dumps({"type": "session.created", "session": {"id": "sess_synthetic"}})) + + await session.run() + + assert session.logging.logged_failures == (upstream_close,) + assert session.logging.logged_sessions == () + + @pytest.mark.asyncio async def test_upstream_close_after_relayed_events_still_logs_the_session_as_success(): client_ws: Final = _client_ws_that_never_sends() From 842623529048ceb836c746f8b99835260142a229 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 21:25:46 -0700 Subject: [PATCH 195/410] feat(ocr): add Cohere Parse support for cohere and azure_ai --- litellm/llms/azure_ai/ocr/__init__.py | 2 + .../ocr/cohere_parse_transformation.py | 91 ++++++ litellm/llms/azure_ai/ocr/common_utils.py | 9 + litellm/llms/base_llm/ocr/transformation.py | 4 + litellm/llms/cohere/ocr/__init__.py | 3 + litellm/llms/cohere/ocr/transformation.py | 292 ++++++++++++++++++ ...odel_prices_and_context_window_backup.json | 19 ++ litellm/ocr/main.py | 2 + litellm/utils.py | 5 + model_prices_and_context_window.json | 19 ++ ...st_azure_ai_cohere_parse_transformation.py | 166 ++++++++++ .../llms/cohere/ocr/test_cohere_parse_cost.py | 58 ++++ .../ocr/test_cohere_parse_transformation.py | 217 +++++++++++++ .../ocr/test_ocr_native_format.py | 10 + 14 files changed, 897 insertions(+) create mode 100644 litellm/llms/azure_ai/ocr/cohere_parse_transformation.py create mode 100644 litellm/llms/cohere/ocr/__init__.py create mode 100644 litellm/llms/cohere/ocr/transformation.py create mode 100644 tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py create mode 100644 tests/test_litellm/llms/cohere/ocr/test_cohere_parse_cost.py create mode 100644 tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py diff --git a/litellm/llms/azure_ai/ocr/__init__.py b/litellm/llms/azure_ai/ocr/__init__.py index ade1165b848..998d0570882 100644 --- a/litellm/llms/azure_ai/ocr/__init__.py +++ b/litellm/llms/azure_ai/ocr/__init__.py @@ -1,5 +1,6 @@ """Azure AI OCR module.""" +from .cohere_parse_transformation import AzureAICohereParseConfig from .common_utils import get_azure_ai_ocr_config from .document_intelligence.transformation import ( AzureDocumentIntelligenceOCRConfig, @@ -7,6 +8,7 @@ from .document_intelligence.transformation import ( from .transformation import AzureAIOCRConfig __all__ = [ + "AzureAICohereParseConfig", "AzureAIOCRConfig", "AzureDocumentIntelligenceOCRConfig", "get_azure_ai_ocr_config", diff --git a/litellm/llms/azure_ai/ocr/cohere_parse_transformation.py b/litellm/llms/azure_ai/ocr/cohere_parse_transformation.py new file mode 100644 index 00000000000..121f970c59b --- /dev/null +++ b/litellm/llms/azure_ai/ocr/cohere_parse_transformation.py @@ -0,0 +1,91 @@ +"""Cohere Parse served from Azure AI Foundry (`/providers/cohere/v2/parse`).""" + +from collections.abc import Mapping +from typing import Final + +import httpx + +from litellm.litellm_core_utils.prompt_templates.image_handling import ( + async_convert_url_to_base64, + convert_url_to_base64, +) +from litellm.llms.azure_ai.common_utils import get_azure_ai_auth_headers +from litellm.llms.cohere.ocr.transformation import COHERE_PARSE_PATH, CohereParseConfig +from litellm.secret_managers.main import get_secret_str + +AZURE_AI_API_KEY_ENV_VAR: Final = "AZURE_AI_API_KEY" +AZURE_AI_API_BASE_ENV_VAR: Final = "AZURE_AI_API_BASE" +AZURE_AI_COHERE_PROVIDER_PATH: Final = "/providers/cohere" +AZURE_AI_MODELS_PATH_SUFFIX: Final = "/models" + + +class AzureAICohereParseConfig(CohereParseConfig): + """Same request and response shape as Cohere Parse, behind Azure AI auth and URL layout. + + Foundry cannot fetch external URLs, so remote images are inlined as base64 data URIs. + """ + + def get_api_key_env_var(self) -> str | None: + return AZURE_AI_API_KEY_ENV_VAR + + def _llm_provider(self) -> str: + return "azure_ai" + + def validate_environment( + self, + headers: Mapping[str, str], + model: str, + api_key: str | None = None, + api_base: str | None = None, + litellm_params: Mapping[str, object] | None = None, + **kwargs: object, # kwargs-ok: BaseOCRConfig.validate_environment signature + ) -> dict[str, str]: # mutable-ok: BaseOCRConfig signature + resolved_base: Final = api_base or get_secret_str(AZURE_AI_API_BASE_ENV_VAR) + if resolved_base is None: + raise ValueError( + f"Missing Azure AI API Base - Set {AZURE_AI_API_BASE_ENV_VAR} environment variable " + "or pass api_base parameter" + ) + resolved_key: Final = api_key or get_secret_str(AZURE_AI_API_KEY_ENV_VAR) + return { # mutable-ok: BaseOCRConfig signature + **get_azure_ai_auth_headers(api_key=resolved_key, litellm_params=litellm_params), + "Content-Type": "application/json", + **headers, + } + + def get_complete_url( + self, + api_base: str | None, + model: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object] | None = None, + **kwargs: object, # kwargs-ok: BaseOCRConfig.get_complete_url signature + ) -> str: + resolved_base: Final = api_base or get_secret_str(AZURE_AI_API_BASE_ENV_VAR) + if resolved_base is None: + raise ValueError( + f"Missing Azure AI API Base - Set {AZURE_AI_API_BASE_ENV_VAR} environment variable " + "or pass api_base parameter" + ) + url: Final = httpx.URL(resolved_base) + if not url.is_absolute_url: + raise ValueError( + "Azure AI API Base must be an absolute URL including scheme (e.g. " + f"'https://.services.ai.azure.com'). Got api_base={resolved_base!r}." + ) + path: Final = url.path.rstrip("/") + if path.endswith(COHERE_PARSE_PATH): + return str(url.copy_with(path=path)) + if path.endswith(f"{AZURE_AI_COHERE_PROVIDER_PATH}/v2"): + return str(url.copy_with(path=f"{path}/parse")) + return str( + url.copy_with( + path=f"{path.removesuffix(AZURE_AI_MODELS_PATH_SUFFIX)}{AZURE_AI_COHERE_PROVIDER_PATH}{COHERE_PARSE_PATH}" + ) + ) + + def _resolve_image_url_sync(self, image_url: str) -> str: + return convert_url_to_base64(image_url) + + async def _resolve_image_url_async(self, image_url: str) -> str: + return await async_convert_url_to_base64(image_url) diff --git a/litellm/llms/azure_ai/ocr/common_utils.py b/litellm/llms/azure_ai/ocr/common_utils.py index ac1a1f5af0a..a4cd0c7a30b 100644 --- a/litellm/llms/azure_ai/ocr/common_utils.py +++ b/litellm/llms/azure_ai/ocr/common_utils.py @@ -24,6 +24,10 @@ def is_azure_document_intelligence_model(model: str) -> bool: return "doc-intelligence" in lowered or "documentintelligence" in lowered +def is_azure_cohere_parse_model(model: str) -> bool: + return "parse" in model.lower() + + def get_azure_ai_ocr_config(model: str) -> Optional["BaseOCRConfig"]: """ Determine which Azure AI OCR configuration to use based on the model name. @@ -46,6 +50,7 @@ def get_azure_ai_ocr_config(model: str) -> Optional["BaseOCRConfig"]: >>> get_azure_ai_ocr_config("azure_ai/pixtral-12b-2409") """ + from litellm.llms.azure_ai.ocr.cohere_parse_transformation import AzureAICohereParseConfig from litellm.llms.azure_ai.ocr.document_intelligence.transformation import ( AzureDocumentIntelligenceOCRConfig, ) @@ -56,6 +61,10 @@ def get_azure_ai_ocr_config(model: str) -> Optional["BaseOCRConfig"]: verbose_logger.debug("Routing %s to Azure Document Intelligence OCR config", model) return AzureDocumentIntelligenceOCRConfig() + if is_azure_cohere_parse_model(model): + verbose_logger.debug("Routing %s to Azure AI Cohere Parse config", model) + return AzureAICohereParseConfig() + # Default to Mistral-based OCR for other azure_ai models verbose_logger.debug("Routing %s to Azure AI (Mistral) OCR config", model) return AzureAIOCRConfig() diff --git a/litellm/llms/base_llm/ocr/transformation.py b/litellm/llms/base_llm/ocr/transformation.py index 75306cd572a..08ae077cb2f 100644 --- a/litellm/llms/base_llm/ocr/transformation.py +++ b/litellm/llms/base_llm/ocr/transformation.py @@ -142,6 +142,10 @@ class BaseOCRConfig: """ return None + def supports_rust_bridge(self) -> bool: + """Whether the Rust OCR bridge may serve this config when it is enabled for the provider.""" + return True + def map_ocr_params( self, non_default_params: dict, diff --git a/litellm/llms/cohere/ocr/__init__.py b/litellm/llms/cohere/ocr/__init__.py new file mode 100644 index 00000000000..7742c7e0035 --- /dev/null +++ b/litellm/llms/cohere/ocr/__init__.py @@ -0,0 +1,3 @@ +from litellm.llms.cohere.ocr.transformation import CohereParseConfig + +__all__ = ("CohereParseConfig",) diff --git a/litellm/llms/cohere/ocr/transformation.py b/litellm/llms/cohere/ocr/transformation.py new file mode 100644 index 00000000000..87454980aa4 --- /dev/null +++ b/litellm/llms/cohere/ocr/transformation.py @@ -0,0 +1,292 @@ +"""Cohere Parse (`POST /v2/parse`) exposed through LiteLLM's OCR interface.""" + +from collections.abc import Mapping, Sequence +from types import MappingProxyType +from typing import TYPE_CHECKING, Final, Literal + +import httpx +from pydantic import BaseModel, ConfigDict, TypeAdapter +from typing_extensions import ReadOnly, TypedDict + +from litellm.exceptions import BadRequestError, UnsupportedParamsError +from litellm.llms.base_llm.ocr.transformation import ( + OCR_REQUEST_FORMAT_PARAM, + BaseOCRConfig, + DocumentType, + OCRPage, + OCRPageImage, + OCRRequestData, + OCRRequestFormat, + OCRResponse, + OCRUsageInfo, + parse_ocr_request_format, +) +from litellm.llms.cohere.common_utils import CohereError +from litellm.secret_managers.main import get_secret_str + +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + +COHERE_API_KEY_ENV_VAR: Final = "COHERE_API_KEY" +COHERE_PARSE_API_BASE: Final = "https://api.cohere.com" +COHERE_PARSE_PATH: Final = "/v2/parse" +COHERE_PARSE_OUTPUT_FORMAT_PARAM: Final = "output_format" +COHERE_PARSE_OUTPUT_FORMATS: Final = ("markdown", "blocks") +COHERE_PARSE_DEFAULT_OUTPUT_FORMAT: Final = "markdown" +COHERE_PARSE_SUPPORTED_PARAMS: Final = (COHERE_PARSE_OUTPUT_FORMAT_PARAM, OCR_REQUEST_FORMAT_PARAM) +COHERE_PARSE_IMAGE_ONLY_MESSAGE: Final = ( + "Cohere Parse only accepts `image_url` documents (an image URL or a base64 image data URI); " + "`document_url` and PDF inputs are not supported." +) + +_NATIVE_RESPONSE_ADAPTER: Final = TypeAdapter(dict[str, object]) +_BOUNDING_BOX_ADAPTER: Final = TypeAdapter(Mapping[str, object]) + + +class _CohereParseDocument(TypedDict): + type: ReadOnly[Literal["image_url"]] + image_url: ReadOnly[str] + + +class _CohereParseRequestBody(TypedDict): + model: ReadOnly[str] + document: ReadOnly[_CohereParseDocument] + output_format: ReadOnly[str] + + +class _MarkdownPage(TypedDict): + index: ReadOnly[int] + markdown: ReadOnly[str] + images: ReadOnly[Sequence[OCRPageImage] | None] + + +class _BlocksPage(_MarkdownPage): + blocks: ReadOnly[Sequence[Mapping[str, object]]] + + +class _CohereParseMarkdown(BaseModel): + model_config = ConfigDict(frozen=True, extra="allow") + + content: str = "" + images: Sequence[Mapping[str, object]] | None = None + + +class _CohereParsePage(BaseModel): + model_config = ConfigDict(frozen=True, extra="allow") + + index: int | None = None + markdown: _CohereParseMarkdown | None = None + blocks: Sequence[Mapping[str, object]] | None = None + + +class _CohereParseBilledUnits(BaseModel): + model_config = ConfigDict(frozen=True, extra="allow") + + pages: int | None = None + + +class _CohereParseMeta(BaseModel): + model_config = ConfigDict(frozen=True, extra="allow") + + billed_units: _CohereParseBilledUnits | None = None + + +class _CohereParseResponse(BaseModel): + model_config = ConfigDict(frozen=True, extra="allow") + + pages: Sequence[_CohereParsePage] = () + meta: _CohereParseMeta | None = None + + +def _requested_format(optional_params: Mapping[str, object] | None) -> OCRRequestFormat: + if optional_params is None: + return "litellm" + return "native" if optional_params.get(OCR_REQUEST_FORMAT_PARAM) == "native" else "litellm" + + +def _page_image(image: Mapping[str, object]) -> OCRPageImage: + bounding_box: Final = image.get("bounding_box") + if not isinstance(bounding_box, Mapping): + return OCRPageImage.model_validate(image) + bbox: Final = _BOUNDING_BOX_ADAPTER.validate_python(bounding_box) + return OCRPageImage.model_validate(MappingProxyType({**image, "bbox": bbox})) + + +def _normalize_page(page: _CohereParsePage, position: int) -> OCRPage: + markdown: Final = page.markdown + images: Final = tuple(_page_image(image) for image in markdown.images) if markdown and markdown.images else None + normalized: Final[_MarkdownPage] = { + "index": page.index if page.index is not None else position, + "markdown": markdown.content if markdown else "", + "images": images, + } + if page.blocks is None: + return OCRPage.model_validate(normalized) + with_blocks: Final[_BlocksPage] = {**normalized, "blocks": page.blocks} + return OCRPage.model_validate(with_blocks) + + +def _billed_pages(parsed: _CohereParseResponse) -> int | None: + if parsed.meta is None or parsed.meta.billed_units is None: + return None + return parsed.meta.billed_units.pages + + +class CohereParseConfig(BaseOCRConfig): + """Cohere Parse, an image-only document understanding endpoint returning markdown or blocks.""" + + def get_supported_ocr_params(self, model: str) -> list[str]: # mutable-ok: BaseOCRConfig signature + return list(COHERE_PARSE_SUPPORTED_PARAMS) # mutable-ok: BaseOCRConfig signature + + def get_api_key_env_var(self) -> str | None: + return COHERE_API_KEY_ENV_VAR + + def supports_rust_bridge(self) -> bool: + return False + + def _llm_provider(self) -> str: + return "cohere" + + def map_ocr_params( + self, + non_default_params: Mapping[str, object], + optional_params: Mapping[str, object], + model: str, + ) -> dict[str, object]: # mutable-ok: BaseOCRConfig signature + output_format: Final = non_default_params.get(COHERE_PARSE_OUTPUT_FORMAT_PARAM) + if output_format is not None and output_format not in COHERE_PARSE_OUTPUT_FORMATS: + raise UnsupportedParamsError( + message=( + f"Invalid `{COHERE_PARSE_OUTPUT_FORMAT_PARAM}`: {output_format!r}. " + f"Expected one of {', '.join(COHERE_PARSE_OUTPUT_FORMATS)}." + ), + model=model, + llm_provider=self._llm_provider(), + ) + requested_format: Final = non_default_params.get(OCR_REQUEST_FORMAT_PARAM) + request_format: Final = parse_ocr_request_format(requested_format) if requested_format is not None else None + overrides: Final = tuple( + (key, value) + for key, value in ( + (COHERE_PARSE_OUTPUT_FORMAT_PARAM, output_format), + (OCR_REQUEST_FORMAT_PARAM, request_format), + ) + if value is not None + ) + return {**optional_params, **dict(overrides)} # mutable-ok: BaseOCRConfig signature + + def validate_environment( + self, + headers: Mapping[str, str], + model: str, + api_key: str | None = None, + api_base: str | None = None, + litellm_params: Mapping[str, object] | None = None, + **kwargs: object, # kwargs-ok: BaseOCRConfig.validate_environment signature + ) -> dict[str, str]: # mutable-ok: BaseOCRConfig signature + resolved_key: Final = api_key or get_secret_str(COHERE_API_KEY_ENV_VAR) + if resolved_key is None: + raise ValueError( + f"Missing {COHERE_API_KEY_ENV_VAR} - set it in the environment or pass api_key to " + "litellm.ocr()/litellm.aocr()" + ) + return { # mutable-ok: BaseOCRConfig signature + "Authorization": f"Bearer {resolved_key}", + "Content-Type": "application/json", + **headers, + } + + def get_complete_url( + self, + api_base: str | None, + model: str, + optional_params: Mapping[str, object], + litellm_params: Mapping[str, object] | None = None, + **kwargs: object, # kwargs-ok: BaseOCRConfig.get_complete_url signature + ) -> str: + url: Final = httpx.URL(api_base or COHERE_PARSE_API_BASE) + path: Final = url.path.rstrip("/") + if path.endswith(COHERE_PARSE_PATH): + return str(url.copy_with(path=path)) + if path.endswith("/v2"): + return str(url.copy_with(path=f"{path}/parse")) + return str(url.copy_with(path=f"{path}{COHERE_PARSE_PATH}")) + + def _image_url(self, document: DocumentType, model: str) -> str: + image_url: Final = document.get("image_url", "") + if document.get("type") != "image_url" or not image_url or image_url.startswith("data:application/pdf"): + raise BadRequestError( + message=COHERE_PARSE_IMAGE_ONLY_MESSAGE, + model=model, + llm_provider=self._llm_provider(), + ) + return image_url + + def _resolve_image_url_sync(self, image_url: str) -> str: + return image_url + + async def _resolve_image_url_async(self, image_url: str) -> str: + return image_url + + def _build_request(self, model: str, image_url: str, optional_params: Mapping[str, object]) -> OCRRequestData: + body: Final[_CohereParseRequestBody] = { + "model": model, + "document": {"type": "image_url", "image_url": image_url}, + "output_format": str( + optional_params.get(COHERE_PARSE_OUTPUT_FORMAT_PARAM, COHERE_PARSE_DEFAULT_OUTPUT_FORMAT) + ), + } + return OCRRequestData(data=dict(body), files=None) # mutable-ok: OCRRequestData.data is a dict + + def transform_ocr_request( + self, + model: str, + document: DocumentType, + optional_params: Mapping[str, object], + headers: Mapping[str, str], + **kwargs: object, # kwargs-ok: BaseOCRConfig.transform_ocr_request signature + ) -> OCRRequestData: + image_url: Final = self._resolve_image_url_sync(self._image_url(document, model)) + return self._build_request(model=model, image_url=image_url, optional_params=optional_params) + + async def async_transform_ocr_request( + self, + model: str, + document: DocumentType, + optional_params: Mapping[str, object], + headers: Mapping[str, str], + **kwargs: object, # kwargs-ok: BaseOCRConfig.async_transform_ocr_request signature + ) -> OCRRequestData: + image_url: Final = await self._resolve_image_url_async(self._image_url(document, model)) + return self._build_request(model=model, image_url=image_url, optional_params=optional_params) + + def transform_ocr_response( + self, + model: str, + raw_response: httpx.Response, + logging_obj: "LiteLLMLoggingObj", + optional_params: Mapping[str, object] | None = None, + **kwargs: object, # kwargs-ok: BaseOCRConfig.transform_ocr_response signature + ) -> OCRResponse: + native: Final = _NATIVE_RESPONSE_ADAPTER.validate_python(raw_response.json()) + parsed: Final = _CohereParseResponse.model_validate(native) + pages: Final = [ # mutable-ok: OCRResponse.pages is a list + _normalize_page(page, position) for position, page in enumerate(parsed.pages) + ] + billed_pages: Final = _billed_pages(parsed) + response: Final = OCRResponse( + pages=pages, + model=model, + usage_info=OCRUsageInfo(pages_processed=billed_pages if billed_pages is not None else len(pages)), + ) + if _requested_format(optional_params) == "native": + response.set_provider_native_response(native) + return response + + def get_error_class( + self, + error_message: str, + status_code: int, + headers: Mapping[str, str], + ) -> Exception: + return CohereError(status_code=status_code, message=error_message) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 2459ed940e0..4273ec54472 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -10243,6 +10243,16 @@ ], "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/mistral/" }, + "azure_ai/Cohere-parse-v5": { + "deprecation_date": "2026-12-15", + "litellm_provider": "azure_ai", + "mode": "ocr", + "ocr_cost_per_page": 0.0015, + "source": "https://cohere.com/blog/parse", + "supported_endpoints": [ + "/v1/ocr" + ] + }, "azure_ai/doc-intelligence/prebuilt-read": { "litellm_provider": "azure_ai", "ocr_cost_per_page": 0.0015, @@ -14116,6 +14126,15 @@ "output_vector_size": 1536, "supports_embedding_image_input": true }, + "cohere/parse-v5.0": { + "litellm_provider": "cohere", + "mode": "ocr", + "ocr_cost_per_page": 0.0015, + "source": "https://cohere.com/blog/parse", + "supported_endpoints": [ + "/v1/ocr" + ] + }, "cohere.rerank-v3-5:0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, diff --git a/litellm/ocr/main.py b/litellm/ocr/main.py index b260ec6e06f..6c68971f8d5 100644 --- a/litellm/ocr/main.py +++ b/litellm/ocr/main.py @@ -191,6 +191,8 @@ def _prepare_ocr_request( def _rust_ocr_supported(prepared_request: _PreparedOCRRequest) -> bool: if prepared_request.optional_params.get(OCR_REQUEST_FORMAT_PARAM) == "native": return False + if not prepared_request.provider_config.supports_rust_bridge(): + return False return prepared_request.custom_llm_provider in _RUST_OCR_PROVIDERS diff --git a/litellm/utils.py b/litellm/utils.py index 9d20d32d147..52c1859b525 100644 --- a/litellm/utils.py +++ b/litellm/utils.py @@ -9294,6 +9294,11 @@ class ProviderConfigManager: return get_vertex_ai_ocr_config(model=model) + if provider == litellm.LlmProviders.COHERE: + from litellm.llms.cohere.ocr.transformation import CohereParseConfig + + return CohereParseConfig() + if provider == litellm.LlmProviders.REDUCTO: from litellm.llms.reducto.ocr.transformation import ( ReductoParseLegacyConfig, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 2459ed940e0..4273ec54472 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -10243,6 +10243,16 @@ ], "source": "https://azure.microsoft.com/en-us/pricing/details/ai-foundry-models/mistral/" }, + "azure_ai/Cohere-parse-v5": { + "deprecation_date": "2026-12-15", + "litellm_provider": "azure_ai", + "mode": "ocr", + "ocr_cost_per_page": 0.0015, + "source": "https://cohere.com/blog/parse", + "supported_endpoints": [ + "/v1/ocr" + ] + }, "azure_ai/doc-intelligence/prebuilt-read": { "litellm_provider": "azure_ai", "ocr_cost_per_page": 0.0015, @@ -14116,6 +14126,15 @@ "output_vector_size": 1536, "supports_embedding_image_input": true }, + "cohere/parse-v5.0": { + "litellm_provider": "cohere", + "mode": "ocr", + "ocr_cost_per_page": 0.0015, + "source": "https://cohere.com/blog/parse", + "supported_endpoints": [ + "/v1/ocr" + ] + }, "cohere.rerank-v3-5:0": { "input_cost_per_query": 0.002, "input_cost_per_token": 0.0, diff --git a/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py b/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py new file mode 100644 index 00000000000..01e1f59184c --- /dev/null +++ b/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py @@ -0,0 +1,166 @@ +import base64 +import json + +import pytest + +import litellm +from litellm.llms.azure_ai.ocr.cohere_parse_transformation import AzureAICohereParseConfig +from litellm.llms.azure_ai.ocr.common_utils import get_azure_ai_ocr_config +from litellm.llms.azure_ai.ocr.document_intelligence.transformation import AzureDocumentIntelligenceOCRConfig +from litellm.llms.azure_ai.ocr.transformation import AzureAIOCRConfig + +MODEL = "azure_ai/Cohere-parse-v5" +API_BASE = "https://resource.services.ai.azure.com" +PARSE_URL = f"{API_BASE}/providers/cohere/v2/parse" +IMAGE_URL = "https://example.com/receipt.png" +PNG_BYTES = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" +) +PNG_DATA_URI = f"data:image/png;base64,{base64.b64encode(PNG_BYTES).decode()}" + + +def _parse_response() -> dict: + return { + "id": "882bf973-9dfa-4d02-9d30-709247008efd", + "pages": [{"index": 0, "type": "markdown", "markdown": {"content": "# Receipt\n\nTotal Due: $4.00"}}], + "meta": {"api_version": {"version": "2"}, "billed_units": {"pages": 1}}, + } + + +@pytest.fixture() +def disable_aiohttp_transport(monkeypatch): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + yield + litellm.in_memory_llm_clients_cache.flush_cache() + + +@pytest.mark.parametrize( + "model, expected_config", + [ + ("Cohere-parse-v5", AzureAICohereParseConfig), + ("cohere-parse-v5", AzureAICohereParseConfig), + ("parse-v5", AzureAICohereParseConfig), + ("mistral-ocr-4-0", AzureAIOCRConfig), + ("mistral-document-ai-2512", AzureAIOCRConfig), + ("doc-intelligence/prebuilt-read", AzureDocumentIntelligenceOCRConfig), + ], +) +def test_azure_ai_ocr_routing(model: str, expected_config: type) -> None: + assert type(get_azure_ai_ocr_config(model)) is expected_config + + +@pytest.mark.parametrize( + "api_base, expected_url", + [ + (API_BASE, PARSE_URL), + (f"{API_BASE}/", PARSE_URL), + (f"{API_BASE}/models", PARSE_URL), + (f"{API_BASE}/providers/cohere/v2", PARSE_URL), + (f"{API_BASE}/providers/cohere/v2/parse", PARSE_URL), + ], +) +def test_get_complete_url_targets_the_cohere_provider_route(api_base: str, expected_url: str) -> None: + url = AzureAICohereParseConfig().get_complete_url(api_base=api_base, model="Cohere-parse-v5", optional_params={}) + + assert url == expected_url + + +def test_get_complete_url_falls_back_to_env_api_base(monkeypatch) -> None: + monkeypatch.setenv("AZURE_AI_API_BASE", API_BASE) + + url = AzureAICohereParseConfig().get_complete_url(api_base=None, model="Cohere-parse-v5", optional_params={}) + + assert url == PARSE_URL + + +def test_get_complete_url_requires_api_base(monkeypatch) -> None: + monkeypatch.delenv("AZURE_AI_API_BASE", raising=False) + + with pytest.raises(ValueError, match="AZURE_AI_API_BASE"): + AzureAICohereParseConfig().get_complete_url(api_base=None, model="Cohere-parse-v5", optional_params={}) + + +def test_get_complete_url_rejects_relative_api_base() -> None: + with pytest.raises(ValueError, match="absolute URL"): + AzureAICohereParseConfig().get_complete_url( + api_base="resource.services.ai.azure.com", model="Cohere-parse-v5", optional_params={} + ) + + +def test_validate_environment_requires_api_base(monkeypatch) -> None: + monkeypatch.delenv("AZURE_AI_API_BASE", raising=False) + + with pytest.raises(ValueError, match="AZURE_AI_API_BASE"): + AzureAICohereParseConfig().validate_environment(headers={}, model="Cohere-parse-v5", api_key="key") + + +@pytest.mark.asyncio +async def test_aocr_inlines_remote_image_and_posts_to_foundry(disable_aiohttp_transport, respx_mock): + respx_mock.get(IMAGE_URL).respond(content=PNG_BYTES, headers={"Content-Type": "image/png"}) + route = respx_mock.post(PARSE_URL).respond(json=_parse_response()) + + response = await litellm.aocr( + model=MODEL, + document={"type": "image_url", "image_url": IMAGE_URL}, + api_base=API_BASE, + api_key="azure-key", + ) + + request = route.calls.last.request + assert request.headers["Authorization"] == "Bearer azure-key" + assert json.loads(request.content) == { + "model": "Cohere-parse-v5", + "document": {"type": "image_url", "image_url": PNG_DATA_URI}, + "output_format": "markdown", + } + assert response.pages[0].markdown == "# Receipt\n\nTotal Due: $4.00" + assert response.usage_info.pages_processed == 1 + + +@pytest.mark.asyncio +async def test_aocr_passes_data_uri_through_without_fetching(disable_aiohttp_transport, respx_mock): + route = respx_mock.post(PARSE_URL).respond(json=_parse_response()) + + await litellm.aocr( + model=MODEL, + document={"type": "image_url", "image_url": PNG_DATA_URI}, + api_base=API_BASE, + api_key="azure-key", + output_format="blocks", + ) + + body = json.loads(route.calls.last.request.content) + assert body["document"]["image_url"] == PNG_DATA_URI + assert body["output_format"] == "blocks" + + +def test_ocr_sync_inlines_remote_image(respx_mock): + respx_mock.get(IMAGE_URL).respond(content=PNG_BYTES, headers={"Content-Type": "image/png"}) + route = respx_mock.post(PARSE_URL).respond(json=_parse_response()) + + response = litellm.ocr( + model=MODEL, + document={"type": "image_url", "image_url": IMAGE_URL}, + api_base=API_BASE, + api_key="azure-key", + ) + + assert json.loads(route.calls.last.request.content)["document"]["image_url"] == PNG_DATA_URI + assert response.pages[0].markdown == "# Receipt\n\nTotal Due: $4.00" + + +@pytest.mark.asyncio +async def test_aocr_rejects_pdf_before_calling_foundry(disable_aiohttp_transport, respx_mock): + route = respx_mock.post(PARSE_URL).respond(json=_parse_response()) + + with pytest.raises(litellm.BadRequestError, match="only accepts `image_url` documents") as exc_info: + await litellm.aocr( + model=MODEL, + document={"type": "document_url", "document_url": "https://example.com/doc.pdf"}, + api_base=API_BASE, + api_key="azure-key", + ) + + assert exc_info.value.llm_provider == "azure_ai" + assert not route.called diff --git a/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_cost.py b/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_cost.py new file mode 100644 index 00000000000..dfa3c7a056e --- /dev/null +++ b/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_cost.py @@ -0,0 +1,58 @@ +import json +from pathlib import Path + +import pytest + +import litellm +from litellm.cost_calculator import completion_cost +from litellm.llms.base_llm.ocr.transformation import OCRPage, OCRResponse, OCRUsageInfo + +COST_PER_PAGE = 0.0015 +REPO_ROOT = Path(__file__).parents[5] +COST_MAPS = [ + REPO_ROOT / "model_prices_and_context_window.json", + REPO_ROOT / "litellm" / "model_prices_and_context_window_backup.json", +] +MODELS = [("cohere/parse-v5.0", "cohere"), ("azure_ai/Cohere-parse-v5", "azure_ai")] + + +def _ocr_response(model: str, pages_processed: int) -> OCRResponse: + return OCRResponse( + pages=[OCRPage(index=i, markdown=f"page {i}") for i in range(pages_processed)], + model=model, + usage_info=OCRUsageInfo(pages_processed=pages_processed), + ) + + +@pytest.mark.parametrize("cost_map_path", COST_MAPS, ids=lambda path: path.name) +@pytest.mark.parametrize("model, provider", MODELS) +def test_pricing_entry(cost_map_path: Path, model: str, provider: str) -> None: + with open(cost_map_path) as f: + info = json.load(f).get(model) + + assert info is not None, f"{model} missing from {cost_map_path.name}" + assert info["litellm_provider"] == provider + assert info["mode"] == "ocr" + assert info["supported_endpoints"] == ["/v1/ocr"] + assert info["ocr_cost_per_page"] == COST_PER_PAGE + + +@pytest.mark.parametrize("model, provider", MODELS) +def test_model_info_resolves_ocr_mode_and_price(local_model_cost_map, model: str, provider: str) -> None: + info = litellm.get_model_info(model=model, custom_llm_provider=provider) + + assert info["mode"] == "ocr" + assert info["ocr_cost_per_page"] == COST_PER_PAGE + + +@pytest.mark.parametrize("model, provider", MODELS) +@pytest.mark.parametrize("pages_processed", [1, 3]) +def test_cost_scales_with_billed_pages(local_model_cost_map, model: str, provider: str, pages_processed: int) -> None: + cost = completion_cost( + completion_response=_ocr_response(model.split("/", 1)[1], pages_processed), + model=model, + custom_llm_provider=provider, + call_type="ocr", + ) + + assert cost == pytest.approx(COST_PER_PAGE * pages_processed) diff --git a/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py b/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py new file mode 100644 index 00000000000..64f2bb383c2 --- /dev/null +++ b/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py @@ -0,0 +1,217 @@ +import json + +import pytest + +import litellm + +PARSE_URL = "https://api.cohere.com/v2/parse" +MODEL = "cohere/parse-v5.0" +IMAGE_DOCUMENT = {"type": "image_url", "image_url": "https://example.com/receipt.png"} +BOUNDING_BOX = {"top_left_x": 0, "top_left_y": 0, "bottom_right_x": 32, "bottom_right_y": 32} + + +def _markdown_response(billed_pages: int | None = 2) -> dict: + return { + "id": "272900cc-04c0-4da2-a505-2cea58d231bf", + "pages": [ + { + "index": 0, + "type": "markdown", + "markdown": { + "content": "# Receipt\n\nTotal Due: $4.00", + "images": [ + { + "id": "img-0", + "description": "A parking receipt", + "category": "other", + "bounding_box": BOUNDING_BOX, + "bounding_box_normalized": { + "top_left_x": 0, + "top_left_y": 0, + "bottom_right_x": 1, + "bottom_right_y": 1, + }, + } + ], + }, + }, + {"index": 1, "type": "markdown", "markdown": {"content": "Page two"}}, + ], + **( + {"meta": {"api_version": {"version": "2"}, "billed_units": {"pages": billed_pages}}} if billed_pages else {} + ), + } + + +def _blocks_response() -> dict: + return { + "id": "94474f83-e30d-4763-b4bc-52af6e12c4f7", + "pages": [ + { + "index": 0, + "type": "blocks", + "blocks": [{"type": "text", "text": "Total Due: $4.00"}], + } + ], + "meta": {"api_version": {"version": "2"}, "billed_units": {"pages": 1}}, + } + + +@pytest.fixture() +def disable_aiohttp_transport(monkeypatch): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + yield + litellm.in_memory_llm_clients_cache.flush_cache() + + +@pytest.mark.asyncio +async def test_aocr_sends_markdown_parse_request_and_normalizes_pages(disable_aiohttp_transport, respx_mock): + route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) + + response = await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key") + + request = route.calls.last.request + assert request.headers["Authorization"] == "Bearer test-key" + assert json.loads(request.content) == { + "model": "parse-v5.0", + "document": IMAGE_DOCUMENT, + "output_format": "markdown", + } + assert response.object == "ocr" + assert [page.index for page in response.pages] == [0, 1] + assert response.pages[0].markdown == "# Receipt\n\nTotal Due: $4.00" + assert response.pages[1].markdown == "Page two" + assert response.pages[1].images is None + image = response.pages[0].images[0] + assert image.bbox == BOUNDING_BOX + assert image.model_extra["description"] == "A parking receipt" + assert image.model_extra["bounding_box_normalized"]["bottom_right_x"] == 1 + assert response.usage_info.pages_processed == 2 + assert response.get_provider_native_response() is None + + +@pytest.mark.asyncio +async def test_aocr_usage_prefers_billed_units_over_page_count(disable_aiohttp_transport, respx_mock): + respx_mock.post(PARSE_URL).respond(json=_markdown_response(billed_pages=3)) + + response = await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key") + + assert response.usage_info.pages_processed == 3 + + +@pytest.mark.asyncio +async def test_aocr_usage_falls_back_to_page_count_without_meta(disable_aiohttp_transport, respx_mock): + respx_mock.post(PARSE_URL).respond(json=_markdown_response(billed_pages=None)) + + response = await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key") + + assert response.usage_info.pages_processed == 2 + + +@pytest.mark.asyncio +async def test_aocr_blocks_output_format_forwards_param_and_keeps_blocks(disable_aiohttp_transport, respx_mock): + route = respx_mock.post(PARSE_URL).respond(json=_blocks_response()) + + response = await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key", output_format="blocks") + + assert json.loads(route.calls.last.request.content)["output_format"] == "blocks" + assert response.pages[0].markdown == "" + assert response.pages[0].model_extra["blocks"] == [{"type": "text", "text": "Total Due: $4.00"}] + assert response.usage_info.pages_processed == 1 + + +@pytest.mark.asyncio +async def test_aocr_native_format_carries_provider_payload(disable_aiohttp_transport, respx_mock): + payload = _markdown_response() + route = respx_mock.post(PARSE_URL).respond(json=payload) + + response = await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key", req_format="native") + + assert "req_format" not in json.loads(route.calls.last.request.content) + assert response.get_provider_native_response() == payload + assert response.pages[0].markdown == "# Receipt\n\nTotal Due: $4.00" + + +@pytest.mark.asyncio +async def test_aocr_rejects_unknown_output_format_before_calling_provider(disable_aiohttp_transport, respx_mock): + route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) + + with pytest.raises(litellm.BadRequestError, match="Invalid `output_format`: 'html'") as exc_info: + await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key", output_format="html") + + assert exc_info.value.status_code == 400 + assert not route.called + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "document", + [ + {"type": "document_url", "document_url": "https://example.com/doc.pdf"}, + {"type": "image_url", "image_url": "data:application/pdf;base64,JVBERi0="}, + {"type": "image_url", "image_url": ""}, + ], +) +async def test_aocr_rejects_non_image_documents_before_calling_provider( + disable_aiohttp_transport, respx_mock, document +): + route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) + + with pytest.raises(litellm.BadRequestError, match="only accepts `image_url` documents") as exc_info: + await litellm.aocr(model=MODEL, document=document, api_key="test-key") + + assert exc_info.value.status_code == 400 + assert not route.called + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "api_base, expected_url", + [ + ("https://gateway.example.com", "https://gateway.example.com/v2/parse"), + ("https://gateway.example.com/cohere/", "https://gateway.example.com/cohere/v2/parse"), + ("https://gateway.example.com/v2", "https://gateway.example.com/v2/parse"), + ("https://gateway.example.com/v2/parse", "https://gateway.example.com/v2/parse"), + ], +) +async def test_aocr_posts_to_api_base_variants(disable_aiohttp_transport, respx_mock, api_base, expected_url): + route = respx_mock.post(expected_url).respond(json=_markdown_response()) + + await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key", api_base=api_base) + + assert route.called + + +@pytest.mark.asyncio +async def test_aocr_surfaces_provider_error_with_its_status_and_message(disable_aiohttp_transport, respx_mock): + respx_mock.post(PARSE_URL).respond( + status_code=400, json={"id": "83b0d95e", "message": "output_format must be `blocks` or `markdown`"} + ) + + with pytest.raises(litellm.BadRequestError, match="output_format must be") as exc_info: + await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT, api_key="test-key") + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_aocr_reads_api_key_from_environment(disable_aiohttp_transport, respx_mock, monkeypatch): + monkeypatch.setenv("COHERE_API_KEY", "env-key") + route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) + + await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT) + + assert route.calls.last.request.headers["Authorization"] == "Bearer env-key" + + +@pytest.mark.asyncio +async def test_aocr_without_api_key_names_the_env_var(disable_aiohttp_transport, respx_mock, monkeypatch): + monkeypatch.delenv("COHERE_API_KEY", raising=False) + monkeypatch.setattr(litellm, "cohere_key", None) + route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) + + with pytest.raises(Exception, match="Missing COHERE_API_KEY"): + await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT) + + assert not route.called diff --git a/tests/test_litellm/ocr/test_ocr_native_format.py b/tests/test_litellm/ocr/test_ocr_native_format.py index 463213a2071..249fbda713e 100644 --- a/tests/test_litellm/ocr/test_ocr_native_format.py +++ b/tests/test_litellm/ocr/test_ocr_native_format.py @@ -4,11 +4,14 @@ providers that don't support a native response must reject it, and the Rust bridge (which only returns the normalized shape) must not serve native requests. """ +import dataclasses from unittest.mock import MagicMock import pytest import litellm +from litellm.llms.azure_ai.ocr.cohere_parse_transformation import AzureAICohereParseConfig +from litellm.llms.cohere.ocr.transformation import CohereParseConfig from litellm.ocr.main import _PreparedOCRRequest, _rust_ocr_supported DOCUMENT = {"type": "document_url", "document_url": "https://example.com/doc.pdf"} @@ -39,6 +42,13 @@ def test_rust_ocr_skipped_for_native_format(): assert _rust_ocr_supported(_prepared({"req_format": "native"})) is False +@pytest.mark.parametrize("provider_config", [CohereParseConfig(), AzureAICohereParseConfig()]) +def test_rust_ocr_skipped_for_configs_without_bridge_support(provider_config): + prepared = dataclasses.replace(_prepared({}), provider_config=provider_config) + + assert _rust_ocr_supported(prepared) is False + + @pytest.mark.asyncio async def test_native_format_rejected_for_provider_without_support_as_bad_request(): with pytest.raises(litellm.BadRequestError, match="not supported for provider") as exc_info: From d3a179f98871abdacd3b50d041aa61205cfec564 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:07:26 -0700 Subject: [PATCH 196/410] fix(azure_ai): route only cohere parse deployment names to Cohere Parse --- litellm/llms/azure_ai/ocr/common_utils.py | 3 ++- .../azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/litellm/llms/azure_ai/ocr/common_utils.py b/litellm/llms/azure_ai/ocr/common_utils.py index a4cd0c7a30b..2ca2ad9ec2f 100644 --- a/litellm/llms/azure_ai/ocr/common_utils.py +++ b/litellm/llms/azure_ai/ocr/common_utils.py @@ -25,7 +25,8 @@ def is_azure_document_intelligence_model(model: str) -> bool: def is_azure_cohere_parse_model(model: str) -> bool: - return "parse" in model.lower() + lowered: Final = model.lower() + return "cohere" in lowered and "parse" in lowered def get_azure_ai_ocr_config(model: str) -> Optional["BaseOCRConfig"]: diff --git a/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py b/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py index 01e1f59184c..8c4dd25aa77 100644 --- a/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py +++ b/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py @@ -40,7 +40,9 @@ def disable_aiohttp_transport(monkeypatch): [ ("Cohere-parse-v5", AzureAICohereParseConfig), ("cohere-parse-v5", AzureAICohereParseConfig), - ("parse-v5", AzureAICohereParseConfig), + ("cohere/parse-v5", AzureAICohereParseConfig), + ("invoice-parser", AzureAIOCRConfig), + ("parse-v5", AzureAIOCRConfig), ("mistral-ocr-4-0", AzureAIOCRConfig), ("mistral-document-ai-2512", AzureAIOCRConfig), ("doc-intelligence/prebuilt-read", AzureDocumentIntelligenceOCRConfig), From 78ad88f52c0259e20da5c7a2b15ba3d42525fd29 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Fri, 4 Sep 2026 22:14:44 -0700 Subject: [PATCH 197/410] fix(responses): decode JSON-string tool schemas before sending to the provider (#39844) * fix(responses): decode JSON-string tool schemas before sending to the provider A caller that hands a tool schema over already JSON-encoded reached the Responses API with a string `parameters`, and the provider rejected the request with a 400 naming the routed model instead of the offending tool. Decode it at the one place every Responses request converges, and refuse anything that is neither an object nor a string encoding one. Collapses the duplicated input/tool sanitization block shared by the request and compact-request builders into a single owner, so the decode cannot be wired into one path and not the other. * test(responses): pin null tool schemas as accepted, and type the parametrized cases The Responses API serves `parameters: null` and an omitted schema alike, so neither may raise. Pin both against a future tightening, annotate the parametrized inputs, and trim the docstrings back to what the code does not already say. --- .../llms/openai/responses/transformation.py | 81 ++++++++++++---- .../test_openai_responses_transformation.py | 97 +++++++++++++++++++ type-discipline-budget.json | 6 +- 3 files changed, 164 insertions(+), 20 deletions(-) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index b97521b90c2..d7c2fcace09 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -13,6 +13,7 @@ from litellm.litellm_core_utils.core_helpers import process_response_headers from litellm.litellm_core_utils.llm_response_utils.convert_dict_to_response import ( _safe_convert_created_field, ) +from litellm.litellm_core_utils.safe_json_loads import safe_json_loads from litellm.litellm_core_utils.url_utils import encode_url_path_segment from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig from litellm.llms.openai.chat.gpt_5_transformation import is_gpt_reasoning_series_name @@ -205,29 +206,76 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): `remove_cache_control_flag_from_messages_and_tools`; mirror that here. """ - input = self._validate_input_param(input) - tools = response_api_optional_request_params.get("tools") - input, tools = self.remove_cache_control_flag_from_input_and_tools(model=model, input=input, tools=tools) - sanitized_tools: Final = self._flatten_tool_schema_combinators_for_openai( - model=model, tools=tools, litellm_params=litellm_params + replay_safe_input, sanitized_tools = self._prepared_input_and_tools( + model=model, + input=input, + tools=response_api_optional_request_params.get("tools"), + litellm_params=litellm_params, ) if sanitized_tools is not None: response_api_optional_request_params["tools"] = sanitized_tools - replay_safe_input: Final = self._drop_foreign_tool_call_item_ids(input) final_request_params: Final = dict( ResponsesAPIRequestParams(model=model, input=replay_safe_input, **response_api_optional_request_params) ) return final_request_params + def _prepared_input_and_tools( + self, + model: str, + input: str | ResponseInputParam, + tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None, + litellm_params: GenericLiteLLMParams, + ) -> tuple[str | ResponseInputParam, Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None]: + validated_input: Final = self._validate_input_param(input) + stripped_input, stripped_tools = self.remove_cache_control_flag_from_input_and_tools( + model=model, input=validated_input, tools=tools + ) + object_schema_tools: Final = self._tools_with_object_parameters(model=model, tools=stripped_tools) + sanitized_tools: Final = self._flatten_tool_schema_combinators_for_openai( + model=model, tools=object_schema_tools, litellm_params=litellm_params + ) + return self._drop_foreign_tool_call_item_ids(stripped_input), sanitized_tools + + def _tools_with_object_parameters( + self, model: str, tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None + ) -> Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None: + """Decode tool schemas handed over already JSON-encoded, which the Responses validator + rejects with a 400 naming the routed model rather than the tool. A null or absent schema + is left alone because the API accepts both.""" + if tools is None: + return None + decoded: Final = [ # mutable-ok: request tools are a JSON list + self._tool_with_object_parameters(model=model, index=index, tool=tool) for index, tool in enumerate(tools) + ] + return cast("Sequence[ALL_RESPONSES_API_TOOL_PARAMS]", decoded) # cast-ok: dict spread keeps each tool's shape + + def _tool_with_object_parameters(self, model: str, index: int, tool: object) -> object: + if not isinstance(tool, dict) or tool.get("parameters") is None: + return tool + parameters: Final = tool["parameters"] + if isinstance(parameters, dict): + return tool + decoded: Final = safe_json_loads(parameters) if isinstance(parameters, str) else None + if isinstance(decoded, dict): + return {**tool, "parameters": decoded} # mutable-ok: request tools are JSON dicts + raise litellm.BadRequestError( + message=( + f"Invalid type for 'tools[{index}].parameters': expected an object, " + f"but got {type(parameters).__name__} instead." + ), + model=model, + llm_provider=self.custom_llm_provider, + ) + def remove_cache_control_flag_from_input_and_tools( self, model: str, # allows overrides to selectively run this input: str | ResponseInputParam, - tools: list[ALL_RESPONSES_API_TOOL_PARAMS] | None = None, + tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None = None, ) -> tuple[ str | ResponseInputParam, - list[ALL_RESPONSES_API_TOOL_PARAMS] | None, + Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None, ]: """Sibling of `remove_cache_control_flag_from_messages_and_tools` on the chat path. Strips Anthropic-only `cache_control` markers from @@ -272,9 +320,9 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): def _flatten_tool_schema_combinators_for_openai( self, model: str, - tools: list[ALL_RESPONSES_API_TOOL_PARAMS] | None, # mutable-ok: request tools are a JSON list + tools: Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None, litellm_params: GenericLiteLLMParams, - ) -> list[ALL_RESPONSES_API_TOOL_PARAMS] | None: # mutable-ok: request tools are a JSON list + ) -> Sequence[ALL_RESPONSES_API_TOOL_PARAMS] | None: """Flatten top-level schema combinators only where OpenAI's validator rejects them. OpenAI-compatible backends reusing this config (and the ChatGPT backend @@ -293,7 +341,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): flattened: Final = [ # mutable-ok: request tools are a JSON list self._flattened_tool_or_passthrough(tool) for tool in tools ] - return cast("list[ALL_RESPONSES_API_TOOL_PARAMS]", flattened) # cast-ok: dict spread keeps each tool's shape + return cast("Sequence[ALL_RESPONSES_API_TOOL_PARAMS]", flattened) # cast-ok: spread keeps each tool's shape @staticmethod def _flattened_tool_or_passthrough(tool: object) -> object: @@ -786,15 +834,14 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): compact_path: Final = parsed_url.path.rstrip("/") + "/compact" url: Final = str(parsed_url.copy_with(path=compact_path)) - input = self._validate_input_param(input) - tools = response_api_optional_request_params.get("tools") - input, tools = self.remove_cache_control_flag_from_input_and_tools(model=model, input=input, tools=tools) - sanitized_tools: Final = self._flatten_tool_schema_combinators_for_openai( - model=model, tools=tools, litellm_params=litellm_params + replay_safe_input, sanitized_tools = self._prepared_input_and_tools( + model=model, + input=input, + tools=response_api_optional_request_params.get("tools"), + litellm_params=litellm_params, ) if sanitized_tools is not None: response_api_optional_request_params["tools"] = sanitized_tools - replay_safe_input: Final = self._drop_foreign_tool_call_item_ids(input) data: Final = dict( ResponsesAPIRequestParams(model=model, input=replay_safe_input, **response_api_optional_request_params) ) diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index 4ac072d0ca6..a5b748e391c 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -300,6 +300,89 @@ class TestOpenAIResponsesAPIConfig: assert result["input"][0]["id"] == "toolu_01Foreign" + @pytest.mark.parametrize( + "raw_parameters", + [ + '{"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}', + '{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}', + ], + ) + def test_transform_decodes_json_string_tool_parameters(self, raw_parameters: str): + """A JSON-encoded schema must reach the provider as an object.""" + result = self.config.transform_responses_api_request( + model=self.model, + input="weather in Paris", + response_api_optional_request_params={ + "tools": [{"type": "function", "name": "get_weather", "parameters": raw_parameters}] + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert result["tools"][0]["parameters"] == { + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + } + + def test_transform_decodes_json_string_tool_parameters_on_compact_request(self): + """The compact request path builds the same wire body, so it must decode too.""" + _url, data = self.config.transform_compact_response_api_request( + model=self.model, + input="weather in Paris", + response_api_optional_request_params={ + "tools": [{"type": "function", "name": "get_weather", "parameters": '{"type": "object"}'}] + }, + api_base="https://api.openai.com/v1/responses", + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert data["tools"][0]["parameters"] == {"type": "object"} + + @pytest.mark.parametrize("raw_parameters", ['"just a string"', "not json at all", "[1, 2, 3]", 42]) + def test_transform_rejects_tool_parameters_that_are_not_an_object(self, raw_parameters: object): + """Neither an object nor a string encoding one is a client error naming the tool index.""" + with pytest.raises(litellm.BadRequestError) as exc_info: + self.config.transform_responses_api_request( + model=self.model, + input="weather in Paris", + response_api_optional_request_params={ + "tools": [ + {"type": "web_search_preview"}, + {"type": "function", "name": "get_weather", "parameters": raw_parameters}, + ] + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert "tools[1].parameters" in str(exc_info.value) + + def test_transform_leaves_object_null_and_absent_tool_parameters_untouched(self): + """The API accepts an object schema, an explicit null, an omitted schema and a built-in + tool, so decoding must forward all four unchanged rather than raising.""" + schema = {"type": "object", "properties": {"city": {"type": "string"}}} + tools = [ + {"type": "function", "name": "get_weather", "parameters": schema}, + {"type": "function", "name": "null_args", "parameters": None}, + {"type": "function", "name": "no_args"}, + {"type": "web_search_preview"}, + ] + + result = self.config.transform_responses_api_request( + model=self.model, + input="weather in Paris", + response_api_optional_request_params={"tools": tools}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert result["tools"][0]["parameters"] == schema + assert result["tools"][1]["parameters"] is None + assert "parameters" not in result["tools"][2] + assert result["tools"][3] == {"type": "web_search_preview"} + def test_transform_compact_drops_foreign_tool_call_item_ids(self): """The compact request path replays input the same way, so it must apply the same id drop.""" @@ -864,6 +947,20 @@ class TestAzureResponsesAPIConfig: self.model = "gpt-4o" self.logging_obj = MagicMock() + def test_azure_decodes_json_string_tool_parameters(self): + """Azure reaches the same wire through `super()`, after un-nesting a chat-shaped tool.""" + result = self.config.transform_responses_api_request( + model=self.model, + input="weather in Paris", + response_api_optional_request_params={ + "tools": [{"type": "function", "function": {"name": "get_weather", "parameters": '{"type":"object"}'}}] + }, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert result["tools"][0]["parameters"] == {"type": "object"} + def test_azure_get_complete_url_with_version_types(self): """Test Azure get_complete_url with different API version types""" base_url = "https://litellm8397336933.openai.azure.com" diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 4a3ca612d41..481e3591ce9 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,6 +1,6 @@ { "LIT001": { - "limit": 22183 + "limit": 22181 }, "LIT002": { "limit": 26745 @@ -27,10 +27,10 @@ "limit": 0 }, "LIT010": { - "limit": 16468 + "limit": 16464 }, "LIT011": { - "limit": 5510 + "limit": 5506 }, "LIT012": { "limit": 4486 From e9a40ad4d2d6712812e9b9210b28d4c291cdfc0b Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:16:14 -0700 Subject: [PATCH 198/410] fix(fireworks_ai): map developer items after pydantic input items are dumped --- .../fireworks_ai/responses/transformation.py | 5 ++- ...t_fireworks_ai_responses_transformation.py | 43 ++++++++++++++++++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/litellm/llms/fireworks_ai/responses/transformation.py b/litellm/llms/fireworks_ai/responses/transformation.py index 121d3b5d9a9..fb0587553d4 100644 --- a/litellm/llms/fireworks_ai/responses/transformation.py +++ b/litellm/llms/fireworks_ai/responses/transformation.py @@ -68,6 +68,9 @@ class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig): base: Final = (api_base or get_secret_str("FIREWORKS_API_BASE") or FIREWORKS_AI_DEFAULT_API_BASE).rstrip("/") return f"{base}/responses" + def _validate_input_param(self, input: str | ResponseInputParam) -> str | ResponseInputParam: + return _developer_items_as_system(super()._validate_input_param(input)) + def transform_responses_api_request( self, model: str, @@ -78,7 +81,7 @@ class FireworksAIResponsesAPIConfig(OpenAIResponsesAPIConfig): ) -> dict: # mutable-ok: overrides the base class signature return super().transform_responses_api_request( model=resolve_fireworks_resource_name(model), - input=_developer_items_as_system(input), + input=input, response_api_optional_request_params=response_api_optional_request_params, litellm_params=litellm_params, headers=headers, diff --git a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py index f948acb7bf3..b9408d44e9a 100644 --- a/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py +++ b/tests/test_litellm/llms/fireworks_ai/responses/test_fireworks_ai_responses_transformation.py @@ -1,13 +1,14 @@ import json from collections.abc import Mapping from types import MappingProxyType -from typing import Final, TypedDict +from typing import Final, TypedDict, cast from unittest.mock import MagicMock, patch from urllib.parse import quote import httpx import pytest from openai.types.responses import ( + EasyInputMessage, ResponseFunctionToolCall, ResponseOutputMessage, ResponseOutputText, @@ -20,7 +21,7 @@ from typing_extensions import ReadOnly import litellm from litellm.llms.fireworks_ai.responses.transformation import FireworksAIResponsesAPIConfig from litellm.responses.file_search.emulated_handler import should_use_emulated_file_search -from litellm.types.llms.openai import InputTokensDetails, ResponseAPIUsage, ResponsesAPIResponse +from litellm.types.llms.openai import InputTokensDetails, ResponseAPIUsage, ResponseInputParam, ResponsesAPIResponse from litellm.types.router import GenericLiteLLMParams from litellm.types.utils import LlmProviders from litellm.utils import ProviderConfigManager @@ -181,6 +182,44 @@ def test_responses_call_sends_developer_items_as_system_messages() -> None: ) +def test_responses_call_maps_pydantic_developer_items_and_replays_pydantic_output_items() -> None: + client: Final = _mock_http_client(_fireworks_response("accounts/fireworks/models/kimi-k3")) + pydantic_input: Final = cast( + ResponseInputParam, + [ # mutable-ok: the Responses API takes input as a JSON list + EasyInputMessage(role="developer", content="Answer with exactly one word.", type="message"), + ResponseReasoningItem(id="rs_1", summary=(), type="reasoning"), + ResponseFunctionToolCall( + id="fc_1", + call_id="call_abc123", + name="get_weather", + arguments='{"city": "Paris"}', + status="completed", + type="function_call", + ), + FunctionCallOutput(type="function_call_output", call_id="call_abc123", output="21C"), + ], + ) + with patch(HTTPX_CLIENT_FACTORY, return_value=client): + litellm.responses( + model="fireworks_ai/accounts/fireworks/models/kimi-k3", input=pydantic_input, api_key="fw-test-key" + ) + _, _, body = _sent_request(client) + assert tuple(body["input"]) == ( + {"role": "system", "content": "Answer with exactly one word.", "type": "message"}, + {"id": "rs_1", "summary": [], "type": "reasoning"}, + { + "id": "fc_1", + "call_id": "call_abc123", + "name": "get_weather", + "arguments": '{"city": "Paris"}', + "status": "completed", + "type": "function_call", + }, + {"type": "function_call_output", "call_id": "call_abc123", "output": "21C"}, + ) + + def test_file_search_tools_take_litellm_emulated_search_not_fireworks() -> None: config: Final = FireworksAIResponsesAPIConfig() file_search: Final = ({"type": "file_search", "vector_store_ids": ("vs_kb",)},) From 836c20a4b6dd931d119ac2cce92977c3c947e062 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:31:06 -0700 Subject: [PATCH 199/410] test(ai-gateway): drop provider fingerprint and cache identity assertions --- litellm-rust/crates/ai-gateway/src/io/tls.rs | 43 +------------------- 1 file changed, 1 insertion(+), 42 deletions(-) diff --git a/litellm-rust/crates/ai-gateway/src/io/tls.rs b/litellm-rust/crates/ai-gateway/src/io/tls.rs index 16fd11e2e79..a2562f60345 100644 --- a/litellm-rust/crates/ai-gateway/src/io/tls.rs +++ b/litellm-rust/crates/ai-gateway/src/io/tls.rs @@ -69,26 +69,7 @@ where #[cfg(test)] mod tests { - use rustls::CipherSuite; - use rustls::NamedGroup; - use rustls::crypto::{CryptoProvider, aws_lc_rs, ring}; - - use super::{Arc, build_config, tls_config}; - - fn fingerprint(provider: &CryptoProvider) -> (Vec, Vec) { - ( - provider - .cipher_suites - .iter() - .map(|suite| suite.suite()) - .collect(), - provider - .kx_groups - .iter() - .map(|group| group.name()) - .collect(), - ) - } + use super::build_config; #[test] fn builds_a_usable_config_with_both_provider_features_enabled() { @@ -96,26 +77,4 @@ mod tests { assert!(!config.crypto_provider().cipher_suites.is_empty()); } - - #[test] - fn dials_with_ring_rather_than_aws_lc_rs() { - let config = build_config().expect("a client config"); - - assert_eq!( - fingerprint(config.crypto_provider()), - fingerprint(&ring::default_provider()) - ); - assert_ne!( - fingerprint(config.crypto_provider()), - fingerprint(&aws_lc_rs::default_provider()) - ); - } - - #[test] - fn the_trust_store_is_loaded_once_and_shared() { - let first = tls_config().expect("a client config"); - let second = tls_config().expect("a client config"); - - assert!(Arc::ptr_eq(&first, &second)); - } } From e1d900d1c29e7f5dbd5db2ca09ab3592312bb1fe Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:47:09 -0700 Subject: [PATCH 200/410] fix(realtime): store text_tokens without the nested reasoning share The realtime usage writer passed the provider's output_token_details through as sent, so spend logs and callbacks kept a text_tokens that still contained reasoning_tokens while every other completion_tokens_details producer stores the partitioned share. The writer now applies the same rule the cost calculator uses, moved to litellm/types/utils.py so both read one definition, and the calculator keeps it for usage objects that arrive nested from elsewhere --- .../litellm_core_utils/llm_cost_calc/utils.py | 14 +----- litellm/responses/utils.py | 25 ++++++++-- litellm/types/utils.py | 11 ++++ .../responses/test_responses_utils.py | 50 +++++++++++++++++++ tests/test_litellm/test_cost_calculator.py | 39 +++++++++++++++ 5 files changed, 123 insertions(+), 16 deletions(-) diff --git a/litellm/litellm_core_utils/llm_cost_calc/utils.py b/litellm/litellm_core_utils/llm_cost_calc/utils.py index 1b8980abd35..9432fefc368 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/utils.py +++ b/litellm/litellm_core_utils/llm_cost_calc/utils.py @@ -26,6 +26,7 @@ from litellm.types.utils import ( PromptTokensDetailsWrapper, ServiceTier, Usage, + text_tokens_without_nested_reasoning, ) from litellm.utils import get_model_info @@ -852,17 +853,6 @@ class CompletionTokensDetailsResult(TypedDict): video_tokens: int -def _text_tokens_without_nested_reasoning( - completion_tokens: int, - text_tokens: int, - reasoning_tokens: int, - other_modality_tokens: int, -) -> int: - reported_total: Final = text_tokens + reasoning_tokens + other_modality_tokens - nested_reasoning_tokens: Final = min(reasoning_tokens, text_tokens, max(reported_total - completion_tokens, 0)) - return text_tokens - nested_reasoning_tokens - - def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResult: audio_tokens: Final = ( cast( @@ -893,7 +883,7 @@ def parse_completion_tokens_details(usage: Usage) -> CompletionTokensDetailsResu or 0 ) video_tokens: Final = _coerce_token_count(getattr(usage.completion_tokens_details, "video_tokens", 0)) - text_tokens: Final = _text_tokens_without_nested_reasoning( + text_tokens: Final = text_tokens_without_nested_reasoning( completion_tokens=usage.completion_tokens, text_tokens=reported_text_tokens, reasoning_tokens=reasoning_tokens, diff --git a/litellm/responses/utils.py b/litellm/responses/utils.py index 39675faf735..28f590dcb66 100644 --- a/litellm/responses/utils.py +++ b/litellm/responses/utils.py @@ -25,9 +25,15 @@ from litellm.types.utils import ( PromptTokensDetailsWrapper, SpecialEnums, Usage, + text_tokens_without_nested_reasoning, ) +def _output_token_detail(details: object, field: str) -> int | None: + value: Final = getattr(details, field, None) + return value if isinstance(value, int) else None + + def _is_object_sequence(value: object) -> TypeIs[Sequence[object]]: # guard-ok: a list is a Sequence of anything return isinstance(value, list) @@ -1134,11 +1140,22 @@ class ResponseAPILoggingUtils: response_api_usage, "output_tokens_details", None ) if output_tokens_details: + reasoning_tokens: Final = _output_token_detail(output_tokens_details, "reasoning_tokens") + image_tokens: Final = _output_token_detail(output_tokens_details, "image_tokens") + audio_tokens: Final = _output_token_detail(output_tokens_details, "audio_tokens") + reported_text_tokens: Final = _output_token_detail(output_tokens_details, "text_tokens") completion_tokens_details = CompletionTokensDetailsWrapper( - reasoning_tokens=getattr(output_tokens_details, "reasoning_tokens", None), - image_tokens=getattr(output_tokens_details, "image_tokens", None), - text_tokens=getattr(output_tokens_details, "text_tokens", None), - audio_tokens=getattr(output_tokens_details, "audio_tokens", None), + reasoning_tokens=reasoning_tokens, + image_tokens=image_tokens, + text_tokens=None + if reported_text_tokens is None + else text_tokens_without_nested_reasoning( + completion_tokens=completion_tokens, + text_tokens=reported_text_tokens, + reasoning_tokens=reasoning_tokens or 0, + other_modality_tokens=(audio_tokens or 0) + (image_tokens or 0), + ), + audio_tokens=audio_tokens, ) extra_usage_fields: Final = { diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 5052cd6ef48..9b24f1d837b 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -1622,6 +1622,17 @@ class Choices(SafeAttributeModel, OpenAIObject): setattr(self, key, value) +def text_tokens_without_nested_reasoning( + completion_tokens: int, + text_tokens: int, + reasoning_tokens: int, + other_modality_tokens: int, +) -> int: + reported_total: Final = text_tokens + reasoning_tokens + other_modality_tokens + nested_reasoning_tokens: Final = min(reasoning_tokens, text_tokens, max(reported_total - completion_tokens, 0)) + return text_tokens - nested_reasoning_tokens + + class CompletionTokensDetailsWrapper(CompletionTokensDetails): # wrapper for older openai versions text_tokens: int | None = None """Text tokens generated by the model.""" diff --git a/tests/test_litellm/responses/test_responses_utils.py b/tests/test_litellm/responses/test_responses_utils.py index cb6efa21036..9d9eefdceb3 100644 --- a/tests/test_litellm/responses/test_responses_utils.py +++ b/tests/test_litellm/responses/test_responses_utils.py @@ -440,6 +440,56 @@ class TestResponseAPILoggingUtils: assert result.completion_tokens_details.text_tokens == 20 assert result.completion_tokens_details.audio_tokens is None + def test_transform_realtime_usage_partitions_reasoning_out_of_text_tokens(self): + """Realtime nests reasoning_tokens inside text_tokens; the stored text share excludes them.""" + usage = { + "input_tokens": 237, + "output_tokens": 70, + "total_tokens": 307, + "input_token_details": {"text_tokens": 43, "audio_tokens": 0, "image_tokens": 194, "cached_tokens": 0}, + "output_token_details": {"text_tokens": 70, "audio_tokens": 0, "reasoning_tokens": 52}, + } + + result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage) + + assert result.completion_tokens == 70 + assert result.completion_tokens_details is not None + assert result.completion_tokens_details.text_tokens == 18 + assert result.completion_tokens_details.reasoning_tokens == 52 + assert result.completion_tokens_details.audio_tokens == 0 + + def test_transform_realtime_usage_partitions_reasoning_beside_audio_output(self): + """Audio output stays as reported; only the text share sheds the nested reasoning tokens.""" + usage = { + "input_tokens": 100, + "output_tokens": 70, + "total_tokens": 170, + "input_token_details": {"text_tokens": 100, "audio_tokens": 0, "cached_tokens": 0}, + "output_token_details": {"text_tokens": 39, "audio_tokens": 31, "reasoning_tokens": 23}, + } + + result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage) + + assert result.completion_tokens_details is not None + assert result.completion_tokens_details.text_tokens == 16 + assert result.completion_tokens_details.audio_tokens == 31 + assert result.completion_tokens_details.reasoning_tokens == 23 + + def test_transform_response_api_usage_keeps_partitioned_text_tokens(self): + """A provider already reporting text_tokens beside reasoning_tokens is stored as sent.""" + usage = { + "input_tokens": 10, + "output_tokens": 20, + "total_tokens": 30, + "output_tokens_details": {"text_tokens": 12, "reasoning_tokens": 5}, + } + + result = ResponseAPILoggingUtils._transform_response_api_usage_to_chat_usage(usage) + + assert result.completion_tokens_details is not None + assert result.completion_tokens_details.text_tokens == 12 + assert result.completion_tokens_details.reasoning_tokens == 5 + def test_transform_response_api_usage_carries_extra_provider_fields(self): """Non-standard usage fields (e.g. xAI tool details) must survive chat normalization.""" details = {"web_search_calls": 2, "x_search_calls": 0} diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 3eb051982e4..1f14fdc2c67 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -4534,3 +4534,42 @@ def test_handle_realtime_stream_cost_calculation_bills_nested_reasoning_tokens_o ) assert total_cost == pytest.approx(expected) assert total_cost == pytest.approx(0.0002362) + + +def test_collect_and_combine_realtime_usage_stores_partitioned_text_tokens() -> None: + """The combined usage that lands in spend logs keeps reasoning out of text_tokens for every turn.""" + results: OpenAIRealtimeStreamList = [ + {"type": "session.created", "session": {"model": "gpt-realtime-2.1-mini"}}, + { + "type": "response.done", + "response": { + "usage": { + "total_tokens": 307, + "input_tokens": 237, + "output_tokens": 70, + "input_token_details": {"text_tokens": 43, "audio_tokens": 0, "image_tokens": 194, "cached_tokens": 0}, + "output_token_details": {"text_tokens": 70, "audio_tokens": 0, "reasoning_tokens": 52}, + } + }, + }, + { + "type": "response.done", + "response": { + "usage": { + "total_tokens": 363, + "input_tokens": 300, + "output_tokens": 63, + "input_token_details": {"text_tokens": 106, "audio_tokens": 0, "image_tokens": 194, "cached_tokens": 0}, + "output_token_details": {"text_tokens": 63, "audio_tokens": 0, "reasoning_tokens": 43}, + } + }, + }, + ] + + combined = RealtimeAPITokenUsageProcessor.collect_and_combine_usage_from_realtime_stream_results(results=results) + + assert combined.completion_tokens == 133 + assert combined.completion_tokens_details is not None + assert combined.completion_tokens_details.reasoning_tokens == 95 + assert combined.completion_tokens_details.text_tokens == 38 + assert combined.completion_tokens_details.audio_tokens == 0 From 004a8201167eb0bd85d52e86473adb9b9b8d1ee7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:52:12 -0700 Subject: [PATCH 201/410] fix(ocr): send each provider a health-check document it accepts Health checks probed every OCR deployment with a PDF, which Cohere Parse rejects, so /health, background health checks, and the UI Test Connection button marked Cohere Parse deployments unhealthy. BaseOCRConfig gains a get_health_check_document hook (PDF by default) that CohereParseConfig overrides with a 1x1 PNG data URI. cohere also gains ocr in the provider endpoint matrix --- .../health_check_helpers.py | 18 +++++++----- litellm/llms/base_llm/ocr/transformation.py | 8 +++++ litellm/llms/cohere/ocr/transformation.py | 9 ++++++ .../provider_endpoints_support_backup.json | 1 + provider_endpoints_support.json | 1 + .../test_health_check_helpers.py | 29 +++++++++++++++++++ ...st_azure_ai_cohere_parse_transformation.py | 16 ++++++++++ .../ocr/test_cohere_parse_transformation.py | 12 ++++++++ 8 files changed, 87 insertions(+), 7 deletions(-) diff --git a/litellm/litellm_core_utils/health_check_helpers.py b/litellm/litellm_core_utils/health_check_helpers.py index c745bbea5c4..9f8878d36f0 100644 --- a/litellm/litellm_core_utils/health_check_helpers.py +++ b/litellm/litellm_core_utils/health_check_helpers.py @@ -6,14 +6,13 @@ import base64 from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Final, Literal -from litellm.types.utils import LIST_BATCHES_SUPPORTED_PROVIDERS +from litellm.llms.base_llm.ocr.transformation import BaseOCRConfig, DocumentType +from litellm.types.utils import LIST_BATCHES_SUPPORTED_PROVIDERS, LlmProviders if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging from litellm.types.utils import ImageResponse -# Minimal PDF for health checks - base64 encoded 1-page PDF with just "test" -TEST_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMSAwIFIKL01lZGlhQm94IFswIDAgNjEyIDc5Ml0KL0NvbnRlbnRzIDQgMCBSCi9SZXNvdXJjZXMgPDwvRm9udCA8PC9GMSAyIDAgUj4+Pj4+PgplbmRvYmoKNCAwIG9iago8PC9MZW5ndGggNDQ+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjEwMCA3MDAgVGQKKHRlc3QpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKMiAwIG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYT4+CmVuZG9iagoxIDAgb2JqCjw8L1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDE+PgplbmRvYmoKNSAwIG9iago8PC9UeXBlIC9DYXRhbG9nCi9QYWdlcyAxIDAgUj4+CmVuZG9iagp0cmFpbGVyCjw8L1NpemUgNgovUm9vdCA1IDAgUj4+CnN0YXJ0eHJlZgozMjQKJSVFT0Y=" # Minimal image for health checks - base64 encoded 512x512 blue circle on a white background PNG TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAJk0lEQVR42u3VQREAIRADwVWCOmTjBVzwSLorCri6nbkAVBpPACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAAAgAAAIAZdY+HgEBgIRr/meeGgGA8EMvDAgAuPh6gACAi68HCAA4+mKAAICjLwYIALj7SoAAgLuvBAgA7r4pAQKAu29KgADg7psSIAA4/SYDCADuvikBAoDTbzKAAOD0mwwgADj9JgMIAE6/yQACgNNvMoAA4PSbDCAAOP0mAwgATr/JAAKA668BIAA4/TKAAOD0mwwgALj+pgEIAE6/yQACgOtvGoAA4PSbDCAAuP6mAQgATr/JAAKA628agADg9JsMIAC4/qYBCACuv2kAAoDrbxqAAOD0mwwgALj+pgEIAK6/aQACgOtvGoAA4PqbBiAArr+ZBiAATr+ZDCAArr+ZBiAArr+ZBiAArr+ZBiAArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggArr+ZBggAAmAmAAKA62+mAQKA62+mAQKA62/m1xYAXH/TAAQA1980AAHA9TcNQAAEwEwAEADX30wDEADX30wDEADX30wDEADX30wDEAABMBMABMD1N9MABMD1N9MABEAAzAQAAXD9zTQAAXD9zTRAABAAMwEQAFx/Mw0QAFx/Mw0QAATATAAEANffTAMEANffTAMEAAEwEwABwPU30wABQADMBEAAXH8z0wABcP3NTAMEQADMTAAEwPU3Mw0QAAEwMwEQANffTAMQAAEwEwAEwPU30wAEQADMBAABcP3NNAABEAAzAUAAXH8zDUAABMBMABAA199MAwQAATATAAHA9TfTAAFAAMwEQABw/c00QAAQADMBEAAEwEwABMD1NzMNEAABMDMBEADX38w0QAAEwMwEQAAEwMwEQABcfzPTAAEQADMTAAEQADMTAAFw/c1MAwRAAMxMAARAAMxMAATA9TczDRAAATATAARAAMwEAAFw/c00AAEQADMBQAAEwEwAEADX30wDBAABMBMAAUAAzARAABAAMwEQAFx/Mw0QAAEwMwEQAAEwMwEQAAEwMwEQANffzDRAAATAzARAAATAzARAAATAzARAAATAzARAAFx/M9MAARAAMxMAARAAMxMAARAAMxMAARAAMxMAAXD9zUwDBEAAzEwABEAAzAQAARAAMwFAAATATAAQAAEwEwABQADMBEAAEAAzARAAXH8zDRAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAAATAzARAA19/MNEAANMDM9UcABMBMABAAATATAAHwBAJgJgACgACYCYAAIABmAiAA+IvMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAABMDMBEAANMDMXH8BEAAzEwABEAAzEwABEAAzEwABEAAzEwABEAAzEwABEAAzAUAABMBMABAADTBz/REAATATAAFAAMwEQAAQADMBEAAEwEwABAANMHP9BUAAzEwABEAAzEwABEAAzEwABEAAzEwABEADzMz1FwABMDMBEAABMDMBEAABMDMBEAANMDPXXwAEwMwEQAAEwMwEQAAEwMwEQAA0wMxcfwEQADMTAAEQADMBQAA0wMz1RwAEwEwAEAABMBMABEADzFx/AUAAzARAABAAMwEQADTAzPUXAATATAAEAAEwEwABQAPMXH8BEAAzEwABEAAzEwAB0AAzc/0FQADMTAAEQAPMzPUXAAEwMwEQAAEwMwEQAA0wM9dfAATAzARAADTAzFx/ARAAMwFAADTAzPVHAATATAAQAA0wc/0RAAEwEwAEQAPMXH8EQADMBAAB0AAz118AEAAzARAANMDM9RcABMBMAAQADTBz/QUAATATAAFAA8xcfwFAA8xcfwFAAMwEQADQADPXXwAEwMwEQAA0wMxcfwHQADNz/QVAAMxMAARAA8xcfwRAA8xcfwRAAMwEAAHQADPXHwHQADPXHwEQADMBQAA0wMz1RwA0wMz1RwAEwEwAEAANMHP9BQANMHP9BQANMHP9BQANMHP9BQABMBMAAUADzFx/AUADzFx/AUADzFx/AUADzFx/AUADzFx/AUADzPVHABAAEwAEAA0w1x8BQAPM9UcA0ABz/REANMBcfwQADTDXHwHQADPXHwHQADPXHwHQADPXHwHQADPXHwHQADPXHwGQATOnHwHQADPXHwHQADPXXwDQADPXXwDQADPXXwDQADPXXwCQAXP6EQA0wFx/BAANMNcfAUADzPVHAJABc/oRADTAXH8EABkwpx8BQAPM9UcAkAFz+hEANMBcfwQAGTCnHwFAA8z1RwCQAXP6EQBkwJx+BAANMNcfAUAGzOlHAJABc/oRAGTA6QcBQAacfhAAZMDpRwBABpx+BABkwOlHAEAGnH4EAJTA3UcAQAacfgQAlMDdRwBACdx9BACUwN1HAEAJ3H0EAJTA3UcAQAwcfQQAmmLgsyIA0NIDHw4BgJYe+DQIAISHwVMjAJDQDI+AAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACACAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAgAAAIAAACAIAAACAAAAgAAAIAgAAAIAAACAAAAgCAAAAIAABNHpialFcmLajuAAAAAElFTkSuQmCC" @@ -29,6 +28,14 @@ def get_image_file_for_health_check() -> bytes: return base64.b64decode(TEST_IMAGE_BASE64) +def _ocr_health_check_document(model: str, custom_llm_provider: str) -> DocumentType: + from litellm.utils import ProviderConfigManager + + provider: Final = next((known for known in LlmProviders if known.value == custom_llm_provider), None) + config: Final = ProviderConfigManager.get_provider_ocr_config(model=model, provider=provider) if provider else None + return (config or BaseOCRConfig()).get_health_check_document() + + class HealthCheckHelpers: @staticmethod async def ahealth_check_wildcard_models( @@ -247,9 +254,6 @@ class HealthCheckHelpers: ), "ocr": lambda: litellm.aocr( **_filter_model_params(model_params=model_params), - document={ - "type": "document_url", - "document_url": TEST_PDF_URL, - }, + document=_ocr_health_check_document(model=model, custom_llm_provider=custom_llm_provider), ), } diff --git a/litellm/llms/base_llm/ocr/transformation.py b/litellm/llms/base_llm/ocr/transformation.py index 08ae077cb2f..8111f9a194a 100644 --- a/litellm/llms/base_llm/ocr/transformation.py +++ b/litellm/llms/base_llm/ocr/transformation.py @@ -33,6 +33,8 @@ OCR_REQUEST_FORMAT_HEADER: Final = "x-req-format" PROVIDER_NATIVE_RESPONSE_KEY: Final = "provider_native_response" +HEALTH_CHECK_PDF_DATA_URI: Final = "data:application/pdf;base64,JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9UeXBlIC9QYWdlCi9QYXJlbnQgMSAwIFIKL01lZGlhQm94IFswIDAgNjEyIDc5Ml0KL0NvbnRlbnRzIDQgMCBSCi9SZXNvdXJjZXMgPDwvRm9udCA8PC9GMSAyIDAgUj4+Pj4+PgplbmRvYmoKNCAwIG9iago8PC9MZW5ndGggNDQ+PgpzdHJlYW0KQlQKL0YxIDI0IFRmCjEwMCA3MDAgVGQKKHRlc3QpIFRqCkVUCmVuZHN0cmVhbQplbmRvYmoKMiAwIG9iago8PC9UeXBlIC9Gb250Ci9TdWJ0eXBlIC9UeXBlMQovQmFzZUZvbnQgL0hlbHZldGljYT4+CmVuZG9iagoxIDAgb2JqCjw8L1R5cGUgL1BhZ2VzCi9LaWRzIFszIDAgUl0KL0NvdW50IDE+PgplbmRvYmoKNSAwIG9iago8PC9UeXBlIC9DYXRhbG9nCi9QYWdlcyAxIDAgUj4+CmVuZG9iagp0cmFpbGVyCjw8L1NpemUgNgovUm9vdCA1IDAgUj4+CnN0YXJ0eHJlZgozMjQKJSVFT0Y=" + def parse_ocr_request_format(value: object) -> OCRRequestFormat: if value == "litellm": @@ -146,6 +148,12 @@ class BaseOCRConfig: """Whether the Rust OCR bridge may serve this config when it is enabled for the provider.""" return True + def get_health_check_document(self) -> DocumentType: + return { # mutable-ok: litellm.aocr rejects any document that is not a dict + "type": "document_url", + "document_url": HEALTH_CHECK_PDF_DATA_URI, + } + def map_ocr_params( self, non_default_params: dict, diff --git a/litellm/llms/cohere/ocr/transformation.py b/litellm/llms/cohere/ocr/transformation.py index 87454980aa4..dd15d5360a6 100644 --- a/litellm/llms/cohere/ocr/transformation.py +++ b/litellm/llms/cohere/ocr/transformation.py @@ -34,6 +34,9 @@ COHERE_PARSE_OUTPUT_FORMAT_PARAM: Final = "output_format" COHERE_PARSE_OUTPUT_FORMATS: Final = ("markdown", "blocks") COHERE_PARSE_DEFAULT_OUTPUT_FORMAT: Final = "markdown" COHERE_PARSE_SUPPORTED_PARAMS: Final = (COHERE_PARSE_OUTPUT_FORMAT_PARAM, OCR_REQUEST_FORMAT_PARAM) +COHERE_PARSE_HEALTH_CHECK_IMAGE_DATA_URI: Final = ( + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4//8/AAX+Av4N70a4AAAAAElFTkSuQmCC" +) COHERE_PARSE_IMAGE_ONLY_MESSAGE: Final = ( "Cohere Parse only accepts `image_url` documents (an image URL or a base64 image data URI); " "`document_url` and PDF inputs are not supported." @@ -144,6 +147,12 @@ class CohereParseConfig(BaseOCRConfig): def supports_rust_bridge(self) -> bool: return False + def get_health_check_document(self) -> DocumentType: + return { # mutable-ok: litellm.aocr rejects any document that is not a dict + "type": "image_url", + "image_url": COHERE_PARSE_HEALTH_CHECK_IMAGE_DATA_URI, + } + def _llm_provider(self) -> str: return "cohere" diff --git a/litellm/provider_endpoints_support_backup.json b/litellm/provider_endpoints_support_backup.json index 9d6b1e18f59..dbeaccdda2d 100644 --- a/litellm/provider_endpoints_support_backup.json +++ b/litellm/provider_endpoints_support_backup.json @@ -559,6 +559,7 @@ "moderations": false, "batches": false, "rerank": true, + "ocr": true, "a2a": true, "interactions": true } diff --git a/provider_endpoints_support.json b/provider_endpoints_support.json index 41ed8e1d975..c71f4a82a4a 100644 --- a/provider_endpoints_support.json +++ b/provider_endpoints_support.json @@ -594,6 +594,7 @@ "moderations": false, "batches": false, "rerank": true, + "ocr": true, "a2a": true, "interactions": true } diff --git a/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py b/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py index ee2a31beff7..89b377af3a0 100644 --- a/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py @@ -453,3 +453,32 @@ async def test_realtime_health_check_uses_model_level_vertex_params(): "Authorization": "Bearer model-level-token", "x-goog-user-project": "model-level-project", } + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model, custom_llm_provider, expected_document_type, expected_uri_prefix", + [ + ("mistral/mistral-ocr-latest", "mistral", "document_url", "data:application/pdf;base64,"), + ("azure_ai/mistral-document-ai-2512", "azure_ai", "document_url", "data:application/pdf;base64,"), + ("cohere/parse-v5.0", "cohere", "image_url", "data:image/png;base64,"), + ("azure_ai/Cohere-parse-v5", "azure_ai", "image_url", "data:image/png;base64,"), + ], +) +async def test_ocr_health_check_sends_the_document_kind_the_provider_config_accepts( + model, custom_llm_provider, expected_document_type, expected_uri_prefix +): + handlers = HealthCheckHelpers.get_mode_handlers( + model=model, + custom_llm_provider=custom_llm_provider, + model_params={"model": model, "api_key": "sk-test"}, + ) + + with patch( # test-quality-ok: the public health-check path has no dependency injection seam + "litellm.aocr", new_callable=AsyncMock, return_value={} + ) as mock_aocr: + await handlers["ocr"]() + + document = mock_aocr.call_args.kwargs["document"] + assert document["type"] == expected_document_type + assert document[expected_document_type].startswith(expected_uri_prefix) diff --git a/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py b/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py index 8c4dd25aa77..3f98e9b6a2d 100644 --- a/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py +++ b/tests/test_litellm/llms/azure_ai/ocr/test_azure_ai_cohere_parse_transformation.py @@ -166,3 +166,19 @@ async def test_aocr_rejects_pdf_before_calling_foundry(disable_aiohttp_transport assert exc_info.value.llm_provider == "azure_ai" assert not route.called + + +@pytest.mark.asyncio +async def test_ahealth_check_ocr_sends_an_image_to_the_foundry_cohere_parse_deployment( + disable_aiohttp_transport, respx_mock +): + route = respx_mock.post(PARSE_URL).respond(json=_parse_response()) + + result = await litellm.ahealth_check( + model_params={"model": MODEL, "api_base": API_BASE, "api_key": "test-key"}, mode="ocr" + ) + + document = json.loads(route.calls.last.request.content)["document"] + assert document["type"] == "image_url" + assert document["image_url"].startswith("data:image/png;base64,") + assert "error" not in result diff --git a/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py b/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py index 64f2bb383c2..cb9af56f5e0 100644 --- a/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py +++ b/tests/test_litellm/llms/cohere/ocr/test_cohere_parse_transformation.py @@ -215,3 +215,15 @@ async def test_aocr_without_api_key_names_the_env_var(disable_aiohttp_transport, await litellm.aocr(model=MODEL, document=IMAGE_DOCUMENT) assert not route.called + + +@pytest.mark.asyncio +async def test_ahealth_check_ocr_sends_an_image_cohere_parse_accepts(disable_aiohttp_transport, respx_mock): + route = respx_mock.post(PARSE_URL).respond(json=_markdown_response()) + + result = await litellm.ahealth_check(model_params={"model": MODEL, "api_key": "test-key"}, mode="ocr") + + document = json.loads(route.calls.last.request.content)["document"] + assert document["type"] == "image_url" + assert document["image_url"].startswith("data:image/png;base64,") + assert "error" not in result From 74613f9bd47d8e3068e6e2f1f519675ac15b7ab8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:52:26 -0700 Subject: [PATCH 202/410] fix(realtime): redact credentials from the relayed upstream close The handshake error path already runs client-facing error strings through _redact_string; the relay's _close_client did not, so a secret echoed in an upstream close reason could reach the client verbatim. Mirror the handshake path and scrub the close message and reason before relaying them. --- .../litellm_core_utils/realtime_streaming.py | 8 +++++--- .../test_realtime_streaming.py | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index 984934daaac..b448cb7c9ff 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol, TypedDict, cas from typing_extensions import ReadOnly import litellm -from litellm._logging import verbose_logger +from litellm._logging import _redact_string, verbose_logger from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig from litellm.types.llms.openai import ( @@ -1567,12 +1567,14 @@ class RealTimeStreaming: await asyncio.gather(forward_task, client_task, return_exceptions=True) async def _close_client(self, close: BackendClose) -> None: + redacted_message: Final = _redact_string(close.message) + redacted_reason: Final = _redact_string(close.reason) try: if close.code != 1000: - await self.websocket.send_text(realtime_error_event(close.message, error_type="server_error")) + await self.websocket.send_text(realtime_error_event(redacted_message, error_type="server_error")) await self.websocket.close( code=client_close_code(close.code), - reason=websocket_close_reason(close.reason, fallback=close.message), + reason=websocket_close_reason(redacted_reason, fallback=redacted_message), ) except Exception as e: # noqa: BLE001 # the client may already be gone; the session is over either way verbose_logger.debug("Could not relay the upstream close to the client: %s", e) diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py index 09757c35570..bcfacf16205 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py @@ -3229,6 +3229,24 @@ async def test_bidirectional_forward_relays_upstream_policy_close_to_client(): client_ws.close.assert_awaited_once_with(code=1008, reason=_UPSTREAM_REFUSAL) +@pytest.mark.asyncio +async def test_upstream_close_reason_with_a_secret_is_redacted_before_reaching_the_client(): + """LIT-6973: the relayed close mirrors the handshake path and scrubs credential + patterns, so an upstream error echoing a token never reaches the client verbatim.""" + secret: Final = "sk-live-abcdef0123456789abcdef0123" + client_ws: Final = _client_ws_that_never_sends() + upstream_close: Final = ConnectionClosed(Close(1008, f"auth failed for {secret}"), None) + session: Final = _relay_session(client_ws, _backend_ws_closing_with(upstream_close)) + + await session.run() + + (error_event,) = _error_events_sent_to(client_ws) + assert secret not in error_event["error"]["message"] + relayed_reason: Final = client_ws.close.await_args.kwargs["reason"] + assert secret not in relayed_reason + assert "REDACTED" in relayed_reason + + @pytest.mark.asyncio async def test_bidirectional_forward_maps_abnormal_upstream_close_to_internal_error(): client_ws: Final = _client_ws_that_never_sends() From 53178486a0c919481bd40da62bfa4ff98c5c2e5a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 22:59:22 -0700 Subject: [PATCH 203/410] style(tests): wrap realtime cost test lines to the 120-column limit --- .../llm_cost_calc/test_llm_cost_calc_utils.py | 8 +++++-- tests/test_litellm/test_cost_calculator.py | 22 +++++++++++++++---- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index e7a92a96f34..e8eae73df3f 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -4800,7 +4800,9 @@ def test_generic_cost_per_token_bills_reasoning_nested_in_text_tokens_once(_loca ) -def test_generic_cost_per_token_keeps_billing_reasoning_reported_beside_text_tokens(_local_model_cost_map: None) -> None: +def test_generic_cost_per_token_keeps_billing_reasoning_reported_beside_text_tokens( + _local_model_cost_map: None, +) -> None: """Providers whose text_tokens exclude reasoning (text + reasoning == completion) stay billed in full.""" model = "gpt-realtime-2.1-mini" @@ -4835,4 +4837,6 @@ def test_generic_cost_per_token_bills_nested_reasoning_once_beside_audio_output( info = litellm.get_model_info(model=model, custom_llm_provider="openai") assert breakdown.reasoning_cost == pytest.approx(20 * info["output_cost_per_token"]) - assert completion_cost == pytest.approx(30 * info["output_cost_per_token"] + 70 * info["output_cost_per_audio_token"]) + assert completion_cost == pytest.approx( + 30 * info["output_cost_per_token"] + 70 * info["output_cost_per_audio_token"] + ) diff --git a/tests/test_litellm/test_cost_calculator.py b/tests/test_litellm/test_cost_calculator.py index 1f14fdc2c67..57246ec08a5 100644 --- a/tests/test_litellm/test_cost_calculator.py +++ b/tests/test_litellm/test_cost_calculator.py @@ -4494,7 +4494,9 @@ def test_batch_cost_calculator_gpt_6_astra_bills_half_the_standard_rate(_local_m assert completion_cost == pytest.approx(500 * 2.5e-5) -def test_handle_realtime_stream_cost_calculation_bills_nested_reasoning_tokens_once(_local_model_cost_map: None) -> None: +def test_handle_realtime_stream_cost_calculation_bills_nested_reasoning_tokens_once( + _local_model_cost_map: None, +) -> None: """Realtime response.done nests reasoning_tokens inside text_tokens, so they are billed once.""" results: OpenAIRealtimeStreamList = [ {"type": "session.created", "session": {"model": "gpt-realtime-2.1-mini"}}, @@ -4530,7 +4532,9 @@ def test_handle_realtime_stream_cost_calculation_bills_nested_reasoning_tokens_o info = litellm.get_model_info(model="azure/gpt-realtime-2.1-mini", custom_llm_provider="azure") expected = ( - 43 * info["input_cost_per_token"] + 194 * info["input_cost_per_image_token"] + 23 * info["output_cost_per_token"] + 43 * info["input_cost_per_token"] + + 194 * info["input_cost_per_image_token"] + + 23 * info["output_cost_per_token"] ) assert total_cost == pytest.approx(expected) assert total_cost == pytest.approx(0.0002362) @@ -4547,7 +4551,12 @@ def test_collect_and_combine_realtime_usage_stores_partitioned_text_tokens() -> "total_tokens": 307, "input_tokens": 237, "output_tokens": 70, - "input_token_details": {"text_tokens": 43, "audio_tokens": 0, "image_tokens": 194, "cached_tokens": 0}, + "input_token_details": { + "text_tokens": 43, + "audio_tokens": 0, + "image_tokens": 194, + "cached_tokens": 0, + }, "output_token_details": {"text_tokens": 70, "audio_tokens": 0, "reasoning_tokens": 52}, } }, @@ -4559,7 +4568,12 @@ def test_collect_and_combine_realtime_usage_stores_partitioned_text_tokens() -> "total_tokens": 363, "input_tokens": 300, "output_tokens": 63, - "input_token_details": {"text_tokens": 106, "audio_tokens": 0, "image_tokens": 194, "cached_tokens": 0}, + "input_token_details": { + "text_tokens": 106, + "audio_tokens": 0, + "image_tokens": 194, + "cached_tokens": 0, + }, "output_token_details": {"text_tokens": 63, "audio_tokens": 0, "reasoning_tokens": 43}, } }, From 0ee3bec046bf0f15eebdddc1d52610d389ca220f Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:20:21 -0700 Subject: [PATCH 204/410] fix(image_edit): await an async transform hook and hide the URL policy verdict from callers The image edit handler now awaits BaseImageEditConfig.async_transform_image_edit_request, and Black Forest Labs overrides it so URL images and masks download through async_safe_get instead of the blocking safe_get on the event loop. Rejected image fetches raise a fixed policy message with the user_url_allowed_hosts hint rather than echoing the resolver's verdict (resolved IP, DNS failure) back to the caller. The test fixture also fails any request-path call of the sync convert_url_to_base64 so a regression cannot pass unnoticed. --- .../prompt_templates/image_handling.py | 12 +- .../base_llm/image_edit/transformation.py | 19 ++ .../image_edit/transformation.py | 102 ++++++----- litellm/llms/custom_httpx/llm_http_handler.py | 2 +- tests/test_litellm/conftest.py | 10 +- .../litellm_core_utils/test_image_handling.py | 65 ++++--- .../test_bfl_image_edit_transformation.py | 78 ++++++++- .../custom_httpx/test_llm_http_handler.py | 163 +++++++++++++++++- 8 files changed, 379 insertions(+), 72 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index dcad7776b58..4e7dfdb4017 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -78,6 +78,14 @@ def _process_image_response(response: Response, url: str) -> str: return result +def _url_policy_rejection(url: str, verdict: SSRFError) -> "litellm.ImageFetchError": + verbose_logger.warning("Image fetch of %s rejected by the URL policy: %s", url, verdict) + return litellm.ImageFetchError( + "Error: Unable to fetch image from URL. The proxy's URL policy rejected this host; " + f"an admin can allow it with `user_url_allowed_hosts` in general_settings. url={url}" + ) + + async def async_convert_url_to_base64(url: str) -> str: if url.startswith("data:") and ";base64," in url: return url @@ -100,7 +108,7 @@ async def async_convert_url_to_base64(url: str) -> str: except litellm.ImageFetchError: raise except SSRFError as e: - raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL. {e} url={url}") from e + raise _url_policy_rejection(url, e) from e except Exception: pass raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL after 3 attempts. url={url}") @@ -128,7 +136,7 @@ def convert_url_to_base64(url: str) -> str: except litellm.ImageFetchError: raise except SSRFError as e: - raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL. {e} url={url}") from e + raise _url_policy_rejection(url, e) from e except Exception as e: verbose_logger.exception(e) raise litellm.ImageFetchError( diff --git a/litellm/llms/base_llm/image_edit/transformation.py b/litellm/llms/base_llm/image_edit/transformation.py index 9a25d3294e0..4faf0aaaf30 100644 --- a/litellm/llms/base_llm/image_edit/transformation.py +++ b/litellm/llms/base_llm/image_edit/transformation.py @@ -1,5 +1,6 @@ import types from abc import ABC, abstractmethod +from collections.abc import Mapping from typing import TYPE_CHECKING, Any import httpx @@ -102,6 +103,24 @@ class BaseImageEditConfig(ABC): ) -> tuple[dict, RequestFiles]: pass + async def async_transform_image_edit_request( + self, + model: str, + prompt: str | None, + image: FileTypes | None, + image_edit_optional_request_params: Mapping[str, object], + litellm_params: GenericLiteLLMParams, + headers: Mapping[str, str], + ) -> tuple[dict, RequestFiles]: + return self.transform_image_edit_request( + model=model, + prompt=prompt, + image=image, + image_edit_optional_request_params=dict(image_edit_optional_request_params), + litellm_params=litellm_params, + headers=dict(headers), + ) + def finalize_image_edit_request_data(self, data: dict, resolved_request_url: str) -> dict: """ Last pass on the request dict after ``transform_image_edit_request``, using the diff --git a/litellm/llms/black_forest_labs/image_edit/transformation.py b/litellm/llms/black_forest_labs/image_edit/transformation.py index 013053e5bd5..c6de9f9ba65 100644 --- a/litellm/llms/black_forest_labs/image_edit/transformation.py +++ b/litellm/llms/black_forest_labs/image_edit/transformation.py @@ -9,6 +9,7 @@ API Reference: https://docs.bfl.ai/ import base64 import time +from collections.abc import Mapping from typing import TYPE_CHECKING, Any, Final import httpx @@ -16,7 +17,7 @@ from httpx._types import RequestFiles import litellm from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH -from litellm.litellm_core_utils.url_utils import safe_get +from litellm.litellm_core_utils.url_utils import async_safe_get, safe_get from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.secret_managers.main import get_secret_str from litellm.types.images.main import ImageEditOptionalRequestParams @@ -37,6 +38,22 @@ else: LiteLLMLoggingObj = Any +_BFL_REQUEST_PARAMS: Final = ( + "seed", + "output_format", + "safety_tolerance", + "prompt_upsampling", + "aspect_ratio", + "steps", + "guidance", + "grow_mask", + "top", + "bottom", + "left", + "right", +) + + class BlackForestLabsImageEditConfig(BaseImageEditConfig): """ Configuration for Black Forest Labs image editing. @@ -85,34 +102,10 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): BFL-specific params are passed through directly. """ optional_params: Final[dict[str, Any]] = {} - - # Pass through BFL-specific params - bfl_params: Final = [ - "seed", - "output_format", - "safety_tolerance", - "prompt_upsampling", - # Kontext-specific - "aspect_ratio", - # Fill/Inpaint-specific - "steps", - "guidance", - "grow_mask", - # Expand-specific - "top", - "bottom", - "left", - "right", - ] - - # Convert TypedDict to regular dict for access - params_dict: Final = dict(image_edit_optional_params) - - for param in bfl_params: - if param in params_dict: - value = params_dict[param] - if value is not None: - optional_params[param] = value + params: Final[Mapping[str, object]] = image_edit_optional_params + for param in _BFL_REQUEST_PARAMS: + if (value := params.get(param)) is not None: + optional_params[param] = value # Set default output format if "output_format" not in optional_params: @@ -251,23 +244,8 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): "input_image": b64_image, } - # Add optional params (only BFL-recognized parameters) - bfl_request_params: Final = [ - "seed", - "output_format", - "safety_tolerance", - "prompt_upsampling", - "aspect_ratio", - "steps", - "guidance", - "grow_mask", - "top", - "bottom", - "left", - "right", - ] for key, value in image_edit_optional_request_params.items(): - if key in bfl_request_params and value is not None: + if key in _BFL_REQUEST_PARAMS and value is not None: request_body[key] = value # Handle mask if provided (for inpainting) @@ -277,7 +255,39 @@ class BlackForestLabsImageEditConfig(BaseImageEditConfig): request_body["mask"] = base64.b64encode(mask_bytes).decode("utf-8") # BFL uses JSON, not multipart - return empty files - return request_body, [] + return request_body, () + + async def async_transform_image_edit_request( + self, + model: str, + prompt: str | None, + image: FileTypes | None, + image_edit_optional_request_params: Mapping[str, object], + litellm_params: GenericLiteLLMParams, + headers: Mapping[str, str], + ) -> tuple[dict, RequestFiles]: + downloaded_image: Final = await self._fetch_remote_image(image) + downloaded_mask: Final = await self._fetch_remote_image(image_edit_optional_request_params.get("mask")) + return self.transform_image_edit_request( + model=model, + prompt=prompt, + image=image if downloaded_image is None else downloaded_image, + image_edit_optional_request_params=( + dict(image_edit_optional_request_params) + if downloaded_mask is None + else {**image_edit_optional_request_params, "mask": downloaded_mask} + ), + litellm_params=litellm_params, + headers=dict(headers), + ) + + async def _fetch_remote_image(self, image: object) -> bytes | None: + candidate: Final = image[0] if isinstance(image, list) and image else image + if not isinstance(candidate, str) or not candidate.startswith(("http://", "https://")): + return None + response: Final = await async_safe_get(litellm.module_level_aclient, candidate, timeout=60.0) + response.raise_for_status() + return response.content def transform_image_edit_response( self, diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index d57aee025d5..6a17841faa2 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -6787,7 +6787,7 @@ class BaseLLMHTTPHandler: litellm_params=dict(litellm_params), ) - data, files = image_edit_provider_config.transform_image_edit_request( + data, files = await image_edit_provider_config.async_transform_image_edit_request( model=model, image=image, prompt=prompt, diff --git a/tests/test_litellm/conftest.py b/tests/test_litellm/conftest.py index aa8ba168ecc..a4f32df46ae 100644 --- a/tests/test_litellm/conftest.py +++ b/tests/test_litellm/conftest.py @@ -607,7 +607,8 @@ ONE_PIXEL_PNG = base64.b64decode( @pytest.fixture def async_only_image_fetch(monkeypatch): - from litellm.litellm_core_utils.prompt_templates import image_handling + from litellm.litellm_core_utils.prompt_templates import factory, image_handling + from litellm.llms.gemini.chat import transformation as gemini_chat_transformation fetch = SimpleNamespace( fetched=[], @@ -627,6 +628,13 @@ def async_only_image_fetch(monkeypatch): request=httpx.Request("GET", url), ) + def forbid_sync_convert(url, *args, **kwargs): + if url.startswith(("http://", "https://")): + raise litellm.ImageFetchError(f"sync convert_url_to_base64 ran on the request path: {url}") + return url + monkeypatch.setattr(image_handling, "safe_get", forbid_sync_fetch) monkeypatch.setattr(image_handling, "async_safe_get", serve_png) + for module in (image_handling, factory, gemini_chat_transformation): + monkeypatch.setattr(module, "convert_url_to_base64", forbid_sync_convert) return fetch diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index e7b28d0d3a2..d8ccc9251dd 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -428,36 +428,61 @@ async def test_async_inline_remote_media_cancels_the_other_fetches_when_one_fail assert time.perf_counter() - started < 1 -async def test_async_convert_url_to_base64_reports_a_blocked_url_without_retrying(monkeypatch): +_SSRF_VERDICTS = ( + "URL targets a blocked address (10.0.0.8). If this is a legitimate internal service, " + "add the host to `user_url_allowed_hosts` in general_settings.", + "DNS resolution failed for 'internal.example': [Errno 8] nodename nor servname provided, or not known", + "No addresses found for 'internal.example'", +) + + +def _assert_one_verdict_free_message(messages, url): + assert len(set(messages)) == 1 + assert "10.0.0.8" not in messages[0] + assert "DNS" not in messages[0] + assert "No addresses" not in messages[0] + assert "user_url_allowed_hosts" in messages[0] + assert url in messages[0] + + +async def test_async_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeypatch): attempts = [] - - async def block(client, url, **kwargs): - attempts.append(url) - raise SSRFError("URL targets a blocked address (10.0.0.8)") - - monkeypatch.setattr(image_handling, "async_safe_get", block) + messages = [] url = f"http://internal.example/{uuid.uuid4()}.png" - with pytest.raises(litellm.ImageFetchError, match=r"blocked address \(10\.0\.0\.8\)"): - await async_convert_url_to_base64(url) + for verdict in _SSRF_VERDICTS: - assert attempts == [url] + async def block(client, fetched_url, verdict=verdict, **kwargs): + attempts.append(fetched_url) + raise SSRFError(verdict) + + monkeypatch.setattr(image_handling, "async_safe_get", block) + with pytest.raises(litellm.ImageFetchError) as raised: + await async_convert_url_to_base64(url) + messages.append(raised.value.message) + + assert attempts == [url] * len(_SSRF_VERDICTS) + _assert_one_verdict_free_message(messages, url) -def test_convert_url_to_base64_reports_a_blocked_url_without_retrying(monkeypatch): +def test_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeypatch): attempts = [] - - def block(client, url, **kwargs): - attempts.append(url) - raise SSRFError("URL targets a blocked address (10.0.0.8)") - - monkeypatch.setattr(image_handling, "safe_get", block) + messages = [] url = f"http://internal.example/{uuid.uuid4()}.png" - with pytest.raises(litellm.ImageFetchError, match=r"blocked address \(10\.0\.0\.8\)"): - convert_url_to_base64(url) + for verdict in _SSRF_VERDICTS: - assert attempts == [url] + def block(client, fetched_url, verdict=verdict, **kwargs): + attempts.append(fetched_url) + raise SSRFError(verdict) + + monkeypatch.setattr(image_handling, "safe_get", block) + with pytest.raises(litellm.ImageFetchError) as raised: + convert_url_to_base64(url) + messages.append(raised.value.message) + + assert attempts == [url] * len(_SSRF_VERDICTS) + _assert_one_verdict_free_message(messages, url) async def test_async_inline_remote_media_caps_in_flight_fetches_per_request(monkeypatch): diff --git a/tests/test_litellm/llms/black_forest_labs/image_edit/test_bfl_image_edit_transformation.py b/tests/test_litellm/llms/black_forest_labs/image_edit/test_bfl_image_edit_transformation.py index ec243b7058d..d9d6e813d86 100644 --- a/tests/test_litellm/llms/black_forest_labs/image_edit/test_bfl_image_edit_transformation.py +++ b/tests/test_litellm/llms/black_forest_labs/image_edit/test_bfl_image_edit_transformation.py @@ -16,6 +16,9 @@ import httpx import pytest +from litellm.llms.black_forest_labs.image_edit import ( + transformation as bfl_transformation, +) from litellm.llms.black_forest_labs.image_edit.transformation import ( BlackForestLabsImageEditConfig, ) @@ -186,7 +189,7 @@ class TestBlackForestLabsImageEditTransformation: assert data["output_format"] == "jpeg" # BFL uses JSON, not multipart - files should be empty - assert files == [] + assert files == () def test_transform_image_edit_request_with_mask(self): """Test request transformation with mask for inpainting.""" @@ -299,3 +302,76 @@ class TestBlackForestLabsImageEditTransformation: def test_use_multipart_form_data_returns_false(self): """Test that use_multipart_form_data returns False for BFL.""" assert self.config.use_multipart_form_data() is False + + +async def test_async_transform_image_edit_request_downloads_url_images_with_the_async_fetcher(monkeypatch): + served = b"png-bytes-from-cdn" + fetched = [] + + def forbid_sync_fetch(client, url, **kwargs): + raise AssertionError(f"sync image fetch ran on the event loop: {url}") + + async def serve(client, url, **kwargs): + fetched.append((url, kwargs.get("timeout"))) + return httpx.Response(200, content=served, request=httpx.Request("GET", url)) + + monkeypatch.setattr(bfl_transformation, "safe_get", forbid_sync_fetch) + monkeypatch.setattr(bfl_transformation, "async_safe_get", serve) + + data, files = await BlackForestLabsImageEditConfig().async_transform_image_edit_request( + model="flux-kontext-pro", + prompt="Add a red hat", + image="https://cdn.example/photo.png", + image_edit_optional_request_params={"mask": "https://cdn.example/mask.png", "seed": 7}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert base64.b64decode(data["input_image"]) == served + assert base64.b64decode(data["mask"]) == served + assert data["seed"] == 7 + assert files == () + assert fetched == [("https://cdn.example/photo.png", 60.0), ("https://cdn.example/mask.png", 60.0)] + + +async def test_async_transform_image_edit_request_never_fetches_for_local_images(monkeypatch): + def refuse(*args, **kwargs): + raise AssertionError("no network fetch expected for local image bytes") + + monkeypatch.setattr(bfl_transformation, "safe_get", refuse) + monkeypatch.setattr(bfl_transformation, "async_safe_get", refuse) + + data, _ = await BlackForestLabsImageEditConfig().async_transform_image_edit_request( + model="flux-kontext-pro", + prompt="Add a red hat", + image=[BytesIO(b"first"), BytesIO(b"other")], + image_edit_optional_request_params={"mask": b"mask-bytes"}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert base64.b64decode(data["input_image"]) == b"first" + assert base64.b64decode(data["mask"]) == b"mask-bytes" + + +async def test_async_transform_image_edit_request_downloads_only_the_first_url_of_a_list(monkeypatch): + fetched = [] + + async def serve(client, url, **kwargs): + fetched.append(url) + return httpx.Response(200, content=b"first-bytes", request=httpx.Request("GET", url)) + + monkeypatch.setattr(bfl_transformation, "safe_get", lambda *args, **kwargs: pytest.fail("sync fetch ran")) + monkeypatch.setattr(bfl_transformation, "async_safe_get", serve) + + data, _ = await BlackForestLabsImageEditConfig().async_transform_image_edit_request( + model="flux-kontext-pro", + prompt="Add a red hat", + image=["https://cdn.example/a.png", "https://cdn.example/b.png"], + image_edit_optional_request_params={}, + litellm_params=GenericLiteLLMParams(), + headers={}, + ) + + assert fetched == ["https://cdn.example/a.png"] + assert base64.b64decode(data["input_image"]) == b"first-bytes" 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 f1614654ffb..3afd57bbf34 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 @@ -19,6 +19,7 @@ from litellm.llms.base_llm.audio_transcription.transformation import ( BaseAudioTranscriptionConfig, ) from litellm.llms.base_llm.chat.transformation import BaseConfig, BaseLLMException +from litellm.llms.base_llm.image_edit.transformation import BaseImageEditConfig from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.llms.custom_httpx.llm_http_handler import ( BaseLLMHTTPHandler, @@ -31,7 +32,7 @@ from litellm.llms.azure.videos.transformation import AzureVideoConfig from litellm.llms.openai.videos.transformation import OpenAIVideoConfig from litellm.types.llms.openai import ResponsesAPIResponse from litellm.types.router import GenericLiteLLMParams -from litellm.types.utils import ModelResponse, TranscriptionResponse +from litellm.types.utils import ImageObject, ImageResponse, ModelResponse, TranscriptionResponse _ACTIVE_KEY = "_code_interpreter_interception_active" _SANDBOX_KEY = "_code_interpreter_interception_sandbox_key" @@ -3244,6 +3245,11 @@ class _TransformRecordingConfig(BaseConfig): def get_error_class(self, error_message, status_code, headers): return BaseLLMException(status_code=status_code, message=error_message, headers=headers) + def get_model_response_iterator(self, streaming_response, sync_stream, json_mode=False): + return litellm.OpenAIGPTConfig().get_model_response_iterator( + streaming_response=streaming_response, sync_stream=sync_stream, json_mode=json_mode + ) + def _start_async_completion(config, logging_obj=None): captured = {} @@ -3312,3 +3318,158 @@ async def test_completion_keeps_sync_transform_request_before_returning_by_defau assert config.transform_calls == ["sync"] assert captured["body"] == {"transformed_by": "sync"} assert response.choices[0].message.content == "sync" + + +def _sse_echoing_transformed_by(request): + transformed_by = json.loads(request.content)["transformed_by"] + chunk = { + "id": "chatcmpl-1", + "object": "chat.completion.chunk", + "created": 1, + "model": "stub-model", + "choices": [{"index": 0, "delta": {"content": transformed_by}, "finish_reason": None}], + } + return httpx.Response( + 200, + content=f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n".encode(), + headers={"content-type": "text/event-stream"}, + request=request, + ) + + +def _streaming_logging_obj(): + from litellm.litellm_core_utils.litellm_logging import Logging + + logging_obj = Logging( + model="stub-model", + messages=[{"role": "user", "content": "hi"}], + stream=True, + call_type="acompletion", + start_time=time.time(), + litellm_call_id="async-transform-stream", + function_id="f", + ) + logging_obj.update_environment_variables( + model="stub-model", user="", optional_params={}, litellm_params={}, custom_llm_provider="openai" + ) + return logging_obj + + +async def test_completion_streams_after_the_async_transform_request(): + config = _TransformRecordingConfig(transform_async=True) + loop_thread = threading.current_thread() + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=httpx.MockTransport(_sse_echoing_transformed_by)) + + stream = await BaseLLMHTTPHandler().completion( + model="stub-model", + messages=[{"role": "user", "content": "hi"}], + api_base="https://llm.example/v1/chat", + custom_llm_provider="openai", + model_response=ModelResponse(), + encoding=None, + logging_obj=_streaming_logging_obj(), + optional_params={}, + timeout=10.0, + litellm_params={}, + acompletion=True, + stream=True, + client=client, + provider_config=config, + ) + collected = [chunk async for chunk in stream] + + assert config.transform_calls == ["async"] + assert config.sign_threads and all(thread is not loop_thread for thread in config.sign_threads) + assert "".join(chunk.choices[0].delta.content or "" for chunk in collected) == "async" + + +class _ImageEditRecordingConfig(BaseImageEditConfig): + def __init__(self): + self.transform_calls = [] + + def get_supported_openai_params(self, model): + return [] + + def map_openai_params(self, image_edit_optional_params, model, drop_params): + return dict(image_edit_optional_params) + + def validate_environment(self, headers, model, api_key=None, litellm_params=None, api_base=None): + return {} + + def get_complete_url(self, model, api_base, litellm_params): + return "https://images.example/v1/edits" + + def use_multipart_form_data(self): + return False + + def transform_image_edit_request( + self, model, prompt, image, image_edit_optional_request_params, litellm_params, headers + ): + self.transform_calls.append("sync") + return {"transformed_by": "sync"}, [] + + async def async_transform_image_edit_request( + self, model, prompt, image, image_edit_optional_request_params, litellm_params, headers + ): + self.transform_calls.append("async") + return {"transformed_by": "async"}, [] + + def transform_image_edit_response(self, model, raw_response, logging_obj): + return ImageResponse(data=[ImageObject(b64_json=raw_response.json()["transformed_by"])]) + + +def _echo_json_transport(captured): + def handle(request): + captured["body"] = json.loads(request.content) + return httpx.Response(200, json=captured["body"]) + + return httpx.MockTransport(handle) + + +async def test_async_image_edit_handler_awaits_the_async_transform(): + config = _ImageEditRecordingConfig() + captured = {} + client = AsyncHTTPHandler() + client.client = httpx.AsyncClient(transport=_echo_json_transport(captured)) + + response = await BaseLLMHTTPHandler().async_image_edit_handler( + model="edit-model", + image=b"raw-image", + prompt="add a hat", + image_edit_provider_config=config, + image_edit_optional_request_params={}, + custom_llm_provider="openai", + litellm_params=GenericLiteLLMParams(), + logging_obj=Mock(), + timeout=10.0, + client=client, + ) + + assert config.transform_calls == ["async"] + assert captured["body"] == {"transformed_by": "async"} + assert response.data[0].b64_json == "async" + + +def test_image_edit_handler_keeps_the_sync_transform(): + config = _ImageEditRecordingConfig() + captured = {} + client = HTTPHandler() + client.client = httpx.Client(transport=_echo_json_transport(captured)) + + response = BaseLLMHTTPHandler().image_edit_handler( + model="edit-model", + image=b"raw-image", + prompt="add a hat", + image_edit_provider_config=config, + image_edit_optional_request_params={}, + custom_llm_provider="openai", + litellm_params=GenericLiteLLMParams(), + logging_obj=Mock(), + timeout=10.0, + client=client, + ) + + assert config.transform_calls == ["sync"] + assert captured["body"] == {"transformed_by": "sync"} + assert response.data[0].b64_json == "sync" From 3920cf4dfe458b6d4982e2a64c162de21defa2a0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:45:37 -0700 Subject: [PATCH 205/410] fix(image_handling): tell callers when the image host did not resolve instead of blaming the URL policy validate_url raises HostResolutionError, a SSRFError subclass, for the two DNS outcomes (lookup failed, no addresses). The image fetch helper maps that to a "host could not be resolved" message and keeps the user_url_allowed_hosts hint for the policy verdicts it can actually fix. --- .../prompt_templates/image_handling.py | 14 +++-- litellm/litellm_core_utils/url_utils.py | 8 ++- .../litellm_core_utils/test_image_handling.py | 56 +++++++++++-------- .../litellm_core_utils/test_url_utils.py | 19 ++++++- 4 files changed, 64 insertions(+), 33 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index 4e7dfdb4017..99efbd13320 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -15,7 +15,7 @@ import litellm from litellm import verbose_logger from litellm.caching.caching import InMemoryCache from litellm.constants import MAX_IMAGE_URL_DOWNLOAD_SIZE_MB -from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get, safe_get +from litellm.litellm_core_utils.url_utils import HostResolutionError, SSRFError, async_safe_get, safe_get from litellm.types.llms.openai import AllMessageValues MAX_IMGS_IN_MEMORY: Final = 10 @@ -78,8 +78,12 @@ def _process_image_response(response: Response, url: str) -> str: return result -def _url_policy_rejection(url: str, verdict: SSRFError) -> "litellm.ImageFetchError": - verbose_logger.warning("Image fetch of %s rejected by the URL policy: %s", url, verdict) +def _rejected_image_fetch(url: str, verdict: SSRFError) -> "litellm.ImageFetchError": + verbose_logger.warning("Image fetch of %s rejected before any request went out: %s", url, verdict) + if isinstance(verdict, HostResolutionError): + return litellm.ImageFetchError( + f"Error: Unable to fetch image from URL. The image host could not be resolved. url={url}" + ) return litellm.ImageFetchError( "Error: Unable to fetch image from URL. The proxy's URL policy rejected this host; " f"an admin can allow it with `user_url_allowed_hosts` in general_settings. url={url}" @@ -108,7 +112,7 @@ async def async_convert_url_to_base64(url: str) -> str: except litellm.ImageFetchError: raise except SSRFError as e: - raise _url_policy_rejection(url, e) from e + raise _rejected_image_fetch(url, e) from e except Exception: pass raise litellm.ImageFetchError(f"Error: Unable to fetch image from URL after 3 attempts. url={url}") @@ -136,7 +140,7 @@ def convert_url_to_base64(url: str) -> str: except litellm.ImageFetchError: raise except SSRFError as e: - raise _url_policy_rejection(url, e) from e + raise _rejected_image_fetch(url, e) from e except Exception as e: verbose_logger.exception(e) raise litellm.ImageFetchError( diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index fa070a648f5..f4388d5fd12 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -93,6 +93,10 @@ class SSRFError(ValueError): """Raised when a URL targets a blocked network.""" +class HostResolutionError(SSRFError): + pass + + def encode_url_path_segment(value: object, *, field_name: str = "path parameter") -> str: """Percent-encode one user-controlled URL path segment. @@ -324,10 +328,10 @@ def validate_url(url: str) -> tuple[str, str]: try: addrinfo: Final = socket.getaddrinfo(hostname, effective_port, proto=socket.IPPROTO_TCP) except socket.gaierror as e: - raise SSRFError(f"DNS resolution failed for '{hostname}': {e}") + raise HostResolutionError(f"DNS resolution failed for '{hostname}': {e}") if not addrinfo: - raise SSRFError(f"No addresses found for '{hostname}'") + raise HostResolutionError(f"No addresses found for '{hostname}'") if not is_allowlisted: for addrinfo_entry in addrinfo: diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index d8ccc9251dd..3a909298aba 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -17,7 +17,7 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import ( async_inline_remote_media, convert_url_to_base64, ) -from litellm.litellm_core_utils.url_utils import SSRFError +from litellm.litellm_core_utils.url_utils import HostResolutionError, SSRFError @pytest.fixture(autouse=True) @@ -115,9 +115,7 @@ class StreamingLargeImageClient: request=Request("GET", url), ) # Mock the iter_bytes method to return our generator - response.iter_bytes = lambda chunk_size=8192: generate_chunks( - size_bytes, chunk_size - ) + response.iter_bytes = lambda chunk_size=8192: generate_chunks(size_bytes, chunk_size) return response @@ -215,9 +213,7 @@ def test_streaming_download_handles_petabyte_file(monkeypatch): """ # Simulate a 1 petabyte file (1,000,000 GB) # Without streaming protection, this would cause OOM or hang indefinitely - client = StreamingLargeImageClient( - size_mb=1_000_000_000, include_content_length=False - ) + client = StreamingLargeImageClient(size_mb=1_000_000_000, include_content_length=False) monkeypatch.setattr(litellm, "module_level_client", client) with pytest.raises(litellm.ImageFetchError) as excinfo: @@ -429,20 +425,32 @@ async def test_async_inline_remote_media_cancels_the_other_fetches_when_one_fail _SSRF_VERDICTS = ( - "URL targets a blocked address (10.0.0.8). If this is a legitimate internal service, " - "add the host to `user_url_allowed_hosts` in general_settings.", - "DNS resolution failed for 'internal.example': [Errno 8] nodename nor servname provided, or not known", - "No addresses found for 'internal.example'", + ( + SSRFError( + "URL targets a blocked address (10.0.0.8). If this is a legitimate internal service, " + "add the host to `user_url_allowed_hosts` in general_settings." + ), + "The proxy's URL policy rejected this host; an admin can allow it with `user_url_allowed_hosts`", + ), + ( + HostResolutionError( + "DNS resolution failed for 'internal.example': [Errno 8] nodename nor servname provided, or not known" + ), + "The image host could not be resolved", + ), + (HostResolutionError("No addresses found for 'internal.example'"), "The image host could not be resolved"), ) -def _assert_one_verdict_free_message(messages, url): - assert len(set(messages)) == 1 - assert "10.0.0.8" not in messages[0] - assert "DNS" not in messages[0] - assert "No addresses" not in messages[0] - assert "user_url_allowed_hosts" in messages[0] - assert url in messages[0] +def _assert_verdict_free_messages(messages, url): + for message, (verdict, expected_guidance) in zip(messages, _SSRF_VERDICTS, strict=True): + assert expected_guidance in message + assert url in message + assert "10.0.0.8" not in message + assert "DNS" not in message + assert "No addresses" not in message + if isinstance(verdict, HostResolutionError): + assert "user_url_allowed_hosts" not in message async def test_async_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeypatch): @@ -450,11 +458,11 @@ async def test_async_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_r messages = [] url = f"http://internal.example/{uuid.uuid4()}.png" - for verdict in _SSRF_VERDICTS: + for verdict, _ in _SSRF_VERDICTS: async def block(client, fetched_url, verdict=verdict, **kwargs): attempts.append(fetched_url) - raise SSRFError(verdict) + raise verdict monkeypatch.setattr(image_handling, "async_safe_get", block) with pytest.raises(litellm.ImageFetchError) as raised: @@ -462,7 +470,7 @@ async def test_async_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_r messages.append(raised.value.message) assert attempts == [url] * len(_SSRF_VERDICTS) - _assert_one_verdict_free_message(messages, url) + _assert_verdict_free_messages(messages, url) def test_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeypatch): @@ -470,11 +478,11 @@ def test_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeyp messages = [] url = f"http://internal.example/{uuid.uuid4()}.png" - for verdict in _SSRF_VERDICTS: + for verdict, _ in _SSRF_VERDICTS: def block(client, fetched_url, verdict=verdict, **kwargs): attempts.append(fetched_url) - raise SSRFError(verdict) + raise verdict monkeypatch.setattr(image_handling, "safe_get", block) with pytest.raises(litellm.ImageFetchError) as raised: @@ -482,7 +490,7 @@ def test_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeyp messages.append(raised.value.message) assert attempts == [url] * len(_SSRF_VERDICTS) - _assert_one_verdict_free_message(messages, url) + _assert_verdict_free_messages(messages, url) async def test_async_inline_remote_media_caps_in_flight_fetches_per_request(monkeypatch): diff --git a/tests/test_litellm/litellm_core_utils/test_url_utils.py b/tests/test_litellm/litellm_core_utils/test_url_utils.py index fccdc1a2a0a..151c7ceb5bd 100644 --- a/tests/test_litellm/litellm_core_utils/test_url_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_url_utils.py @@ -9,6 +9,7 @@ import pytest import litellm from litellm.litellm_core_utils import url_utils from litellm.litellm_core_utils.url_utils import ( + HostResolutionError, SSRFError, _is_blocked_ip, assert_same_origin, @@ -161,10 +162,24 @@ class TestValidateUrl: assert "/path" in rewritten assert "key=value" in rewritten - def test_dns_failure_raises(self, mock_dns_failure): - with pytest.raises(SSRFError, match="DNS resolution failed"): + def test_dns_failure_raises_a_host_resolution_error(self, mock_dns_failure): + with pytest.raises(HostResolutionError, match="DNS resolution failed"): validate_url("http://this-domain-does-not-exist-xyz123.invalid/test") + def test_empty_resolution_raises_a_host_resolution_error(self, monkeypatch): + monkeypatch.setattr(url_utils.socket, "getaddrinfo", lambda *args, **kwargs: []) + with pytest.raises(HostResolutionError, match="No addresses found"): + validate_url("http://this-domain-resolves-to-nothing.invalid/test") + + def test_blocked_address_is_not_a_host_resolution_error(self, monkeypatch): + def fake(host, port, *a, **kw): + return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.8", port or 80))] + + monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) + with pytest.raises(SSRFError, match="blocked address") as raised: + validate_url("http://internal.example/test") + assert not isinstance(raised.value, HostResolutionError) + def test_blocks_localhost_hostname(self, monkeypatch): def fake(host, port, *a, **kw): return [ From de2ba3fab1daab25ba2517ea1a93cf7120f996f5 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 4 Sep 2026 23:50:01 -0700 Subject: [PATCH 206/410] fix(make check): lint the test tree on tests-only changes like CI does CI's required lint job runs ruff with ruff-tests.toml over tests/ and the test-quality budget gate, but scripts/pre_commit_lint.sh only triggered make lint on litellm/ files, so a tests-only commit passed make check with a no-op note and then failed CI (a duplicate test name, ruff F811, did exactly that). When tests/ Python files are in scope and no litellm/ files are, run ruff with ruff-tests.toml over the changed test files and make lint-test-quality, with the matching partial-staging warning, summary line, and no-op condition. --- scripts/pre_commit_lint.sh | 25 +++++- tests/test_litellm/test_pre_commit_lint.py | 98 ++++++++++++++++++++-- 2 files changed, 114 insertions(+), 9 deletions(-) diff --git a/scripts/pre_commit_lint.sh b/scripts/pre_commit_lint.sh index d38a0eee3de..ad1b4e793ab 100755 --- a/scripts/pre_commit_lint.sh +++ b/scripts/pre_commit_lint.sh @@ -12,6 +12,8 @@ # - litellm/ Python -> `make lint` (test-linting.yml's lint job) # - tests/e2e Python -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step) # + raw HTTP client ban (test-code-quality.yml's check_e2e_no_raw_requests) +# - tests/ Python -> ruff over ruff-tests.toml + `make lint-test-quality` (test-linting.yml's +# test-tree ruff and test-quality budget steps) # - dashboard -> prettier + eslint + lint budgets (test-litellm-ui-build.yml's frontend-lint) # - proxy/types -> regenerate the lazy OpenAPI snapshot and dashboard API types, fail on drift (check-ui-api-types.yml) # @@ -88,15 +90,18 @@ existing_files() { litellm_py_pattern='^litellm/.*\.py$' e2e_py_pattern='^tests/e2e/.*\.py$' +tests_py_pattern='^tests/.*\.py$' spec_pattern='^(litellm/(proxy|types)/.*|ui/litellm-dashboard/(scripts/gen-api-types\.mjs|package\.json|package-lock\.json|src/lib/http/schema\.d\.ts))$' ui_prettier_pattern='^ui/litellm-dashboard/.*\.(js|jsx|ts|tsx|mjs|cjs|json|css|scss|md|mdx|yml|yaml|html)$' ui_eslint_pattern='^ui/litellm-dashboard/.*\.(js|jsx|ts|tsx|mjs|cjs)$' -# CI's lint job (test-linting.yml) only inspects litellm/, so a tests-only or -# scripts-only commit can't turn it red; scope the trigger there to skip the slow -# make lint when it couldn't catch anything. +# CI's lint job (test-linting.yml) is make lint: litellm/ checks plus two test-tree +# steps (ruff over ruff-tests.toml and the test-quality budget). Trigger the slow +# make lint on litellm/ files only; a tests-only commit runs just those two steps. litellm_py_files=$(scope_match "$litellm_py_pattern") e2e_py_files=$(scope_match "$e2e_py_pattern") +tests_py_changed=$(scope_match "$tests_py_pattern") +tests_py_files=$(printf '%s\n' "$tests_py_changed" | existing_files) # ruff format (and CI's format step) skip enterprise; the rest of make lint covers it. fmt_files=$(printf '%s\n' "$litellm_py_files" | grep -v '^litellm/enterprise/' | existing_files) # check-ui-api-types.yml triggers on any file under litellm/proxy or litellm/types @@ -136,6 +141,7 @@ if [ -n "$staged" ]; then } warn_skipped "Python lint (make lint)" "$litellm_py_pattern" "$litellm_py_files" warn_skipped "tests/e2e checks (basedpyright + raw HTTP client ban)" "$e2e_py_pattern" "$e2e_py_files" + warn_skipped "test-tree lint (ruff-tests.toml + test-quality budget)" "$tests_py_pattern" "$tests_py_changed" warn_skipped "dashboard lint (prettier + eslint + lint budgets)" "$ui_prettier_pattern" "$ui_prettier_changed" warn_skipped "dashboard API-type sync (npm run gen:api)" "$spec_pattern" "$spec_files" fi @@ -225,6 +231,16 @@ if [ -n "$e2e_py_files" ]; then || { echo "✗ Raw HTTP client import in tests/e2e. Route the call through tests/e2e/e2e_http.py, then re-run make check." >&2; status=1; } fi +if [ -n "$tests_py_changed" ] && [ -z "$litellm_py_files" ]; then + if [ -n "$tests_py_files" ]; then + echo "check: linting the test tree (ruff check --config ruff-tests.toml, scoped tests files)" + printf '%s\n' "$tests_py_files" | xargs uv run --no-sync ruff check --config ruff-tests.toml \ + || { echo "✗ Test-tree ruff failed. Fix the errors above, then re-run make check." >&2; status=1; } + fi + echo "check: checking the test-quality budget (make lint-test-quality)" + make lint-test-quality || { echo "✗ Test-quality budget failed. Fix the errors above, then re-run make check." >&2; status=1; } +fi + dashboard_checks() { echo "check: linting dashboard (prettier + eslint + lint budgets)" if [ ! -d ui/litellm-dashboard/node_modules ]; then @@ -313,10 +329,11 @@ summary_item() { echo "check: summary" summary_item "Python lint (make lint)" "$litellm_py_files" "no litellm/ Python files in scope" summary_item "tests/e2e checks (basedpyright + raw HTTP client ban)" "$e2e_py_files" "no tests/e2e Python files in scope" +summary_item "test-tree lint (ruff-tests.toml + test-quality budget)" "$tests_py_changed" "no tests/ Python files in scope" summary_item "dashboard lint (prettier + eslint + lint budgets)" "$ui_prettier_changed$ui_eslint_changed" "no dashboard files in scope" summary_item "dashboard API-type sync (npm run gen:api)" "$spec_files" "no litellm/proxy, litellm/types, or generator files in scope" -if [ -z "$litellm_py_files$e2e_py_files$ui_prettier_changed$ui_eslint_changed$spec_files" ]; then +if [ -z "$litellm_py_files$e2e_py_files$tests_py_changed$ui_prettier_changed$ui_eslint_changed$spec_files" ]; then echo "check: NOTE - no gating lint check matches the files in scope, so nothing ran:" >&2 printf '%s\n' "$scope" | sed 's/^/ /' >&2 echo " A pass here is a no-op, not a lint verdict." >&2 diff --git a/tests/test_litellm/test_pre_commit_lint.py b/tests/test_litellm/test_pre_commit_lint.py index aa2260e89ea..22ae0e1bfaa 100644 --- a/tests/test_litellm/test_pre_commit_lint.py +++ b/tests/test_litellm/test_pre_commit_lint.py @@ -40,6 +40,9 @@ case "$*" in sleep 60 fi ;; + lint-test-quality) + [ "${STUB_FAIL:-}" = "test-quality" ] && exit 1 + ;; esac exit 0 """ @@ -69,6 +72,10 @@ case "$*" in *orjson*) [ -n "${STUB_BARRIER_DIR:-}" ] && barrier_sync genapi "python dashboard" ;; + "run --no-sync ruff check --config ruff-tests.toml"*) + [ -n "${STUB_ARGS_DIR:-}" ] && echo "$*" >> "$STUB_ARGS_DIR/ruff_tests.args" + [ "${STUB_FAIL:-}" = "tests-ruff" ] && exit 1 + ;; esac exit 0 """ @@ -153,6 +160,13 @@ def _set_base_ref(repo: Path) -> None: ) +def _stage_file(repo: Path, relative: str, body: str) -> None: + path = repo / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body) + subprocess.run(["git", "add", relative], cwd=repo, check=True) + + def test_nothing_staged_scopes_to_working_tree_diff_and_runs_checks(tmp_path: Path) -> None: repo, bin_dir = _sandbox(tmp_path) _commit_all(repo, "base") @@ -405,6 +419,7 @@ def test_run_ends_with_a_summary_of_ran_and_skipped_blocks(tmp_path: Path) -> No assert "ran: dashboard lint (prettier + eslint + lint budgets)" in proc.stdout assert "ran: dashboard API-type sync (npm run gen:api)" in proc.stdout assert "skipped: tests/e2e checks (basedpyright + raw HTTP client ban) (no tests/e2e Python files in scope)" in proc.stdout + assert "skipped: test-tree lint (ruff-tests.toml + test-quality budget) (no tests/ Python files in scope)" in proc.stdout assert "check: PASS" in proc.stdout assert "check: FAIL" not in proc.stdout @@ -412,20 +427,93 @@ def test_run_ends_with_a_summary_of_ran_and_skipped_blocks(tmp_path: Path) -> No def test_staged_files_matching_no_check_print_an_explicit_noop_note_and_nonempty_log(tmp_path: Path) -> None: repo, bin_dir = _sandbox(tmp_path) _commit_all(repo, "base") - tests_dir = repo / "tests" / "test_litellm" - tests_dir.mkdir(parents=True) - (tests_dir / "test_x.py").write_text("def test_x() -> None: ...\n") - subprocess.run(["git", "add", "tests"], cwd=repo, check=True) + _stage_file(repo, "scripts/tool.py", "def main() -> None: ...\n") proc = _run(repo, bin_dir, {}) assert proc.returncode == 0, proc.stdout + proc.stderr assert "no gating lint check matches the files in scope, so nothing ran" in proc.stdout - assert "tests/test_litellm/test_x.py" in proc.stdout + assert "scripts/tool.py" in proc.stdout assert "a no-op, not a lint verdict" in proc.stdout assert "check: PASS" in proc.stdout assert "linting Python" not in proc.stdout log = (repo / ".git" / "pre_commit_lint.log").read_text() assert "check: summary" in log assert "skipped: Python lint (make lint) (no litellm/ Python files in scope)" in log + assert "skipped: test-tree lint (ruff-tests.toml + test-quality budget) (no tests/ Python files in scope)" in log + + +def test_tests_only_change_runs_test_tree_ruff_on_staged_files_and_the_quality_gate(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + _commit_all(repo, "base") + args_dir = tmp_path / "args" + args_dir.mkdir() + _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") + _stage_file(repo, "tests/test_b.py", "def test_b() -> None: ...\n") + _stage_file(repo, "tests/fixtures/data.json", "{}\n") + proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir)}) + assert proc.returncode == 0, proc.stdout + proc.stderr + ruff_args = (args_dir / "ruff_tests.args").read_text().splitlines() + assert ruff_args == ["run --no-sync ruff check --config ruff-tests.toml tests/test_a.py tests/test_b.py"] + assert "ran: test-tree lint (ruff-tests.toml + test-quality budget)" in proc.stdout + assert "no gating lint check matches" not in proc.stdout + assert "linting Python" not in proc.stdout + assert "check: PASS" in proc.stdout + + +@pytest.mark.parametrize( + ("fail", "message"), + [ + ("tests-ruff", "Test-tree ruff failed"), + ("test-quality", "Test-quality budget failed"), + ], +) +def test_a_failing_test_tree_check_fails_a_tests_only_run(tmp_path: Path, fail: str, message: str) -> None: + repo, bin_dir = _sandbox(tmp_path) + _commit_all(repo, "base") + _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") + proc = _run(repo, bin_dir, {"STUB_FAIL": fail}) + assert proc.returncode == 1 + assert message in proc.stdout + proc.stderr + assert "check: FAIL" in proc.stdout + + +def test_tests_changed_alongside_litellm_files_defer_to_make_lint(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + _commit_all(repo, "base") + args_dir = tmp_path / "args" + args_dir.mkdir() + _stage_file(repo, "litellm/foo.py", "x = 2\n") + _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") + proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir), "STUB_FAIL": "test-quality"}) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "linting Python" in proc.stdout + assert not (args_dir / "ruff_tests.args").exists() + assert "ran: test-tree lint (ruff-tests.toml + test-quality budget)" in proc.stdout + + +def test_deleted_test_file_still_runs_the_quality_gate_without_feeding_ruff_the_missing_file(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") + _commit_all(repo, "base") + args_dir = tmp_path / "args" + args_dir.mkdir() + subprocess.run(["git", "rm", "-q", "tests/test_a.py"], cwd=repo, check=True) + proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir), "STUB_FAIL": "test-quality"}) + assert proc.returncode == 1 + assert "Test-quality budget failed" in proc.stdout + proc.stderr + assert not (args_dir / "ruff_tests.args").exists() + + +def test_partial_staging_warns_when_test_files_are_left_unstaged(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") + _commit_all(repo, "base") + _stage_file(repo, "notes.md", "hi\n") + (repo / "tests" / "test_a.py").write_text("def test_a() -> None:\n assert True\n") + proc = _run(repo, bin_dir, {}) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "SKIPPED test-tree lint (ruff-tests.toml + test-quality budget)" in proc.stdout + assert "tests/test_a.py" in proc.stdout + assert "make lint-test-quality" not in proc.stdout def test_run_queues_through_the_machine_wide_gate_slot_lock(tmp_path: Path) -> None: From fafd294878fa7d7de600d70ed58906e5c0c900d2 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Fri, 4 Sep 2026 23:52:33 -0700 Subject: [PATCH 207/410] fix(mcp): let config.yaml MCP servers pin server_id (#39286) * fix(mcp): let config.yaml MCP servers pin server_id A config-defined MCP server's id is a hash of server_name|url|transport| auth_type|alias, recomputed on every config load, so editing any of those fields mints a new id. Every key and team granted the old id via object_permission.mcp_servers keeps pointing at an id that no longer exists, and the server disappears from tools/list for them with nothing logged. load_servers_from_config now uses an explicit server_id from the server's config entry when present and falls back to the existing hash otherwise, so grants survive url/name/alias edits. Rejected at config load: a blank or non-string server_id, two entries claiming the same id, a pinned id already held by a database-backed server, and a pinned id that is another entry's server_name or alias (expand_permission_list matches ids before names, so that one would capture the other server's grants). Because the database registry loads after the config on startup, a database row that lands on a pinned config id is reported as a warning from the database reload instead, where it is decidable; the warning is latched on the shadowed set so the config-reload timer does not reprint it every interval. Deployments that do not set server_id keep the exact id they have today. * fix(mcp): close two more pinned-id capture paths A pinned server_id equal to an alias supplied through litellm_settings mcp_aliases was accepted, because the collision index only held the entry's own alias field. expand_permission_list matches ids before names, so grants written for the aliased server resolved to the pinning one. mcp_aliases keys whose target is a config server are now reserved the same way. A pinned server_id equal to a database-backed server's name, server_name or alias had the same effect against the database side, and could not be rejected at config load because the database registry is not loaded yet. The database reload now warns about it, latched like the existing shadow warning. * fix(mcp): reserve only the aliases the loader actually assigns Reserving every mcp_aliases key targeting a config server was too broad in two ways: the mapping is ignored when the entry sets its own alias, and only the first mapping for a server is ever applied. Both cases made a pinned server_id that could never have collided abort proxy startup. Reserve only the name load_servers_from_config will really assign. The database capture warning also fired for a database server whose own id is the config server_id. There the database row wins the id outright through get_registry precedence, so the shadow warning above it is the accurate one and the capture message contradicted it. Skip those rows. Also mark the two litellm-internal patches in the reload test helper, which the test-quality gate counts; the database reload has no other seam. * fix(mcp): match the loader's alias check exactly, is None not falsiness load_servers_from_config consults mcp_aliases only when the entry has no alias key at all, so an entry setting alias: "" gets no mapped alias. The collision index used falsiness and reserved the mapped name anyway, which failed startup on a pinned server_id that could never have collided with it. * fix(mcp): skip one identifier, not the whole database row A database row can shadow one config server_id by id and capture another by name at the same time. Skipping the entire row when its id shadowed a config entry dropped the second warning, leaving the operator with half a diagnosis. Skip only the identifier equal to the row's own id. * fix(mcp): reject conflicting self-pinned server ids * fix(mcp): validate config server names before building the identifier index The collision check reads every entry's body up front, so a malformed entry under an invalid name surfaced as an AttributeError instead of the name validation error the loader gave before this change. --- .../mcp_server/mcp_server_manager.py | 223 ++++++- .../mcp_server/test_mcp_server_manager.py | 563 ++++++++++++++++++ 2 files changed, 782 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index bfc5f629faf..bcbcc6bc579 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -13,9 +13,19 @@ import json import os import re import time -from collections.abc import AsyncIterator, Awaitable, Callable, Mapping, Sequence +from collections.abc import ( + AsyncIterator, + Awaitable, + Callable, + Container, + Iterable, + Mapping, + MutableMapping, + Sequence, +) from contextlib import asynccontextmanager from dataclasses import dataclass, replace +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Literal, TypeAlias, TypedDict, cast from urllib.parse import ParseResult, urlparse @@ -307,6 +317,7 @@ class MCPServerConfig(TypedDict, total=False): :meth:`MCPServerManager.load_servers_from_config`. Every key is optional: YAML supplies whatever the admin wrote, and each read applies its own default.""" + server_id: ReadOnly[str] alias: str description: str mcp_info: MCPInfo @@ -400,6 +411,164 @@ def _blank_to_none(value: str | None) -> str | None: return value.strip() or None +def _pinned_config_server_id(raw_server_id: object, server_name: str) -> str | None: + """Return the ``server_id`` an admin pinned for this config.yaml server, or ``None`` when absent. + + Without a pin the id is derived by hashing ``server_name|url|transport|auth_type|alias``, so + editing any of those fields mints a new id and every ``object_permission.mcp_servers`` grant + holding the old one silently stops matching. A pinned id is used verbatim and survives those + edits. Blank and non-string values are rejected rather than silently falling back to the hash, + because a config that pins an id and still churns is the failure this field exists to prevent. + + Under ``LITELLM_USE_SHORT_MCP_TOOL_PREFIX`` the tool prefix is derived from the server_id, so + pinning an id other than the one already in use renames every tool that server exposes. + """ + if raw_server_id is None: + return None + if not isinstance(raw_server_id, str) or not raw_server_id.strip(): + raise ValueError( + f"Invalid config for MCP server '{server_name}': server_id must be a non-empty string " + f"(got {raw_server_id!r})." + ) + return raw_server_id.strip() + + +def _first_mapped_alias(server_name: str, mcp_aliases: Mapping[str, str] | None) -> str | None: + """The ``mcp_aliases`` name ``load_servers_from_config`` will assign to this server, if any. + + Mirrors that loop, which takes the first mapping pointing at the server and stops. A later + mapping for the same server is never applied, so it stays free for another entry to pin. + """ + if mcp_aliases is None: + return None + return next( + (alias_name for alias_name, target_server_name in mcp_aliases.items() if target_server_name == server_name), + None, + ) + + +def _assigned_alias( + server_name: str, server_config: MCPServerConfig, mcp_aliases: Mapping[str, str] | None +) -> str | None: + """The alias ``load_servers_from_config`` will give this entry: its own, else the first mapping. + + ``is None``, not falsiness: the loader only consults the mapping when the key is absent, so an + entry that sets ``alias: ""`` gets no mapped alias and reserves nothing. + """ + alias: Final = server_config.get("alias") + return _first_mapped_alias(server_name, mcp_aliases) if alias is None else alias + + +def _validate_config_server_names(mcp_servers_config: Mapping[str, MCPServerConfig]) -> None: + """Reject bad server names before ``_config_identifier_owners`` reads any entry's body. + + The identifier index walks every entry up front, so without this pass a malformed entry under + a bad name would surface as an ``AttributeError`` from the index instead of the name error. + """ + for server_name in mcp_servers_config: + validate_mcp_server_name(server_name) + + +def _config_identifier_owners( + mcp_servers_config: Mapping[str, MCPServerConfig], + mcp_aliases: Mapping[str, str] | None, +) -> Mapping[str, frozenset[str]]: + """Map every server_name and alias in the config to the entries that own it. + + ``expand_permission_list`` resolves a grant against the registry keys before it falls back to + matching alias and server_name, so an id equal to another entry's name or alias captures that + entry's grants. Derived ids are hashes and never collide with a name, so this only matters once + an id is pinned. + + An alias is either set on the entry or mapped to it from ``litellm_settings.mcp_aliases``. Only + a name the loader below will really assign is reserved: the mapping is ignored for an entry that + sets its own ``alias``, and only the first mapping wins for one that does not, so reserving every + mapping would fail startup on a pin that was never going to collide. + + One identifier can have several owners when an entry's alias equals another entry's name. All of + them are kept: a grant naming that identifier resolves to every match while no id is pinned, and + a pin equal to it would narrow the grant to the pinning entry alone, even when that entry is one + of the owners. + """ + claims: Final = tuple( + (identifier, server_name) + for server_name, server_config in mcp_servers_config.items() + for identifier in (server_name, _assigned_alias(server_name, server_config, mcp_aliases)) + if identifier + ) + return MappingProxyType( + {identifier: frozenset(owner for claimed, owner in claims if claimed == identifier) for identifier, _ in claims} + ) + + +def _config_ids_capturing_db_identifiers( + config_server_ids: Container[str], + db_servers: Iterable[MCPServer], +) -> frozenset[str]: + """Config server ids that are a database-backed server's name, server_name or alias. + + ``expand_permission_list`` matches a grant against the registry keys before it matches names, so + such an id answers every grant written for the database server, and the database server itself + stops being reachable by name. The config load cannot catch this because the database registry + is not loaded yet, so it is reported from the reload that does have both halves. + + An identifier equal to the database server's own id is skipped: ``get_registry`` is + ``config_mcp_servers | registry``, so there the database server wins the id outright and the + shadow warning above is the accurate one. Reporting both would contradict. The skip is per + identifier rather than per server, so a row that shadows one config id and captures another + still reports the capture. + """ + return frozenset( + identifier + for server in db_servers + for identifier in (server.name, server.server_name, server.alias) + if identifier and identifier != server.server_id and identifier in config_server_ids + ) + + +def _reject_config_server_id_collision( + assigned_server_ids: Mapping[str, str], + server_id: str, + server_name: str, + pinned: bool, + db_backed_server_ids: Mapping[str, object], + identifier_owners: Mapping[str, frozenset[str]], +) -> None: + """Raise when ``server_id`` is already taken, either by an earlier config entry or by the database. + + Two config entries sharing an id would silently overwrite each other in ``config_mcp_servers``, + and an id already held by a database-backed server is hidden by it, because ``get_registry`` is + ``config_mcp_servers | registry`` and the right operand wins. A pinned id that is another + entry's server_name or alias captures that entry's permission grants the same way. Derived ids + cannot collide (the unique config key is part of the hash input), so all three only happen once + an id is pinned. + + Pinning an identifier this entry itself owns is allowed, because a grant naming it already + resolved here, but only when no other entry owns it too. An entry whose alias is this entry's + server_name shares the identifier, and pinning it would take that entry's grants. + """ + claimed_by = assigned_server_ids.get(server_id) + if claimed_by is not None: + raise ValueError( + f"Invalid config for MCP server '{server_name}': server_id '{server_id}' is already " + f"used by MCP server '{claimed_by}'. Each mcp_servers entry needs its own id." + ) + if pinned and server_id in db_backed_server_ids: + raise ValueError( + f"Invalid config for MCP server '{server_name}': server_id '{server_id}' belongs to a " + "database-backed MCP server. The database entry takes precedence over config.yaml, so " + "this server would never be reachable." + ) + other_owners: Final = identifier_owners.get(server_id, frozenset()) - frozenset((server_name,)) + if pinned and other_owners: + owner_names: Final = "', '".join(sorted(other_owners)) + raise ValueError( + f"Invalid config for MCP server '{server_name}': server_id '{server_id}' is the " + f"server_name or alias of MCP server '{owner_names}'. Permission entries naming " + f"'{server_id}' would resolve to '{server_name}' alone and no longer reach '{owner_names}'." + ) + + def _uses_issuer_anchor(manual_issuer: str | None, is_discovery_auth_type: bool) -> bool: """Whether the endpoints are authoritatively anchored to an admin-pinned issuer (RFC 8414 §3.3). @@ -1565,6 +1734,11 @@ class MCPServerManager: # empty result, or failure). Used to throttle re-probes for servers that do # not return instructions, and to apply a short cooldown after failures. self._upstream_initialize_instructions_probed_at: dict[str, float] = {} + # Last set of config server ids found shadowed by database rows. reload_servers_from_database + # runs on the config-reload timer, so this keeps a standing misconfiguration from re-logging + # the same warning every interval; a change in the set logs again. + self._warned_shadowed_config_server_ids: frozenset[str] = frozenset() + self._warned_capturing_config_server_ids: frozenset[str] = frozenset() self._oauth_discovery_on_startup = _mcp_oauth_discovery_on_startup_enabled() self._oauth_discovery_generation_counter = 0 self._oauth_discovery_slots: tuple[_OAuthDiscoverySlot, ...] = () @@ -1958,10 +2132,14 @@ class MCPServerManager: # Track which aliases have been used to ensure only first occurrence is used used_aliases: Final = set() + # server_id -> the config server_name that claimed it, so a pinned id cannot silently + # overwrite another server's entry in self.config_mcp_servers. + assigned_server_ids: MutableMapping[str, str] = {} # mutable-ok: per-load collision index + _validate_config_server_names(mcp_servers_config) + identifier_owners: Final = _config_identifier_owners(mcp_servers_config, mcp_aliases) for server_name, raw_server_config in mcp_servers_config.items(): server_config: MCPServerConfig = raw_server_config - validate_mcp_server_name(server_name) _mcp_info: MCPInfo = server_config.get("mcp_info", None) or {} # Preserve all custom fields from config while setting defaults for core fields mcp_info: MCPInfo = _mcp_info.copy() @@ -1994,14 +2172,24 @@ class MCPServerManager: name_for_prefix = get_server_prefix(temp_server) server_url = server_config.get("url", None) or "" - # Generate stable server ID based on parameters - server_id = self._generate_stable_server_id( + # An explicitly pinned server_id wins; otherwise derive one from the parameters. + pinned_server_id = _pinned_config_server_id(server_config.get("server_id"), server_name) + server_id = pinned_server_id or self._generate_stable_server_id( server_name=server_name, url=server_url, transport=server_config.get("transport", MCPTransport.http), auth_type=server_config.get("auth_type", None), alias=alias, ) + _reject_config_server_id_collision( + assigned_server_ids, + server_id, + server_name, + pinned=pinned_server_id is not None, + db_backed_server_ids=self.registry, + identifier_owners=identifier_owners, + ) + assigned_server_ids[server_id] = server_name _warn_on_server_name_fields( server_id=server_id, @@ -6123,6 +6311,33 @@ class MCPServerManager: verbose_logger.debug("MCP registry refreshed (%s servers in registry)", len(registered_registry)) + # get_registry() is ``config_mcp_servers | registry``, so a database row sharing an id with a + # config.yaml server hides that server everywhere. Only reachable once an operator pins + # ``server_id`` in config.yaml; say so rather than letting the server disappear silently. + shadowed_config_server_ids: Final = frozenset(self.config_mcp_servers.keys() & registered_registry.keys()) + if shadowed_config_server_ids and shadowed_config_server_ids != self._warned_shadowed_config_server_ids: + verbose_logger.warning( + "config.yaml MCP server_id(s) %s are also database-backed MCP servers. The database " + "entry takes precedence, so the config.yaml server is unreachable. Give the config " + "entry a different server_id.", + ", ".join(sorted(shadowed_config_server_ids)), + ) + self._warned_shadowed_config_server_ids = shadowed_config_server_ids + + # The mirror image of the block above: a config server_id that is a database server's name + # answers that server's grants instead, because ids are matched before names. + capturing_config_server_ids: Final = _config_ids_capturing_db_identifiers( + self.config_mcp_servers.keys(), registered_registry.values() + ) + if capturing_config_server_ids and capturing_config_server_ids != self._warned_capturing_config_server_ids: + verbose_logger.warning( + "config.yaml MCP server_id(s) %s are the name or alias of a database-backed MCP " + "server. Permission entries naming them resolve to the config.yaml server, not the " + "database one. Give the config entry a different server_id.", + ", ".join(sorted(capturing_config_server_ids)), + ) + self._warned_capturing_config_server_ids = capturing_config_server_ids + await self._hydrate_config_servers_dcr_clients() def get_mcp_servers_from_ids(self, server_ids: list[str]) -> list[MCPServer]: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 02b1a19081a..9745e508703 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -11428,6 +11428,569 @@ class TestOpenApiHandlerRelaysUpstreamAuth: assert "upstream returned HTTP 503" in result.content[0].text +class TestConfigServerIdPinning: + """config.yaml servers may pin ``server_id`` so permission grants survive connection edits.""" + + @staticmethod + def _config(**overrides: object) -> dict[str, dict[str, object]]: + return { + "docs_server": { + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + **overrides, + } + } + + @pytest.mark.asyncio + async def test_derived_id_churns_when_connection_fields_change(self): + """The behavior the pin exists to escape: editing the url mints a brand-new id.""" + manager = MCPServerManager() + + await manager.load_servers_from_config(self._config()) + before = next(iter(manager.config_mcp_servers)) + + manager.config_mcp_servers.clear() + await manager.load_servers_from_config(self._config(url="https://prod.example.com/mcp")) + after = next(iter(manager.config_mcp_servers)) + + assert before != after + + @pytest.mark.asyncio + async def test_pinned_id_survives_url_transport_auth_and_alias_edits(self): + manager = MCPServerManager() + + await manager.load_servers_from_config(self._config(server_id="docs-prod-1")) + assert list(manager.config_mcp_servers) == ["docs-prod-1"] + assert manager.config_mcp_servers["docs-prod-1"].server_id == "docs-prod-1" + + manager.config_mcp_servers.clear() + await manager.load_servers_from_config( + self._config( + server_id="docs-prod-1", + url="https://prod.example.com/mcp", + transport=MCPTransport.sse, + auth_type=MCPAuth.bearer_token, + alias="docs", + ) + ) + + assert list(manager.config_mcp_servers) == ["docs-prod-1"] + assert manager.config_mcp_servers["docs-prod-1"].url == "https://prod.example.com/mcp" + + @pytest.mark.asyncio + async def test_absent_server_id_keeps_the_derived_hash(self): + manager = MCPServerManager() + + await manager.load_servers_from_config(self._config()) + + derived = manager._generate_stable_server_id( + server_name="docs_server", + url="https://example.com/mcp", + transport=MCPTransport.http, + auth_type=None, + alias=None, + ) + assert list(manager.config_mcp_servers) == [derived] + + @pytest.mark.asyncio + @pytest.mark.parametrize("bad_value", ["", " ", 123, True, ["docs-prod-1"]]) + async def test_blank_or_non_string_server_id_is_rejected(self, bad_value: Any): + manager = MCPServerManager() + + with pytest.raises(ValueError, match="server_id must be a non-empty string"): + await manager.load_servers_from_config(self._config(server_id=bad_value)) + + @pytest.mark.asyncio + async def test_two_servers_pinning_the_same_id_are_rejected(self): + manager = MCPServerManager() + config: Dict[str, Any] = { + "docs_server": {"url": "https://a.example.com/mcp", "server_id": "shared-id"}, + "wiki_server": {"url": "https://b.example.com/mcp", "server_id": "shared-id"}, + } + + with pytest.raises(ValueError, match="already used by MCP server 'docs_server'"): + await manager.load_servers_from_config(config) + + @pytest.mark.asyncio + async def test_pinned_id_colliding_with_a_derived_id_is_rejected(self): + """A pin that lands on another entry's derived hash collides just as hard.""" + manager = MCPServerManager() + derived = manager._generate_stable_server_id( + server_name="docs_server", + url="https://a.example.com/mcp", + transport=MCPTransport.http, + auth_type=None, + alias=None, + ) + config: Dict[str, Any] = { + "docs_server": {"url": "https://a.example.com/mcp", "transport": MCPTransport.http}, + "wiki_server": {"url": "https://b.example.com/mcp", "server_id": derived}, + } + + with pytest.raises(ValueError, match="already used by MCP server 'docs_server'"): + await manager.load_servers_from_config(config) + + @pytest.mark.asyncio + async def test_pinned_id_colliding_with_a_db_backed_server_is_rejected(self): + """get_registry() is ``config | registry``, so the db row would hide the config server. + + The registry is seeded by hand because on a real startup the config loads before the + database does, so this check only fires on a later reload. The startup ordering is covered + by ``test_db_row_arriving_on_a_pinned_config_id_warns``; the warning there is not redundant. + """ + manager = MCPServerManager() + manager.registry["db-uuid-1"] = MCPServer( + server_id="db-uuid-1", + name="db_server", + transport=MCPTransport.http, + url="https://db.example.com/mcp", + ) + + with pytest.raises(ValueError, match="belongs to a database-backed MCP server"): + await manager.load_servers_from_config(self._config(server_id="db-uuid-1")) + + @pytest.mark.asyncio + async def test_derived_id_matching_a_db_backed_server_is_not_rejected(self): + """Only a pinned id is an authoring error; a hash collision must not fail startup.""" + manager = MCPServerManager() + derived = manager._generate_stable_server_id( + server_name="docs_server", + url="https://example.com/mcp", + transport=MCPTransport.http, + auth_type=None, + alias=None, + ) + manager.registry[derived] = MCPServer( + server_id=derived, + name="db_server", + transport=MCPTransport.http, + url="https://db.example.com/mcp", + ) + + await manager.load_servers_from_config(self._config()) + + assert derived in manager.config_mcp_servers + + @pytest.mark.asyncio + async def test_pinned_id_is_stripped_of_surrounding_whitespace(self): + manager = MCPServerManager() + + await manager.load_servers_from_config(self._config(server_id=" docs-prod-1 ")) + + assert list(manager.config_mcp_servers) == ["docs-prod-1"] + + @staticmethod + async def _reload_with_db_server(manager: MCPServerManager, server_id: str, db_name: str = "db_server") -> None: + row = LiteLLM_MCPServerTable( + server_id=server_id, + server_name=db_name, + alias=db_name, + url="https://db.example.com/mcp", + transport=MCPTransport.http, + ) + raw_row = MagicMock() + raw_row.model_dump.return_value = row.model_dump() + repository = MagicMock() + repository.table.find_many = AsyncMock(return_value=[raw_row]) + built = MCPServer( + server_id=server_id, + name=db_name, + server_name=db_name, + url="https://db.example.com/mcp", + transport=MCPTransport.http, + ) + with ( + patch( # test-quality-ok: the db reload path has no seam but its own repository + "litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPServerRepository", + return_value=repository, + ), + patch( # test-quality-ok: same, the prisma client is fetched inside the reload + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=MagicMock(), + ), + patch.object(manager, "build_mcp_server_from_table", new=AsyncMock(return_value=built)), + ): + await manager.reload_servers_from_database() + + @pytest.mark.asyncio + async def test_db_row_arriving_on_a_pinned_config_id_warns(self, caplog): + """The db row loads after config on startup, so the config server is hidden then, not at load.""" + manager = MCPServerManager() + await manager.load_servers_from_config(self._config(server_id="docs-prod-1")) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await self._reload_with_db_server(manager, "docs-prod-1") + + assert any("docs-prod-1" in m and "database entry takes precedence" in m for m in caplog.messages) + assert manager.get_registry()["docs-prod-1"].url == "https://db.example.com/mcp" + + @pytest.mark.asyncio + async def test_db_row_with_a_distinct_id_does_not_warn(self, caplog): + manager = MCPServerManager() + await manager.load_servers_from_config(self._config(server_id="docs-prod-1")) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await self._reload_with_db_server(manager, "db-uuid-1") + + assert all("database entry takes precedence" not in m for m in caplog.messages) + assert set(manager.get_registry()) == {"docs-prod-1", "db-uuid-1"} + + @pytest.mark.asyncio + async def test_pinned_id_matching_another_entrys_server_name_is_rejected(self): + """expand_permission_list resolves against registry keys first, so this steals the grants.""" + manager = MCPServerManager() + + with pytest.raises(ValueError, match="server_name or alias of MCP server 'wiki_server'"): + await manager.load_servers_from_config( + { + "wiki_server": {"url": "https://wiki.example.com/mcp", "transport": MCPTransport.http}, + "docs_server": { + "server_id": "wiki_server", + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + }, + } + ) + + @pytest.mark.asyncio + async def test_pinned_id_matching_another_entrys_alias_is_rejected(self): + manager = MCPServerManager() + + with pytest.raises(ValueError, match="server_name or alias of MCP server 'wiki_server'"): + await manager.load_servers_from_config( + { + "wiki_server": { + "alias": "wiki", + "url": "https://wiki.example.com/mcp", + "transport": MCPTransport.http, + }, + "docs_server": { + "server_id": "wiki", + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + }, + } + ) + + @pytest.mark.asyncio + async def test_pinning_a_servers_own_name_is_allowed(self): + """The most natural pin an operator writes; it resolves to the same server either way.""" + manager = MCPServerManager() + + await manager.load_servers_from_config(self._config(server_id="docs_server")) + + assert list(manager.config_mcp_servers) == ["docs_server"] + + @pytest.mark.asyncio + async def test_pinning_a_servers_own_alias_is_allowed(self): + manager = MCPServerManager() + + await manager.load_servers_from_config(self._config(alias="docs", server_id="docs")) + + assert list(manager.config_mcp_servers) == ["docs"] + + @pytest.mark.asyncio + @pytest.mark.parametrize("aliasing_entry_first", [True, False]) + async def test_pinning_own_name_that_is_another_entrys_alias_is_rejected(self, aliasing_entry_first: bool): + """A grant naming 'docs_server' reaches both servers unpinned; the pin would narrow it to one.""" + manager = MCPServerManager() + wiki = ( + "wiki_server", + {"alias": "docs_server", "url": "https://wiki.example.com/mcp", "transport": MCPTransport.http}, + ) + docs = ( + "docs_server", + {"server_id": "docs_server", "url": "https://example.com/mcp", "transport": MCPTransport.http}, + ) + + with pytest.raises(ValueError, match="server_name or alias of MCP server 'wiki_server'"): + await manager.load_servers_from_config(dict((wiki, docs) if aliasing_entry_first else (docs, wiki))) + + @pytest.mark.asyncio + async def test_pinning_own_name_that_is_another_entrys_mapped_alias_is_rejected(self): + manager = MCPServerManager() + + with pytest.raises(ValueError, match="server_name or alias of MCP server 'wiki_server'"): + await manager.load_servers_from_config( + { + "wiki_server": {"url": "https://wiki.example.com/mcp", "transport": MCPTransport.http}, + "docs_server": { + "server_id": "docs_server", + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + }, + }, + mcp_aliases={"docs_server": "wiki_server"}, + ) + + @pytest.mark.asyncio + async def test_pinning_own_alias_shared_with_a_later_entry_is_rejected(self): + """Nothing rejects duplicate aliases, so the first entry's pin would answer the second's grants.""" + manager = MCPServerManager() + + with pytest.raises(ValueError, match="server_name or alias of MCP server 'docs_server'"): + await manager.load_servers_from_config( + { + "wiki_server": { + "alias": "shared", + "server_id": "shared", + "url": "https://wiki.example.com/mcp", + "transport": MCPTransport.http, + }, + "docs_server": { + "alias": "shared", + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + }, + } + ) + + @pytest.mark.asyncio + async def test_own_name_pin_resolves_grants_like_the_unpinned_name(self): + """The negative control: a sole-owner self-pin must keep loading and answer the same grants.""" + manager = MCPServerManager() + + await manager.load_servers_from_config( + { + "wiki_server": {"alias": "wiki", "url": "https://wiki.example.com/mcp", "transport": MCPTransport.http}, + "docs_server": { + "server_id": "docs_server", + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + }, + } + ) + wiki_id = next(sid for sid, server in manager.config_mcp_servers.items() if server.alias == "wiki") + + assert manager.expand_permission_list(["docs_server"]) == ["docs_server"] + assert manager.expand_permission_list(["wiki"]) == [wiki_id] + + @pytest.mark.asyncio + async def test_derived_id_is_not_checked_against_names(self): + """Unpinned configs must keep loading; only a pinned id can be an authoring error.""" + manager = MCPServerManager() + + await manager.load_servers_from_config( + { + "wiki_server": {"url": "https://wiki.example.com/mcp", "transport": MCPTransport.http}, + "docs_server": {"url": "https://example.com/mcp", "transport": MCPTransport.http}, + } + ) + + assert len(manager.config_mcp_servers) == 2 + + @pytest.mark.asyncio + async def test_shadow_warning_is_not_repeated_on_every_reload(self, caplog): + """reload_servers_from_database runs on the config-reload timer; one warning, not one a tick.""" + manager = MCPServerManager() + await manager.load_servers_from_config(self._config(server_id="docs-prod-1")) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await self._reload_with_db_server(manager, "docs-prod-1") + first_round = [m for m in caplog.messages if "database entry takes precedence" in m] + await self._reload_with_db_server(manager, "docs-prod-1") + second_round = [m for m in caplog.messages if "database entry takes precedence" in m] + + assert len(first_round) == 1 + assert second_round == first_round + + @pytest.mark.asyncio + async def test_shadow_warning_fires_again_when_the_shadowed_set_changes(self, caplog): + manager = MCPServerManager() + await manager.load_servers_from_config(self._config(server_id="docs-prod-1")) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await self._reload_with_db_server(manager, "docs-prod-1") + await self._reload_with_db_server(manager, "db-uuid-1") + await self._reload_with_db_server(manager, "docs-prod-1") + + assert len([m for m in caplog.messages if "database entry takes precedence" in m]) == 2 + + @pytest.mark.asyncio + async def test_pinned_id_matching_a_mapped_alias_is_rejected(self): + """An alias can also arrive from litellm_settings.mcp_aliases; it is reserved just the same.""" + manager = MCPServerManager() + + with pytest.raises(ValueError, match="server_name or alias of MCP server 'wiki_server'"): + await manager.load_servers_from_config( + { + "wiki_server": {"url": "https://wiki.example.com/mcp", "transport": MCPTransport.http}, + "docs_server": { + "server_id": "wiki", + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + }, + }, + {"wiki": "wiki_server"}, + ) + + @pytest.mark.asyncio + async def test_pinning_a_servers_own_mapped_alias_is_allowed(self): + manager = MCPServerManager() + + await manager.load_servers_from_config( + self._config(server_id="docs"), + {"docs": "docs_server"}, + ) + + assert list(manager.config_mcp_servers) == ["docs"] + + @pytest.mark.asyncio + async def test_mapped_alias_for_an_unknown_server_reserves_nothing(self): + """A dangling mcp_aliases entry is never applied, so it must not fail an unrelated pin.""" + manager = MCPServerManager() + + await manager.load_servers_from_config( + self._config(server_id="wiki"), + {"wiki": "a_server_that_does_not_exist"}, + ) + + assert list(manager.config_mcp_servers) == ["wiki"] + + @pytest.mark.asyncio + async def test_config_id_that_is_a_db_server_name_warns(self, caplog): + """The mirror of the shadow case: here the config entry captures the db server's grants.""" + manager = MCPServerManager() + await manager.load_servers_from_config(self._config(server_id="db_server")) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await self._reload_with_db_server(manager, "db-uuid-1") + + assert any("db_server" in m and "name or alias of a database-backed" in m for m in caplog.messages) + + @pytest.mark.asyncio + async def test_capture_warning_is_not_repeated_on_every_reload(self, caplog): + manager = MCPServerManager() + await manager.load_servers_from_config(self._config(server_id="db_server")) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await self._reload_with_db_server(manager, "db-uuid-1") + await self._reload_with_db_server(manager, "db-uuid-1") + + assert len([m for m in caplog.messages if "name or alias of a database-backed" in m]) == 1 + + @pytest.mark.asyncio + async def test_config_id_unrelated_to_db_names_does_not_warn(self, caplog): + manager = MCPServerManager() + await manager.load_servers_from_config(self._config(server_id="docs-prod-1")) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await self._reload_with_db_server(manager, "db-uuid-1") + + assert all("name or alias of a database-backed" not in m for m in caplog.messages) + + @pytest.mark.asyncio + async def test_mapped_alias_for_a_server_with_its_own_alias_reserves_nothing(self): + """load_servers_from_config ignores the mapping when the entry sets alias, so it is free.""" + manager = MCPServerManager() + + await manager.load_servers_from_config( + { + "wiki_server": { + "alias": "wiki_prod", + "url": "https://wiki.example.com/mcp", + "transport": MCPTransport.http, + }, + "docs_server": { + "server_id": "wiki", + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + }, + }, + {"wiki": "wiki_server"}, + ) + + assert "wiki" in manager.config_mcp_servers + assert len(manager.config_mcp_servers) == 2 + + @pytest.mark.asyncio + async def test_only_the_first_mapped_alias_for_a_server_is_reserved(self): + """Only the first mapping is applied, so pinning the second one must still load.""" + manager = MCPServerManager() + + await manager.load_servers_from_config( + { + "wiki_server": {"url": "https://wiki.example.com/mcp", "transport": MCPTransport.http}, + "docs_server": { + "server_id": "wiki_two", + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + }, + }, + {"wiki_one": "wiki_server", "wiki_two": "wiki_server"}, + ) + + assert "wiki_two" in manager.config_mcp_servers + + @pytest.mark.asyncio + async def test_invalid_name_is_reported_before_any_entry_body_is_read(self): + """The identifier index walks every entry up front, so a bad name must still fail on the name.""" + with pytest.raises(Exception, match="Server name cannot contain"): + await MCPServerManager().load_servers_from_config({"my-server": None}) + + @pytest.mark.asyncio + async def test_a_shadowing_db_server_reports_only_the_shadow_warning(self, caplog): + """The db row wins the id outright, so the capture message would contradict the shadow one.""" + manager = MCPServerManager() + await manager.load_servers_from_config(self._config(server_id="db_server")) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await self._reload_with_db_server(manager, "db_server") + + assert any("database entry takes precedence" in m for m in caplog.messages) + assert all("name or alias of a database-backed" not in m for m in caplog.messages) + assert manager.get_registry()["db_server"].url == "https://db.example.com/mcp" + + @pytest.mark.asyncio + async def test_an_explicitly_blank_alias_still_blocks_the_mapping(self): + """The loader only consults mcp_aliases when the key is absent, so a blank alias frees it.""" + manager = MCPServerManager() + + await manager.load_servers_from_config( + { + "wiki_server": { + "alias": "", + "url": "https://wiki.example.com/mcp", + "transport": MCPTransport.http, + }, + "docs_server": { + "server_id": "wiki", + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + }, + }, + {"wiki": "wiki_server"}, + ) + + assert "wiki" in manager.config_mcp_servers + assert manager.config_mcp_servers["wiki"].url == "https://example.com/mcp" + + @pytest.mark.asyncio + async def test_a_row_that_shadows_one_id_still_reports_capturing_another(self, caplog): + """Skipping is per identifier, not per row, so the second collision is not lost.""" + manager = MCPServerManager() + await manager.load_servers_from_config( + { + "docs_server": { + "server_id": "shadow_x", + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + }, + "wiki_server": { + "server_id": "capture_y", + "url": "https://wiki.example.com/mcp", + "transport": MCPTransport.http, + }, + } + ) + + with caplog.at_level(logging.WARNING, logger="LiteLLM"): + await self._reload_with_db_server(manager, "shadow_x", db_name="capture_y") + + assert any("shadow_x" in m and "database entry takes precedence" in m for m in caplog.messages) + assert any("capture_y" in m and "name or alias of a database-backed" in m for m in caplog.messages) + + class TestLitellmAdmissionKeyIsNeverTheSubjectToken: """The bearer that admitted the request as a LiteLLM key must not be sent to the IdP as the RFC 8693 subject_token (or ID-JAG assertion). Only ``x-litellm-api-key`` disambiguates: with it From f3cf5578989e558438b11c335b69702812d6b738 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Fri, 4 Sep 2026 23:57:53 -0700 Subject: [PATCH 208/410] feat(dashboard): configure classifier vision input (#39840) --- .../add_model/ClassificationMethodConfig.tsx | 10 ++- .../add_model/ClassifierVisionConfig.tsx | 79 ++++++++++++++++++ .../add_model/ComplexityRouterConfig.test.tsx | 81 +++++++++++++++++++ .../build_complexity_router_config.test.ts | 40 ++++++++- .../build_complexity_router_config.ts | 10 +-- .../edit_auto_router_modal.test.tsx | 57 +++++++++++++ 6 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/add_model/ClassifierVisionConfig.tsx diff --git a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx index cc66103fc86..15cfff01766 100644 --- a/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ClassificationMethodConfig.tsx @@ -15,6 +15,7 @@ import { RestrictedSection, restrictedBy } from "./TierRestrictions"; import HeuristicScoringConfig from "./HeuristicScoringConfig"; import ClassifierReasoningEffortSelect from "./ClassifierReasoningEffortSelect"; import ClassifierCircuitBreakerConfig from "./ClassifierCircuitBreakerConfig"; +import ClassifierVisionConfig from "./ClassifierVisionConfig"; import type { ReasoningEffort } from "./complexity_router_tiers"; import { useComplexityScorerDefaults } from "@/app/(dashboard)/hooks/autoRouter/useComplexityScorerDefaults"; import { @@ -315,12 +316,13 @@ const ClassificationMethodConfig: React.FC = ({ timeout_ms: value.classifier_llm_config?.timeout_ms ?? DEFAULT_CLASSIFIER_TIMEOUT_MS, classification_rubric: selectedRubric, }; - onChange({ + const nextValue: ComplexityRouterConfigValue = { ...value, ...(selectedRubric && { classifier_llm_config: rubricConfig }), classification_prompt: classificationPrompt, classification_examples: classificationExamples, - }); + }; + onChange(nextValue); }; const handleClassifierModelChange = (model: string) => { @@ -577,6 +579,10 @@ const ClassificationMethodConfig: React.FC = ({ value={value.classifier_llm_config ?? { model: "", timeout_ms: DEFAULT_CLASSIFIER_TIMEOUT_MS }} onChange={(classifier_llm_config) => onChange({ ...value, classifier_llm_config })} /> + onChange({ ...value, classifier_llm_config })} + />
    Classifier Prompt diff --git a/ui/litellm-dashboard/src/components/add_model/ClassifierVisionConfig.tsx b/ui/litellm-dashboard/src/components/add_model/ClassifierVisionConfig.tsx new file mode 100644 index 00000000000..34c41fff006 --- /dev/null +++ b/ui/litellm-dashboard/src/components/add_model/ClassifierVisionConfig.tsx @@ -0,0 +1,79 @@ +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; +import React from "react"; + +import type { ClassifierLLMConfigWire } from "./build_complexity_router_config"; + +export const DEFAULT_CLASSIFIER_VISION_ENABLED = false; +export const DEFAULT_CLASSIFIER_VISION_MAX_IMAGES = 1; + +const MAX_IMAGES_ID = "classifier-vision-max-images"; + +interface ClassifierVisionConfigProps { + value: ClassifierLLMConfigWire; + onChange: (value: ClassifierLLMConfigWire) => void; +} + +const ClassifierVisionConfig: React.FC = ({ value, onChange }) => { + const [draftMaxImages, setDraftMaxImages] = React.useState(null); + const enabled = value.vision?.enabled ?? DEFAULT_CLASSIFIER_VISION_ENABLED; + + const handleMaxImagesChange = (raw: string): void => { + setDraftMaxImages(raw); + const parsed = Number(raw); + if (raw.trim() === "" || !Number.isFinite(parsed)) return; + onChange({ + ...value, + vision: { ...value.vision, enabled, max_images: Math.max(1, Math.round(parsed)) }, + }); + }; + + return ( +
    +
    + { + if (!visionEnabled) { + const { vision: _vision, ...withoutVision } = value; + onChange(withoutVision); + return; + } + onChange({ + ...value, + vision: { + ...value.vision, + enabled: true, + max_images: value.vision?.max_images ?? DEFAULT_CLASSIFIER_VISION_MAX_IMAGES, + }, + }); + }} + aria-label="Use images for classification" + /> + Use images for classification +
    + + Send inline image data to the classifier so it can choose a tier from what the image shows. + + {enabled && ( +
    + + handleMaxImagesChange(event.target.value)} + onBlur={() => setDraftMaxImages(null)} + className="w-full" + /> +
    + )} +
    + ); +}; + +export default ClassifierVisionConfig; diff --git a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx index 0590b524a06..2970e14b335 100644 --- a/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/ComplexityRouterConfig.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, renderWithProviders, screen, within } from "../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; +import React from "react"; import { vi } from "vitest"; import ComplexityRouterConfig, { ComplexityRouterConfigValue } from "./ComplexityRouterConfig"; vi.mock( @@ -1690,3 +1691,83 @@ describe("ComplexityRouterConfig tier editing", () => { expect(screen.queryByText("Display names rename the built-in tiers", { exact: false })).not.toBeInTheDocument(); }); }); + +describe("classifier vision settings", () => { + const llmValue: ComplexityRouterConfigValue = { + ...defaultValue, + classifier_type: "llm", + classifier_llm_config: { model: "gpt-3.5-turbo", timeout_ms: 3000 }, + }; + + const VisionFixture = ({ onChange = vi.fn() }: { onChange?: ReturnType }) => { + const [value, setValue] = React.useState(llmValue); + return ( + { + setValue(nextValue); + onChange(nextValue); + }} + /> + ); + }; + + it("starts off and reveals the default cap when enabled", () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); + + const vision = screen.getByRole("switch", { name: "Use images for classification" }); + expect(vision).not.toBeChecked(); + expect(screen.queryByLabelText("Maximum images per request")).not.toBeInTheDocument(); + + fireEvent.click(vision); + + expect(screen.getByLabelText("Maximum images per request")).toHaveValue("1"); + }); + + it("writes the switch and a clamped image cap into the classifier config", () => { + const onChange = vi.fn(); + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); + + fireEvent.click(screen.getByRole("switch", { name: "Use images for classification" })); + expect(onChange).toHaveBeenLastCalledWith({ + ...llmValue, + classifier_llm_config: { ...llmValue.classifier_llm_config, vision: { enabled: true, max_images: 1 } }, + }); + + fireEvent.change(screen.getByLabelText("Maximum images per request"), { target: { value: "1.7" } }); + expect(onChange).toHaveBeenLastCalledWith({ + ...llmValue, + classifier_llm_config: { ...llmValue.classifier_llm_config, vision: { enabled: true, max_images: 2 } }, + }); + }); + + it("keeps the image cap draft empty until a valid value is entered", () => { + const onChange = vi.fn(); + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); + fireEvent.click(screen.getByRole("switch", { name: "Use images for classification" })); + onChange.mockClear(); + + const input = screen.getByLabelText("Maximum images per request"); + fireEvent.change(input, { target: { value: "" } }); + + expect(input).toHaveValue(""); + expect(onChange).not.toHaveBeenCalled(); + + fireEvent.change(input, { target: { value: "0" } }); + expect(onChange).toHaveBeenLastCalledWith({ + ...llmValue, + classifier_llm_config: { ...llmValue.classifier_llm_config, vision: { enabled: true, max_images: 1 } }, + }); + }); + + it("is absent when the classifier is heuristic", () => { + renderWithProviders(); + fireEvent.click(screen.getByText("Advanced: Classification Method")); + + expect(screen.queryByText("Use images for classification")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts index 1b5bb9e72eb..17c1a83fd67 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.test.ts @@ -1170,12 +1170,13 @@ describe("buildComplexityRouterConfig stall escalation", () => { }); it("emits the toggle and both knobs when it is on", () => { - const config = buildComplexityRouterConfig({ + const params = { ...baseParams, stallEscalationEnabled: true, stallEscalationWindow: 8, stallEscalationRepeatThreshold: 4, - }); + }; + const config = buildComplexityRouterConfig(params); expect(config.stall_escalation_enabled).toBe(true); expect(config.stall_escalation_window).toBe(8); expect(config.stall_escalation_repeat_threshold).toBe(4); @@ -1207,3 +1208,38 @@ describe("dryRunRejection", () => { expect(dryRunRejection({ valid: true, error: null })).toBeNull(); }); }); + +describe("classifier vision wire payload", () => { + const vision = { enabled: true, max_images: 3 }; + const classifierLlmConfig = { model: "classifier", timeout_ms: 3000, vision }; + + it("keeps vision through the standard-tier payload", () => { + const params = { ...baseParams, classifierType: "llm" as const, classifierLlmConfig }; + const payload = buildComplexityRouterConfig(params); + + expect(payload.classifier_llm_config).toMatchObject({ vision }); + }); + + it("keeps vision through the custom-tier payload", () => { + const customTierSet = { + tiers: [ + { id: "simple", name: "simple", definition: "small talk", models: ["gpt-4o-mini"] }, + { id: "complex", name: "complex", definition: "hard work", models: ["gpt-4o"] }, + ], + fallback_tier_id: "simple", + }; + const payload = buildComplexityRouterConfig({ ...baseParams, customTierSet, classifierLlmConfig }); + + expect(payload.classifier_llm_config).toMatchObject({ vision }); + }); + + it("keeps an untouched classifier config free of vision", () => { + const payload = buildComplexityRouterConfig({ + ...baseParams, + classifierType: "llm", + classifierLlmConfig: { model: "classifier", timeout_ms: 3000 }, + }); + + expect(payload.classifier_llm_config).not.toHaveProperty("vision"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts index d7974484970..7769fb832fe 100644 --- a/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts +++ b/ui/litellm-dashboard/src/components/add_model/build_complexity_router_config.ts @@ -1,8 +1,5 @@ -import { KeywordTierRule } from "./KeywordTierRules"; - -type ClassifierLLMConfigWire = ClassifierLLMConfig & { vision?: { enabled?: boolean; max_images?: number } }; - import type { ModelGroup } from "../llm_calls/fetch_models"; +import { KeywordTierRule } from "./KeywordTierRules"; import { type CustomTierSet, type TierRow, @@ -42,6 +39,9 @@ import { usesLlmClassifier, } from "./ComplexityRouterConfig"; +export type ClassifierVisionConfig = { enabled?: boolean; max_images?: number }; +export type ClassifierLLMConfigWire = ClassifierLLMConfig & { vision?: ClassifierVisionConfig }; + /** * Drop an empty system_prompt so the payload carries an override only when there is one. The * backend rejects a blank string rather than reading it as "use the default", and sending `""` @@ -124,7 +124,7 @@ export interface BuildComplexityRouterConfigParams { planModeMinTier: string | undefined; tierLabels: ComplexityTierLabels | undefined; classifierType: ClassifierType; - classifierLlmConfig: ClassifierLLMConfig | undefined; + classifierLlmConfig: ClassifierLLMConfigWire | undefined; classifierContextWindowSize: number | undefined; classifierContextBudgetChars: number | undefined; classifierContextIncludeAssistantTurns: boolean | undefined; diff --git a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx index 9a55d3c0703..e45f84fa646 100644 --- a/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx +++ b/ui/litellm-dashboard/src/components/edit_auto_router/edit_auto_router_modal.test.tsx @@ -1036,3 +1036,60 @@ describe("EditAutoRouterModal with a stored custom tier set", () => { expect(savedConfig().tier_model_configs).toEqual(CUSTOM_STORED.tier_model_configs); }); }); + +describe("EditAutoRouterModal classifier vision", () => { + beforeEach(() => { + modelPatchUpdateCall.mockClear(); + }); + + const STORED_CONFIG = { + tiers: { SIMPLE: ["gpt-4o-mini"], MEDIUM: ["gpt-4o-mini"], COMPLEX: ["gpt-4o-mini"], REASONING: ["gpt-4o-mini"] }, + classifier_type: "llm", + classifier_llm_config: { + model: "gpt-4o-mini", + timeout_ms: 3000, + vision: { enabled: true, max_images: 2 }, + }, + }; + + const renderModal = () => + renderWithProviders( + , + ); + + it("hydrates and keeps a stored vision setting through an untouched save", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByText("Advanced: Classification Method")); + expect(screen.getByRole("switch", { name: "Use images for classification" })).toBeChecked(); + expect(screen.getByLabelText("Maximum images per request")).toHaveValue("2"); + + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedConfig().classifier_llm_config).toMatchObject({ vision: { enabled: true, max_images: 2 } }); + }); + + it("removes vision when the operator turns it off", async () => { + const user = userEvent.setup(); + renderModal(); + + await user.click(await screen.findByText("Advanced: Classification Method")); + await user.click(screen.getByRole("switch", { name: "Use images for classification" })); + await user.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(modelPatchUpdateCall).toHaveBeenCalled()); + expect(savedConfig().classifier_llm_config).not.toHaveProperty("vision"); + }); +}); From 29ac88ebc6bb93bda02138699c5ebe08bdd4e8cc Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Fri, 4 Sep 2026 23:59:51 -0700 Subject: [PATCH 209/410] fix(batches): register ownership for every batch create path (#39810) * fix(batches): register ownership for every batch create path Since the team isolation change, the managed files hook decided whether a response came from a create by looking for the managed input file id on it, which only the unified input path sets. Batches created from a model-encoded input file id, a model param, or a raw provider id with ?provider= never got an ownership row, so they vanished from GET /v1/batches for the key that created them. The create endpoint now stamps a create marker on the response before the hooks run, and the hook keys ownership registration and the batch-created metric on that marker instead of on the input id format. * test(batches): assert ownership registration through the managed files hook The endpoint tests asserted the private create marker, which is wiring, not behaviour. They now run the create through the real managed files hook and assert the ownership row is written for the creating key on every create path, with the unified path driven by a genuine encoded input file id instead of patched decoders. --- .../proxy/hooks/managed_files.py | 8 +-- litellm/proxy/batches_endpoints/endpoints.py | 3 + .../openai_files_endpoints/common_utils.py | 2 + .../proxy/hooks/test_managed_files.py | 16 ++--- .../proxy/test_managed_files_hook.py | 52 +++++++++++++++ .../proxy/batches_endpoints/test_endpoints.py | 64 ++++++++++++++++++- 6 files changed, 129 insertions(+), 16 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py index e11c6e70540..bc1eb6cebc2 100644 --- a/enterprise/litellm_enterprise/proxy/hooks/managed_files.py +++ b/enterprise/litellm_enterprise/proxy/hooks/managed_files.py @@ -46,6 +46,7 @@ from litellm.proxy._types import ( UserAPIKeyAuth, ) from litellm.proxy.openai_files_endpoints.common_utils import ( + BATCH_CREATE_HIDDEN_PARAM, FILE_LIST_CONTINUATION_CHUNK_SIZE, MAX_FILE_LIST_LIMIT, _is_base64_encoded_unified_file_id, @@ -1321,7 +1322,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): ## Check if unified_file_id is in the response unified_file_id = response._hidden_params.get("unified_file_id") # managed file id unified_batch_id = response._hidden_params.get("unified_batch_id") # managed batch id - is_batch_create: Final = unified_file_id is not None + is_batch_create: Final = response._hidden_params.get(BATCH_CREATE_HIDDEN_PARAM) is True model_id = cast(Optional[str], response._hidden_params.get("model_id")) model_name = cast(Optional[str], response._hidden_params.get("model_name")) @@ -1410,10 +1411,7 @@ class _PROXY_LiteLLMManagedFiles(CustomLogger, BaseFileEndpoints): litellm_parent_otel_span=user_api_key_dict.parent_otel_span, ) - # Only record batch creation metric on actual create (not retrieve/cancel). - # unified_file_id in _hidden_params is only set by the create_batch endpoint. - original_unified_file_id = response._hidden_params.get("unified_file_id") - if original_unified_file_id: + if is_batch_create: prom_logger = self._get_prometheus_logger() if prom_logger: batch_provider = "" diff --git a/litellm/proxy/batches_endpoints/endpoints.py b/litellm/proxy/batches_endpoints/endpoints.py index be889a22cae..5c4bacd757c 100644 --- a/litellm/proxy/batches_endpoints/endpoints.py +++ b/litellm/proxy/batches_endpoints/endpoints.py @@ -25,6 +25,7 @@ from litellm.proxy.common_utils.openai_endpoint_utils import ( get_custom_llm_provider_from_request_query, ) from litellm.proxy.openai_files_endpoints.common_utils import ( + BATCH_CREATE_HIDDEN_PARAM, _is_base64_encoded_unified_file_id, add_internal_model_credentials, apply_team_provider_credentials, @@ -347,6 +348,8 @@ async def create_batch( **_create_batch_data, ) + response._hidden_params[BATCH_CREATE_HIDDEN_PARAM] = True + ### CALL HOOKS ### - modify outgoing data response = await proxy_logging_obj.post_call_success_hook( data=data, user_api_key_dict=user_api_key_dict, response=response diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 992ed0d814d..15eeddbc489 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -37,6 +37,8 @@ MAX_FILE_LIST_LIMIT: Final = 10000 FILE_LIST_CONTINUATION_CHUNK_SIZE: Final = 500 +BATCH_CREATE_HIDDEN_PARAM: Final = "batch_create" + def validate_file_list_limit(limit: int | None) -> None: """Reject a ``limit`` outside the range OpenAI documents for GET /v1/files.""" diff --git a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py index 57394f1cebe..7e96c956664 100644 --- a/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py +++ b/tests/enterprise/litellm_enterprise/proxy/hooks/test_managed_files.py @@ -10,6 +10,7 @@ from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFi from litellm.caching import DualCache from litellm.proxy._types import CallTypes from litellm.proxy.openai_files_endpoints.common_utils import ( + BATCH_CREATE_HIDDEN_PARAM, _is_base64_encoded_unified_file_id, encode_file_id_with_model, ) @@ -3185,7 +3186,7 @@ def _batch_response(batch_id, output_file_id=None, is_create=False): output_file_id=output_file_id, ) if is_create: - batch._hidden_params["unified_file_id"] = "unified-input-file-id" + batch._hidden_params[BATCH_CREATE_HIDDEN_PARAM] = True return batch @@ -3411,11 +3412,8 @@ async def test_provider_format_file_without_ownership_row_stays_accessible(): @pytest.mark.asyncio -async def test_post_call_batch_create_stores_ownership_row(): - """ - Batch creation (response hidden params carry the unified input file id) - must write an ownership row attributed to the creating key. - """ +@pytest.mark.parametrize("batch_id", [MODEL_ENCODED_BATCH_ID, RAW_PROVIDER_BATCH_ID]) +async def test_post_call_batch_create_stores_ownership_row(batch_id): from litellm.proxy._types import UserAPIKeyAuth prisma_client = AsyncMock() @@ -3432,13 +3430,11 @@ async def test_post_call_batch_create_stores_ownership_row(): user_api_key_dict=UserAPIKeyAuth( user_id="user_a", team_id="team_a", parent_otel_span=MagicMock() ), - response=_batch_response(MODEL_ENCODED_BATCH_ID, is_create=True), + response=_batch_response(batch_id, is_create=True), ) upsert_call = prisma_client.db.litellm_managedobjecttable.upsert.await_args - assert upsert_call.kwargs["where"] == { - "unified_object_id": MODEL_ENCODED_BATCH_ID - } + assert upsert_call.kwargs["where"] == {"unified_object_id": batch_id} create_data = upsert_call.kwargs["data"]["create"] assert create_data["created_by"] == "user_a" assert create_data["team_id"] == "team_a" diff --git a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py index f3ad8a8592e..091b958d7c3 100644 --- a/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py +++ b/tests/test_litellm/enterprise/proxy/test_managed_files_hook.py @@ -15,6 +15,7 @@ from typing import Optional from unittest.mock import AsyncMock, MagicMock, patch from litellm.proxy._types import LitellmUserRoles, ProxyException, UserAPIKeyAuth +from litellm.proxy.openai_files_endpoints.common_utils import BATCH_CREATE_HIDDEN_PARAM from litellm.types.llms.openai import FileListPage, OpenAIFileObject from litellm.types.utils import LiteLLMBatch @@ -1540,6 +1541,11 @@ async def test_batch_create_hook_persists_creating_key_and_tags(): managed_files = _make_managed_files_instance() creator = UserAPIKeyAuth(api_key="sk-the-creator", user_id="alice", parent_otel_span=None) create_response = _make_batch_response(status="validating", output_file_id=None) + create_response._hidden_params = { + BATCH_CREATE_HIDDEN_PARAM: True, + "model_id": "model-deploy-xyz", + "model_name": "azure/gpt-4", + } await managed_files.async_post_call_success_hook( data={"litellm_metadata": {"tags": ["env:prod", "team:ml"], "user_api_key": creator.api_key}}, @@ -1554,6 +1560,52 @@ async def test_batch_create_hook_persists_creating_key_and_tags(): assert stored["user_api_key_dict"] is creator +@pytest.mark.asyncio +async def test_batch_create_hook_records_created_metric_once(): + managed_files = _make_managed_files_instance() + prometheus_logger = MagicMock() + managed_files._get_prometheus_logger = MagicMock(return_value=prometheus_logger) + create_response = _make_batch_response(status="validating", output_file_id=None) + create_response._hidden_params = { + BATCH_CREATE_HIDDEN_PARAM: True, + "model_id": "model-deploy-xyz", + "model_name": "azure/gpt-4", + } + + await managed_files.async_post_call_success_hook( + data={}, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-the-creator", user_id="alice", parent_otel_span=None), + response=create_response, + ) + + prometheus_logger.record_managed_batch_created.assert_called_once() + recorded = prometheus_logger.record_managed_batch_created.call_args.kwargs + assert recorded["model"] == "azure/gpt-4" + assert recorded["api_provider"] == "azure" + assert recorded["user"] == "alice" + + +@pytest.mark.asyncio +async def test_batch_retrieve_hook_does_not_record_created_metric(): + managed_files = _make_managed_files_instance() + prometheus_logger = MagicMock() + managed_files._get_prometheus_logger = MagicMock(return_value=prometheus_logger) + retrieve_response = _make_batch_response(status="in_progress", output_file_id=None) + retrieve_response._hidden_params = { + "unified_batch_id": "some-unified-batch-id", + "model_id": "model-deploy-xyz", + "model_name": "azure/gpt-4", + } + + await managed_files.async_post_call_success_hook( + data={}, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-the-poller", user_id="bob", parent_otel_span=None), + response=retrieve_response, + ) + + prometheus_logger.record_managed_batch_created.assert_not_called() + + @pytest.mark.asyncio async def test_batch_retrieve_hook_does_not_claim_attribution(): """A retrieve carries unified_batch_id but no unified_file_id, so it must not rewrite diff --git a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py index 2f6a5a3b0e0..a37c8ff2bb4 100644 --- a/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/batches_endpoints/test_endpoints.py @@ -29,6 +29,7 @@ added to this layer raises instead of silently passing - the inventory of seams cannot drift without a test failure. """ +import base64 import json from contextlib import ExitStack from dataclasses import dataclass @@ -36,7 +37,7 @@ from typing import Any, Dict, Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest - +from litellm_enterprise.proxy.hooks.managed_files import _PROXY_LiteLLMManagedFiles import litellm import litellm.proxy.batches_endpoints.endpoints as endpoints @@ -989,6 +990,67 @@ async def test_create__uses_acreate_batch_route_type(harness, openai_env_creds): assert harness.pre_call.call_args.kwargs["route_type"] == "acreate_batch" +def install_managed_files_hook(harness: Harness) -> AsyncMock: + prisma_client = AsyncMock() + managed_files = _PROXY_LiteLLMManagedFiles(MagicMock(async_set_cache=AsyncMock()), prisma_client=prisma_client) + harness.logging.post_call_success_hook = AsyncMock(side_effect=managed_files.async_post_call_success_hook) + harness.router.model_list = [] + return prisma_client + + +TEAM_A_KEY = UserAPIKeyAuth(api_key="sk-team-a", user_id="user_a", team_id="team_a") + + +def assert_ownership_registered_for_team_a(prisma_client: AsyncMock, batch_id: str) -> None: + upsert = prisma_client.db.litellm_managedobjecttable.upsert + upsert.assert_awaited_once() + assert upsert.await_args.kwargs["where"] == {"unified_object_id": batch_id} + created = upsert.await_args.kwargs["data"]["create"] + assert created["created_by"] == "user_a" + assert created["team_id"] == "team_a" + prisma_client.db.litellm_managedobjecttable.update_many.assert_not_awaited() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "body", + [ + {"input_file_id": AZURE_FILE_ID}, + {"input_file_id": "file-plain", "model": "vertex-model"}, + {"input_file_id": "file-plain"}, + ], + ids=["model_encoded_file_id", "model_param", "provider_fallback"], +) +async def test_create__registers_ownership_for_creator(harness, openai_env_creds, body): + set_body(harness, {**body, "endpoint": "/v1/chat/completions", "completion_window": "24h"}) + prisma_client = install_managed_files_hook(harness) + + resp = await call_create(harness, user=TEAM_A_KEY) + + assert_ownership_registered_for_team_a(prisma_client, resp.id) + + +@pytest.mark.asyncio +async def test_create__unified_file_id_registers_ownership_for_creator(harness): + unified_input_file_id = base64.urlsafe_b64encode( + b"litellm_proxy:application/octet-stream;unified_id,input-uuid;target_model_names,gpt-4o-mini" + ).decode() + set_body( + harness, + { + "input_file_id": unified_input_file_id, + "endpoint": "/v1/chat/completions", + "completion_window": "24h", + }, + ) + prisma_client = install_managed_files_hook(harness) + + resp = await call_create(harness, user=TEAM_A_KEY) + + assert harness.router_acreate.call_count == 1 + assert_ownership_registered_for_team_a(prisma_client, resp.id) + + @pytest.mark.asyncio async def test_create__metadata_sanitized_before_forwarding(harness, openai_env_creds): set_body( From d22962248c1c659279e7389e6c2e1d1b640b400a Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:07:25 -0700 Subject: [PATCH 210/410] fix(check): run CI's whole-tree test ruff and widen the test-tree trigger The scoped xargs list missed a ruff-tests.toml rule change and skipped ruff on deletions, so the block now runs test-linting.yml's exact command over tests/. ruff-tests.toml, test-quality-budget.json, and scripts/check_test_quality.py trigger the block too, and it sits after the background launches so the dashboard and gen:api jobs overlap it. --- scripts/pre_commit_lint.sh | 36 ++++---- tests/test_litellm/test_pre_commit_lint.py | 102 ++++++++++++++++----- 2 files changed, 96 insertions(+), 42 deletions(-) diff --git a/scripts/pre_commit_lint.sh b/scripts/pre_commit_lint.sh index ad1b4e793ab..ab84d2d518a 100755 --- a/scripts/pre_commit_lint.sh +++ b/scripts/pre_commit_lint.sh @@ -12,7 +12,8 @@ # - litellm/ Python -> `make lint` (test-linting.yml's lint job) # - tests/e2e Python -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step) # + raw HTTP client ban (test-code-quality.yml's check_e2e_no_raw_requests) -# - tests/ Python -> ruff over ruff-tests.toml + `make lint-test-quality` (test-linting.yml's +# - tests/ Python, ruff-tests.toml, test-quality-budget.json, scripts/check_test_quality.py +# -> ruff over ruff-tests.toml + `make lint-test-quality` (test-linting.yml's # test-tree ruff and test-quality budget steps) # - dashboard -> prettier + eslint + lint budgets (test-litellm-ui-build.yml's frontend-lint) # - proxy/types -> regenerate the lazy OpenAPI snapshot and dashboard API types, fail on drift (check-ui-api-types.yml) @@ -90,18 +91,17 @@ existing_files() { litellm_py_pattern='^litellm/.*\.py$' e2e_py_pattern='^tests/e2e/.*\.py$' -tests_py_pattern='^tests/.*\.py$' +test_tree_pattern='^(tests/.*\.py|ruff-tests\.toml|test-quality-budget\.json|scripts/check_test_quality\.py)$' spec_pattern='^(litellm/(proxy|types)/.*|ui/litellm-dashboard/(scripts/gen-api-types\.mjs|package\.json|package-lock\.json|src/lib/http/schema\.d\.ts))$' ui_prettier_pattern='^ui/litellm-dashboard/.*\.(js|jsx|ts|tsx|mjs|cjs|json|css|scss|md|mdx|yml|yaml|html)$' ui_eslint_pattern='^ui/litellm-dashboard/.*\.(js|jsx|ts|tsx|mjs|cjs)$' # CI's lint job (test-linting.yml) is make lint: litellm/ checks plus two test-tree # steps (ruff over ruff-tests.toml and the test-quality budget). Trigger the slow -# make lint on litellm/ files only; a tests-only commit runs just those two steps. +# make lint on litellm/ files only; without them, the test-tree steps run on their own below. litellm_py_files=$(scope_match "$litellm_py_pattern") e2e_py_files=$(scope_match "$e2e_py_pattern") -tests_py_changed=$(scope_match "$tests_py_pattern") -tests_py_files=$(printf '%s\n' "$tests_py_changed" | existing_files) +test_tree_files=$(scope_match "$test_tree_pattern") # ruff format (and CI's format step) skip enterprise; the rest of make lint covers it. fmt_files=$(printf '%s\n' "$litellm_py_files" | grep -v '^litellm/enterprise/' | existing_files) # check-ui-api-types.yml triggers on any file under litellm/proxy or litellm/types @@ -141,7 +141,7 @@ if [ -n "$staged" ]; then } warn_skipped "Python lint (make lint)" "$litellm_py_pattern" "$litellm_py_files" warn_skipped "tests/e2e checks (basedpyright + raw HTTP client ban)" "$e2e_py_pattern" "$e2e_py_files" - warn_skipped "test-tree lint (ruff-tests.toml + test-quality budget)" "$tests_py_pattern" "$tests_py_changed" + warn_skipped "test-tree lint (ruff-tests.toml + test-quality budget)" "$test_tree_pattern" "$test_tree_files" warn_skipped "dashboard lint (prettier + eslint + lint budgets)" "$ui_prettier_pattern" "$ui_prettier_changed" warn_skipped "dashboard API-type sync (npm run gen:api)" "$spec_pattern" "$spec_files" fi @@ -231,16 +231,6 @@ if [ -n "$e2e_py_files" ]; then || { echo "✗ Raw HTTP client import in tests/e2e. Route the call through tests/e2e/e2e_http.py, then re-run make check." >&2; status=1; } fi -if [ -n "$tests_py_changed" ] && [ -z "$litellm_py_files" ]; then - if [ -n "$tests_py_files" ]; then - echo "check: linting the test tree (ruff check --config ruff-tests.toml, scoped tests files)" - printf '%s\n' "$tests_py_files" | xargs uv run --no-sync ruff check --config ruff-tests.toml \ - || { echo "✗ Test-tree ruff failed. Fix the errors above, then re-run make check." >&2; status=1; } - fi - echo "check: checking the test-quality budget (make lint-test-quality)" - make lint-test-quality || { echo "✗ Test-quality budget failed. Fix the errors above, then re-run make check." >&2; status=1; } -fi - dashboard_checks() { echo "check: linting dashboard (prettier + eslint + lint budgets)" if [ ! -d ui/litellm-dashboard/node_modules ]; then @@ -304,6 +294,15 @@ if [ -n "$spec_files" ]; then set +m fi +if [ -n "$test_tree_files" ] && [ -z "$litellm_py_files" ]; then + echo "check: linting the test tree (ruff check --config ruff-tests.toml tests)" + uv run --no-sync ruff check --config ruff-tests.toml tests \ + || { echo "✗ Test-tree ruff failed. Fix the errors above, then re-run make check." >&2; status=1; } + echo "check: checking the test-quality budget (make lint-test-quality)" + make lint-test-quality \ + || { echo "✗ Test-quality budget failed. Fix the errors above, then re-run make check." >&2; status=1; } +fi + if [ -n "${python_pid:-}" ]; then wait "$python_pid" || status=1 cat "$python_log"; rm -f "$python_log" @@ -329,11 +328,12 @@ summary_item() { echo "check: summary" summary_item "Python lint (make lint)" "$litellm_py_files" "no litellm/ Python files in scope" summary_item "tests/e2e checks (basedpyright + raw HTTP client ban)" "$e2e_py_files" "no tests/e2e Python files in scope" -summary_item "test-tree lint (ruff-tests.toml + test-quality budget)" "$tests_py_changed" "no tests/ Python files in scope" +summary_item "test-tree lint (ruff-tests.toml + test-quality budget)" "$test_tree_files" \ + "no tests/ Python files or test-tree lint inputs in scope" summary_item "dashboard lint (prettier + eslint + lint budgets)" "$ui_prettier_changed$ui_eslint_changed" "no dashboard files in scope" summary_item "dashboard API-type sync (npm run gen:api)" "$spec_files" "no litellm/proxy, litellm/types, or generator files in scope" -if [ -z "$litellm_py_files$e2e_py_files$tests_py_changed$ui_prettier_changed$ui_eslint_changed$spec_files" ]; then +if [ -z "$litellm_py_files$e2e_py_files$test_tree_files$ui_prettier_changed$ui_eslint_changed$spec_files" ]; then echo "check: NOTE - no gating lint check matches the files in scope, so nothing ran:" >&2 printf '%s\n' "$scope" | sed 's/^/ /' >&2 echo " A pass here is a no-op, not a lint verdict." >&2 diff --git a/tests/test_litellm/test_pre_commit_lint.py b/tests/test_litellm/test_pre_commit_lint.py index 22ae0e1bfaa..e17abf2fa9f 100644 --- a/tests/test_litellm/test_pre_commit_lint.py +++ b/tests/test_litellm/test_pre_commit_lint.py @@ -11,6 +11,12 @@ import pytest ROOT = Path(__file__).resolve().parents[2] SCRIPT = ROOT / "scripts" / "pre_commit_lint.sh" +WHOLE_TREE_RUFF = "run --no-sync ruff check --config ruff-tests.toml tests" +TEST_TREE_RAN = "ran: test-tree lint (ruff-tests.toml + test-quality budget)" +TEST_TREE_SKIPPED = ( + "skipped: test-tree lint (ruff-tests.toml + test-quality budget) " + "(no tests/ Python files or test-tree lint inputs in scope)" +) BARRIER_HELPER = """barrier_sync() { touch "$STUB_BARRIER_DIR/$1.started" @@ -30,6 +36,7 @@ BARRIER_HELPER = """barrier_sync() { MAKE_STUB = """#!/bin/sh . "$STUB_BIN/barrier.sh" +[ -n "${STUB_ARGS_DIR:-}" ] && echo "$*" >> "$STUB_ARGS_DIR/make.args" case "$*" in lint) [ "${STUB_FAIL:-}" = "make-lint" ] && exit 1 @@ -419,7 +426,7 @@ def test_run_ends_with_a_summary_of_ran_and_skipped_blocks(tmp_path: Path) -> No assert "ran: dashboard lint (prettier + eslint + lint budgets)" in proc.stdout assert "ran: dashboard API-type sync (npm run gen:api)" in proc.stdout assert "skipped: tests/e2e checks (basedpyright + raw HTTP client ban) (no tests/e2e Python files in scope)" in proc.stdout - assert "skipped: test-tree lint (ruff-tests.toml + test-quality budget) (no tests/ Python files in scope)" in proc.stdout + assert TEST_TREE_SKIPPED in proc.stdout assert "check: PASS" in proc.stdout assert "check: FAIL" not in proc.stdout @@ -438,41 +445,83 @@ def test_staged_files_matching_no_check_print_an_explicit_noop_note_and_nonempty log = (repo / ".git" / "pre_commit_lint.log").read_text() assert "check: summary" in log assert "skipped: Python lint (make lint) (no litellm/ Python files in scope)" in log - assert "skipped: test-tree lint (ruff-tests.toml + test-quality budget) (no tests/ Python files in scope)" in log + assert TEST_TREE_SKIPPED in log -def test_tests_only_change_runs_test_tree_ruff_on_staged_files_and_the_quality_gate(tmp_path: Path) -> None: +def _recorded(args_dir: Path, name: str) -> list[str]: + path = args_dir / name + return path.read_text().splitlines() if path.exists() else [] + + +def test_tests_only_change_runs_the_whole_test_tree_ruff_and_the_quality_gate(tmp_path: Path) -> None: repo, bin_dir = _sandbox(tmp_path) _commit_all(repo, "base") args_dir = tmp_path / "args" args_dir.mkdir() _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") - _stage_file(repo, "tests/test_b.py", "def test_b() -> None: ...\n") _stage_file(repo, "tests/fixtures/data.json", "{}\n") proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir)}) assert proc.returncode == 0, proc.stdout + proc.stderr - ruff_args = (args_dir / "ruff_tests.args").read_text().splitlines() - assert ruff_args == ["run --no-sync ruff check --config ruff-tests.toml tests/test_a.py tests/test_b.py"] - assert "ran: test-tree lint (ruff-tests.toml + test-quality budget)" in proc.stdout + assert _recorded(args_dir, "ruff_tests.args") == [WHOLE_TREE_RUFF] + assert _recorded(args_dir, "make.args") == ["lint-test-quality"] + assert TEST_TREE_RAN in proc.stdout assert "no gating lint check matches" not in proc.stdout assert "linting Python" not in proc.stdout assert "check: PASS" in proc.stdout @pytest.mark.parametrize( - ("fail", "message"), - [ - ("tests-ruff", "Test-tree ruff failed"), - ("test-quality", "Test-quality budget failed"), - ], + "changed", + ["ruff-tests.toml", "test-quality-budget.json", "scripts/check_test_quality.py", "tests/e2e/test_x.py"], ) -def test_a_failing_test_tree_check_fails_a_tests_only_run(tmp_path: Path, fail: str, message: str) -> None: +def test_test_tree_lint_inputs_trigger_the_test_tree_checks(tmp_path: Path, changed: str) -> None: + repo, bin_dir = _sandbox(tmp_path) + _commit_all(repo, "base") + args_dir = tmp_path / "args" + args_dir.mkdir() + _stage_file(repo, changed, "x = 1\n") + proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir)}) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert _recorded(args_dir, "ruff_tests.args") == [WHOLE_TREE_RUFF] + assert "lint-test-quality" in _recorded(args_dir, "make.args") + assert TEST_TREE_RAN in proc.stdout + + +def test_nothing_staged_tests_only_working_tree_change_runs_the_test_tree_checks(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") + _commit_all(repo, "base") + _set_base_ref(repo) + args_dir = tmp_path / "args" + args_dir.mkdir() + (repo / "tests" / "test_a.py").write_text("def test_a() -> None:\n assert True\n") + proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir)}) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert "nothing staged; scoping to the working tree's diff" in proc.stdout + assert _recorded(args_dir, "ruff_tests.args") == [WHOLE_TREE_RUFF] + assert _recorded(args_dir, "make.args") == ["lint-test-quality"] + + +def test_a_failing_test_tree_ruff_fails_the_run_and_still_runs_the_quality_gate(tmp_path: Path) -> None: + repo, bin_dir = _sandbox(tmp_path) + _commit_all(repo, "base") + args_dir = tmp_path / "args" + args_dir.mkdir() + _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") + proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir), "STUB_FAIL": "tests-ruff"}) + assert proc.returncode == 1 + assert "Test-tree ruff failed" in proc.stdout + proc.stderr + assert "check: FAIL" in proc.stdout + assert _recorded(args_dir, "make.args") == ["lint-test-quality"] + + +def test_a_failing_quality_gate_fails_a_tests_only_run(tmp_path: Path) -> None: repo, bin_dir = _sandbox(tmp_path) _commit_all(repo, "base") _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") - proc = _run(repo, bin_dir, {"STUB_FAIL": fail}) + proc = _run(repo, bin_dir, {"STUB_FAIL": "test-quality"}) assert proc.returncode == 1 - assert message in proc.stdout + proc.stderr + assert "Test-quality budget failed" in proc.stdout + proc.stderr assert "check: FAIL" in proc.stdout @@ -486,34 +535,39 @@ def test_tests_changed_alongside_litellm_files_defer_to_make_lint(tmp_path: Path proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir), "STUB_FAIL": "test-quality"}) assert proc.returncode == 0, proc.stdout + proc.stderr assert "linting Python" in proc.stdout - assert not (args_dir / "ruff_tests.args").exists() - assert "ran: test-tree lint (ruff-tests.toml + test-quality budget)" in proc.stdout + assert _recorded(args_dir, "ruff_tests.args") == [] + assert _recorded(args_dir, "make.args") == ["lint"] + assert TEST_TREE_RAN in proc.stdout -def test_deleted_test_file_still_runs_the_quality_gate_without_feeding_ruff_the_missing_file(tmp_path: Path) -> None: +def test_deleted_test_file_still_runs_the_test_tree_checks(tmp_path: Path) -> None: repo, bin_dir = _sandbox(tmp_path) _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") _commit_all(repo, "base") args_dir = tmp_path / "args" args_dir.mkdir() subprocess.run(["git", "rm", "-q", "tests/test_a.py"], cwd=repo, check=True) - proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir), "STUB_FAIL": "test-quality"}) - assert proc.returncode == 1 - assert "Test-quality budget failed" in proc.stdout + proc.stderr - assert not (args_dir / "ruff_tests.args").exists() + proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir)}) + assert proc.returncode == 0, proc.stdout + proc.stderr + assert _recorded(args_dir, "ruff_tests.args") == [WHOLE_TREE_RUFF] + assert _recorded(args_dir, "make.args") == ["lint-test-quality"] + assert TEST_TREE_RAN in proc.stdout def test_partial_staging_warns_when_test_files_are_left_unstaged(tmp_path: Path) -> None: repo, bin_dir = _sandbox(tmp_path) _stage_file(repo, "tests/test_a.py", "def test_a() -> None: ...\n") _commit_all(repo, "base") + args_dir = tmp_path / "args" + args_dir.mkdir() _stage_file(repo, "notes.md", "hi\n") (repo / "tests" / "test_a.py").write_text("def test_a() -> None:\n assert True\n") - proc = _run(repo, bin_dir, {}) + proc = _run(repo, bin_dir, {"STUB_ARGS_DIR": str(args_dir)}) assert proc.returncode == 0, proc.stdout + proc.stderr assert "SKIPPED test-tree lint (ruff-tests.toml + test-quality budget)" in proc.stdout assert "tests/test_a.py" in proc.stdout - assert "make lint-test-quality" not in proc.stdout + assert _recorded(args_dir, "ruff_tests.args") == [] + assert _recorded(args_dir, "make.args") == [] def test_run_queues_through_the_machine_wide_gate_slot_lock(tmp_path: Path) -> None: From 36b0d80d3a1459f116f07e45b205e953e21c8978 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:09:00 -0700 Subject: [PATCH 211/410] test(cost): pin the nested-reasoning helper's clamps The strip in text_tokens_without_nested_reasoning is capped at the reasoning share, the reported text, and the over-sum past completion_tokens. Dropping the caps to a bare over-sum passed every existing test, so this pins each cap with a parametrized helper test plus one billing test where text over-reports past the reasoning share and only the nested share may be netted out --- .../llm_cost_calc/test_llm_cost_calc_utils.py | 23 ++++++++++++++ tests/test_litellm/types/test_types_utils.py | 31 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py index e8eae73df3f..c9fe53651cd 100644 --- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py +++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py @@ -4821,6 +4821,29 @@ def test_generic_cost_per_token_keeps_billing_reasoning_reported_beside_text_tok assert completion_cost == pytest.approx(44 * info["output_cost_per_token"]) +def test_generic_cost_per_token_strips_only_the_reasoning_share_when_text_over_reports( + _local_model_cost_map: None, +) -> None: + """Text over-reported past the reasoning share keeps its extra tokens billed; only the nested reasoning is netted out.""" + + model = "gpt-realtime-2.1-mini" + usage = Usage( + prompt_tokens=120, + completion_tokens=100, + total_tokens=220, + completion_tokens_details=CompletionTokensDetailsWrapper(text_tokens=100, audio_tokens=70, reasoning_tokens=10), + ) + + _, completion_cost = generic_cost_per_token(model=model, usage=usage, custom_llm_provider="openai") + breakdown = get_token_type_cost_breakdown(model=model, custom_llm_provider="openai", usage=usage) + + info = litellm.get_model_info(model=model, custom_llm_provider="openai") + assert breakdown.reasoning_cost == pytest.approx(10 * info["output_cost_per_token"]) + assert completion_cost == pytest.approx( + 100 * info["output_cost_per_token"] + 70 * info["output_cost_per_audio_token"] + ) + + def test_generic_cost_per_token_bills_nested_reasoning_once_beside_audio_output(_local_model_cost_map: None) -> None: """Audio-output realtime usage nests reasoning inside text_tokens next to audio_tokens; text is billed net of it.""" diff --git a/tests/test_litellm/types/test_types_utils.py b/tests/test_litellm/types/test_types_utils.py index 554604ab200..5f44ba1773e 100644 --- a/tests/test_litellm/types/test_types_utils.py +++ b/tests/test_litellm/types/test_types_utils.py @@ -3,7 +3,7 @@ from typing import Final import pytest -from litellm.types.utils import HiddenParams, all_litellm_params +from litellm.types.utils import HiddenParams, all_litellm_params, text_tokens_without_nested_reasoning def test_rust_is_a_known_litellm_param(): @@ -768,3 +768,32 @@ def test_image_response_keeps_background(): response = ImageResponse(created=1, data=[{"b64_json": "aGk="}], background="transparent", output_format="png") assert response.background == "transparent" assert response.model_dump()["background"] == "transparent" + + +@pytest.mark.parametrize( + ("completion_tokens", "text_tokens", "reasoning_tokens", "other_modality_tokens", "expected_text_tokens"), + ( + pytest.param(50, 30, 20, 0, 30, id="details_sum_to_completion_is_a_no_op"), + pytest.param(34, 30, 24, 0, 10, id="strip_is_capped_at_the_over_sum"), + pytest.param(100, 100, 10, 70, 90, id="only_the_reasoning_share_is_stripped_when_text_over_reports_further"), + pytest.param(10, 5, 20, 0, 0, id="text_never_goes_negative_when_reasoning_exceeds_it"), + ), +) +def test_text_tokens_without_nested_reasoning_clamps( + completion_tokens: int, + text_tokens: int, + reasoning_tokens: int, + other_modality_tokens: int, + expected_text_tokens: int, +) -> None: + """The strip never exceeds the reasoning share, the reported text, or the over-sum past completion_tokens.""" + + assert ( + text_tokens_without_nested_reasoning( + completion_tokens=completion_tokens, + text_tokens=text_tokens, + reasoning_tokens=reasoning_tokens, + other_modality_tokens=other_modality_tokens, + ) + == expected_text_tokens + ) From 827554954d305b7c8c24f524c961cf7a27e3a029 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:16:14 -0700 Subject: [PATCH 212/410] fix(image_handling): answer every SSRF rejection with one message so error text cannot probe internal hostnames --- .../prompt_templates/image_handling.py | 10 ++--- litellm/litellm_core_utils/url_utils.py | 8 +--- .../litellm_core_utils/test_image_handling.py | 41 ++++++++----------- .../litellm_core_utils/test_url_utils.py | 19 +-------- 4 files changed, 24 insertions(+), 54 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/image_handling.py b/litellm/litellm_core_utils/prompt_templates/image_handling.py index 99efbd13320..24f3b8bca7f 100644 --- a/litellm/litellm_core_utils/prompt_templates/image_handling.py +++ b/litellm/litellm_core_utils/prompt_templates/image_handling.py @@ -15,7 +15,7 @@ import litellm from litellm import verbose_logger from litellm.caching.caching import InMemoryCache from litellm.constants import MAX_IMAGE_URL_DOWNLOAD_SIZE_MB -from litellm.litellm_core_utils.url_utils import HostResolutionError, SSRFError, async_safe_get, safe_get +from litellm.litellm_core_utils.url_utils import SSRFError, async_safe_get, safe_get from litellm.types.llms.openai import AllMessageValues MAX_IMGS_IN_MEMORY: Final = 10 @@ -80,13 +80,9 @@ def _process_image_response(response: Response, url: str) -> str: def _rejected_image_fetch(url: str, verdict: SSRFError) -> "litellm.ImageFetchError": verbose_logger.warning("Image fetch of %s rejected before any request went out: %s", url, verdict) - if isinstance(verdict, HostResolutionError): - return litellm.ImageFetchError( - f"Error: Unable to fetch image from URL. The image host could not be resolved. url={url}" - ) return litellm.ImageFetchError( - "Error: Unable to fetch image from URL. The proxy's URL policy rejected this host; " - f"an admin can allow it with `user_url_allowed_hosts` in general_settings. url={url}" + "Error: Unable to fetch image from URL. The proxy could not resolve this host or its URL policy rejected it; " + f"an admin can check the proxy log and `user_url_allowed_hosts` in general_settings. url={url}" ) diff --git a/litellm/litellm_core_utils/url_utils.py b/litellm/litellm_core_utils/url_utils.py index f4388d5fd12..fa070a648f5 100644 --- a/litellm/litellm_core_utils/url_utils.py +++ b/litellm/litellm_core_utils/url_utils.py @@ -93,10 +93,6 @@ class SSRFError(ValueError): """Raised when a URL targets a blocked network.""" -class HostResolutionError(SSRFError): - pass - - def encode_url_path_segment(value: object, *, field_name: str = "path parameter") -> str: """Percent-encode one user-controlled URL path segment. @@ -328,10 +324,10 @@ def validate_url(url: str) -> tuple[str, str]: try: addrinfo: Final = socket.getaddrinfo(hostname, effective_port, proto=socket.IPPROTO_TCP) except socket.gaierror as e: - raise HostResolutionError(f"DNS resolution failed for '{hostname}': {e}") + raise SSRFError(f"DNS resolution failed for '{hostname}': {e}") if not addrinfo: - raise HostResolutionError(f"No addresses found for '{hostname}'") + raise SSRFError(f"No addresses found for '{hostname}'") if not is_allowlisted: for addrinfo_entry in addrinfo: diff --git a/tests/test_litellm/litellm_core_utils/test_image_handling.py b/tests/test_litellm/litellm_core_utils/test_image_handling.py index 3a909298aba..8fa4bd6c14d 100644 --- a/tests/test_litellm/litellm_core_utils/test_image_handling.py +++ b/tests/test_litellm/litellm_core_utils/test_image_handling.py @@ -17,7 +17,7 @@ from litellm.litellm_core_utils.prompt_templates.image_handling import ( async_inline_remote_media, convert_url_to_base64, ) -from litellm.litellm_core_utils.url_utils import HostResolutionError, SSRFError +from litellm.litellm_core_utils.url_utils import SSRFError @pytest.fixture(autouse=True) @@ -425,32 +425,25 @@ async def test_async_inline_remote_media_cancels_the_other_fetches_when_one_fail _SSRF_VERDICTS = ( - ( - SSRFError( - "URL targets a blocked address (10.0.0.8). If this is a legitimate internal service, " - "add the host to `user_url_allowed_hosts` in general_settings." - ), - "The proxy's URL policy rejected this host; an admin can allow it with `user_url_allowed_hosts`", + SSRFError( + "URL targets a blocked address (10.0.0.8). If this is a legitimate internal service, " + "add the host to `user_url_allowed_hosts` in general_settings." ), - ( - HostResolutionError( - "DNS resolution failed for 'internal.example': [Errno 8] nodename nor servname provided, or not known" - ), - "The image host could not be resolved", - ), - (HostResolutionError("No addresses found for 'internal.example'"), "The image host could not be resolved"), + SSRFError("DNS resolution failed for 'internal.example': [Errno 8] nodename nor servname provided, or not known"), + SSRFError("No addresses found for 'internal.example'"), ) def _assert_verdict_free_messages(messages, url): - for message, (verdict, expected_guidance) in zip(messages, _SSRF_VERDICTS, strict=True): - assert expected_guidance in message - assert url in message - assert "10.0.0.8" not in message - assert "DNS" not in message - assert "No addresses" not in message - if isinstance(verdict, HostResolutionError): - assert "user_url_allowed_hosts" not in message + assert len(messages) == len(_SSRF_VERDICTS) + assert len(set(messages)) == 1, "a caller must not be able to tell a blocked host from one that does not resolve" + message = messages[0] + assert "The proxy could not resolve this host or its URL policy rejected it" in message + assert "user_url_allowed_hosts" in message + assert url in message + assert "10.0.0.8" not in message + assert "DNS" not in message + assert "No addresses" not in message async def test_async_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeypatch): @@ -458,7 +451,7 @@ async def test_async_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_r messages = [] url = f"http://internal.example/{uuid.uuid4()}.png" - for verdict, _ in _SSRF_VERDICTS: + for verdict in _SSRF_VERDICTS: async def block(client, fetched_url, verdict=verdict, **kwargs): attempts.append(fetched_url) @@ -478,7 +471,7 @@ def test_convert_url_to_base64_hides_the_ssrf_verdict_and_does_not_retry(monkeyp messages = [] url = f"http://internal.example/{uuid.uuid4()}.png" - for verdict, _ in _SSRF_VERDICTS: + for verdict in _SSRF_VERDICTS: def block(client, fetched_url, verdict=verdict, **kwargs): attempts.append(fetched_url) diff --git a/tests/test_litellm/litellm_core_utils/test_url_utils.py b/tests/test_litellm/litellm_core_utils/test_url_utils.py index 151c7ceb5bd..fccdc1a2a0a 100644 --- a/tests/test_litellm/litellm_core_utils/test_url_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_url_utils.py @@ -9,7 +9,6 @@ import pytest import litellm from litellm.litellm_core_utils import url_utils from litellm.litellm_core_utils.url_utils import ( - HostResolutionError, SSRFError, _is_blocked_ip, assert_same_origin, @@ -162,24 +161,10 @@ class TestValidateUrl: assert "/path" in rewritten assert "key=value" in rewritten - def test_dns_failure_raises_a_host_resolution_error(self, mock_dns_failure): - with pytest.raises(HostResolutionError, match="DNS resolution failed"): + def test_dns_failure_raises(self, mock_dns_failure): + with pytest.raises(SSRFError, match="DNS resolution failed"): validate_url("http://this-domain-does-not-exist-xyz123.invalid/test") - def test_empty_resolution_raises_a_host_resolution_error(self, monkeypatch): - monkeypatch.setattr(url_utils.socket, "getaddrinfo", lambda *args, **kwargs: []) - with pytest.raises(HostResolutionError, match="No addresses found"): - validate_url("http://this-domain-resolves-to-nothing.invalid/test") - - def test_blocked_address_is_not_a_host_resolution_error(self, monkeypatch): - def fake(host, port, *a, **kw): - return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.8", port or 80))] - - monkeypatch.setattr(url_utils.socket, "getaddrinfo", fake) - with pytest.raises(SSRFError, match="blocked address") as raised: - validate_url("http://internal.example/test") - assert not isinstance(raised.value, HostResolutionError) - def test_blocks_localhost_hostname(self, monkeypatch): def fake(host, port, *a, **kw): return [ From af3ddb477a852f20898aeefd7bc35713188f98da Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:22:20 -0700 Subject: [PATCH 213/410] fix(realtime): release the budget reservation on a failed session and scrub relayed close details A refused or failed /v1/realtime session never ran the success cost callback or a failure hook, so its pre-call budget reservation stayed open and kept the key/team/user spend counters pinned above real spend, 429ing later requests on the same key until the counter's TTL expired. The endpoint now reconciles the reservation in a finally, reusing a shared release_or_invalidate_budget_reservation helper that mirrors the success/failure paths (release to zero, else invalidate the reserved counters and finalize). The relayed upstream close message and reason also go through the proxy's client-facing redaction, so a credential, internal hostname, private IP, or server path echoed by the upstream never reaches the client verbatim. --- .../litellm_core_utils/realtime_streaming.py | 6 +- litellm/proxy/proxy_server.py | 12 +++ .../spend_tracking/budget_reservation.py | 25 ++++++ .../test_realtime_streaming.py | 21 +++-- tests/test_litellm/proxy/test_proxy_server.py | 83 +++++++++++++++++++ 5 files changed, 137 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index b448cb7c9ff..c670278d3fb 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any, Final, NoReturn, Protocol, TypedDict, cas from typing_extensions import ReadOnly import litellm -from litellm._logging import _redact_string, verbose_logger +from litellm._logging import redact_internal_details_from_client_message, verbose_logger from litellm.litellm_core_utils.logging_worker import GLOBAL_LOGGING_WORKER from litellm.llms.base_llm.realtime.transformation import BaseRealtimeConfig from litellm.types.llms.openai import ( @@ -1567,8 +1567,8 @@ class RealTimeStreaming: await asyncio.gather(forward_task, client_task, return_exceptions=True) async def _close_client(self, close: BackendClose) -> None: - redacted_message: Final = _redact_string(close.message) - redacted_reason: Final = _redact_string(close.reason) + redacted_message: Final = redact_internal_details_from_client_message(close.message) + redacted_reason: Final = redact_internal_details_from_client_message(close.reason) try: if close.code != 1000: await self.websocket.send_text(realtime_error_event(redacted_message, error_type="server_error")) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 61bcdea94d4..06cfee45918 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11453,6 +11453,16 @@ def _realtime_query_params_template(model: str | None, intent: str | None) -> tu return tuple(params) +async def _release_realtime_budget_reservation(user_api_key_dict: UserAPIKeyAuth) -> None: + from litellm.proxy.spend_tracking.budget_reservation import ( + release_or_invalidate_budget_reservation, + ) + + await release_or_invalidate_budget_reservation( + budget_reservation=user_api_key_dict.budget_reservation, + ) + + @app.websocket("/openai/v1/realtime") @app.websocket("/v1/realtime") @app.websocket("/realtime") @@ -11592,6 +11602,8 @@ async def realtime_websocket_endpoint( ) except Exception: # noqa: BLE001 # the lower layer may have closed the socket already; closing twice is not an error verbose_proxy_logger.debug("Could not close realtime client websocket; it is already gone") + finally: + await _release_realtime_budget_reservation(user_api_key_dict) ###################################################################### diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py index 91d2ece7a51..2ee7320b82c 100644 --- a/litellm/proxy/spend_tracking/budget_reservation.py +++ b/litellm/proxy/spend_tracking/budget_reservation.py @@ -373,6 +373,31 @@ async def invalidate_budget_reservation_counters( await _invalidate_spend_counter(counter_key=counter_key) +async def release_or_invalidate_budget_reservation( + budget_reservation: dict | None, # mutable-ok: stamps finalized on the caller's shared reservation dict +) -> None: + """Reconcile a still-open reservation on a terminal path that settles no cost. + + A failed or upstream-refused request never runs the success cost callback, so + its pre-call reservation stays open and keeps the spend counter pinned above + real spend until the counter's TTL expires, 429ing later requests on the same + key. Release it to zero; if the release itself fails (e.g. the counter store is + unreachable) drop the reserved counters directly and mark the reservation + finalized so nothing reprocesses it. Idempotent: the finalized guard makes a + second call a no-op once success or failure handling already reconciled. + """ + if budget_reservation is None or budget_reservation.get("finalized") is True: + return + try: + await release_budget_reservation(budget_reservation=budget_reservation) + except Exception: # noqa: BLE001 # a cleanup failure must not pin the counter; drop it directly instead + verbose_proxy_logger.exception("Failed to release budget reservation; invalidating counters") + try: + await invalidate_budget_reservation_counters(budget_reservation=budget_reservation) + finally: + budget_reservation["finalized"] = True + + async def _get_budget_counters( request_body: dict, valid_token: UserAPIKeyAuth, diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py index bcfacf16205..5352c894f87 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py @@ -3229,21 +3229,28 @@ async def test_bidirectional_forward_relays_upstream_policy_close_to_client(): client_ws.close.assert_awaited_once_with(code=1008, reason=_UPSTREAM_REFUSAL) +@pytest.mark.parametrize( + "leaked_detail", + ( + pytest.param("sk-live-abcdef0123456789abcdef0123", id="credential"), + pytest.param("vertex-int.svc.cluster.local", id="internal-hostname"), + pytest.param("/etc/litellm/service-account.json", id="filesystem-path"), + ), +) @pytest.mark.asyncio -async def test_upstream_close_reason_with_a_secret_is_redacted_before_reaching_the_client(): - """LIT-6973: the relayed close mirrors the handshake path and scrubs credential - patterns, so an upstream error echoing a token never reaches the client verbatim.""" - secret: Final = "sk-live-abcdef0123456789abcdef0123" +async def test_upstream_close_details_are_scrubbed_before_reaching_the_client(leaked_detail: str): + """LIT-6973: the relayed close goes through the proxy's client-facing redaction, so an upstream + error echoing a credential, an internal host, or a server path never reaches the client verbatim.""" client_ws: Final = _client_ws_that_never_sends() - upstream_close: Final = ConnectionClosed(Close(1008, f"auth failed for {secret}"), None) + upstream_close: Final = ConnectionClosed(Close(1008, f"upstream rejected: {leaked_detail}"), None) session: Final = _relay_session(client_ws, _backend_ws_closing_with(upstream_close)) await session.run() (error_event,) = _error_events_sent_to(client_ws) - assert secret not in error_event["error"]["message"] + assert leaked_detail not in error_event["error"]["message"] relayed_reason: Final = client_ws.close.await_args.kwargs["reason"] - assert secret not in relayed_reason + assert leaked_detail not in relayed_reason assert "REDACTED" in relayed_reason diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index d91928a203e..57e2cfc3332 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -9521,6 +9521,89 @@ def test_realtime_websocket_route_aliases_registered(): ) +def _lit6973_fake_realtime_ws() -> MagicMock: + ws = MagicMock() + ws.headers = {} + ws.scope = {"headers": [], "type": "websocket"} + ws.url = "ws://testserver/v1/realtime" + ws.accept = AsyncMock() + ws.send_text = AsyncMock() + ws.close = AsyncMock() + return ws + + +async def _lit6973_drive_refused_realtime_session(reservation: dict) -> None: + """Drive realtime_websocket_endpoint through a session the upstream refused. + + route_request resolves normally because the relay handles the refusal + internally (sends the error event, closes the client), so neither the + success cost callback nor a failure hook runs on _ProxyDBLogger. The + endpoint itself must reconcile the pre-call budget reservation, so the + real release runs (entries is empty, so it touches no counter store) and + the caller asserts on the observable reservation state afterwards.""" + from litellm.proxy import proxy_server as ps + + user_api_key_dict: Final = UserAPIKeyAuth(api_key="sk-test", token="hashed-token") + user_api_key_dict.budget_reservation = reservation + + completed: Final = asyncio.get_running_loop().create_future() + completed.set_result(None) + + pre_call: Final = AsyncMock(return_value=({"model": "vertex_ai/gemini-live-2.5-flash"}, MagicMock())) + can_call = patch.object(ps, "can_key_call_resolved_model", new=AsyncMock()) # test-quality-ok: no HTTP boundary; fakes in-process auth to reach the finally under test + pre = patch.object(ps.ProxyBaseLLMRequestProcessing, "common_processing_pre_call_logic", new=pre_call) # test-quality-ok: fakes phase-1 wiring; assertion checks observable reservation state + route = patch.object(ps, "route_request", new=AsyncMock(return_value=completed)) # test-quality-ok: fakes the relay that already handled the refusal so the session returns normally + with can_call, pre, route: + await ps.realtime_websocket_endpoint( + websocket=_lit6973_fake_realtime_ws(), + model="vertex_ai/gemini-live-2.5-flash", + intent=None, + guardrails=None, + user_api_key_dict=user_api_key_dict, + ) + + +@pytest.mark.asyncio +async def test_refused_realtime_session_releases_the_budget_reservation(): + """LIT-6973: reclassifying a refused realtime session as a failure removed the + success-path reservation release, so the pre-call reservation stayed open and + pinned the key/team/user spend counters, locking the key after a couple of + refusals. The endpoint must reconcile it: the reservation ends up finalized.""" + reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []} + + await _lit6973_drive_refused_realtime_session(reservation) + + assert reservation["finalized"] is True + + +@pytest.mark.asyncio +async def test_release_or_invalidate_falls_back_to_invalidating_the_counters(): + """If releasing the reservation itself fails (e.g. the counter store is down), + the reserved counters must be invalidated directly so the estimate does not + stay pinned, and the reservation is finalized so nothing reprocesses it.""" + from litellm.proxy import proxy_server as ps + from litellm.proxy.spend_tracking import budget_reservation as br + + reservation: Final = { + "reserved_cost": 0.55, + "input_cost": 0.0, + "finalized": False, + "entries": [{"counter_key": "spend:key:hashed-token"}], + } + invalidated: Final[list[str]] = [] + + async def _record(counter_key: str) -> None: + invalidated.append(counter_key) + + failing_release = patch.object(br, "release_budget_reservation", new=AsyncMock(side_effect=RuntimeError("counter store down"))) # test-quality-ok: forces the failure branch; assertion observes which counter key got invalidated + sink = patch.object(ps, "_invalidate_spend_counter", new=_record) # test-quality-ok: fakes the counter-store sink so the invalidated key is observable + with failing_release, sink: + await br.release_or_invalidate_budget_reservation(budget_reservation=reservation) + + assert invalidated == ["spend:key:hashed-token"] + assert reservation["finalized"] is True + + class TestTransformRequestBannedParams: """ /utils/transform_request applies the same banned-param check as LLM endpoints. From e3366dddf44c7450a856ed472c2966798413ef36 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 00:26:16 -0700 Subject: [PATCH 214/410] fix(check): trigger the test-tree checks on the gate script and drop the scope comment --- scripts/pre_commit_lint.sh | 8 +++----- tests/test_litellm/test_pre_commit_lint.py | 8 +++++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/scripts/pre_commit_lint.sh b/scripts/pre_commit_lint.sh index ab84d2d518a..f245803408c 100755 --- a/scripts/pre_commit_lint.sh +++ b/scripts/pre_commit_lint.sh @@ -12,7 +12,8 @@ # - litellm/ Python -> `make lint` (test-linting.yml's lint job) # - tests/e2e Python -> `make lint-e2e-basedpyright` (test-linting.yml's e2e type-check step) # + raw HTTP client ban (test-code-quality.yml's check_e2e_no_raw_requests) -# - tests/ Python, ruff-tests.toml, test-quality-budget.json, scripts/check_test_quality.py +# - tests/ Python, ruff-tests.toml, test-quality-budget.json, scripts/check_test_quality.py, +# scripts/test_quality_gate.py # -> ruff over ruff-tests.toml + `make lint-test-quality` (test-linting.yml's # test-tree ruff and test-quality budget steps) # - dashboard -> prettier + eslint + lint budgets (test-litellm-ui-build.yml's frontend-lint) @@ -91,14 +92,11 @@ existing_files() { litellm_py_pattern='^litellm/.*\.py$' e2e_py_pattern='^tests/e2e/.*\.py$' -test_tree_pattern='^(tests/.*\.py|ruff-tests\.toml|test-quality-budget\.json|scripts/check_test_quality\.py)$' +test_tree_pattern='^(tests/.*\.py|ruff-tests\.toml|test-quality-budget\.json|scripts/(check_test_quality|test_quality_gate)\.py)$' spec_pattern='^(litellm/(proxy|types)/.*|ui/litellm-dashboard/(scripts/gen-api-types\.mjs|package\.json|package-lock\.json|src/lib/http/schema\.d\.ts))$' ui_prettier_pattern='^ui/litellm-dashboard/.*\.(js|jsx|ts|tsx|mjs|cjs|json|css|scss|md|mdx|yml|yaml|html)$' ui_eslint_pattern='^ui/litellm-dashboard/.*\.(js|jsx|ts|tsx|mjs|cjs)$' -# CI's lint job (test-linting.yml) is make lint: litellm/ checks plus two test-tree -# steps (ruff over ruff-tests.toml and the test-quality budget). Trigger the slow -# make lint on litellm/ files only; without them, the test-tree steps run on their own below. litellm_py_files=$(scope_match "$litellm_py_pattern") e2e_py_files=$(scope_match "$e2e_py_pattern") test_tree_files=$(scope_match "$test_tree_pattern") diff --git a/tests/test_litellm/test_pre_commit_lint.py b/tests/test_litellm/test_pre_commit_lint.py index e17abf2fa9f..b84cb8aa657 100644 --- a/tests/test_litellm/test_pre_commit_lint.py +++ b/tests/test_litellm/test_pre_commit_lint.py @@ -472,7 +472,13 @@ def test_tests_only_change_runs_the_whole_test_tree_ruff_and_the_quality_gate(tm @pytest.mark.parametrize( "changed", - ["ruff-tests.toml", "test-quality-budget.json", "scripts/check_test_quality.py", "tests/e2e/test_x.py"], + [ + "ruff-tests.toml", + "test-quality-budget.json", + "scripts/check_test_quality.py", + "scripts/test_quality_gate.py", + "tests/e2e/test_x.py", + ], ) def test_test_tree_lint_inputs_trigger_the_test_tree_checks(tmp_path: Path, changed: str) -> None: repo, bin_dir = _sandbox(tmp_path) From 1fe87e8e25206c039be5e87a5808a30f47cc3183 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:11:24 -0700 Subject: [PATCH 215/410] fix(realtime): settle the budget reservation only for sessions the success log does not own The blanket finally release from the previous commit also zeroed the reservation of successful sessions. Success settlement is enqueued on the logging worker, not awaited, so the endpoint's finally ran first and released the reservation the cost callback still had to reconcile, dropping the real spend from the key/team/user counters. The relay now stamps a synchronous marker (REALTIME_SESSION_SUCCESS_LOGGED_KEY) on the shared logging object at the single success-dispatch site, and the endpoint releases the reservation only when that marker is absent. Refused or failed sessions, which never log success, still release; successful sessions leave the reservation for the cost callback to settle to actual spend. Exactly one settler touches each reservation, so the idempotent reconcile never double-adjusts. --- .../litellm_core_utils/realtime_streaming.py | 4 ++ litellm/proxy/proxy_server.py | 7 ++- .../test_realtime_streaming.py | 32 +++++++++++ tests/test_litellm/proxy/test_proxy_server.py | 55 +++++++++++++------ 4 files changed, 80 insertions(+), 18 deletions(-) diff --git a/litellm/litellm_core_utils/realtime_streaming.py b/litellm/litellm_core_utils/realtime_streaming.py index c670278d3fb..75046f2cf87 100644 --- a/litellm/litellm_core_utils/realtime_streaming.py +++ b/litellm/litellm_core_utils/realtime_streaming.py @@ -35,6 +35,9 @@ else: CLIENT_CONNECTION_CLASS = Any +REALTIME_SESSION_SUCCESS_LOGGED_KEY: Final = "realtime_session_success_logged" + + @dataclass(frozen=True, slots=True) class BackendClose: code: int @@ -421,6 +424,7 @@ class RealTimeStreaming: self._logging_worker.ensure_initialized_and_enqueue( self.logging_obj.dispatch_success_handlers(self.messages, prefer_async_handlers=True) ) + self.logging_obj.model_call_details[REALTIME_SESSION_SUCCESS_LOGGED_KEY] = True async def _send_to_backend(self, message: str) -> bool: """Send a message to the backend WebSocket. diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 06cfee45918..7d59dfa86c4 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11603,7 +11603,12 @@ async def realtime_websocket_endpoint( except Exception: # noqa: BLE001 # the lower layer may have closed the socket already; closing twice is not an error verbose_proxy_logger.debug("Could not close realtime client websocket; it is already gone") finally: - await _release_realtime_budget_reservation(user_api_key_dict) + from litellm.litellm_core_utils.realtime_streaming import ( + REALTIME_SESSION_SUCCESS_LOGGED_KEY, + ) + + if not litellm_logging_obj.model_call_details.get(REALTIME_SESSION_SUCCESS_LOGGED_KEY): + await _release_realtime_budget_reservation(user_api_key_dict) ###################################################################### diff --git a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py index 5352c894f87..9c0f6f59463 100644 --- a/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py +++ b/tests/test_litellm/litellm_core_utils/test_realtime_streaming.py @@ -14,6 +14,7 @@ import litellm from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.litellm_core_utils.realtime_streaming import ( + REALTIME_SESSION_SUCCESS_LOGGED_KEY, RealTimeStreaming, client_sent_openai_beta_realtime_header, ) @@ -3380,3 +3381,34 @@ async def test_client_hanging_up_with_a_websockets_close_is_not_mistaken_for_the assert session.logging.logged_sessions == ((),) assert session.logging.logged_failures == () client_ws.close.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_success_logging_stamps_the_reservation_ownership_marker(): + """LIT-6973: only the success path enqueues the cost callback that settles the + session's budget reservation, so it stamps REALTIME_SESSION_SUCCESS_LOGGED_KEY on + the shared logging object. The proxy endpoint reads that stamp to decide whether to + release the reservation itself, so a logged-as-success session must carry it.""" + client_ws: Final = _client_ws_that_never_sends() + session_created: Final = json.dumps({"type": "session.created", "session": {"id": "sess_1"}}).encode() + upstream_close: Final = ConnectionClosed(Close(1000, ""), None) + session: Final = _relay_session(client_ws, _backend_ws_closing_with(session_created, upstream_close)) + + await session.run() + + assert session.logging.logged_sessions != () + assert session.logging.model_call_details.get(REALTIME_SESSION_SUCCESS_LOGGED_KEY) is True + + +@pytest.mark.asyncio +async def test_refused_session_does_not_stamp_the_reservation_ownership_marker(): + """A refused session logs a failure, not a success, so it must not stamp + REALTIME_SESSION_SUCCESS_LOGGED_KEY. If it did, the proxy endpoint would skip its + own reservation release and the refused session's reservation would stay pinned.""" + upstream_close: Final = ConnectionClosed(Close(1008, _UPSTREAM_REFUSAL), None) + session: Final = _relay_session(_client_ws_that_never_sends(), _backend_ws_closing_with(upstream_close)) + + await session.run() + + assert session.logging.logged_failures == (upstream_close,) + assert REALTIME_SESSION_SUCCESS_LOGGED_KEY not in session.logging.model_call_details diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 57e2cfc3332..697d4c182c7 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -9532,27 +9532,34 @@ def _lit6973_fake_realtime_ws() -> MagicMock: return ws -async def _lit6973_drive_refused_realtime_session(reservation: dict) -> None: - """Drive realtime_websocket_endpoint through a session the upstream refused. +async def _lit6973_drive_realtime_session(reservation: dict, *, backend_logged_success: bool) -> None: + """Drive realtime_websocket_endpoint to just before its budget-reservation finally. - route_request resolves normally because the relay handles the refusal - internally (sends the error event, closes the client), so neither the - success cost callback nor a failure hook runs on _ProxyDBLogger. The - endpoint itself must reconcile the pre-call budget reservation, so the - real release runs (entries is empty, so it touches no counter store) and - the caller asserts on the observable reservation state afterwards.""" + route_request resolves normally in both cases: the relay owns the session + once route_request returns. A successful session enqueues its success cost + callback and stamps REALTIME_SESSION_SUCCESS_LOGGED_KEY on the shared logging + object; a refused one does neither. The endpoint keys its reservation cleanup + off that stamp, so backend_logged_success reproduces both branches. The fake + logging object carries a real model_call_details dict so the stamp is + observable, and the reservation has empty entries so the real release touches + no counter store.""" + from litellm.litellm_core_utils.realtime_streaming import REALTIME_SESSION_SUCCESS_LOGGED_KEY from litellm.proxy import proxy_server as ps user_api_key_dict: Final = UserAPIKeyAuth(api_key="sk-test", token="hashed-token") user_api_key_dict.budget_reservation = reservation - completed: Final = asyncio.get_running_loop().create_future() - completed.set_result(None) + logging_obj: Final = MagicMock() + logging_obj.model_call_details = {} - pre_call: Final = AsyncMock(return_value=({"model": "vertex_ai/gemini-live-2.5-flash"}, MagicMock())) + async def fake_llm_call() -> None: + if backend_logged_success: + logging_obj.model_call_details[REALTIME_SESSION_SUCCESS_LOGGED_KEY] = True + + pre_call: Final = AsyncMock(return_value=({"model": "vertex_ai/gemini-live-2.5-flash"}, logging_obj)) can_call = patch.object(ps, "can_key_call_resolved_model", new=AsyncMock()) # test-quality-ok: no HTTP boundary; fakes in-process auth to reach the finally under test pre = patch.object(ps.ProxyBaseLLMRequestProcessing, "common_processing_pre_call_logic", new=pre_call) # test-quality-ok: fakes phase-1 wiring; assertion checks observable reservation state - route = patch.object(ps, "route_request", new=AsyncMock(return_value=completed)) # test-quality-ok: fakes the relay that already handled the refusal so the session returns normally + route = patch.object(ps, "route_request", new=AsyncMock(return_value=fake_llm_call())) # test-quality-ok: fakes the relay whose success/refusal outcome the endpoint reads off the logging object with can_call, pre, route: await ps.realtime_websocket_endpoint( websocket=_lit6973_fake_realtime_ws(), @@ -9565,17 +9572,31 @@ async def _lit6973_drive_refused_realtime_session(reservation: dict) -> None: @pytest.mark.asyncio async def test_refused_realtime_session_releases_the_budget_reservation(): - """LIT-6973: reclassifying a refused realtime session as a failure removed the - success-path reservation release, so the pre-call reservation stayed open and - pinned the key/team/user spend counters, locking the key after a couple of - refusals. The endpoint must reconcile it: the reservation ends up finalized.""" + """LIT-6973: a refused realtime session enqueues no success cost callback, so + the pre-call reservation would stay open and pin the key/team/user spend + counters, locking the key after a couple of refusals. The endpoint sees no + success stamp and reconciles it: the reservation ends up finalized.""" reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []} - await _lit6973_drive_refused_realtime_session(reservation) + await _lit6973_drive_realtime_session(reservation, backend_logged_success=False) assert reservation["finalized"] is True +@pytest.mark.asyncio +async def test_successful_realtime_session_leaves_the_reservation_for_the_cost_callback(): + """A billable realtime session settles its reservation through the enqueued + success cost callback, not the endpoint. The endpoint must not finalize it in + its finally, or it would reconcile the reservation to zero before the cost + callback applies real spend, so billable sessions stop counting against budget. + With the success stamp present, the endpoint leaves the reservation untouched.""" + reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []} + + await _lit6973_drive_realtime_session(reservation, backend_logged_success=True) + + assert reservation["finalized"] is False + + @pytest.mark.asyncio async def test_release_or_invalidate_falls_back_to_invalidating_the_counters(): """If releasing the reservation itself fails (e.g. the counter store is down), From aca1c54391ceef578c31603fcd7872b267b451ca Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:26:36 -0700 Subject: [PATCH 216/410] refactor(proxy): build the OpenAI websocket refusal frame from a TypedDict The two dict literals behind the refusal event counted against the LIT002 ceiling once the base branch used up its headroom, so the frame is now a ReadOnly TypedDict built in one shot. Importing Literal explicitly also makes the UP037 suppression on the Vertex discovery signature unnecessary, so it goes. --- .../llm_passthrough_endpoints.py | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index e92c949299c..32da2658b99 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -16,12 +16,13 @@ import re from collections.abc import AsyncGenerator, Callable, Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType -from typing import TYPE_CHECKING, Annotated, Final, Protocol, cast +from typing import TYPE_CHECKING, Annotated, Final, Literal, Protocol, cast import httpx from fastapi import APIRouter, Depends, HTTPException, Request, Response, WebSocket from fastapi.responses import StreamingResponse from starlette.websockets import WebSocketState +from typing_extensions import ReadOnly, TypedDict import litellm from litellm import get_llm_provider @@ -1775,7 +1776,7 @@ def _upstream_headers_for_vertex_route(endpoint: str, headers: Mapping[str, str] def get_vertex_pass_through_handler( - call_type: Literal["discovery", "aiplatform"], # noqa: UP037 # ruff reports quoted Literal values here + call_type: Literal["discovery", "aiplatform"], ) -> BaseVertexAIPassThroughHandler: if call_type == "discovery": return VertexAIDiscoveryPassThroughHandler() @@ -2352,6 +2353,16 @@ class _OpenAIWebsocketRefusal: message: str +class _OpenAIWebsocketErrorDetail(TypedDict): + type: ReadOnly[Literal["invalid_request_error"]] + message: ReadOnly[str] + + +class _OpenAIWebsocketErrorFrame(TypedDict): + type: ReadOnly[Literal["error"]] + error: ReadOnly[_OpenAIWebsocketErrorDetail] + + _OPENAI_WS_DISABLED_REFUSAL: Final = _OpenAIWebsocketRefusal( close_reason="OpenAI websocket passthrough is disabled", message=( @@ -2451,14 +2462,11 @@ async def openai_websocket_proxy_route( refusal: Final = await _openai_websocket_refusal(user_api_key_dict, general_settings, model_allowlists) if refusal is not None: await websocket.accept(subprotocol=negotiated_subprotocol) - await websocket.send_text( - json.dumps( - { - "type": "error", - "error": {"type": "invalid_request_error", "message": refusal.message}, - } - ) - ) + error_frame: Final[_OpenAIWebsocketErrorFrame] = { + "type": "error", + "error": {"type": "invalid_request_error", "message": refusal.message}, + } + await websocket.send_text(json.dumps(error_frame)) await websocket.close(code=1008, reason=refusal.close_reason) return From 2d2b5dabf27405fd413dfbfd71aef5a304775a75 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 01:44:16 -0700 Subject: [PATCH 217/410] fix(test-quality-gate): tear the base worktree down on SIGTERM and SIGHUP --- scripts/test_quality_gate.py | 22 ++++-- tests/test_litellm/test_test_quality_gate.py | 77 ++++++++++++++++++++ 2 files changed, 92 insertions(+), 7 deletions(-) diff --git a/scripts/test_quality_gate.py b/scripts/test_quality_gate.py index 7d34b194f1c..4f79c9488b1 100644 --- a/scripts/test_quality_gate.py +++ b/scripts/test_quality_gate.py @@ -34,13 +34,14 @@ import argparse import json import re import shutil +import signal import subprocess import sys import tempfile from collections import Counter from collections.abc import Mapping, Sequence from pathlib import Path -from types import MappingProxyType +from types import FrameType, MappingProxyType from typing import Final, NamedTuple REPO_ROOT: Final = Path(__file__).resolve().parent.parent @@ -48,6 +49,7 @@ CHECKER: Final = REPO_ROOT / "scripts" / "check_test_quality.py" BUDGET_PATH: Final = REPO_ROOT / "test-quality-budget.json" TARGET: Final = "tests" DEFAULT_BASE: Final = "origin/litellm_internal_staging" +TERMINATION_SIGNALS: Final = (signal.SIGTERM, signal.SIGHUP) _HUNK: Final = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", re.MULTILINE) _FILE_HEADER: Final = re.compile(r"^\+\+\+ b/(.+)$", re.MULTILINE) @@ -116,22 +118,28 @@ def count_by_rule(violations: Sequence[Violation]) -> Mapping[str, int]: return MappingProxyType(dict(Counter(v.code for v in violations))) -def base_counts(ref: str) -> Mapping[str, int]: +def _exit_on_termination(signum: int, _frame: FrameType | None) -> None: + raise SystemExit(128 + signum) + + +def base_counts(ref: str, repo_root: Path = REPO_ROOT, checker: Path = CHECKER) -> Mapping[str, int]: """Rule counts at `ref`, measured with the *current* rule logic rather than whatever the checker looked like at that commit.""" + for termination in TERMINATION_SIGNALS: + signal.signal(termination, _exit_on_termination) parent: Final = Path(tempfile.mkdtemp(prefix="tq_base_")) worktree: Final = parent / "wt" try: - _run(["git", "worktree", "add", "--detach", str(worktree), ref]) + _run(["git", "worktree", "add", "--detach", str(worktree), ref], cwd=repo_root) (worktree / "scripts").mkdir(parents=True, exist_ok=True) - checker: Final = worktree / "scripts" / "check_test_quality.py" - shutil.copy(CHECKER, checker) - return count_by_rule(_check(worktree, checker)) + base_checker: Final = worktree / "scripts" / "check_test_quality.py" + shutil.copy(checker, base_checker) + return count_by_rule(_check(worktree, base_checker)) finally: # Teardown must never raise, or it masks the real error when the body failed. subprocess.run( ["git", "worktree", "remove", "--force", str(worktree)], - cwd=REPO_ROOT, capture_output=True, text=True, + cwd=repo_root, capture_output=True, text=True, ) shutil.rmtree(parent, ignore_errors=True) diff --git a/tests/test_litellm/test_test_quality_gate.py b/tests/test_litellm/test_test_quality_gate.py index 8cce6bc735a..0664153f671 100644 --- a/tests/test_litellm/test_test_quality_gate.py +++ b/tests/test_litellm/test_test_quality_gate.py @@ -9,7 +9,13 @@ file:line. """ import importlib.util +import os +import signal +import subprocess import sys +import time +from collections.abc import Callable +from contextlib import suppress from pathlib import Path _REPO_ROOT = Path(__file__).resolve().parents[2] @@ -23,6 +29,15 @@ _spec.loader.exec_module(gate) _BUDGET = {"TQ001": {"limit": 10}, "TQ003": {"limit": 5}} +_SCAN_BASE = ( + "import importlib.util, pathlib, sys\n" + "spec = importlib.util.spec_from_file_location('test_quality_gate', sys.argv[1])\n" + "gate = importlib.util.module_from_spec(spec)\n" + "sys.modules[spec.name] = gate\n" + "spec.loader.exec_module(gate)\n" + "gate.base_counts('HEAD', repo_root=pathlib.Path(sys.argv[2]), checker=pathlib.Path(sys.argv[3]))\n" +) + def test_a_rule_within_its_limit_is_not_a_breach(): assert gate.evaluate({"TQ001": 10}, {"TQ001": 10}, _BUDGET) == () @@ -146,3 +161,65 @@ def test_the_shipped_budget_covers_every_rule_the_checker_can_emit(): budget = json.loads((_REPO_ROOT / "test-quality-budget.json").read_text()) assert set(budget) == {"TQ001", "TQ002", "TQ003", "TQ004", "TQ005", "TQ006", "TQ007", "TQ008"} assert all(spec["limit"] >= 0 for spec in budget.values()) + + +def _git(cwd: Path, *args: str) -> str: + proc = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + return proc.stdout.strip() + + +def _committed_repo(tmp_path: Path) -> Path: + repo = tmp_path / "repo" + (repo / "tests").mkdir(parents=True) + (repo / "tests" / "test_seed.py").write_text("def test_seed():\n assert True\n") + _git(repo, "init", "-q", "-b", "main") + _git(repo, "config", "user.email", "gate@example.com") + _git(repo, "config", "user.name", "gate") + _git(repo, "config", "commit.gpgsign", "false") + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "seed") + return repo + + +def _wait_until(predicate: Callable[[], bool], timeout_seconds: float) -> bool: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.05) + return predicate() + + +def _reap(process: subprocess.Popen[bytes]) -> None: + with suppress(subprocess.TimeoutExpired): + process.wait(timeout=10) + if process.poll() is None: + process.kill() + process.wait(timeout=10) + + +def _registered_worktrees(repo: Path) -> int: + listing = _git(repo, "worktree", "list", "--porcelain") + return sum(line.startswith("worktree ") for line in listing.splitlines()) + + +def test_a_terminated_base_scan_still_removes_its_worktree(tmp_path: Path) -> None: + repo = _committed_repo(tmp_path) + scanning = tmp_path / "scanning" + slow_checker = tmp_path / "slow_checker.py" + slow_checker.write_text(f"import pathlib, time\npathlib.Path({str(scanning)!r}).touch()\ntime.sleep(30)\n") + temp_dir = tmp_path / "tmp" + temp_dir.mkdir() + scan = subprocess.Popen( + [sys.executable, "-c", _SCAN_BASE, str(_MODULE_PATH), str(repo), str(slow_checker)], + env={**os.environ, "TMPDIR": str(temp_dir)}, + ) + try: + assert _wait_until(scanning.exists, 30), "the base scan never reached the checker" + scan.send_signal(signal.SIGTERM) + assert scan.wait(timeout=30) == 128 + signal.SIGTERM + finally: + _reap(scan) + assert _registered_worktrees(repo) == 1 + assert list(temp_dir.iterdir()) == [] From 952f082e3ee80eb6bec0855a752ee3c101652744 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:07:14 -0700 Subject: [PATCH 218/410] fix(test-quality-gate): keep a termination signal the parent already ignores ignored The SIGTERM/SIGHUP teardown handlers were installed unconditionally, so a base scan started under nohup (SIGHUP inherited as SIG_IGN) would start dying on hangups it was told to ignore. Install them only where the disposition is still the default, and cover the ignored case with a regression test that hangs up a scan started with SIGHUP ignored and expects it to finish. --- scripts/test_quality_gate.py | 9 +++- tests/test_litellm/test_test_quality_gate.py | 56 ++++++++++++++++---- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/scripts/test_quality_gate.py b/scripts/test_quality_gate.py index 4f79c9488b1..c3316915b5e 100644 --- a/scripts/test_quality_gate.py +++ b/scripts/test_quality_gate.py @@ -122,11 +122,16 @@ def _exit_on_termination(signum: int, _frame: FrameType | None) -> None: raise SystemExit(128 + signum) +def _install_termination_handlers() -> None: + for termination in TERMINATION_SIGNALS: + if signal.getsignal(termination) == signal.SIG_DFL: + signal.signal(termination, _exit_on_termination) + + def base_counts(ref: str, repo_root: Path = REPO_ROOT, checker: Path = CHECKER) -> Mapping[str, int]: """Rule counts at `ref`, measured with the *current* rule logic rather than whatever the checker looked like at that commit.""" - for termination in TERMINATION_SIGNALS: - signal.signal(termination, _exit_on_termination) + _install_termination_handlers() parent: Final = Path(tempfile.mkdtemp(prefix="tq_base_")) worktree: Final = parent / "wt" try: diff --git a/tests/test_litellm/test_test_quality_gate.py b/tests/test_litellm/test_test_quality_gate.py index 0664153f671..b3511eabbfb 100644 --- a/tests/test_litellm/test_test_quality_gate.py +++ b/tests/test_litellm/test_test_quality_gate.py @@ -17,6 +17,7 @@ import time from collections.abc import Callable from contextlib import suppress from pathlib import Path +from typing import NamedTuple _REPO_ROOT = Path(__file__).resolve().parents[2] _MODULE_PATH = _REPO_ROOT / "scripts" / "test_quality_gate.py" @@ -37,6 +38,7 @@ _SCAN_BASE = ( "spec.loader.exec_module(gate)\n" "gate.base_counts('HEAD', repo_root=pathlib.Path(sys.argv[2]), checker=pathlib.Path(sys.argv[3]))\n" ) +_SCAN_BASE_WITH_SIGHUP_IGNORED = "import signal\nsignal.signal(signal.SIGHUP, signal.SIG_IGN)\n" + _SCAN_BASE def test_a_rule_within_its_limit_is_not_a_breach(): @@ -204,22 +206,56 @@ def _registered_worktrees(repo: Path) -> int: return sum(line.startswith("worktree ") for line in listing.splitlines()) -def test_a_terminated_base_scan_still_removes_its_worktree(tmp_path: Path) -> None: +class _StalledScan(NamedTuple): + process: subprocess.Popen[bytes] + repo: Path + release: Path + temp_dir: Path + + +def _base_scan_stalled_in_its_checker(tmp_path: Path, driver: str) -> _StalledScan: repo = _committed_repo(tmp_path) scanning = tmp_path / "scanning" + release = tmp_path / "release" slow_checker = tmp_path / "slow_checker.py" - slow_checker.write_text(f"import pathlib, time\npathlib.Path({str(scanning)!r}).touch()\ntime.sleep(30)\n") + slow_checker.write_text( + "import pathlib, time\n" + f"pathlib.Path({str(scanning)!r}).touch()\n" + f"while not pathlib.Path({str(release)!r}).exists():\n" + " time.sleep(0.05)\n" + ) temp_dir = tmp_path / "tmp" temp_dir.mkdir() scan = subprocess.Popen( - [sys.executable, "-c", _SCAN_BASE, str(_MODULE_PATH), str(repo), str(slow_checker)], + [sys.executable, "-c", driver, str(_MODULE_PATH), str(repo), str(slow_checker)], env={**os.environ, "TMPDIR": str(temp_dir)}, ) - try: - assert _wait_until(scanning.exists, 30), "the base scan never reached the checker" - scan.send_signal(signal.SIGTERM) - assert scan.wait(timeout=30) == 128 + signal.SIGTERM - finally: + if not _wait_until(scanning.exists, 30): _reap(scan) - assert _registered_worktrees(repo) == 1 - assert list(temp_dir.iterdir()) == [] + raise AssertionError("the base scan never reached the checker") + return _StalledScan(scan, repo, release, temp_dir) + + +def test_a_terminated_base_scan_still_removes_its_worktree(tmp_path: Path) -> None: + stalled = _base_scan_stalled_in_its_checker(tmp_path, _SCAN_BASE) + try: + stalled.process.send_signal(signal.SIGTERM) + assert stalled.process.wait(timeout=30) == 128 + signal.SIGTERM + finally: + _reap(stalled.process) + assert _registered_worktrees(stalled.repo) == 1 + assert list(stalled.temp_dir.iterdir()) == [] + + +def test_a_base_scan_keeps_ignoring_the_hangup_its_parent_ignored(tmp_path: Path) -> None: + stalled = _base_scan_stalled_in_its_checker(tmp_path, _SCAN_BASE_WITH_SIGHUP_IGNORED) + try: + stalled.process.send_signal(signal.SIGHUP) + time.sleep(1) + assert stalled.process.poll() is None, "a hangup the parent ignored killed the scan" + stalled.release.touch() + assert stalled.process.wait(timeout=30) == 0 + finally: + _reap(stalled.process) + assert _registered_worktrees(stalled.repo) == 1 + assert list(stalled.temp_dir.iterdir()) == [] From 5a35e6d41f76d2b09a258b3e2b051e3e7e745c79 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:18:33 -0700 Subject: [PATCH 219/410] fix(realtime): release the budget reservation when a session is rejected before the relay starts The three pre-relay exits of realtime_websocket_endpoint (missing model, key/model access denied, pre-call rejection such as a rate limit or a guardrail) returned before the finally that releases the auth-time budget reservation, so a rejected session pinned the key at the reserved amount until the counter TTL expired and its next requests got budget_exceeded while /key/info showed spend 0. A single _reject_realtime_session helper now releases the reservation before sending the error event and closing, and release_or_invalidate_budget_reservation shields the release from a second cancellation and logs, rather than raises, a failing invalidate fallback so it can never mask the session's own outcome. --- litellm/proxy/proxy_server.py | 43 ++++++---- .../spend_tracking/budget_reservation.py | 4 +- tests/test_litellm/proxy/test_proxy_server.py | 79 +++++++++++++++++-- 3 files changed, 103 insertions(+), 23 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7d59dfa86c4..65fe3ede822 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11463,6 +11463,25 @@ async def _release_realtime_budget_reservation(user_api_key_dict: UserAPIKeyAuth ) +async def _reject_realtime_session( + websocket: WebSocket, + user_api_key_dict: UserAPIKeyAuth, + *, + code: int, + reason: str, + error_message: str | None = None, +) -> None: + await _release_realtime_budget_reservation(user_api_key_dict) + if error_message is not None: + try: + await websocket.send_text( + json.dumps({"type": "error", "error": {"type": "guardrail_error", "message": error_message}}) + ) + except Exception: # noqa: BLE001 # best-effort notice: a dead client socket must not skip the close below + verbose_proxy_logger.debug("Could not send realtime pre-call error event to client; closing anyway") + await websocket.close(code=code, reason=reason) + + @app.websocket("/openai/v1/realtime") @app.websocket("/v1/realtime") @app.websocket("/realtime") @@ -11488,7 +11507,9 @@ async def realtime_websocket_endpoint( if intent == "transcription": route_model = "gpt-realtime-whisper" else: - await websocket.close(code=1008, reason="model query parameter is required") + await _reject_realtime_session( + websocket, user_api_key_dict, code=1008, reason="model query parameter is required" + ) return assert route_model is not None try: @@ -11499,7 +11520,7 @@ async def realtime_websocket_endpoint( llm_router=llm_router, ) except ProxyException as e: - await websocket.close(code=1008, reason=e.message[:120]) + await _reject_realtime_session(websocket, user_api_key_dict, code=1008, reason=e.message[:120]) return await websocket.accept(**accept_kwargs) @@ -11558,21 +11579,9 @@ async def realtime_websocket_endpoint( ) except Exception as e: verbose_proxy_logger.exception("Realtime pre-call error") - try: - await websocket.send_text( - json.dumps( - { - "type": "error", - "error": { - "type": "guardrail_error", - "message": str(e), - }, - } - ) - ) - except Exception: - pass - await websocket.close(code=1011, reason="Pre-call error") + await _reject_realtime_session( + websocket, user_api_key_dict, code=1011, reason="Pre-call error", error_message=str(e) + ) return # Phase 2: route to upstream LLM. diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py index 2ee7320b82c..ed2bc87597c 100644 --- a/litellm/proxy/spend_tracking/budget_reservation.py +++ b/litellm/proxy/spend_tracking/budget_reservation.py @@ -389,11 +389,13 @@ async def release_or_invalidate_budget_reservation( if budget_reservation is None or budget_reservation.get("finalized") is True: return try: - await release_budget_reservation(budget_reservation=budget_reservation) + await asyncio.shield(release_budget_reservation(budget_reservation=budget_reservation)) except Exception: # noqa: BLE001 # a cleanup failure must not pin the counter; drop it directly instead verbose_proxy_logger.exception("Failed to release budget reservation; invalidating counters") try: await invalidate_budget_reservation_counters(budget_reservation=budget_reservation) + except Exception: # noqa: BLE001 # nothing left to try; the finalized stamp below keeps it from being reprocessed + verbose_proxy_logger.exception("Failed to invalidate budget reservation counters after release failed") finally: budget_reservation["finalized"] = True diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 697d4c182c7..8b151d9e1f6 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -9532,8 +9532,15 @@ def _lit6973_fake_realtime_ws() -> MagicMock: return ws -async def _lit6973_drive_realtime_session(reservation: dict, *, backend_logged_success: bool) -> None: - """Drive realtime_websocket_endpoint to just before its budget-reservation finally. +async def _lit6973_drive_realtime_session( + reservation: dict, *, backend_logged_success: bool, phase_one_exit: str | None = None +) -> MagicMock: + """Drive realtime_websocket_endpoint through one of its reservation-settling exits. + + phase_one_exit picks a rejection before the relay: "model_access" makes the + key/model check raise ProxyException, "pre_call" makes pre-call processing + (rate limits, guardrails) raise. Neither reaches route_request, so no success + log can own the reservation and the endpoint has to release it on that exit. route_request resolves normally in both cases: the relay owns the session once route_request returns. A successful session enqueues its success cost @@ -9556,18 +9563,30 @@ async def _lit6973_drive_realtime_session(reservation: dict, *, backend_logged_s if backend_logged_success: logging_obj.model_call_details[REALTIME_SESSION_SUCCESS_LOGGED_KEY] = True - pre_call: Final = AsyncMock(return_value=({"model": "vertex_ai/gemini-live-2.5-flash"}, logging_obj)) - can_call = patch.object(ps, "can_key_call_resolved_model", new=AsyncMock()) # test-quality-ok: no HTTP boundary; fakes in-process auth to reach the finally under test + from litellm.proxy._types import ProxyException + + model_access_error: Final = ( + ProxyException(message="key cannot access model", type="auth_error", param="model", code=401) + if phase_one_exit == "model_access" + else None + ) + pre_call_error: Final = Exception("Rate limit exceeded") if phase_one_exit == "pre_call" else None + pre_call: Final = AsyncMock( + side_effect=pre_call_error, return_value=({"model": "vertex_ai/gemini-live-2.5-flash"}, logging_obj) + ) + ws: Final = _lit6973_fake_realtime_ws() + can_call = patch.object(ps, "can_key_call_resolved_model", new=AsyncMock(side_effect=model_access_error)) # test-quality-ok: no HTTP boundary; fakes in-process auth to reach the exit under test pre = patch.object(ps.ProxyBaseLLMRequestProcessing, "common_processing_pre_call_logic", new=pre_call) # test-quality-ok: fakes phase-1 wiring; assertion checks observable reservation state route = patch.object(ps, "route_request", new=AsyncMock(return_value=fake_llm_call())) # test-quality-ok: fakes the relay whose success/refusal outcome the endpoint reads off the logging object with can_call, pre, route: await ps.realtime_websocket_endpoint( - websocket=_lit6973_fake_realtime_ws(), + websocket=ws, model="vertex_ai/gemini-live-2.5-flash", intent=None, guardrails=None, user_api_key_dict=user_api_key_dict, ) + return ws @pytest.mark.asyncio @@ -9583,6 +9602,39 @@ async def test_refused_realtime_session_releases_the_budget_reservation(): assert reservation["finalized"] is True +@pytest.mark.asyncio +async def test_realtime_session_rejected_in_pre_call_releases_the_budget_reservation(): + """A rate-limit or guardrail rejection happens before route_request, so the + relay never runs and no success log can own the reservation. The endpoint + must release it on that exit too, or the key stays pinned at the reserved + amount and its next requests 429 with budget_exceeded while /key/info shows + spend 0 (reproduced live with rpm_limit=1). The client still gets the + pre-call error event and the 1011 close it got before.""" + reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []} + + ws: Final = await _lit6973_drive_realtime_session( + reservation, backend_logged_success=False, phase_one_exit="pre_call" + ) + + assert reservation["finalized"] is True + assert json.loads(ws.send_text.await_args.args[0])["error"]["message"] == "Rate limit exceeded" + ws.close.assert_awaited_once_with(code=1011, reason="Pre-call error") + + +@pytest.mark.asyncio +async def test_realtime_session_denied_model_access_releases_the_budget_reservation(): + """The key/model access check rejects before the socket is even accepted; + that exit skipped the release as well, pinning the reservation.""" + reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []} + + ws: Final = await _lit6973_drive_realtime_session( + reservation, backend_logged_success=False, phase_one_exit="model_access" + ) + + assert reservation["finalized"] is True + ws.close.assert_awaited_once_with(code=1008, reason="key cannot access model") + + @pytest.mark.asyncio async def test_successful_realtime_session_leaves_the_reservation_for_the_cost_callback(): """A billable realtime session settles its reservation through the enqueued @@ -9625,6 +9677,23 @@ async def test_release_or_invalidate_falls_back_to_invalidating_the_counters(): assert reservation["finalized"] is True +@pytest.mark.asyncio +async def test_release_or_invalidate_finalizes_even_when_the_invalidate_fallback_fails(): + """Both counter-store calls failing must not raise out of the realtime + endpoint's finally (it would mask the session's own outcome) and must still + stamp finalized so nothing retries the same reservation.""" + from litellm.proxy.spend_tracking import budget_reservation as br + + reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []} + failing_release = patch.object(br, "release_budget_reservation", new=AsyncMock(side_effect=RuntimeError("counter store down"))) # test-quality-ok: forces the fallback branch + failing_invalidate = patch.object(br, "invalidate_budget_reservation_counters", new=AsyncMock(side_effect=RuntimeError("still down"))) # test-quality-ok: forces the fallback itself to fail + + with failing_release, failing_invalidate: + await br.release_or_invalidate_budget_reservation(budget_reservation=reservation) + + assert reservation["finalized"] is True + + class TestTransformRequestBannedParams: """ /utils/transform_request applies the same banned-param check as LLM endpoints. From 37722eba68149c5f3e59ed0f3c12798a84aa1bc4 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 02:35:26 -0700 Subject: [PATCH 220/410] fix(realtime): close a rejected client before releasing its budget reservation A slow or unreachable counter store made a pre-relay rejection wait behind the reservation release before the client saw the error event and the close. Close first and release in finally, mirroring the relay's own failure path, so a client that already hung up still gets its reservation released. --- litellm/proxy/proxy_server.py | 20 ++++---- tests/test_litellm/proxy/test_proxy_server.py | 47 ++++++++++++++++++- 2 files changed, 56 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 65fe3ede822..a5c60d8e976 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -11471,15 +11471,17 @@ async def _reject_realtime_session( reason: str, error_message: str | None = None, ) -> None: - await _release_realtime_budget_reservation(user_api_key_dict) - if error_message is not None: - try: - await websocket.send_text( - json.dumps({"type": "error", "error": {"type": "guardrail_error", "message": error_message}}) - ) - except Exception: # noqa: BLE001 # best-effort notice: a dead client socket must not skip the close below - verbose_proxy_logger.debug("Could not send realtime pre-call error event to client; closing anyway") - await websocket.close(code=code, reason=reason) + try: + if error_message is not None: + try: + await websocket.send_text( + json.dumps({"type": "error", "error": {"type": "guardrail_error", "message": error_message}}) + ) + except Exception: # noqa: BLE001 # best-effort notice: a dead client socket must not skip the close below + verbose_proxy_logger.debug("Could not send realtime pre-call error event to client; closing anyway") + await websocket.close(code=code, reason=reason) + finally: + await _release_realtime_budget_reservation(user_api_key_dict) @app.websocket("/openai/v1/realtime") diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 8b151d9e1f6..4d70c9d436f 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -9533,7 +9533,11 @@ def _lit6973_fake_realtime_ws() -> MagicMock: async def _lit6973_drive_realtime_session( - reservation: dict, *, backend_logged_success: bool, phase_one_exit: str | None = None + reservation: dict, + *, + backend_logged_success: bool, + phase_one_exit: str | None = None, + websocket: MagicMock | None = None, ) -> MagicMock: """Drive realtime_websocket_endpoint through one of its reservation-settling exits. @@ -9574,7 +9578,7 @@ async def _lit6973_drive_realtime_session( pre_call: Final = AsyncMock( side_effect=pre_call_error, return_value=({"model": "vertex_ai/gemini-live-2.5-flash"}, logging_obj) ) - ws: Final = _lit6973_fake_realtime_ws() + ws: Final = websocket if websocket is not None else _lit6973_fake_realtime_ws() can_call = patch.object(ps, "can_key_call_resolved_model", new=AsyncMock(side_effect=model_access_error)) # test-quality-ok: no HTTP boundary; fakes in-process auth to reach the exit under test pre = patch.object(ps.ProxyBaseLLMRequestProcessing, "common_processing_pre_call_logic", new=pre_call) # test-quality-ok: fakes phase-1 wiring; assertion checks observable reservation state route = patch.object(ps, "route_request", new=AsyncMock(return_value=fake_llm_call())) # test-quality-ok: fakes the relay whose success/refusal outcome the endpoint reads off the logging object @@ -9635,6 +9639,45 @@ async def test_realtime_session_denied_model_access_releases_the_budget_reservat ws.close.assert_awaited_once_with(code=1008, reason="key cannot access model") +@pytest.mark.asyncio +async def test_rejected_realtime_session_closes_the_client_before_releasing_the_reservation(): + """The counter release can block on a slow or unreachable store, and a + rejected client must not sit behind it: the relay's own failure path closes + the client first and releases in its finally, so the pre-relay rejection + has to close first as well. The fake close checks the reservation is still + open when the client is closed.""" + reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []} + ws: Final = _lit6973_fake_realtime_ws() + + async def close_while_reservation_is_still_open(**_: object) -> None: + assert reservation["finalized"] is False, "client was closed only after the reservation release" + + ws.close = AsyncMock(side_effect=close_while_reservation_is_still_open) + + await _lit6973_drive_realtime_session( + reservation, backend_logged_success=False, phase_one_exit="pre_call", websocket=ws + ) + + ws.close.assert_awaited_once_with(code=1011, reason="Pre-call error") + assert reservation["finalized"] is True + + +@pytest.mark.asyncio +async def test_rejected_realtime_session_releases_the_reservation_when_the_client_is_already_gone(): + """A client that hung up before the rejection makes the close raise; the + reservation must still be released, or the key stays pinned.""" + reservation: Final = {"reserved_cost": 0.55, "input_cost": 0.0, "finalized": False, "entries": []} + ws: Final = _lit6973_fake_realtime_ws() + ws.close = AsyncMock(side_effect=RuntimeError("client already disconnected")) + + with pytest.raises(RuntimeError, match="client already disconnected"): + await _lit6973_drive_realtime_session( + reservation, backend_logged_success=False, phase_one_exit="model_access", websocket=ws + ) + + assert reservation["finalized"] is True + + @pytest.mark.asyncio async def test_successful_realtime_session_leaves_the_reservation_for_the_cost_callback(): """A billable realtime session settles its reservation through the enqueued From d9929379003a2b5ea2d6c584fb9c1088a7e6aab7 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 5 Sep 2026 03:17:11 -0700 Subject: [PATCH 221/410] fix(responses): read reasoning support from the cost map instead of model-name rules --- .../llms/openai/responses/transformation.py | 7 ------- ...odel_prices_and_context_window_backup.json | 4 ++++ model_prices_and_context_window.json | 4 ++++ .../test_openai_responses_transformation.py | 2 ++ .../test_litellm/test_model_prices_schema.py | 21 +++++++++++++++++++ 5 files changed, 31 insertions(+), 7 deletions(-) diff --git a/litellm/llms/openai/responses/transformation.py b/litellm/llms/openai/responses/transformation.py index cdfbbb5be5c..123e9a1dda4 100644 --- a/litellm/llms/openai/responses/transformation.py +++ b/litellm/llms/openai/responses/transformation.py @@ -117,14 +117,7 @@ class OpenAIResponsesAPIConfig(BaseResponsesAPIConfig): return OpenAIGPT5Config.effort_resolves_to_none(model, effort) - @staticmethod - def _is_o_series_name(model: str) -> bool: - base: Final = model.split("/")[-1] - return len(base) > 1 and base[0] == "o" and base[1].isdigit() - def _supports_reasoning_param(self, model: str) -> bool: - if self._is_gpt_5_model(model=model) or self._is_o_series_name(model=model): - return True base: Final = model.split("/")[-1] if base not in litellm.open_ai_chat_completion_models: return True diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 2459ed940e0..99e728a30bc 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -37115,6 +37115,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -37155,6 +37156,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -37366,6 +37368,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -37406,6 +37409,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 2459ed940e0..99e728a30bc 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -37115,6 +37115,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -37155,6 +37156,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -37366,6 +37368,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, @@ -37406,6 +37409,7 @@ "supports_parallel_function_calling": true, "supports_pdf_input": true, "supports_prompt_caching": true, + "supports_reasoning": true, "supports_response_schema": true, "supports_system_messages": true, "supports_tool_choice": true, diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py index bf382d4d8ce..cc884fd7dc1 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_transformation.py @@ -2034,9 +2034,11 @@ class TestReasoningFollowsModelSupport: ("gpt-4o", False), ("gpt-4.1", False), ("gpt-4o-mini", False), + ("gpt-5-search-api", False), ("gpt-5.6", True), ("o3", True), ("o3-deep-research", True), + ("o4-mini-deep-research", True), ("codex-mini-latest", True), ("computer-use-preview", True), ], diff --git a/tests/test_litellm/test_model_prices_schema.py b/tests/test_litellm/test_model_prices_schema.py index 6114d1d8aba..79609032fbd 100644 --- a/tests/test_litellm/test_model_prices_schema.py +++ b/tests/test_litellm/test_model_prices_schema.py @@ -173,3 +173,24 @@ def test_dated_variants_carry_base_alias_service_tier_pricing(prices: dict): "sync the tier keys so service-tier requests against pinned snapshots are not " "billed at standard rates:\n" + "\n".join(drifted) ) + + +def is_openai_o_series(name: str) -> bool: + base = name.split("/")[-1] + return len(base) > 1 and base[0] == "o" and base[1].isdigit() + + +def test_openai_o_series_entries_carry_supports_reasoning(prices: dict): + unflagged = [ + name + for name, entry in prices.items() + if isinstance(entry, dict) + and entry.get("litellm_provider") == "openai" + and is_openai_o_series(name) + and entry.get("supports_reasoning") is not True + ] + assert unflagged == [], ( + "OpenAI o-series models are reasoning models, and the Responses API drops the " + "`reasoning` param for any mapped OpenAI model whose entry lacks supports_reasoning; " + "flag these entries:\n" + "\n".join(unflagged) + ) From 03da725ee4de2414056765f1968794e4c0634ce2 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 5 Sep 2026 09:30:42 -0700 Subject: [PATCH 222/410] Apply suggestion from @greptile-apps[bot] Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> --- tests/test_litellm/integrations/test_shadow_eval_logger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index d67e957ca16..371f6f75a05 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -159,7 +159,7 @@ def _reasoning_judge_router( if kwargs["metadata"].get(INTERNAL_CALL_ORIGIN_METADATA_KEY) == SHADOW_EVAL_ROUTER_CALL_ORIGIN: kwargs["metadata"]["routing_decision"] = {"tier_label": "SIMPLE", "routed_model": "cheap-model"} return {"choices": [{"message": {"content": "shadow answer"}}]} - budget_for_the_answer = kwargs["max_tokens"] - reasoning_tokens + budget_for_the_answer: Final = kwargs["max_tokens"] - reasoning_tokens return {"choices": [{"message": {"content": verdict[: max(0, budget_for_the_answer)]}}]} router.acompletion = MagicMock(side_effect=acompletion) From 00b49ccc8bb0de73891376e5ee1e8bd58295ba2d Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 5 Sep 2026 09:31:18 -0700 Subject: [PATCH 223/410] fix(auto-router compression): honor the policy on the SDK path and on re-save The router reused the model hop's compression for routing whenever both hops named the same guardrail, on the premise that arm_pre_call had already run it. Only the proxy calls arm_pre_call, so through the SDK nothing armed the guardrail and nothing had compressed anything: the shortcut skipped routing compression too and served the request with no compression on either hop. The reuse is now conditional on the model hop actually having been armed. The Admin UI hydrated an absent auto_router_model_compression as same-as-routing, while the backend reads it as no model-hop compression. Opening a router configured with only auto_router_routing_compression and saving any unrelated edit wrote the routing guardrail onto the model hop, silently starting to compress the model call. Both carry a regression test that fails when the fix is reverted. --- .../guardrails/auto_router_compression.py | 15 + litellm/router.py | 8 +- tests/test_litellm/test_router.py | 967 ++++++------------ .../buildAutoRouterCompression.test.ts | 15 +- .../add_model/buildAutoRouterCompression.ts | 8 +- 5 files changed, 347 insertions(+), 666 deletions(-) diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py index 2ab779658b5..e7f58662249 100644 --- a/litellm/proxy/guardrails/auto_router_compression.py +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -45,6 +45,19 @@ def suppressed_compression_guardrails() -> frozenset[str]: return _suppressed_compression_guardrails.get() +# Whether `arm_pre_call` actually armed a model-side compression guardrail for this +# request. Only the proxy calls `arm_pre_call`, so on the SDK path nothing arms and +# nothing compresses; the router must not assume the model hop already ran. +_model_hop_armed: Final[contextvars.ContextVar[bool]] = contextvars.ContextVar( + "litellm_auto_router_model_hop_armed", default=False +) + + +def model_hop_compression_armed() -> bool: + """True when this request's model-side compression guardrail was actually armed.""" + return _model_hop_armed.get() + + @dataclass(frozen=True, slots=True) class AutoRouterCompressionPolicy: """An auto router's compression choice for each hop. ``None`` means no compression.""" @@ -147,6 +160,7 @@ async def arm_pre_call( guardrail the policy names (if any) even when it isn't ``default_on``. """ _suppressed_compression_guardrails.set(frozenset()) + _model_hop_armed.set(False) if llm_router is None: return @@ -179,6 +193,7 @@ async def arm_pre_call( ) if policy.model is not None: + _model_hop_armed.set(True) _, metadata = get_or_create_metadata_bucket(data) requested: Final = metadata.get("guardrails") existing: Final = tuple(requested) if isinstance(requested, (list, tuple)) else () diff --git a/litellm/router.py b/litellm/router.py index 989914b1610..b61e4e29e09 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -13039,6 +13039,7 @@ class Router: from litellm.proxy.guardrails.auto_router_compression import ( messages_for_routing, + model_hop_compression_armed, policy_for_model, team_id_from_request, ) @@ -13057,8 +13058,13 @@ class Router: # (arm_pre_call armed it whether or not it is `default_on`); reuse that result # for routing too instead of paying for a second compression call against the # same content. + # + # Only the proxy calls arm_pre_call, so that reuse is conditional on it having + # actually run: on the SDK path nothing arms the model hop and nothing has + # compressed anything, and taking the shortcut there would skip both hops and + # silently serve the request with no compression at all. needs_independent_routing_compression: Final = compression_policy is not None and not ( - compression_policy.is_same and compression_policy.model is not None + compression_policy.is_same and compression_policy.model is not None and model_hop_compression_armed() ) routing_messages: Final = ( await messages_for_routing(policy=compression_policy, messages=messages, request_kwargs=request_kwargs) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index a59c9c98163..9e6a88d3433 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -15,7 +15,6 @@ import pytest import respx - import litellm from litellm import Router from litellm.exceptions import MidStreamFallbackError @@ -137,31 +136,18 @@ def test_router_model_group_encrypted_content_affinity_callback_registration(): num_retries=0, ) callbacks = router.optional_callbacks or [] - encrypted_content_callbacks = [ - cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck) - ] - deployment_callback = next( - cb for cb in callbacks if isinstance(cb, DeploymentAffinityCheck) - ) + encrypted_content_callbacks = [cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck)] + deployment_callback = next(cb for cb in callbacks if isinstance(cb, DeploymentAffinityCheck)) assert len(encrypted_content_callbacks) == 1 assert encrypted_content_callbacks[0].enable_global_affinity is False - assert ( - encrypted_content_callbacks[0].model_group_affinity_config - == model_group_affinity_config - ) - assert callbacks.index(encrypted_content_callbacks[0]) < callbacks.index( - deployment_callback - ) - assert litellm.callbacks.index(encrypted_content_callbacks[0]) < ( - litellm.callbacks.index(deployment_callback) - ) + assert encrypted_content_callbacks[0].model_group_affinity_config == model_group_affinity_config + assert callbacks.index(encrypted_content_callbacks[0]) < callbacks.index(deployment_callback) + assert litellm.callbacks.index(encrypted_content_callbacks[0]) < (litellm.callbacks.index(deployment_callback)) router._add_encrypted_content_affinity_check(enable_global_affinity=True) callbacks = router.optional_callbacks or [] - encrypted_content_callbacks = [ - cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck) - ] + encrypted_content_callbacks = [cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck)] assert len(encrypted_content_callbacks) == 1 assert encrypted_content_callbacks[0].enable_global_affinity is True assert encrypted_content_callbacks[0].router is router @@ -192,13 +178,9 @@ async def test_encrypted_content_affinity_model_group_config_is_additive(): }, target_deployment, ] - encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id( - "deployment-b", "rs_test" - ) + encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id("deployment-b", "rs_test") - assert EncryptedContentAffinityCheck.has_model_group_affinity_enabled( - {model_group: ["encrypted_content_affinity"]} - ) + assert EncryptedContentAffinityCheck.has_model_group_affinity_enabled({model_group: ["encrypted_content_affinity"]}) assert not EncryptedContentAffinityCheck.has_model_group_affinity_enabled(None) per_group_check = EncryptedContentAffinityCheck( @@ -239,10 +221,7 @@ async def test_encrypted_content_affinity_model_group_config_is_additive(): ) assert unfiltered == healthy_deployments - assert ( - "encrypted_content_affinity_enabled" - not in disabled_request_kwargs["litellm_metadata"] - ) + assert "encrypted_content_affinity_enabled" not in disabled_request_kwargs["litellm_metadata"] global_check = EncryptedContentAffinityCheck( enable_global_affinity=True, @@ -262,9 +241,7 @@ async def test_encrypted_content_affinity_model_group_config_is_additive(): ) assert globally_filtered == [target_deployment] - assert global_request_kwargs["litellm_metadata"][ - "encrypted_content_affinity_enabled" - ] + assert global_request_kwargs["litellm_metadata"]["encrypted_content_affinity_enabled"] @pytest.mark.asyncio @@ -311,18 +288,10 @@ async def test_encrypted_content_affinity_takes_priority_over_user_key_affinity( num_retries=0, ) callbacks = router.optional_callbacks or [] - deployment_callback = next( - cb for cb in callbacks if isinstance(cb, DeploymentAffinityCheck) - ) - encrypted_content_callback = next( - cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck) - ) - assert callbacks.index(encrypted_content_callback) < callbacks.index( - deployment_callback - ) - assert litellm.callbacks.index(encrypted_content_callback) < ( - litellm.callbacks.index(deployment_callback) - ) + deployment_callback = next(cb for cb in callbacks if isinstance(cb, DeploymentAffinityCheck)) + encrypted_content_callback = next(cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck)) + assert callbacks.index(encrypted_content_callback) < callbacks.index(deployment_callback) + assert litellm.callbacks.index(encrypted_content_callback) < (litellm.callbacks.index(deployment_callback)) cache_key = DeploymentAffinityCheck.get_affinity_cache_key( model_group=model_group, @@ -333,9 +302,7 @@ async def test_encrypted_content_affinity_takes_priority_over_user_key_affinity( value={"model_id": "deployment-a"}, ttl=60, ) - encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id( - "deployment-b", "rs_test" - ) + encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id("deployment-b", "rs_test") request_kwargs = { "input": [{"type": "reasoning", "id": encoded_id}], "litellm_metadata": {"user_api_key_hash": user_api_key_hash}, @@ -939,9 +906,7 @@ async def test_arouter_aretrieve_batch(): ], ) - with patch.object( - litellm, "aretrieve_batch", return_value=AsyncMock() - ) as mock_aretrieve_batch: + with patch.object(litellm, "aretrieve_batch", return_value=AsyncMock()) as mock_aretrieve_batch: try: response = await router.aretrieve_batch( model="gpt-3.5-turbo", @@ -962,9 +927,7 @@ async def test_arouter_aretrieve_file_content(): Test that router.acreate_file with JSONL file returns the correct response """ - with patch.object( - litellm, "afile_content", return_value=AsyncMock() - ) as mock_afile_content: + with patch.object(litellm, "afile_content", return_value=AsyncMock()) as mock_afile_content: router = litellm.Router( model_list=[ { @@ -1025,7 +988,7 @@ async def test_arouter_filter_team_based_models(): assert result is not None # FAILS - with pytest.raises(Exception, match='No deployments available for selected model, Try again in') as e: + with pytest.raises(Exception, match="No deployments available for selected model, Try again in") as e: result = await router.acompletion( model="gpt-3.5-turbo", messages=[{"role": "user", "content": "Hello, world!"}], @@ -1109,9 +1072,7 @@ def test_arouter_should_include_deployment(): model=deployment_with_team_and_public_name, team_id="test-team", ) - assert ( - result is True - ), "Should return True when team_id and team_public_model_name match" + assert result is True, "Should return True when team_id and team_public_model_name match" # Test Case 2: Team-specific deployment - team_id matches but model_name doesn't match team_public_model_name result = router.should_include_deployment( @@ -1119,9 +1080,9 @@ def test_arouter_should_include_deployment(): model=deployment_with_team_and_public_name, team_id="test-team", ) - assert ( - result is False - ), "Should return False when team_id matches but model_name doesn't match team_public_model_name" + assert result is False, ( + "Should return False when team_id matches but model_name doesn't match team_public_model_name" + ) # Test Case 3: Team-specific deployment - team_id doesn't match result = router.should_include_deployment( @@ -1137,30 +1098,18 @@ def test_arouter_should_include_deployment(): model=deployment_with_team_no_public_name, team_id="test-team", ) - assert ( - result is True - ), "Should return True when team deployment has no team_public_model_name to match" + assert result is True, "Should return True when team deployment has no team_public_model_name to match" # Test Case 5: Non-team deployment - model_name matches and no team_id - result = router.should_include_deployment( - model_name="gpt-4", model=deployment_without_team, team_id=None - ) - assert ( - result is True - ), "Should return True when model_name matches and deployment has no team_id" + result = router.should_include_deployment(model_name="gpt-4", model=deployment_without_team, team_id=None) + assert result is True, "Should return True when model_name matches and deployment has no team_id" # Test Case 6: Non-team deployment - model_name matches but team_id provided (should still work) - result = router.should_include_deployment( - model_name="gpt-4", model=deployment_without_team, team_id="any-team" - ) - assert ( - result is True - ), "Should return True when model_name matches non-team deployment, regardless of team_id param" + result = router.should_include_deployment(model_name="gpt-4", model=deployment_without_team, team_id="any-team") + assert result is True, "Should return True when model_name matches non-team deployment, regardless of team_id param" # Test Case 7: Non-team deployment - model_name doesn't match - result = router.should_include_deployment( - model_name="different-model", model=deployment_without_team, team_id=None - ) + result = router.should_include_deployment(model_name="different-model", model=deployment_without_team, team_id=None) assert result is False, "Should return False when model_name doesn't match" # Test Case 8: Team deployment accessed without matching team_id @@ -1169,9 +1118,7 @@ def test_arouter_should_include_deployment(): model=deployment_with_team_and_public_name, team_id=None, ) - assert ( - result is True - ), "Should return True when matching model with exact model_name" + assert result is True, "Should return True when matching model with exact model_name" def test_arouter_responses_api_bridge(): @@ -1221,9 +1168,7 @@ def test_arouter_responses_api_bridge(): "status": "completed", "output": [], } - mock_response.text = ( - '{"id": "resp_test", "object": "response", "status": "completed", "output": []}' - ) + mock_response.text = '{"id": "resp_test", "object": "response", "status": "completed", "output": []}' with patch.object(client, "post", return_value=mock_response) as mock_post: try: @@ -1297,7 +1242,7 @@ def test_add_invalid_provider_to_router(): ], ) - with pytest.raises(Exception, match='Unsupported provider - vertex_ai_eu') as e: + with pytest.raises(Exception, match="Unsupported provider - vertex_ai_eu") as e: router.add_deployment( Deployment( model_name="vertex_ai/*", @@ -1349,15 +1294,9 @@ async def test_router_ageneric_api_call_with_fallbacks_helper(): }, } - with patch.object( - router, "_update_kwargs_with_deployment" - ) as mock_update_kwargs: - with patch.object( - router, "async_routing_strategy_pre_call_checks" - ) as mock_pre_call_checks: - with patch.object( - router, "_get_client", return_value=None - ) as mock_get_client: + with patch.object(router, "_update_kwargs_with_deployment") as mock_update_kwargs: + with patch.object(router, "async_routing_strategy_pre_call_checks") as mock_pre_call_checks: + with patch.object(router, "_get_client", return_value=None) as mock_get_client: result = await router._ageneric_api_call_with_fallbacks_helper( model="gpt-3.5-turbo", original_generic_function=mock_generic_function, @@ -1392,7 +1331,7 @@ async def test_router_ageneric_api_call_with_fallbacks_helper(): with patch.object(router, "async_get_available_deployment") as mock_get_deployment: mock_get_deployment.side_effect = Exception("No deployment available") - with pytest.raises(Exception, match='No deployment available') as exc_info: + with pytest.raises(Exception, match="No deployment available") as exc_info: await router._ageneric_api_call_with_fallbacks_helper( model="gpt-3.5-turbo", original_generic_function=mock_generic_function, @@ -1420,15 +1359,9 @@ async def test_router_ageneric_api_call_with_fallbacks_helper(): mock_semaphore = asyncio.Semaphore(1) - with patch.object( - router, "_update_kwargs_with_deployment" - ) as mock_update_kwargs: - with patch.object( - router, "_get_client", return_value=mock_semaphore - ) as mock_get_client: - with patch.object( - router, "async_routing_strategy_pre_call_checks" - ) as mock_pre_call_checks: + with patch.object(router, "_update_kwargs_with_deployment") as mock_update_kwargs: + with patch.object(router, "_get_client", return_value=mock_semaphore) as mock_get_client: + with patch.object(router, "async_routing_strategy_pre_call_checks") as mock_pre_call_checks: result = await router._ageneric_api_call_with_fallbacks_helper( model="gpt-3.5-turbo", original_generic_function=mock_semaphore_function, @@ -1457,16 +1390,10 @@ async def test_router_ageneric_api_call_with_fallbacks_helper(): }, } - with patch.object( - router, "_update_kwargs_with_deployment" - ) as mock_update_kwargs: - with patch.object( - router, "_get_client", return_value=None - ) as mock_get_client: - with patch.object( - router, "async_routing_strategy_pre_call_checks" - ) as mock_pre_call_checks: - with pytest.raises(Exception, match='Mock failure') as exc_info: + with patch.object(router, "_update_kwargs_with_deployment") as mock_update_kwargs: + with patch.object(router, "_get_client", return_value=None) as mock_get_client: + with patch.object(router, "async_routing_strategy_pre_call_checks") as mock_pre_call_checks: + with pytest.raises(Exception, match="Mock failure") as exc_info: await router._ageneric_api_call_with_fallbacks_helper( model="gpt-3.5-turbo", original_generic_function=mock_failing_function, @@ -1534,9 +1461,9 @@ async def test_ageneric_api_call_deployment_model_overrides_alias(): original_generic_function=capture_model, ) - assert ( - captured["model"] == "vertex_ai/gemini-2.5-flash" - ), f"Expected deployment model 'vertex_ai/gemini-2.5-flash', got '{captured['model']}'" + assert captured["model"] == "vertex_ai/gemini-2.5-flash", ( + f"Expected deployment model 'vertex_ai/gemini-2.5-flash', got '{captured['model']}'" + ) @pytest.mark.asyncio @@ -1642,14 +1569,10 @@ def test_router_get_model_access_groups_team_only_models(): ] ) - access_groups = router.get_model_access_groups( - model_name="gpt-3.5-turbo", team_id=None - ) + access_groups = router.get_model_access_groups(model_name="gpt-3.5-turbo", team_id=None) assert len(access_groups) == 0 - access_groups = router.get_model_access_groups( - model_name="gpt-3.5-turbo", team_id="team_1" - ) + access_groups = router.get_model_access_groups(model_name="gpt-3.5-turbo", team_id="team_1") assert list(access_groups.keys()) == ["default-models"] @@ -1744,9 +1667,7 @@ def test_model_group_info_cost_from_db_model_info(): ] ) - with patch.object( - router, "get_deployment_model_info", side_effect=Exception("not found") - ): + with patch.object(router, "get_deployment_model_info", side_effect=Exception("not found")): result = router._cached_get_model_group_info("my-custom-model") assert result is not None assert result.input_cost_per_token == 0.0001 @@ -1774,9 +1695,7 @@ def test_model_group_info_cost_none_when_db_model_info_has_no_cost(): ] ) - with patch.object( - router, "get_deployment_model_info", side_effect=Exception("not found") - ): + with patch.object(router, "get_deployment_model_info", side_effect=Exception("not found")): result = router._cached_get_model_group_info("my-custom-model-no-cost") assert result is not None assert result.input_cost_per_token is None @@ -1846,9 +1765,7 @@ def test_model_group_info_with_stringified_cost_values(): } return None - with patch.object( - router, "get_deployment_model_info", side_effect=_model_info_with_str_costs - ): + with patch.object(router, "get_deployment_model_info", side_effect=_model_info_with_str_costs): result = router._set_model_group_info( model_group="my-custom-model", user_facing_model_group_name="my-custom-model", @@ -1894,9 +1811,7 @@ def test_model_group_info_db_fallback_with_stringified_cost_values(): ] ) - with patch.object( - router, "get_deployment_model_info", side_effect=Exception("not found") - ): + with patch.object(router, "get_deployment_model_info", side_effect=Exception("not found")): result = router._set_model_group_info( model_group="my-custom-model", user_facing_model_group_name="my-custom-model", @@ -2156,6 +2071,7 @@ async def test_acompletion_streaming_iterator(): # Collect streamed chunks — the first chunk succeeds, then the error re-raises collected_chunks = [] + async def _drain(): async for chunk in result: collected_chunks.append(chunk) @@ -2849,11 +2765,7 @@ def _make_responses_iterator( BaseResponsesAPIStreamingIterator, ) - base = ( - LiteLLMCompletionStreamingIterator - if bridge - else BaseResponsesAPIStreamingIterator - ) + base = LiteLLMCompletionStreamingIterator if bridge else BaseResponsesAPIStreamingIterator class _Iter(base): def __init__(self): @@ -2933,9 +2845,7 @@ async def test_aresponses_streaming_iterator_fallback(): BaseResponsesAPIStreamingIterator, ) - router = _make_router_with_fallback( - "anthropic/claude-sonnet-4-6", "vertex_ai/claude-sonnet-4-6" - ) + router = _make_router_with_fallback("anthropic/claude-sonnet-4-6", "vertex_ai/claude-sonnet-4-6") src = _make_responses_iterator( chunks=[MagicMock(type="response.created")], error=MidStreamFallbackError( @@ -3018,9 +2928,9 @@ async def test_aresponses_streaming_iterator_writes_litellm_metadata_on_fallback fbk = mock_fallback_utils.call_args.kwargs["kwargs"] assert "litellm_metadata" in fbk, "wrong metadata_variable_name" assert fbk["litellm_metadata"]["model_group"] == "gpt-4" - assert "model_group" not in fbk.get( - "metadata", {} - ), "model_group leaked into 'metadata' instead of 'litellm_metadata'" + assert "model_group" not in fbk.get("metadata", {}), ( + "model_group leaked into 'metadata' instead of 'litellm_metadata'" + ) @pytest.mark.asyncio @@ -3138,9 +3048,7 @@ async def test_aresponses_streaming_iterator_combines_partial_usage(): fallback_response_object = ResponsesAPIResponse( id="resp_test", created_at=0, model="gpt-4", object="response", output=[] ) - fallback_response_object.usage = ResponseAPIUsage( - input_tokens=20, output_tokens=15, total_tokens=35 - ) + fallback_response_object.usage = ResponseAPIUsage(input_tokens=20, output_tokens=15, total_tokens=35) fallback_event = ResponseCompletedEvent( type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, response=fallback_response_object, @@ -3149,9 +3057,7 @@ async def test_aresponses_streaming_iterator_combines_partial_usage(): with ( patch( "litellm.main.stream_chunk_builder", - return_value=SimpleNamespace( - usage=SimpleNamespace(prompt_tokens=10, completion_tokens=4) - ), + return_value=SimpleNamespace(usage=SimpleNamespace(prompt_tokens=10, completion_tokens=4)), ), patch.object( router, @@ -3452,9 +3358,7 @@ def test_pre_call_checks_skips_token_count_without_max_input_tokens(monkeypatch) monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {}) calls = [] - monkeypatch.setattr( - litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000 - ) + monkeypatch.setattr(litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3482,14 +3386,10 @@ def test_pre_call_checks_counts_once_and_filters_on_max_input_tokens(monkeypatch ], enable_pre_call_checks=True, ) - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) calls = [] - monkeypatch.setattr( - litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000 - ) + monkeypatch.setattr(litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3516,14 +3416,10 @@ def test_pre_call_checks_uses_precounted_tokens(monkeypatch): ], enable_pre_call_checks=True, ) - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) calls = [] - monkeypatch.setattr( - litellm, "token_counter", lambda *a, **k: calls.append(1) or 1 - ) + monkeypatch.setattr(litellm, "token_counter", lambda *a, **k: calls.append(1) or 1) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3550,9 +3446,7 @@ async def test_async_get_healthy_deployments_counts_tokens_off_the_event_loop(mo ], enable_pre_call_checks=True, ) - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1_000_000} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1_000_000}) counting_threads = [] monkeypatch.setattr( @@ -3648,14 +3542,10 @@ def test_pre_call_checks_does_not_recount_inline_after_an_off_loop_failure(monke ], enable_pre_call_checks=True, ) - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) calls = [] - monkeypatch.setattr( - litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000 - ) + monkeypatch.setattr(litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3683,9 +3573,7 @@ async def test_async_get_healthy_deployments_never_recounts_on_the_loop(monkeypa ], enable_pre_call_checks=True, ) - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) counting_threads = [] @@ -3720,9 +3608,7 @@ async def test_acount_pre_call_check_tokens_leaves_the_event_loop_free(monkeypat ], enable_pre_call_checks=True, ) - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3758,9 +3644,7 @@ async def test_acount_pre_call_check_tokens_skips_without_max_input_tokens(monke monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {}) calls = [] - monkeypatch.setattr( - litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000 - ) + monkeypatch.setattr(litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000) count = await router._acount_pre_call_check_tokens( model="m", @@ -3788,9 +3672,7 @@ def test_pre_call_checks_counts_tokens_from_responses_input_string(monkeypatch): ], enable_pre_call_checks=True, ) - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1}) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3815,9 +3697,7 @@ def test_pre_call_checks_counts_tokens_from_responses_input_list(monkeypatch): ], enable_pre_call_checks=True, ) - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1}) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3859,9 +3739,7 @@ def test_pre_call_checks_counts_responses_instructions_tokens(monkeypatch): ) assert with_instructions_tokens > input_only_tokens - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": input_only_tokens} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": input_only_tokens}) with pytest.raises(litellm.ContextWindowExceededError): router._pre_call_checks( model="m", @@ -3930,9 +3808,7 @@ def test_pre_call_checks_counts_tool_definition_tokens(monkeypatch, prompt_kwarg prompt_only_tokens = router._count_pre_call_check_tokens( messages=prompt_kwargs.get("messages"), input=prompt_kwargs.get("input") ) - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": prompt_only_tokens} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": prompt_only_tokens}) assert len(router._pre_call_checks(model="m", healthy_deployments=deployments, **prompt_kwargs)) == 1 with pytest.raises(litellm.ContextWindowExceededError): @@ -4052,7 +3928,7 @@ def test_count_pre_call_check_tokens_across_api_surfaces(): assert string_input_tokens > 0 assert list_input_tokens > 0 - with pytest.raises(ValueError, match='Either messages or input must be provided to count tokens'): + with pytest.raises(ValueError, match="Either messages or input must be provided to count tokens"): router._count_pre_call_check_tokens(messages=None, input=None) @@ -4067,9 +3943,7 @@ def test_pre_call_checks_no_messages_or_input_does_not_crash(monkeypatch): ], enable_pre_call_checks=True, ) - monkeypatch.setattr( - router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} - ) + monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) counted: list[dict] = [] original = router._count_pre_call_check_tokens @@ -4159,9 +4033,7 @@ def test_get_deployment_model_info_base_model_flow(): } # Test Case 1: Base model flow with custom model info that has base_model - with patch.object( - litellm, "model_cost", {"test-custom-model": mock_custom_model_info} - ): + with patch.object(litellm, "model_cost", {"test-custom-model": mock_custom_model_info}): with patch.object(litellm, "get_model_info") as mock_get_model_info: # Configure mock returns mock_get_model_info.side_effect = lambda model: { @@ -4169,15 +4041,11 @@ def test_get_deployment_model_info_base_model_flow(): "test-model": mock_litellm_model_name_info, }.get(model) - result = router.get_deployment_model_info( - model_id="test-custom-model", model_name="test-model" - ) + result = router.get_deployment_model_info(model_id="test-custom-model", model_name="test-model") # Verify that get_model_info was called for both base model and model name assert mock_get_model_info.call_count == 2 - mock_get_model_info.assert_any_call( - model="gpt-3.5-turbo" - ) # base model call + mock_get_model_info.assert_any_call(model="gpt-3.5-turbo") # base model call mock_get_model_info.assert_any_call(model="test-model") # model name call # Verify the result contains merged information @@ -4188,26 +4056,18 @@ def test_get_deployment_model_info_base_model_flow(): # 2. The result of step 1 gets merged into litellm_model_name_info (custom+base override litellm) # Fields from custom model (should override base model values) - assert ( - result["input_cost_per_token"] == 0.001 - ) # From custom model (overrides base 0.0015) - assert ( - result["output_cost_per_token"] == 0.002 - ) # From custom model (same as base) + assert result["input_cost_per_token"] == 0.001 # From custom model (overrides base 0.0015) + assert result["output_cost_per_token"] == 0.002 # From custom model (same as base) assert result["custom_field"] == "custom_value" # From custom model # Fields from base model that weren't overridden by custom assert result["max_tokens"] == 4096 # From base model assert result["litellm_provider"] == "openai" # From base model - assert ( - result["mode"] == "chat" - ) # From base model (overrides litellm "completion") + assert result["mode"] == "chat" # From base model (overrides litellm "completion") # The key field comes from base model since both base and litellm have it # and base model info overrides litellm model name info in final merge - assert ( - result["key"] == "gpt-3.5-turbo" - ) # From base model (overrides litellm key) + assert result["key"] == "gpt-3.5-turbo" # From base model (overrides litellm key) # Test Case 2: Custom model info without base_model mock_custom_model_info_no_base = { @@ -4226,9 +4086,7 @@ def test_get_deployment_model_info_base_model_flow(): "test-model": mock_litellm_model_name_info, }.get(model) - result = router.get_deployment_model_info( - model_id="test-custom-model-no-base", model_name="test-model" - ) + result = router.get_deployment_model_info(model_id="test-custom-model-no-base", model_name="test-model") # Should only call get_model_info once for model name (no base model) assert mock_get_model_info.call_count == 1 @@ -4248,9 +4106,7 @@ def test_get_deployment_model_info_base_model_flow(): "test-model": mock_litellm_model_name_info, }.get(model) - result = router.get_deployment_model_info( - model_id="non-existent-model", model_name="test-model" - ) + result = router.get_deployment_model_info(model_id="non-existent-model", model_name="test-model") # Should only call get_model_info once for model name assert mock_get_model_info.call_count == 1 @@ -4283,9 +4139,7 @@ def test_get_deployment_model_info_base_model_flow(): mock_get_model_info.side_effect = mock_get_model_info_side_effect - result = router.get_deployment_model_info( - model_id="test-custom-model-invalid", model_name="test-model" - ) + result = router.get_deployment_model_info(model_id="test-custom-model-invalid", model_name="test-model") # Should handle exception gracefully and still return merged result assert result is not None @@ -4294,12 +4148,8 @@ def test_get_deployment_model_info_base_model_flow(): # Test Case 5: Both model_cost.get() and get_model_info() return None with patch.object(litellm, "model_cost", {}): - with patch.object( - litellm, "get_model_info", side_effect=Exception("Not found") - ): - result = router.get_deployment_model_info( - model_id="non-existent", model_name="non-existent" - ) + with patch.object(litellm, "get_model_info", side_effect=Exception("Not found")): + result = router.get_deployment_model_info(model_id="non-existent", model_name="non-existent") # Should return None when no model info is found assert result is None @@ -4322,9 +4172,7 @@ def test_get_deployment_model_info_base_model_flow(): # Model NOT in built-in cost map — raise exception mock_get_model_info.side_effect = Exception("Model not in cost map") - result = router.get_deployment_model_info( - model_id="custom-model-id", model_name="unknown-model" - ) + result = router.get_deployment_model_info(model_id="custom-model-id", model_name="unknown-model") # Should return custom_model_info even when litellm_model_name_model_info is None assert result is not None @@ -4360,15 +4208,11 @@ def test_get_deployment_model_info_base_model_flow(): mock_get_model_info.side_effect = get_info_side_effect - result = router.get_deployment_model_info( - model_id="custom-with-base", model_name="unknown-model" - ) + result = router.get_deployment_model_info(model_id="custom-with-base", model_name="unknown-model") # Should return custom_model_info merged with base model info assert result is not None - assert ( - result["input_cost_per_token"] == 0.01 - ) # From custom (overrides base) + assert result["input_cost_per_token"] == 0.01 # From custom (overrides base) assert result["max_tokens"] == 8192 # From base model assert result["litellm_provider"] == "openai" # From base model @@ -4415,18 +4259,14 @@ def test_get_deployment_model_info_base_model_merge_priority(): "litellm_only_field": "litellm_value", } - with patch.object( - litellm, "model_cost", {"custom-model-id": mock_custom_model_info} - ): + with patch.object(litellm, "model_cost", {"custom-model-id": mock_custom_model_info}): with patch.object(litellm, "get_model_info") as mock_get_model_info: mock_get_model_info.side_effect = lambda model: { "gpt-4": mock_base_model_info, "test-model": mock_litellm_model_name_info, }.get(model) - result = router.get_deployment_model_info( - model_id="custom-model-id", model_name="test-model" - ) + result = router.get_deployment_model_info(model_id="custom-model-id", model_name="test-model") assert result is not None @@ -4436,29 +4276,17 @@ def test_get_deployment_model_info_base_model_merge_priority(): # 3. Result from steps 1-2 overrides litellm_model_name_info # Fields that should come from custom model info (highest priority) - assert ( - result["input_cost_per_token"] == 0.01 - ) # From custom model (overrides base 0.03) - assert ( - result["max_tokens"] == 8000 - ) # From custom model (overrides base 4096) + assert result["input_cost_per_token"] == 0.01 # From custom model (overrides base 0.03) + assert result["max_tokens"] == 8000 # From custom model (overrides base 4096) assert result["custom_only_field"] == "custom_value" # From custom model # Fields that should come from base model (not overridden by custom) - assert ( - result["output_cost_per_token"] == 0.06 - ) # From base model (not in custom) - assert ( - result["litellm_provider"] == "openai" - ) # From base model (not in custom) - assert ( - result["base_only_field"] == "base_value" - ) # From base model (not in custom) + assert result["output_cost_per_token"] == 0.06 # From base model (not in custom) + assert result["litellm_provider"] == "openai" # From base model (not in custom) + assert result["base_only_field"] == "base_value" # From base model (not in custom) # Fields that should come from litellm model name info (not overridden by custom+base) - assert ( - result["mode"] == "completion" - ) # From litellm model name info (not in custom or base) + assert result["mode"] == "completion" # From litellm model name info (not in custom or base) assert ( result["litellm_only_field"] == "litellm_value" ) # From litellm model name info (not in custom or base) @@ -4495,10 +4323,9 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): model="special-bedrock-model", model_name="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", ) - assert ( - result["endpoint"] - == "/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke" - ), f"Expected '/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke', got '{result['endpoint']}'" + assert result["endpoint"] == "/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke", ( + f"Expected '/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke', got '{result['endpoint']}'" + ) # Test Case 2: Bedrock invoke-with-response-stream endpoint kwargs = { @@ -4510,10 +4337,9 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): model="special-bedrock-model", model_name="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", ) - assert ( - result["endpoint"] - == "/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream" - ), f"Expected streaming endpoint with stripped prefix, got '{result['endpoint']}'" + assert result["endpoint"] == "/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream", ( + f"Expected streaming endpoint with stripped prefix, got '{result['endpoint']}'" + ) # Test Case 3: Bedrock converse endpoint kwargs = { @@ -4525,9 +4351,9 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): model="bedrock-model", model_name="bedrock/us.meta.llama3-8b-instruct-v1:0", ) - assert ( - result["endpoint"] == "/model/us.meta.llama3-8b-instruct-v1:0/converse" - ), f"Expected '/model/us.meta.llama3-8b-instruct-v1:0/converse', got '{result['endpoint']}'" + assert result["endpoint"] == "/model/us.meta.llama3-8b-instruct-v1:0/converse", ( + f"Expected '/model/us.meta.llama3-8b-instruct-v1:0/converse', got '{result['endpoint']}'" + ) # Test Case 4: Bedrock provider prefix auto-detected from model_name kwargs = { @@ -4538,9 +4364,9 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): model="router-model", model_name="bedrock/us.meta.llama3-8b-instruct-v1:0", ) - assert ( - result["endpoint"] == "/model/us.meta.llama3-8b-instruct-v1:0/invoke" - ), f"Expected '/model/us.meta.llama3-8b-instruct-v1:0/invoke', got '{result['endpoint']}'" + assert result["endpoint"] == "/model/us.meta.llama3-8b-instruct-v1:0/invoke", ( + f"Expected '/model/us.meta.llama3-8b-instruct-v1:0/invoke', got '{result['endpoint']}'" + ) def test_update_kwargs_with_deployment_uses_pass_through_request_timeout(): @@ -4592,14 +4418,10 @@ async def test_router_acompletion_with_unknown_model_and_default_fallback(): # Initialize the router with a default fallback router = litellm.Router(model_list=model_list, default_fallbacks=["gpt-4o"]) - messages = [ - {"role": "user", "content": "This call should succeed by falling back."} - ] + messages = [{"role": "user", "content": "This call should succeed by falling back."}] # Call completion with a model name that is NOT in the model_list - response = await router.acompletion( - model="completely-unknown-model", messages=messages - ) + response = await router.acompletion(model="completely-unknown-model", messages=messages) # Check that the call did not fail and we received a valid response object. assert response is not None @@ -4691,15 +4513,10 @@ def test_get_deployment_credentials_with_provider_aws_bedrock_runtime_endpoint() ], ) - credentials = router.get_deployment_credentials_with_provider( - model_id="bedrock-claude-model" - ) + credentials = router.get_deployment_credentials_with_provider(model_id="bedrock-claude-model") assert credentials is not None - assert ( - credentials["aws_bedrock_runtime_endpoint"] - == "https://bedrock-runtime.us-east-1.amazonaws.com" - ) + assert credentials["aws_bedrock_runtime_endpoint"] == "https://bedrock-runtime.us-east-1.amazonaws.com" assert credentials["aws_access_key_id"] == "test-access-key" assert credentials["aws_secret_access_key"] == "test-secret-key" assert credentials["aws_region_name"] == "us-east-1" @@ -4726,9 +4543,7 @@ def test_get_deployment_credentials_with_provider_includes_bucket_name(): ], ) - credentials = router.get_deployment_credentials_with_provider( - model_id="vertex-gemini" - ) + credentials = router.get_deployment_credentials_with_provider(model_id="vertex-gemini") assert credentials is not None assert credentials["gcs_bucket_name"] == "my-batch-bucket" @@ -4768,9 +4583,7 @@ def test_get_deployment_credentials_with_provider_resolves_credential_name(): ], ) - credentials = router.get_deployment_credentials_with_provider( - model_id="azure-gpt-4" - ) + credentials = router.get_deployment_credentials_with_provider(model_id="azure-gpt-4") assert credentials is not None assert credentials["api_key"] == "resolved-api-key" @@ -4806,9 +4619,7 @@ def test_get_deployment_credentials_with_provider_bedrock_batch_fields(): ], ) - credentials = router.get_deployment_credentials_with_provider( - model_id="bedrock-batch-model" - ) + credentials = router.get_deployment_credentials_with_provider(model_id="bedrock-batch-model") assert credentials is not None assert credentials["custom_llm_provider"] == "bedrock" @@ -4851,9 +4662,7 @@ def test_get_deployment_credentials_with_provider_preserves_aws_auth_params(): ], ) - credentials = router.get_deployment_credentials_with_provider( - model_id="bedrock-batch-model" - ) + credentials = router.get_deployment_credentials_with_provider(model_id="bedrock-batch-model") assert credentials is not None for key, value in aws_auth_params.items(): @@ -4888,15 +4697,11 @@ def test_get_deployment_credentials_with_provider_team_wildcard_priority(): ], ) - team_credentials = router.get_deployment_credentials_with_provider( - model_id="openai/gpt-5.2", team_id="team-1" - ) + team_credentials = router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-1") assert team_credentials is not None assert team_credentials["api_key"] == "team-key" - global_credentials = router.get_deployment_credentials_with_provider( - model_id="openai/gpt-5.2" - ) + global_credentials = router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2") assert global_credentials is not None assert global_credentials["api_key"] == "global-key" @@ -4937,15 +4742,11 @@ def test_get_deployment_credentials_with_provider_skips_other_team_deployment(): assert other_team_credentials is not None assert other_team_credentials["vertex_project"] == "shared-project" - unscoped_credentials = router.get_deployment_credentials_with_provider( - model_id="gemini-2.5-pro" - ) + unscoped_credentials = router.get_deployment_credentials_with_provider(model_id="gemini-2.5-pro") assert unscoped_credentials is not None assert unscoped_credentials["vertex_project"] == "shared-project" - owner_credentials = router.get_deployment_credentials_with_provider( - model_id="gemini-2.5-pro", team_id="team-b" - ) + owner_credentials = router.get_deployment_credentials_with_provider(model_id="gemini-2.5-pro", team_id="team-b") assert owner_credentials is not None assert owner_credentials["vertex_project"] == "team-b-project" @@ -4972,16 +4773,8 @@ def test_get_deployment_credentials_with_provider_no_fallback_to_other_team_only ], ) - assert ( - router.get_deployment_credentials_with_provider( - model_id="gemini-2.5-pro", team_id="team-a" - ) - is None - ) - assert ( - router.get_deployment_credentials_with_provider(model_id="gemini-2.5-pro") - is None - ) + assert router.get_deployment_credentials_with_provider(model_id="gemini-2.5-pro", team_id="team-a") is None + assert router.get_deployment_credentials_with_provider(model_id="gemini-2.5-pro") is None def test_deployment_usable_by_team_helpers(): @@ -5021,9 +4814,7 @@ def test_deployment_usable_by_team_helpers(): assert router._deployment_usable_by_team(shared, "team-a") is True assert router._deployment_usable_by_team(shared, None) is True - picked = router._get_model_group_deployment_usable_by_team( - model_group_name="gemini-2.5-pro", team_id="team-a" - ) + picked = router._get_model_group_deployment_usable_by_team(model_group_name="gemini-2.5-pro", team_id="team-a") assert picked is not None assert picked.litellm_params.vertex_project == "shared-project" @@ -5033,12 +4824,7 @@ def test_deployment_usable_by_team_helpers(): assert owner_picked is not None assert owner_picked.litellm_params.vertex_project == "team-b-project" - assert ( - router._get_model_group_deployment_usable_by_team( - model_group_name="unknown-model", team_id="team-a" - ) - is None - ) + assert router._get_model_group_deployment_usable_by_team(model_group_name="unknown-model", team_id="team-a") is None def test_get_deployment_credentials_with_provider_skips_other_team_wildcard(): @@ -5070,9 +4856,7 @@ def test_get_deployment_credentials_with_provider_skips_other_team_wildcard(): assert other_team_credentials is not None assert other_team_credentials["api_key"] == "global-key" - owner_credentials = router.get_deployment_credentials_with_provider( - model_id="openai/gpt-5.2", team_id="team-b" - ) + owner_credentials = router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-b") assert owner_credentials is not None assert owner_credentials["api_key"] == "team-b-key" @@ -5084,21 +4868,11 @@ def test_team_wildcard_credentials_not_usable_after_delete_deployment(): """ router = litellm.Router(model_list=[_team_wildcard_model(api_key="old-key")]) - assert ( - router.get_deployment_credentials_with_provider( - model_id="openai/gpt-5.2", team_id="team-1" - ) - is not None - ) + assert router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-1") is not None router.delete_deployment(id="team-wildcard-id") - assert ( - router.get_deployment_credentials_with_provider( - model_id="openai/gpt-5.2", team_id="team-1" - ) - is None - ) + assert router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-1") is None def test_global_wildcard_pattern_router_evicts_stale_entry_on_upsert_and_delete(): @@ -5171,22 +4945,13 @@ def test_team_wildcard_credentials_refreshed_on_upsert_and_set_model_list(): router = litellm.Router(model_list=[_team_wildcard_model(api_key="old-key")]) - router.upsert_deployment( - deployment=Deployment(**_team_wildcard_model(api_key="new-key")) - ) - credentials = router.get_deployment_credentials_with_provider( - model_id="openai/gpt-5.2", team_id="team-1" - ) + router.upsert_deployment(deployment=Deployment(**_team_wildcard_model(api_key="new-key"))) + credentials = router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-1") assert credentials is not None assert credentials["api_key"] == "new-key" router.set_model_list(model_list=[]) - assert ( - router.get_deployment_credentials_with_provider( - model_id="openai/gpt-5.2", team_id="team-1" - ) - is None - ) + assert router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-1") is None def test_get_available_guardrail_single_deployment(): @@ -5375,9 +5140,7 @@ async def test_anthropic_messages_call_type_is_cached(): startTime=1234567890.0, endTime=1234567891.0, completionStartTime=1234567890.5, - model_map_information=StandardLoggingModelInformation( - model_map_key="gpt-3.5-turbo", model_map_value=None - ), + model_map_information=StandardLoggingModelInformation(model_map_key="gpt-3.5-turbo", model_map_value=None), model="gpt-3.5-turbo", model_id="model-123", model_group="openai-gpt", @@ -5456,12 +5219,8 @@ async def test_anthropic_messages_call_type_is_cached(): ) # This assertion will FAIL if anthropic_messages is filtered out - assert ( - cached_result is not None - ), "Model ID should be cached for anthropic_messages call type" - assert ( - cached_result["model_id"] == test_model_id - ), f"Expected {test_model_id}, got {cached_result['model_id']}" + assert cached_result is not None, "Model ID should be cached for anthropic_messages call type" + assert cached_result["model_id"] == test_model_id, f"Expected {test_model_id}, got {cached_result['model_id']}" def test_update_kwargs_with_deployment_propagates_model_tags(): @@ -5486,9 +5245,7 @@ def test_update_kwargs_with_deployment_propagates_model_tags(): ) kwargs: dict = {"metadata": {}} - deployment = router.get_deployment_by_model_group_name( - model_group_name="gpt-4o-mini" - ) + deployment = router.get_deployment_by_model_group_name(model_group_name="gpt-4o-mini") router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) # Deployment tags should be propagated to kwargs metadata @@ -5517,9 +5274,7 @@ def test_update_kwargs_with_deployment_merges_tags_without_duplicates(): # Simulate request that already has tags (from request body or key/team level) kwargs: dict = {"metadata": {"tags": ["user-tag", "shared-tag"]}} - deployment = router.get_deployment_by_model_group_name( - model_group_name="gpt-4o-mini" - ) + deployment = router.get_deployment_by_model_group_name(model_group_name="gpt-4o-mini") router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) # Both sources should be merged, no duplicates @@ -5546,9 +5301,7 @@ def test_update_kwargs_with_deployment_no_tags(): ) kwargs: dict = {"metadata": {}} - deployment = router.get_deployment_by_model_group_name( - model_group_name="gpt-4o-mini" - ) + deployment = router.get_deployment_by_model_group_name(model_group_name="gpt-4o-mini") router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) # No tags key should be added if deployment has no tags @@ -5586,9 +5339,7 @@ def test_update_kwargs_with_deployment_merges_tools(): }, ], } - deployment = router.get_deployment_by_model_group_name( - model_group_name="o3-deep-research" - ) + deployment = router.get_deployment_by_model_group_name(model_group_name="o3-deep-research") router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) # Tools should be merged: deployment first, then request @@ -5619,9 +5370,7 @@ def test_update_kwargs_with_deployment_merge_tools_deployment_only(): ) kwargs: dict = {"metadata": {}} - deployment = router.get_deployment_by_model_group_name( - model_group_name="o3-deep-research" - ) + deployment = router.get_deployment_by_model_group_name(model_group_name="o3-deep-research") router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) assert kwargs["tools"] == [{"type": "web_search"}] @@ -5650,9 +5399,7 @@ def test_update_kwargs_with_deployment_merge_tools_request_overrides_tool_choice "metadata": {}, "tool_choice": "none", } - deployment = router.get_deployment_by_model_group_name( - model_group_name="o3-deep-research" - ) + deployment = router.get_deployment_by_model_group_name(model_group_name="o3-deep-research") router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) # Request tool_choice should be preserved (merged tools still applied) @@ -5754,12 +5501,8 @@ def test_update_kwargs_with_deployment_model_info_in_litellm_metadata(): ) kwargs: dict = {} - deployment = router.get_deployment_by_model_group_name( - model_group_name="claude-sonnet-4" - ) - router._update_kwargs_with_deployment( - deployment=deployment, kwargs=kwargs, function_name="generic_api_call" - ) + deployment = router.get_deployment_by_model_group_name(model_group_name="claude-sonnet-4") + router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name="generic_api_call") assert "litellm_metadata" in kwargs model_info = kwargs["litellm_metadata"]["model_info"] @@ -5791,12 +5534,8 @@ def test_update_kwargs_with_deployment_model_info_in_metadata(): ) kwargs: dict = {} - deployment = router.get_deployment_by_model_group_name( - model_group_name="claude-sonnet-4" - ) - router._update_kwargs_with_deployment( - deployment=deployment, kwargs=kwargs, function_name=None - ) + deployment = router.get_deployment_by_model_group_name(model_group_name="claude-sonnet-4") + router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name=None) assert "metadata" in kwargs model_info = kwargs["metadata"]["model_info"] @@ -5909,6 +5648,7 @@ async def test_acompletion_streaming_iterator_does_not_log_success_on_terminal_f initial_kwargs=dict(initial_kwargs), ) collected = [] + async def _drain(): async for chunk in result: collected.append(chunk) @@ -5935,6 +5675,7 @@ async def test_acompletion_streaming_iterator_does_not_log_success_on_terminal_f initial_kwargs=dict(initial_kwargs), ) collected = [] + async def _drain(): async for chunk in result: collected.append(chunk) @@ -6151,23 +5892,17 @@ def test_multiregion_team_deployments_unique_model_names(): assert len(deployments) == 0 # With team_id: O(n) scan finds BOTH regional deployments - deployments = router._get_all_deployments( - model_name="claude-sonnet", team_id="metis-team" - ) + deployments = router._get_all_deployments(model_name="claude-sonnet", team_id="metis-team") assert len(deployments) == 2 deployment_names = {d["model_name"] for d in deployments} assert deployment_names == {"metis-claude-us-east-1", "metis-claude-us-west-2"} # Each deployment has a unique ID (critical for cooldown/retry to work) deployment_ids = {d["model_info"]["id"] for d in deployments} - assert ( - len(deployment_ids) == 2 - ), "Each deployment must have a unique ID for cooldown tracking" + assert len(deployment_ids) == 2, "Each deployment must have a unique ID for cooldown tracking" # Wrong team: returns nothing - deployments = router._get_all_deployments( - model_name="claude-sonnet", team_id="other-team" - ) + deployments = router._get_all_deployments(model_name="claude-sonnet", team_id="other-team") assert len(deployments) == 0 @@ -6212,12 +5947,8 @@ async def test_multiregion_team_failover_between_regions(): ) # Verify the router finds both deployments for the team - deployments = router._get_all_deployments( - model_name="claude-sonnet", team_id="metis-team" - ) - assert ( - len(deployments) == 2 - ), "Router must find both regional deployments by team_public_model_name" + deployments = router._get_all_deployments(model_name="claude-sonnet", team_id="metis-team") + assert len(deployments) == 2, "Router must find both regional deployments by team_public_model_name" # Make a normal request — should succeed from one of the regions response = await router.acompletion( @@ -6342,9 +6073,7 @@ def test_explicit_model_access_does_not_force_access_group_filtering(): }, ) - deployment_groups = [ - d.get("model_info", {}).get("access_groups") for d in deployments - ] + deployment_groups = [d.get("model_info", {}).get("access_groups") for d in deployments] assert ["AG1"] in deployment_groups assert ["AG2"] in deployment_groups @@ -6389,9 +6118,7 @@ def test_access_group_filter_empty_does_not_bypass_via_litellm_model_fallback( orig_groups = router.get_model_access_groups - def fake_get_model_access_groups( - model_name=None, model_access_group=None, team_id=None - ): + def fake_get_model_access_groups(model_name=None, model_access_group=None, team_id=None): if model_name == "gpt-5" and model_access_group is None: return {"AG1": ["gpt-5"], "AG2": ["gpt-5"]} return orig_groups( @@ -6466,9 +6193,7 @@ def test_access_group_block_does_not_silently_use_default_fallback_model( orig_groups = router.get_model_access_groups - def fake_get_model_access_groups( - model_name=None, model_access_group=None, team_id=None - ): + def fake_get_model_access_groups(model_name=None, model_access_group=None, team_id=None): if model_name == "gpt-5" and model_access_group is None: return {"AG1": ["gpt-5"], "AG2": ["gpt-5"]} return orig_groups( @@ -6535,9 +6260,7 @@ def test_access_group_block_via_litellm_model_branch_does_not_use_default_fallba orig_groups = router.get_model_access_groups - def fake_get_model_access_groups( - model_name=None, model_access_group=None, team_id=None - ): + def fake_get_model_access_groups(model_name=None, model_access_group=None, team_id=None): if model_name == "gpt-5" and model_access_group is None: return {"AG1": ["gpt-5"], "AG2": ["gpt-5"]} return orig_groups( @@ -6592,9 +6315,7 @@ def test_try_early_resolve_deployments_for_model_not_in_names(): ) assert ( - router_in_names._try_early_resolve_deployments_for_model_not_in_names( - model="gpt-5", request_team_id=None - ) + router_in_names._try_early_resolve_deployments_for_model_not_in_names(model="gpt-5", request_team_id=None) is None ) assert ( @@ -6616,10 +6337,8 @@ def test_try_early_resolve_deployments_for_model_not_in_names(): ] ) - pattern_result = ( - pattern_router._try_early_resolve_deployments_for_model_not_in_names( - model="openai/gpt-4o-mini", request_team_id=None - ) + pattern_result = pattern_router._try_early_resolve_deployments_for_model_not_in_names( + model="openai/gpt-4o-mini", request_team_id=None ) assert pattern_result is not None resolved_model, pattern_deployments = pattern_result @@ -6645,10 +6364,8 @@ def test_try_early_resolve_deployments_for_model_not_in_names(): }, } - default_result = ( - default_router._try_early_resolve_deployments_for_model_not_in_names( - model="brand-new-model", request_team_id=None - ) + default_result = default_router._try_early_resolve_deployments_for_model_not_in_names( + model="brand-new-model", request_team_id=None ) assert default_result is not None resolved_model, default_deployment = default_result @@ -6656,10 +6373,7 @@ def test_try_early_resolve_deployments_for_model_not_in_names(): assert isinstance(default_deployment, dict) assert default_deployment["litellm_params"]["model"] == "brand-new-model" # The original default_deployment must not be mutated. - assert ( - default_router.default_deployment["litellm_params"]["model"] - == "openai/will-be-overridden" - ) + assert default_router.default_deployment["litellm_params"]["model"] == "openai/will-be-overridden" def _router_with_two_deployments(blocked_flags): @@ -6707,10 +6421,7 @@ def _seed_unhealthy_states(router, unhealthy_ids, timestamp=None): ts = timestamp if timestamp is not None else time.time() router.health_state_cache.set_deployment_health_states( - { - uid: {"is_healthy": False, "timestamp": ts, "reason": "test_unhealthy"} - for uid in unhealthy_ids - } + {uid: {"is_healthy": False, "timestamp": ts, "reason": "test_unhealthy"} for uid in unhealthy_ids} ) @@ -6781,9 +6492,7 @@ async def test_async_get_fully_unhealthy_model_names_noop_with_allowed_fails_pol @pytest.mark.asyncio async def test_async_get_healthy_deployments_skips_blocked_deployment(): router = _router_with_two_deployments([True, False]) - healthy, all_dep = await router._async_get_healthy_deployments( - model="gpt-4o", parent_otel_span=None - ) + healthy, all_dep = await router._async_get_healthy_deployments(model="gpt-4o", parent_otel_span=None) healthy_ids = [d["model_info"]["id"] for d in healthy] assert "dep-0" not in healthy_ids assert "dep-1" in healthy_ids @@ -6792,9 +6501,7 @@ async def test_async_get_healthy_deployments_skips_blocked_deployment(): def test_get_healthy_deployments_sync_skips_blocked_deployment(): router = _router_with_two_deployments([False, True]) - healthy, all_dep = router._get_healthy_deployments( - model="gpt-4o", parent_otel_span=None - ) + healthy, all_dep = router._get_healthy_deployments(model="gpt-4o", parent_otel_span=None) healthy_ids = [d["model_info"]["id"] for d in healthy] assert "dep-0" in healthy_ids assert "dep-1" not in healthy_ids @@ -6811,9 +6518,7 @@ def test_filter_blocked_deployments_drops_blocked_keeps_unblocked(): @pytest.mark.asyncio async def test_public_async_get_healthy_deployments_skips_blocked_on_primary_path(): router = _router_with_two_deployments([True, False]) - deployments = await router.async_get_healthy_deployments( - model="gpt-4o", request_kwargs={} - ) + deployments = await router.async_get_healthy_deployments(model="gpt-4o", request_kwargs={}) assert isinstance(deployments, list) ids = [d["model_info"]["id"] for d in deployments] assert "dep-0" not in ids @@ -6855,9 +6560,7 @@ def _router_with_two_pass_through_deployments(blocked_flags): def test_get_available_deployment_for_pass_through_skips_blocked(): router = _router_with_two_pass_through_deployments([True, False]) - deployment = router.get_available_deployment_for_pass_through( - model="gpt-4o", request_kwargs={} - ) + deployment = router.get_available_deployment_for_pass_through(model="gpt-4o", request_kwargs={}) assert deployment["model_info"]["id"] == "pt-1" @@ -6866,9 +6569,7 @@ def test_get_available_deployment_for_pass_through_raises_when_dict_blocked(): router = _router_with_two_pass_through_deployments([True, True]) with pytest.raises(litellm.ServiceUnavailableError): - router.get_available_deployment_for_pass_through( - model="pt-0", request_kwargs={} - ) + router.get_available_deployment_for_pass_through(model="pt-0", request_kwargs={}) def test_initialize_deployment_for_pass_through_keeps_bedrock_iam_deployment(): @@ -6892,9 +6593,7 @@ def test_initialize_deployment_for_pass_through_keeps_bedrock_iam_deployment(): } ] ) - assert [m["model_info"]["id"] for m in router.get_model_list()] == [ - "bedrock-iam-pt" - ] + assert [m["model_info"]["id"] for m in router.get_model_list()] == ["bedrock-iam-pt"] def test_pass_through_deployment_api_key_resolves_via_get_credentials(): @@ -6905,12 +6604,7 @@ def test_pass_through_deployment_api_key_resolves_via_get_credentials(): router = _router_with_two_pass_through_deployments([False, False]) passthrough_router = PassthroughEndpointRouter(llm_router_getter=lambda: router) assert len(router.get_model_list()) == 2 - assert ( - passthrough_router.get_credentials( - custom_llm_provider="openai", region_name=None - ) - == "sk-fake-for-tests" - ) + assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-fake-for-tests" def test_get_deployment_credentials_returns_none_for_blocked_deployment(): @@ -6944,16 +6638,9 @@ def test_is_deployment_blocked_static_helper_reflects_blocked_flag(): # No model_info on deployment object → treated as not blocked assert litellm.Router._is_deployment_blocked(object()) is False missing_blocked = types.SimpleNamespace() + assert litellm.Router._is_deployment_blocked(types.SimpleNamespace(model_info=missing_blocked)) is False assert ( - litellm.Router._is_deployment_blocked( - types.SimpleNamespace(model_info=missing_blocked) - ) - is False - ) - assert ( - litellm.Router._is_deployment_blocked( - types.SimpleNamespace(model_info=types.SimpleNamespace(blocked=True)) - ) + litellm.Router._is_deployment_blocked(types.SimpleNamespace(model_info=types.SimpleNamespace(blocked=True))) is True ) @@ -6993,9 +6680,7 @@ class TestRouterRequestTimeoutPropagation: litellm.request_timeout = original_value litellm.request_timeout_explicitly_set = original_flag - def test_request_timeout_stored_independently_when_both_set( - self, explicit_request_timeout - ): + def test_request_timeout_stored_independently_when_both_set(self, explicit_request_timeout): router = self._make_router(timeout=330) assert router.timeout == 330 assert router.request_timeout == 300 @@ -7013,22 +6698,16 @@ class TestRouterRequestTimeoutPropagation: litellm.request_timeout = original_value litellm.request_timeout_explicitly_set = original_flag - def test_non_stream_prefers_request_timeout_over_router_timeout( - self, explicit_request_timeout - ): + def test_non_stream_prefers_request_timeout_over_router_timeout(self, explicit_request_timeout): router = self._make_router(timeout=330) assert router._get_non_stream_timeout(kwargs={}, data={}) == 300 - def test_stream_prefers_request_timeout_over_router_timeout( - self, explicit_request_timeout - ): + def test_stream_prefers_request_timeout_over_router_timeout(self, explicit_request_timeout): router = self._make_router(timeout=330) # stream=True resolves through _get_stream_timeout; request_timeout must win. assert router._get_timeout(kwargs={"stream": True}, data={}) == 300 - def test_explicit_stream_timeout_still_wins_over_request_timeout( - self, explicit_request_timeout - ): + def test_explicit_stream_timeout_still_wins_over_request_timeout(self, explicit_request_timeout): router = self._make_router(timeout=330, stream_timeout=45) assert router._get_stream_timeout(kwargs={}, data={}) == 45 @@ -7044,22 +6723,13 @@ class TestRouterRequestTimeoutPropagation: litellm.request_timeout = original_value litellm.request_timeout_explicitly_set = original_flag - def test_per_deployment_timeout_overrides_request_timeout( - self, explicit_request_timeout - ): + def test_per_deployment_timeout_overrides_request_timeout(self, explicit_request_timeout): router = self._make_router(timeout=330) assert router._get_non_stream_timeout(kwargs={}, data={"timeout": 120}) == 120 - def test_per_request_timeout_overrides_request_timeout( - self, explicit_request_timeout - ): + def test_per_request_timeout_overrides_request_timeout(self, explicit_request_timeout): router = self._make_router(timeout=330) - assert ( - router._get_non_stream_timeout( - kwargs={"timeout": 60}, data={"timeout": 120} - ) - == 60 - ) + assert router._get_non_stream_timeout(kwargs={"timeout": 60}, data={"timeout": 120}) == 60 # --------------------------------------------------------------------------- @@ -7368,9 +7038,7 @@ class TestAdvisorSubCallCooldown: ) def _cooled_down_ids(self, router): - active = router.cooldown_cache.get_active_cooldowns( - model_ids=["dep-1"], parent_otel_span=None - ) + active = router.cooldown_cache.get_active_cooldowns(model_ids=["dep-1"], parent_otel_span=None) return [entry[0] for entry in active] @pytest.mark.asyncio @@ -7379,12 +7047,7 @@ class TestAdvisorSubCallCooldown: router = self._router() now = datetime.now() - assert ( - router.deployment_callback_on_failure( - self._kwargs(self._auth_error()), None, now, now - ) - is True - ) + assert router.deployment_callback_on_failure(self._kwargs(self._auth_error()), None, now, now) is True assert "dep-1" in self._cooled_down_ids(router) def test_advisor_orchestration_failure_does_not_cool_down_deployment(self): @@ -7399,12 +7062,7 @@ class TestAdvisorSubCallCooldown: mark_advisor_orchestration_failure(exception) now = datetime.now() - assert ( - router.deployment_callback_on_failure( - self._kwargs(exception), None, now, now - ) - is False - ) + assert router.deployment_callback_on_failure(self._kwargs(exception), None, now, now) is False assert "dep-1" not in self._cooled_down_ids(router) @@ -7461,13 +7119,13 @@ def test_stream_chunks_have_generated_content_detects_text_and_non_text(): audio_chunk = _chunk(audio_delta) assert _stream_chunks_have_generated_content([audio_chunk]) is True - images_delta = Delta(images=[{"image_url": {"url": "https://example.com/img.png"}, "index": 0, "type": "image_url"}]) + images_delta = Delta( + images=[{"image_url": {"url": "https://example.com/img.png"}, "index": 0, "type": "image_url"}] + ) images_chunk = _chunk(images_delta) assert _stream_chunks_have_generated_content([images_chunk]) is True - annotations_delta = Delta( - annotations=[{"type": "url_citation", "url_citation": {"url": "https://example.com"}}] - ) + annotations_delta = Delta(annotations=[{"type": "url_citation", "url_citation": {"url": "https://example.com"}}]) annotations_chunk = _chunk(annotations_delta) assert _stream_chunks_have_generated_content([annotations_chunk]) is True @@ -7511,12 +7169,8 @@ def test_get_configured_token_limits_skips_wildcard_pattern_matching(): ] ) - with patch.object( - router.pattern_router, "route", side_effect=AssertionError("pattern route called") - ): - assert router.get_configured_token_limits( - "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0" - ) == (None, None) + with patch.object(router.pattern_router, "route", side_effect=AssertionError("pattern route called")): + assert router.get_configured_token_limits("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") == (None, None) def test_get_configured_token_limits_treats_malformed_values_as_absent(): @@ -7589,13 +7243,8 @@ def test_get_configured_mode_skips_wildcard_pattern_matching(): ] ) - with patch.object( - router.pattern_router, "route", side_effect=AssertionError("pattern route called") - ): - assert ( - router.get_configured_mode("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") - is None - ) + with patch.object(router.pattern_router, "route", side_effect=AssertionError("pattern route called")): + assert router.get_configured_mode("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") is None def test_get_configured_mode_treats_malformed_values_as_absent(): @@ -7654,13 +7303,8 @@ def test_get_configured_display_name_skips_wildcard_pattern_matching(): ] ) - with patch.object( - router.pattern_router, "route", side_effect=AssertionError("pattern route called") - ): - assert ( - router.get_configured_display_name("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") - is None - ) + with patch.object(router.pattern_router, "route", side_effect=AssertionError("pattern route called")): + assert router.get_configured_display_name("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") is None def test_get_configured_display_name_treats_malformed_values_as_absent(): @@ -7893,13 +7537,16 @@ async def test_acreate_batch_request_bedrock_tags_override_deployment_tags(): mock_client = MagicMock() mock_client.post = AsyncMock(side_effect=lambda *args, **kwargs: fake_response()) - with patch.object( - CommonBatchFilesUtils, - "sign_aws_request", - return_value=({"Authorization": "signed"}, b"{}"), - ) as mock_sign, patch( - "litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client", - return_value=mock_client, + with ( + patch.object( + CommonBatchFilesUtils, + "sign_aws_request", + return_value=({"Authorization": "signed"}, b"{}"), + ) as mock_sign, + patch( + "litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client", + return_value=mock_client, + ), ): await router.acreate_batch( model="bedrock-batch-model", @@ -7928,13 +7575,13 @@ async def test_avector_store_search_injects_router(): """ from litellm.types.vector_stores import VectorStoreSearchResponse - expected_response = VectorStoreSearchResponse( - object="vector_store.search_results.page", search_query="q", data=[] - ) + expected_response = VectorStoreSearchResponse(object="vector_store.search_results.page", search_query="q", data=[]) mock_asearch = AsyncMock(return_value=expected_response) # Router.__init__ binds asearch via a local import, so patch the module # attribute before constructing the Router. - with patch("litellm.vector_stores.main.asearch", new=mock_asearch): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable + with patch( + "litellm.vector_stores.main.asearch", new=mock_asearch + ): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable router = litellm.Router( model_list=[ { @@ -7968,7 +7615,9 @@ async def test_avector_store_create_does_not_inject_router(): } ] ) - with patch("litellm.vector_stores.main.acreate", new=mock_acreate): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface + with patch( + "litellm.vector_stores.main.acreate", new=mock_acreate + ): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface create_response = await router.avector_store_create(model=None, custom_llm_provider="openai") assert create_response is expected_response @@ -7984,13 +7633,13 @@ def test_vector_store_search_injects_router(): """ from litellm.types.vector_stores import VectorStoreSearchResponse - expected_response = VectorStoreSearchResponse( - object="vector_store.search_results.page", search_query="q", data=[] - ) + expected_response = VectorStoreSearchResponse(object="vector_store.search_results.page", search_query="q", data=[]) mock_search = MagicMock(return_value=expected_response) # Router.__init__ binds search via a local import, so patch the module # attribute before constructing the Router. - with patch("litellm.vector_stores.main.search", new=mock_search): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable + with patch( + "litellm.vector_stores.main.search", new=mock_search + ): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable router = litellm.Router( model_list=[ { @@ -7999,9 +7648,7 @@ def test_vector_store_search_injects_router(): } ] ) - search_response = router.vector_store_search( - vector_store_id="v", query="q", custom_llm_provider="s3_vectors" - ) + search_response = router.vector_store_search(vector_store_id="v", query="q", custom_llm_provider="s3_vectors") assert search_response is expected_response mock_search.assert_called_once() @@ -8015,7 +7662,9 @@ def test_vector_store_create_does_not_inject_router(): mock_create = MagicMock(return_value=expected_response) # Router.__init__ binds create via a local import, so patch the module # attribute before constructing the Router. - with patch("litellm.vector_stores.main.create", new=mock_create): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface + with patch( + "litellm.vector_stores.main.create", new=mock_create + ): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface router = litellm.Router( model_list=[ { @@ -8050,9 +7699,7 @@ class TestPreRoutingStrategyRegistryLifecycle: def _complexity_router_params(default_model: str, tags=None) -> dict: return { "model": "auto_router/complexity_router", - "complexity_router_config": { - "tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o"} - }, + "complexity_router_config": {"tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o"}}, "complexity_router_default_model": default_model, **({"tags": tags} if tags else {}), } @@ -8357,9 +8004,7 @@ class TestPreRoutingStrategyRegistryLifecycle: deployment=Deployment( model_name="hybrid-router", litellm_params=LiteLLM_Params( - **self._hybrid_router_params( - {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o"} - ) + **self._hybrid_router_params({"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o"}) ), model_info=ModelInfo(id="router-1", db_model=True), ) @@ -8468,9 +8113,7 @@ class TestPreRoutingStrategyRegistryLifecycle: ({"model": "openai/gpt-4o"}, False), ] for params, expected in cases: - actual = router._deployment_participates_in_adaptive_routing( - litellm_params=LiteLLM_Params(**params) - ) + actual = router._deployment_participates_in_adaptive_routing(litellm_params=LiteLLM_Params(**params)) assert actual is expected, params["model"] @@ -8657,22 +8300,16 @@ class TestUpsertDeploymentRollback: router.delete_deployment(id="prod-1") assert router.has_model_id("prod-1") is False - router._restore_deployment_after_failed_upsert( - previous_deployment=previous, model_id="prod-1" - ) + router._restore_deployment_after_failed_upsert(previous_deployment=previous, model_id="prod-1") restored = router.get_deployment(model_id="prod-1") assert restored is not None assert restored.litellm_params.model == "gpt-4o" - router._restore_deployment_after_failed_upsert( - previous_deployment=previous, model_id="prod-1" - ) + router._restore_deployment_after_failed_upsert(previous_deployment=previous, model_id="prod-1") assert len(router.model_list) == 1 - router._restore_deployment_after_failed_upsert( - previous_deployment=None, model_id="prod-1" - ) + router._restore_deployment_after_failed_upsert(previous_deployment=None, model_id="prod-1") assert len(router.model_list) == 1 @@ -9438,18 +9075,14 @@ class TestAutoRouterSharedModelNameConnectionParams: return httpx.Response( status_code=200, json={ - "candidates": [ - {"content": {"parts": [{"text": "Paris"}], "role": "model"}, "finishReason": "STOP"} - ], + "candidates": [{"content": {"parts": [{"text": "Paris"}], "role": "model"}, "finishReason": "STOP"}], "usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 1, "totalTokenCount": 6}, "modelVersion": "gemini-3.6-flash", }, request=httpx.Request("POST", "https://generativelanguage.googleapis.com"), ) - @pytest.mark.parametrize( - "plain_entry_first", [True, False], ids=["plain_entry_first", "marker_entry_first"] - ) + @pytest.mark.parametrize("plain_entry_first", [True, False], ids=["plain_entry_first", "marker_entry_first"]) async def test_routed_tier_call_goes_out_on_its_own_endpoint_and_credentials(self, plain_entry_first): """The outbound provider request for the routed tier hits the tier's own Gemini host with the tier's own key, never the plain sibling's api_base or api_key.""" @@ -9572,9 +9205,7 @@ async def _drive_cyclic_fallback(router, capture, recorder=None, **request_kwarg litellm.callbacks.append(recorder) try: with pytest.raises(litellm.InternalServerError): - await router.acompletion( - model="group-a", messages=[{"role": "user", "content": "hi"}], **request_kwargs - ) + await router.acompletion(model="group-a", messages=[{"role": "user", "content": "hi"}], **request_kwargs) finally: router_logger.removeHandler(capture) router_logger.setLevel(previous_level) @@ -9613,9 +9244,9 @@ async def test_retry_breadcrumbs_do_not_carry_the_walk_state(): breadcrumbs = [breadcrumb for hop in recorder.breadcrumbs_per_target for breadcrumb in hop] assert breadcrumbs, "no retry breadcrumbs were recorded" - assert any( - "fallback_depth" in breadcrumb for breadcrumb in breadcrumbs - ), "no breadcrumb carried router walk state, so this test cannot see the leak" + assert any("fallback_depth" in breadcrumb for breadcrumb in breadcrumbs), ( + "no breadcrumb carried router walk state, so this test cannot see the leak" + ) for breadcrumb in breadcrumbs: assert "attempted_targets" not in breadcrumb @@ -9661,7 +9292,9 @@ async def test_retry_breadcrumbs_never_carry_a_forwarded_credential(container_ke breadcrumbs = metadata["previous_models"] assert breadcrumbs, "no retry breadcrumbs were recorded" dumped = json.dumps(breadcrumbs, default=str) - assert container_key in dumped, "the credential-bearing kwarg never reached the breadcrumb, so this test cannot see the leak" + assert container_key in dumped, ( + "the credential-bearing kwarg never reached the breadcrumb, so this test cannot see the leak" + ) assert _BREADCRUMB_CREDENTIAL_CANARY not in dumped @@ -9766,9 +9399,7 @@ async def test_fallback_failure_detail_from_upstream_is_bounded(): await _drive_cyclic_fallback( _cyclic_fallback_router(), capture, - mock_response=litellm.InternalServerError( - message=huge_message, llm_provider="openai", model="group-a" - ), + mock_response=litellm.InternalServerError(message=huge_message, llm_provider="openai", model="group-a"), ) assert capture.messages, "the fallback failure path did not log at ERROR" @@ -9834,9 +9465,7 @@ def test_ensure_deployment_affinity_callback_is_idempotent(): try: router._ensure_deployment_affinity_callback() router._ensure_deployment_affinity_callback() - affinity_callbacks = [ - cb for cb in router.optional_callbacks or [] if isinstance(cb, DeploymentAffinityCheck) - ] + affinity_callbacks = [cb for cb in router.optional_callbacks or [] if isinstance(cb, DeploymentAffinityCheck)] assert len(affinity_callbacks) == 1 finally: for cb in router.optional_callbacks or []: @@ -9978,9 +9607,7 @@ class TestModelGroupAliasReachesPreRoutingStrategies: router = self._router("auto_routers") metadata: dict = {} - response = await router.acompletion( - model="smart-alias", messages=self._messages(), metadata=metadata - ) + response = await router.acompletion(model="smart-alias", messages=self._messages(), metadata=metadata) assert response.choices[0].message.content == "routed by the tier" assert metadata["model_group"] == "smart-alias" @@ -10024,9 +9651,7 @@ class TestAutoRouterCompressionDecoupling: async def apply_guardrail(self, inputs, request_data, input_type, logging_obj=None): self.call_count += 1 structured_messages = inputs.get("structured_messages") or [] - compressed = [ - {**m, "content": f"[COMPRESSED] {m.get('content')}"} for m in structured_messages - ] + compressed = [{**m, "content": f"[COMPRESSED] {m.get('content')}"} for m in structured_messages] return {**inputs, "structured_messages": compressed} @staticmethod @@ -10105,9 +9730,7 @@ class TestAutoRouterCompressionDecoupling: assert registered_guardrail.call_count == 0 @pytest.mark.asyncio - async def test_routing_none_classifies_on_the_live_messages_not_a_pre_guardrail_copy( - self, registered_guardrail - ): + async def test_routing_none_classifies_on_the_live_messages_not_a_pre_guardrail_copy(self, registered_guardrail): """Routing asked for no compression while the model hop compressed, so the only messages left are that guardrail's output and the strategy classifies on them. @@ -10131,11 +9754,37 @@ class TestAutoRouterCompressionDecoupling: assert registered_guardrail.call_count == 0 @pytest.mark.asyncio + @pytest.mark.asyncio + async def test_same_compression_still_compresses_routing_when_nothing_armed_it(self, registered_guardrail): + """Regression: only the proxy calls arm_pre_call. Used through the SDK, nothing + arms the model-side guardrail and nothing has compressed anything, so reusing a + model-hop result that was never produced would serve the request with no + compression on either hop, silently ignoring the configuration.""" + from litellm.proxy.guardrails import auto_router_compression + + router, strategy = self._router( + { + "auto_router_routing_compression": "fake-compress", + "auto_router_model_compression": "fake-compress", + } + ) + uncompressed = self._messages() + assert auto_router_compression.model_hop_compression_armed() is False + + await router.async_pre_routing_hook( + model="smart-router", request_kwargs={"metadata": {}}, messages=uncompressed + ) + + assert strategy.received_messages != uncompressed + assert registered_guardrail.call_count == 1 + async def test_same_compression_on_both_hops_compresses_once(self, registered_guardrail): """The same/different distinction exists so a shared choice does not pay for compression twice: by the time the router runs, `messages` already reflects whatever the ordinary pre-call guardrail pipeline did for the model call, so the routing decision must reuse it rather than calling the guardrail again.""" + from litellm.proxy.guardrails import auto_router_compression + router, strategy = self._router( { "auto_router_routing_compression": "fake-compress", @@ -10145,13 +9794,17 @@ class TestAutoRouterCompressionDecoupling: # Stands in for what the proxy's ordinary pre-call guardrail pipeline would # have already produced for the model call, since `auto_router_model_compression` # names a guardrail: the router never triggers that pipeline itself. - already_compressed_messages = [ - {"role": "user", "content": "[COMPRESSED] What is the capital of France?"} - ] + already_compressed_messages = [{"role": "user", "content": "[COMPRESSED] What is the capital of France?"}] + # arm_pre_call is what would have armed that guardrail, and only the proxy calls + # it; the reuse below is conditional on it having run. + armed = auto_router_compression._model_hop_armed.set(True) - response = await router.async_pre_routing_hook( - model="smart-router", request_kwargs={"metadata": {}}, messages=already_compressed_messages - ) + try: + response = await router.async_pre_routing_hook( + model="smart-router", request_kwargs={"metadata": {}}, messages=already_compressed_messages + ) + finally: + auto_router_compression._model_hop_armed.reset(armed) assert strategy.received_messages == already_compressed_messages assert response.messages == already_compressed_messages @@ -10197,17 +9850,14 @@ class TestAzureBaseModelFallbackLogging: def test_map_known_deployment_name_resolves_without_error_log(self): router = self._router_with_azure_deployment("azure/gpt-4o") - with patch( - "litellm.router.verbose_router_logger.error" - ) as mock_error: + with patch("litellm.router.verbose_router_logger.error") as mock_error: model_info = router.get_router_model_info( deployment=None, received_model_name="my-group", id="azure-base-model-test-id" ) - assert not any( - "Could not identify azure model" in str(call) - for call in mock_error.call_args_list - ), f"unexpected error log: {mock_error.call_args_list}" + assert not any("Could not identify azure model" in str(call) for call in mock_error.call_args_list), ( + f"unexpected error log: {mock_error.call_args_list}" + ) # the fallback resolution must actually surface the map values assert model_info["max_input_tokens"] == litellm.model_cost["azure/gpt-4o"]["max_input_tokens"] assert model_info["input_cost_per_token"] == litellm.model_cost["azure/gpt-4o"]["input_cost_per_token"] @@ -10215,17 +9865,14 @@ class TestAzureBaseModelFallbackLogging: def test_unmappable_deployment_name_still_logs_error(self): router = self._router_with_azure_deployment("azure/my-custom-deployment-name") - with patch( - "litellm.router.verbose_router_logger.error" - ) as mock_error: + with patch("litellm.router.verbose_router_logger.error") as mock_error: model_info = router.get_router_model_info( deployment=None, received_model_name="my-group", id="azure-base-model-test-id" ) - assert any( - "Could not identify azure model" in str(call) - for call in mock_error.call_args_list - ), "expected the error log for an unmappable azure deployment name" + assert any("Could not identify azure model" in str(call) for call in mock_error.call_args_list), ( + "expected the error log for an unmappable azure deployment name" + ) # unmappable names resolve to a zeroed stub — unchanged behavior assert model_info.get("max_input_tokens") is None @@ -10252,6 +9899,7 @@ class TestAzureBaseModelFallbackLogging: ) assert model_info["max_input_tokens"] == litellm.model_cost["azure/gpt-4o-mini"]["max_input_tokens"] + def test_model_group_info_intersects_supported_reasoning_efforts(): router = litellm.Router( model_list=[ @@ -10342,7 +9990,6 @@ def test_model_group_info_reasoning_efforts_are_unknown_when_any_deployment_is_o assert result.supported_reasoning_efforts is None - def test_model_group_info_surfaces_supports_parallel_function_calling(local_model_cost_map): """``/model_group/info`` folds each deployment's registry flags into the group; a deployment whose registry entry declares parallel function calling must flip the group to True instead of False.""" @@ -10575,6 +10222,7 @@ class TestAddDeploymentApiBaseProviderResolution: assert deployment is not None assert deployment.litellm_params.custom_llm_provider == "openai" + # ===================================================================== # anthropic_messages mid-stream-fallback helpers, added for #24004 # (mid-stream fallback not supported for anthropic_messages route type). @@ -10685,10 +10333,7 @@ class _AnthropicMessagesFallbackByteStream: def _anthropic_messages_overloaded_error_chunk() -> bytes: - return ( - b"event: error\n" - b'data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}\n\n' - ) + return b'event: error\ndata: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}\n\n' def _anthropic_messages_invalid_request_error_chunk() -> bytes: @@ -10733,9 +10378,7 @@ async def test_anthropic_messages_streaming_iterator_passthrough(): [_anthropic_messages_content_chunk("hi"), _anthropic_messages_content_chunk(" there")] ) - wrapped = await router._aanthropic_messages_streaming_iterator( - response=source, initial_kwargs={"model": "primary"} - ) + wrapped = await router._aanthropic_messages_streaming_iterator(response=source, initial_kwargs={"model": "primary"}) collected = [chunk async for chunk in wrapped] assert collected == [_anthropic_messages_content_chunk("hi"), _anthropic_messages_content_chunk(" there")] @@ -10754,12 +10397,14 @@ async def test_anthropic_messages_streaming_iterator_flushes_buffered_lifecycle_ [_anthropic_messages_message_start_chunk(), _anthropic_messages_content_chunk("hi"), message_stop] ) - wrapped = await router._aanthropic_messages_streaming_iterator( - response=source, initial_kwargs={"model": "primary"} - ) + wrapped = await router._aanthropic_messages_streaming_iterator(response=source, initial_kwargs={"model": "primary"}) collected = [chunk async for chunk in wrapped] - assert collected == [_anthropic_messages_message_start_chunk(), _anthropic_messages_content_chunk("hi"), message_stop] + assert collected == [ + _anthropic_messages_message_start_chunk(), + _anthropic_messages_content_chunk("hi"), + message_stop, + ] @pytest.mark.asyncio @@ -10771,9 +10416,7 @@ async def test_anthropic_messages_streaming_iterator_flushes_buffered_frames_on_ message_stop = b'event: message_stop\ndata: {"type": "message_stop"}\n\n' source = _AnthropicMessagesFakeByteStream([_anthropic_messages_message_start_chunk(), message_stop]) - wrapped = await router._aanthropic_messages_streaming_iterator( - response=source, initial_kwargs={"model": "primary"} - ) + wrapped = await router._aanthropic_messages_streaming_iterator(response=source, initial_kwargs={"model": "primary"}) collected = [chunk async for chunk in wrapped] assert collected == [_anthropic_messages_message_start_chunk(), message_stop] @@ -10844,7 +10487,9 @@ async def test_anthropic_messages_leading_ping_keepalive_is_forwarded_live(): 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"}) + 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() @@ -12645,9 +12290,9 @@ class TestTierParamsTheTargetAccepts: def test_declared_param_allowlist_ignores_malformed_declarations(self): """A str is iterable, so without the type guard a YAML scalar mistake like allowed_openai_params: reasoning_effort would allowlist single characters.""" - assert litellm.Router._declared_param_allowlist({"allowed_openai_params": ["reasoning_effort", 3]}) == frozenset( - {"reasoning_effort"} - ) + assert litellm.Router._declared_param_allowlist( + {"allowed_openai_params": ["reasoning_effort", 3]} + ) == frozenset({"reasoning_effort"}) assert litellm.Router._declared_param_allowlist({"allowed_openai_params": "reasoning_effort"}) == frozenset() assert litellm.Router._declared_param_allowlist({}) == frozenset() @@ -12727,7 +12372,11 @@ class TestTierParamsTheTargetAccepts: @pytest.mark.parametrize( "deployment", - [{"model_name": "x"}, {"model_name": "x", "litellm_params": {}}, {"model_name": "x", "litellm_params": {"model": "not-a-real-provider/nope"}}], + [ + {"model_name": "x"}, + {"model_name": "x", "litellm_params": {}}, + {"model_name": "x", "litellm_params": {"model": "not-a-real-provider/nope"}}, + ], ) def test_deployment_accepts_param_fails_open(self, deployment): """An unresolvable deployment must not be the reason a param is dropped.""" @@ -12956,9 +12605,7 @@ class TestPreRoutingTierDrivesFallbacks: async def test_the_selected_tier_fallback_chain_runs(self): router = self._router([{"tier1": ["backup-a"]}]) - response = await router.acompletion( - model="smart-router", messages=[{"role": "user", "content": "hi"}] - ) + response = await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}]) assert response.choices[0].message.content == "from backup-a" @@ -12991,9 +12638,7 @@ class TestPreRoutingTierDrivesFallbacks: async def test_a_request_without_a_pre_routing_hook_still_uses_its_own_group(self): router = self._router([{"tier1": ["backup-a"]}]) - response = await router.acompletion( - model="tier1", messages=[{"role": "user", "content": "hi"}] - ) + response = await router.acompletion(model="tier1", messages=[{"role": "user", "content": "hi"}]) assert response.choices[0].message.content == "from backup-a" diff --git a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts index b917fcedaa2..ea6eaf99106 100644 --- a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts @@ -80,9 +80,20 @@ describe("hydrateAutoRouterCompression", () => { expect(state).toEqual({ routing: "headroom-a", sameAsRouting: false, model: "headroom-b" }); }); - it("treats a missing model key as same-as-routing", () => { + it("treats a missing model key as no model-hop compression, not same-as-routing", () => { const state = hydrateAutoRouterCompression({ auto_router_routing_compression: "headroom-a" }); - expect(state).toEqual({ routing: "headroom-a", sameAsRouting: true, model: undefined }); + expect(state).toEqual({ routing: "headroom-a", sameAsRouting: false, model: "none" }); + }); + + it("re-saving a routing-only config leaves the model hop uncompressed", () => { + // Regression: the backend reads an absent model key as no model-hop compression. + // Hydrating it as same-as-routing made opening the router and saving any unrelated + // edit write the routing guardrail onto the model hop, so the model call silently + // started receiving compressed messages. + const stored = { auto_router_routing_compression: "headroom-a" }; + const rebuilt = buildAutoRouterCompressionParams(hydrateAutoRouterCompression(stored)); + expect(rebuilt.auto_router_model_compression).toBe("none"); + expect(rebuilt.auto_router_model_compression).not.toBe("headroom-a"); }); it("round-trips through buildAutoRouterCompressionParams", () => { diff --git a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts index c86416b507f..47d0e3db7e4 100644 --- a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts +++ b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts @@ -53,7 +53,11 @@ export const hydrateAutoRouterCompression = (litellmParams: { const routing = litellmParams.auto_router_routing_compression ?? undefined; if (routing === undefined) return DEFAULT_AUTO_ROUTER_COMPRESSION; - const model = litellmParams.auto_router_model_compression ?? undefined; - const sameAsRouting = model === undefined || model === routing; + // An absent model key is no model-hop compression, not same-as-routing: the backend + // reads it as None (policy_from_litellm_params). Hydrating it as same-as-routing + // would make re-saving an unrelated edit write the routing guardrail onto the model + // hop and silently start compressing the model call. + const model = litellmParams.auto_router_model_compression ?? NO_COMPRESSION; + const sameAsRouting = model === routing; return { routing, sameAsRouting, model: sameAsRouting ? undefined : model }; }; From 0b3687ec56153225d7b8f2a0c2652bf2f589ce2e Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 5 Sep 2026 09:49:00 -0700 Subject: [PATCH 224/410] fix(shadow_eval): import Final for the test helper's annotation --- tests/test_litellm/integrations/test_shadow_eval_logger.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_litellm/integrations/test_shadow_eval_logger.py b/tests/test_litellm/integrations/test_shadow_eval_logger.py index 371f6f75a05..eecd876219e 100644 --- a/tests/test_litellm/integrations/test_shadow_eval_logger.py +++ b/tests/test_litellm/integrations/test_shadow_eval_logger.py @@ -3,6 +3,7 @@ the detached pipeline's single attempt-row write, and the cache-first job lookup import asyncio from datetime import datetime, timedelta, timezone +from typing import Final from unittest.mock import AsyncMock, MagicMock import pytest From d0d09e53438d51b25cb0e0f8a29a329e8d93a7e9 Mon Sep 17 00:00:00 2001 From: tin-berri Date: Sat, 5 Sep 2026 09:51:23 -0700 Subject: [PATCH 225/410] feat(router): meter auto-router tier and prompt customization against the auto_router license feature (#39674) Generalizes the heuristic_v2 ceiling from #39468 into a capability table whose records own their in-process predicate, SQL spelling and refusal wording. The existing heuristic_v2 capability keeps its own one-router ceiling. A single customization capability combines operator-defined tier definitions with every operator-written part of the classifier prompt. The prompt half only applies to classifier types that call an LLM. The shipped default prompt, classification rubric presets, tier-label renames and tier model choices remain ungated. Scope every enforcement point to actual complexity routers. A model-less PATCH or legacy update now decrypts the stored model before accepting strategy-router settings, so a regular model cannot acquire a router config or spend a license slot. Under the existing advisory lock, the cross-pod candidate query returns only model scalars and the count decrypts and classifies them in process; old non-router rows carrying a capability-shaped config no longer block a real complexity router. The signed auto_router license feature makes both ceilings unlimited. --- litellm/constants.py | 2 +- litellm/proxy/auth/litellm_license.py | 11 +- .../model_management_endpoints.py | 157 +++++++--- litellm/proxy/proxy_server.py | 34 +- litellm/router.py | 45 +-- .../router_utils/auto_router_model_naming.py | 134 +++++++- litellm/types/router.py | 4 +- .../proxy/auth/test_litellm_license.py | 18 +- .../test_model_management_endpoints.py | 292 +++++++++++++++--- .../proxy/proxy_server/test_proxy_config.py | 91 +++++- .../router_strategy/test_complexity_router.py | 232 +++++++++++++- .../test_auto_router_model_naming.py | 172 +++++++++-- 12 files changed, 987 insertions(+), 205 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index da731cb5eb2..7d6de612349 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -40,7 +40,7 @@ ROUTER_SETTINGS_MANAGED_OUTSIDE_CONFIG: Final[frozenset[str]] = frozenset( "router_general_settings", "ignore_invalid_deployments", "fallback_access_check", - "heuristic_v2_router_limit", + "auto_router_capability_limit", } ) DEFAULT_BATCH_SIZE: Final = int(os.getenv("DEFAULT_BATCH_SIZE", 512)) diff --git a/litellm/proxy/auth/litellm_license.py b/litellm/proxy/auth/litellm_license.py index 55bb1e3925a..067ac7905c5 100644 --- a/litellm/proxy/auth/litellm_license.py +++ b/litellm/proxy/auth/litellm_license.py @@ -17,7 +17,7 @@ if TYPE_CHECKING: AUTO_ROUTER_LICENSE_FEATURE: Final = "auto_router" -HEURISTIC_V2_LICENSE_REMEDY: Final = "A LiteLLM license with the 'auto_router' feature lifts the limit." +AUTO_ROUTER_LICENSE_REMEDY: Final = "A LiteLLM license with the 'auto_router' feature lifts the limit." class LicenseCheck: @@ -153,11 +153,12 @@ class LicenseCheck: return False return team_count > _max_teams_in_license - def heuristic_v2_router_limit(self) -> int | None: + def auto_router_capability_limit(self) -> int | None: """ - How many heuristic_v2 auto-routers this proxy may hold: unlimited (None) only when the - signed license lists the auto_router feature, otherwise one. A license verified through - the API carries no feature list, so it does not lift the limit either. + How many auto-routers may claim each licensed capability (heuristic_v2, operator-defined + tier_definitions): unlimited (None) only when the signed license lists the auto_router + feature, otherwise one per capability. A license verified through the API carries no + feature list, so it does not lift the limit either. """ if self.airgapped_license_data is None: return 1 diff --git a/litellm/proxy/management_endpoints/model_management_endpoints.py b/litellm/proxy/management_endpoints/model_management_endpoints.py index d4e03a05c52..b77108911aa 100644 --- a/litellm/proxy/management_endpoints/model_management_endpoints.py +++ b/litellm/proxy/management_endpoints/model_management_endpoints.py @@ -50,7 +50,7 @@ from litellm.proxy._types import ( TeamModelDeleteRequest, UserAPIKeyAuth, ) -from litellm.proxy.auth.litellm_license import HEURISTIC_V2_LICENSE_REMEDY +from litellm.proxy.auth.litellm_license import AUTO_ROUTER_LICENSE_REMEDY from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.config_sync_pubsub import ( coordination_redis_cache, @@ -98,11 +98,13 @@ from litellm.router_strategy.complexity_router import ( normalize_classification_prompt, ) from litellm.router_utils.auto_router_model_naming import ( + GATED_AUTO_ROUTER_CAPABILITIES, STRATEGY_ROUTER_PARAM_FIELDS, + capability_limit_violation, carries_complexity_router_settings, - count_heuristic_v2_routers, - heuristic_v2_limit_violation, - uses_heuristic_v2_classifier, + count_capability_routers, + gated_capability_of, + is_complexity_router_model, validate_complexity_router_config_placement, validate_complexity_router_config_write, validate_strategy_router_model_write, @@ -237,11 +239,13 @@ def _strategy_router_write_violation( An auto-router deployment's ``litellm_params.model`` (``auto_router/...``) is the discriminator the router loads it by; a write that mangles it makes the router drop the deployment silently under ``ignore_invalid_deployments``. - Only writes that supply ``litellm_params.model`` are judged on the naming - contract, against the merged (stored + incoming) params, so partial patches - and restores of an already-corrupted row stay legal. A config is judged only - when the write carries one, for the same reason: a rename must not be held - hostage by a stored config it does not touch. Returns the violation, or None. + A patch adding auto-router settings is judged against the effective model, + decrypting the stored model when the patch omits it, so a regular deployment + cannot claim a strategy-router configuration. Unrelated partial patches and + restores that do not touch strategy-router settings stay legal. A config is + judged only when the write carries one, for the same reason: a rename must + not be held hostage by a stored config it does not touch. Returns the + violation, or None. """ if incoming_params is None: return None @@ -256,14 +260,18 @@ def _strategy_router_write_violation( for source in (incoming_params, existing_params) if source is not None and getattr(source, field, None) is not None ) - # Scope reads the incoming model because the stored one is encrypted at rest. - if carries_complexity_router_settings(incoming_params.model, present_fields): + effective_params: Final = _effective_complexity_router_params(incoming_params, existing_params) + effective_model: Final = effective_params.get("model") + if carries_complexity_router_settings( + effective_model if isinstance(effective_model, str) else None, present_fields + ): placement_violation: Final = validate_complexity_router_config_placement(incoming_params.model_extra) if placement_violation is not None: return placement_violation - if incoming_params.model is None: - return None - return validate_strategy_router_model_write(model=incoming_params.model, present_fields=present_fields) + return validate_strategy_router_model_write( + model=effective_model if isinstance(effective_model, str) else "", + present_fields=present_fields, + ) def _raise_on_strategy_router_write_violation( @@ -281,14 +289,23 @@ def _raise_on_strategy_router_write_violation( ) -HEURISTIC_V2_SLOT_LOCK_KEY: Final = 5_872_301 -_HEURISTIC_V2_LOCK_SQL: Final = "SELECT 1 AS locked FROM pg_advisory_xact_lock($1)" -_HEURISTIC_V2_DB_ROWS_SQL: Final = """ -SELECT count(*)::int AS held FROM "LiteLLM_ProxyModelTable" +AUTO_ROUTER_CAPABILITY_SLOT_LOCK_KEY: Final = 5_872_301 +_CAPABILITY_LOCK_SQL: Final = "SELECT 1 AS locked FROM pg_advisory_xact_lock($1)" +_STORED_LITELLM_PARAMS_SQL: Final = ( + "(CASE jsonb_typeof(litellm_params) WHEN 'string' THEN (litellm_params #>> '{}')::jsonb ELSE litellm_params END)" +) +_STORED_COMPLEXITY_CONFIG_SQL: Final = f"{_STORED_LITELLM_PARAMS_SQL} -> 'complexity_router_config'" +_CAPABILITY_DB_ROWS_SQL: Final[Mapping[str, str]] = MappingProxyType( + { + capability.key: f""" +SELECT {_STORED_LITELLM_PARAMS_SQL} ->> 'model' AS model +FROM "LiteLLM_ProxyModelTable" WHERE model_id <> $1 - AND (CASE jsonb_typeof(litellm_params) WHEN 'string' THEN (litellm_params #>> '{}')::jsonb ELSE litellm_params END) - -> 'complexity_router_config' ->> 'classifier_type' = 'heuristic_v2' + AND ({capability.sql_config_predicate.format(config=_STORED_COMPLEXITY_CONFIG_SQL)}) """ + for capability in GATED_AUTO_ROUTER_CAPABILITIES + } +) def _effective_complexity_router_config( @@ -301,13 +318,44 @@ def _effective_complexity_router_config( return existing_params.complexity_router_config -@asynccontextmanager -async def _heuristic_v2_slot( - prisma_client: PrismaClient, *, effective_config: object, model_id: str | None -) -> AsyncGenerator[_ProxyModelTable, None]: - """Hand out the model table to write through while the row's claim on a heuristic_v2 slot is settled. +def _effective_model( + incoming_params: GenericLiteLLMParams | None, existing_params: GenericLiteLLMParams | None +) -> str | None: + """The model a write leaves on the row, decrypting an existing value only when the patch omits it.""" + incoming: Final = None if incoming_params is None else incoming_params.model + if incoming is not None: + return incoming + existing: Final = None if existing_params is None else existing_params.model + if existing is None: + return None + decrypted: Final = decrypt_value_helper( + value=existing, + key="model", + exception_type="debug", + return_original_value=True, + ) + return decrypted if isinstance(decrypted, str) else None - A write that leaves the row on classifier_type heuristic_v2 under a limited license runs + +def _effective_complexity_router_params( + incoming_params: GenericLiteLLMParams | None, existing_params: GenericLiteLLMParams | None +) -> Mapping[str, object]: + """The model and complexity config a write leaves, for placement and capability decisions.""" + return MappingProxyType( + { + "model": _effective_model(incoming_params, existing_params), + "complexity_router_config": _effective_complexity_router_config(incoming_params, existing_params), + } + ) + + +@asynccontextmanager +async def _auto_router_capability_slot( + prisma_client: PrismaClient, *, effective_params: Mapping[str, object], model_id: str | None +) -> AsyncGenerator[_ProxyModelTable, None]: + """Hand out the model table to write through while the row's claim on a licensed capability is settled. + + A write that leaves the row claiming a licensed capability under a limited license runs inside one transaction that takes an advisory lock in its own statement before counting (a statement's snapshot predates anything it locks), so pods cannot both pass the count: the DB rows (any pod, either JSON shape) plus this proxy's config.yaml routers are judged @@ -321,21 +369,37 @@ async def _heuristic_v2_slot( """ from litellm.proxy.proxy_server import _license_check, llm_router - limit: Final = _license_check.heuristic_v2_router_limit() - if limit is None or not uses_heuristic_v2_classifier(effective_config): + limit: Final = _license_check.auto_router_capability_limit() + capability: Final = gated_capability_of(effective_params) + if limit is None or capability is None: yield _proxy_model_table(prisma_client) return async with prisma_client.db.tx() as tx_ctx: tables: Final[_TxModelTables] = tx_ctx - await tx_ctx.query_raw(_HEURISTIC_V2_LOCK_SQL, HEURISTIC_V2_SLOT_LOCK_KEY) - rows: Sequence[Mapping[str, object]] = await tx_ctx.query_raw(_HEURISTIC_V2_DB_ROWS_SQL, model_id or "") - db_held: Final = rows[0].get("held") if rows else 0 + await tx_ctx.query_raw(_CAPABILITY_LOCK_SQL, AUTO_ROUTER_CAPABILITY_SLOT_LOCK_KEY) + rows: Sequence[Mapping[str, object]] = await tx_ctx.query_raw( + _CAPABILITY_DB_ROWS_SQL[capability.key], model_id or "" + ) + db_held: Final = sum( + 1 + for row in rows + for stored_model in (row.get("model"),) + if isinstance(stored_model, str) + and is_complexity_router_model( + decrypt_value_helper( + value=stored_model, + key="model", + exception_type="debug", + return_original_value=True, + ) + ) + ) config_rows: Final = () if llm_router is None else tuple(llm_router.config_deployments()) - held: Final = (db_held if isinstance(db_held, int) else 0) + count_heuristic_v2_routers(config_rows) - violation: Final = heuristic_v2_limit_violation(held=held + 1, limit=limit) + held: Final = db_held + count_capability_routers(config_rows, capability=capability) + violation: Final = capability_limit_violation(capability=capability, held=held + 1, limit=limit) if violation is not None: raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, detail=f"{violation} {HEURISTIC_V2_LICENSE_REMEDY}" + status_code=status.HTTP_403_FORBIDDEN, detail=f"{violation} {AUTO_ROUTER_LICENSE_REMEDY}" ) yield tables.litellm_proxymodeltable await publish_config_change(redis_cache=coordination_redis_cache(), object_type="litellm_proxymodeltable") @@ -791,6 +855,9 @@ async def patch_model( existing_params=db_model.litellm_params, ) + effective_params: Final = _effective_complexity_router_params( + patch_data.litellm_params, db_model.litellm_params + ) requested_model_name: Final = patch_data.model_name stored_model_name: str | None = None @@ -799,11 +866,9 @@ async def patch_model( stored_model_name = update_data.get("model_name") update_data["updated_by"] = user_api_key_dict.user_id or litellm_proxy_admin_name update_data["updated_at"] = cast(str, get_utc_datetime()) - async with _heuristic_v2_slot( + async with _auto_router_capability_slot( prisma_client, - effective_config=_effective_complexity_router_config( - patch_data.litellm_params, db_model.litellm_params - ), + effective_params=effective_params, model_id=model_id, ) as table: return await table.update(where={"model_id": model_id}, data=update_data) @@ -1959,9 +2024,12 @@ async def add_new_model( model_params=priced_model_params, user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, - slot=_heuristic_v2_slot( + slot=_auto_router_capability_slot( prisma_client, - effective_config=priced_model_params.litellm_params.complexity_router_config, + effective_params=_effective_complexity_router_params( + priced_model_params.litellm_params, + None, + ), model_id=priced_model_params.model_info.id, ), ) @@ -2110,6 +2178,9 @@ async def update_model( incoming_params=model_params.litellm_params, existing_params=deployment.litellm_params, ) + effective_params: Final = _effective_complexity_router_params( + model_params.litellm_params, deployment.litellm_params + ) # update DB if store_model_in_db is True: @@ -2147,11 +2218,9 @@ async def update_model( "updated_by": user_api_key_dict.user_id or LITELLM_PROXY_ADMIN_NAME, **({} if renamed_to is None else {"model_name": renamed_to}), } - async with _heuristic_v2_slot( + async with _auto_router_capability_slot( prisma_client, - effective_config=_effective_complexity_router_config( - model_params.litellm_params, deployment.litellm_params - ), + effective_params=effective_params, model_id=_model_id, ) as table: model_response: Final = await table.update( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 0f0abd0eeae..88e3f79ca52 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -118,10 +118,11 @@ from litellm.router_utils.add_retry_fallback_headers import ( get_hidden_params_dict, ) from litellm.router_utils.auto_router_model_naming import ( + GATED_AUTO_ROUTER_CAPABILITIES, STRATEGY_ROUTER_PARAM_FIELDS, + capability_limit_violation, carries_complexity_router_settings, - count_heuristic_v2_routers, - heuristic_v2_limit_violation, + count_capability_routers, validate_complexity_router_config_placement, ) from litellm.types.utils import ( @@ -303,7 +304,7 @@ from litellm.proxy.auth.auth_utils import ( ) from litellm.proxy.auth.fallback_model_access import router_fallback_access_check from litellm.proxy.auth.handle_jwt import JWTHandler -from litellm.proxy.auth.litellm_license import HEURISTIC_V2_LICENSE_REMEDY, LicenseCheck +from litellm.proxy.auth.litellm_license import AUTO_ROUTER_LICENSE_REMEDY, LicenseCheck from litellm.proxy.auth.model_checks import ( expand_wildcard_deployments_for_model_info, get_all_fallbacks, @@ -4340,17 +4341,28 @@ def validate_deployment_complexity_router_placement(model: Mapping[str, object]) raise ValueError(f"model {model.get('model_name', '')!r}: {violation}") -def validate_heuristic_v2_router_limit(model_list: Sequence[Mapping[str, object]], *, limit: int | None) -> None: +def validate_auto_router_capability_limits(model_list: Sequence[Mapping[str, object]], *, limit: int | None) -> None: """ - Refuse to start when config.yaml defines more heuristic_v2 auto-routers than the license allows. + Refuse to start when config.yaml defines more auto-routers claiming a licensed capability than allowed. Checked here rather than left to router registration for the same reason as the two validators above: the proxy builds its router with `ignore_invalid_deployments=True`, so the router's own refusal would turn the extra router into a silently missing model. """ - violation: Final = heuristic_v2_limit_violation(held=count_heuristic_v2_routers(model_list), limit=limit) - if violation is not None: - raise ValueError(f"config.yaml model_list: {violation} {HEURISTIC_V2_LICENSE_REMEDY}") + violations: Final = tuple( + message + for capability in GATED_AUTO_ROUTER_CAPABILITIES + if ( + message := capability_limit_violation( + capability=capability, + held=count_capability_routers(model_list, capability=capability), + limit=limit, + ) + ) + is not None + ) + if violations: + raise ValueError(f"config.yaml model_list: {' '.join(violations)} {AUTO_ROUTER_LICENSE_REMEDY}") def pin_complexity_router_model_id(model: dict) -> None: # mutable-ok: out-param, model_info is stamped in place @@ -5758,7 +5770,7 @@ class ProxyConfig: model_list: Final = config.get("model_list", None) if model_list: router_params["model_list"] = model_list - validate_heuristic_v2_router_limit(model_list, limit=_license_check.heuristic_v2_router_limit()) + validate_auto_router_capability_limits(model_list, limit=_license_check.auto_router_capability_limit()) print( # noqa: T201 "\033[32mLiteLLM: Proxy initialized with Config, Set models:\033[0m" ) @@ -5848,7 +5860,7 @@ class ProxyConfig: ), ignore_invalid_deployments=True, # don't raise an error if a deployment is invalid fallback_access_check=router_fallback_access_check, - heuristic_v2_router_limit=_license_check.heuristic_v2_router_limit, + auto_router_capability_limit=_license_check.auto_router_capability_limit, ) if redis_usage_cache is not None and router.cache.redis_cache is None: @@ -6309,7 +6321,7 @@ class ProxyConfig: search_tools=search_tools, ignore_invalid_deployments=True, fallback_access_check=router_fallback_access_check, - heuristic_v2_router_limit=_license_check.heuristic_v2_router_limit, + auto_router_capability_limit=_license_check.auto_router_capability_limit, ) verbose_proxy_logger.debug("updated llm_router: %s", llm_router) else: diff --git a/litellm/router.py b/litellm/router.py index 6c7611c6236..6943eece90f 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -116,10 +116,11 @@ from litellm.router_utils.add_retry_fallback_headers import ( ) from litellm.router_utils.auto_router_model_naming import ( AUTO_ROUTER_MODEL_PREFIX, + GatedAutoRouterCapability, + capability_limit_violation, + claimed_capability, classify_strategy_router_model, - count_heuristic_v2_routers, - heuristic_v2_limit_violation, - uses_heuristic_v2_classifier, + count_capability_routers, ) from litellm.router_utils.batch_utils import ( _get_router_metadata_variable_name, @@ -208,6 +209,7 @@ from litellm.types.router import ( AlertingConfig, AllowedFailsPolicy, AssistantsTypedDict, + AutoRouterCapabilityLimit, ConsumedRequestTagsStamp, CredentialLiteLLMParams, CustomRoutingStrategyBase, @@ -215,7 +217,6 @@ from litellm.types.router import ( DeploymentTypedDict, FallbackAccessCheck, GuardrailTypedDict, - HeuristicV2RouterLimit, LiteLLM_Params, MockRouterTestingParams, ModelGroupInfo, @@ -692,7 +693,7 @@ class Router: background_health_check_model_groups: Sequence[str] | None = None, enable_weighted_failover: bool = False, fallback_access_check: FallbackAccessCheck | None = None, - heuristic_v2_router_limit: HeuristicV2RouterLimit | None = None, + auto_router_capability_limit: AutoRouterCapabilityLimit | None = None, ) -> None: """ Initialize the Router class with the given parameters for caching, reliability, and routing strategy. @@ -769,7 +770,7 @@ class Router: self.set_verbose = set_verbose self.ignore_invalid_deployments = ignore_invalid_deployments - self.heuristic_v2_router_limit = heuristic_v2_router_limit + self.auto_router_capability_limit = auto_router_capability_limit self.fallback_access_check: Final = fallback_access_check self.debug_level = debug_level self.enable_pre_call_checks = enable_pre_call_checks @@ -8811,20 +8812,21 @@ class Router: if not (isinstance(model_info, Mapping) and model_info.get("db_model")): yield deployment - def heuristic_v2_router_limit_violation(self) -> str | None: + def auto_router_capability_violation(self, capability: GatedAutoRouterCapability) -> str | None: """ - Why one more heuristic_v2 router cannot join this router, or None when it can. + Why one more router claiming ``capability`` cannot join this router, or None when it can. Judged against every deployment currently on the model_list; an upsert pops the row being - edited first, so an edit of an existing heuristic_v2 router keeps its own slot. The limit is - resolved on every call through ``heuristic_v2_router_limit``; unset means unlimited, which - is the SDK default, and the proxy injects a resolver backed by its license. + edited first, so an edit of an existing gated router keeps its own slot. The limit is + resolved on every call through ``auto_router_capability_limit``; unset means unlimited, + which is the SDK default, and the proxy injects a resolver backed by its license. """ - limit: Final = self.heuristic_v2_router_limit() if self.heuristic_v2_router_limit is not None else None - others: Final = count_heuristic_v2_routers( - deployment for deployment in self.model_list if isinstance(deployment, Mapping) + limit: Final = self.auto_router_capability_limit() if self.auto_router_capability_limit is not None else None + others: Final = count_capability_routers( + (deployment for deployment in self.model_list if isinstance(deployment, Mapping)), + capability=capability, ) - return heuristic_v2_limit_violation(held=others + 1, limit=limit) + return capability_limit_violation(capability=capability, held=others + 1, limit=limit) def init_complexity_router_deployment(self, deployment: Deployment): """ @@ -8843,8 +8845,9 @@ class Router: ) complexity_router_config: Final[dict | None] = deployment.litellm_params.complexity_router_config - if uses_heuristic_v2_classifier(complexity_router_config): - limit_violation: Final = self.heuristic_v2_router_limit_violation() + capability: Final = claimed_capability(complexity_router_config) + if capability is not None: + limit_violation: Final = self.auto_router_capability_violation(capability) if limit_violation is not None: raise ValueError(limit_violation) @@ -9674,13 +9677,13 @@ class Router: """Put a deployment back the way it was before a failed upsert popped it. A rollback re-admits state that was already serving, so it does not go through the - heuristic_v2 ceiling a newcomer gets: with the ceiling tightened since the deployment first + capability ceiling a newcomer gets: with the ceiling tightened since the deployment first registered, judging the rollback would drop a serving router over an unrelated failed edit. """ if previous_deployment is None or self.has_model_id(model_id): return - limit_resolver: Final = self.heuristic_v2_router_limit - self.heuristic_v2_router_limit = None + limit_resolver: Final = self.auto_router_capability_limit + self.auto_router_capability_limit = None try: self.add_deployment(deployment=previous_deployment) verbose_router_logger.info( @@ -9696,7 +9699,7 @@ class Router: restore_error, ) finally: - self.heuristic_v2_router_limit = limit_resolver + self.auto_router_capability_limit = limit_resolver @staticmethod def _backend_cost_map_keys(model: str, custom_llm_provider: str | None) -> tuple[str, ...]: diff --git a/litellm/router_utils/auto_router_model_naming.py b/litellm/router_utils/auto_router_model_naming.py index 2efbfb5782e..190c4921d5f 100644 --- a/litellm/router_utils/auto_router_model_naming.py +++ b/litellm/router_utils/auto_router_model_naming.py @@ -10,7 +10,7 @@ the router silently dropping the deployment at load time under ``ignore_invalid_deployments``. """ -from collections.abc import Iterable, Mapping, Sequence +from collections.abc import Callable, Iterable, Mapping, Sequence from dataclasses import dataclass from types import MappingProxyType from typing import Final, Literal, TypeAlias @@ -81,6 +81,11 @@ def classify_strategy_router_model(model: str) -> StrategyRouterKind | None: return "semantic" +def is_complexity_router_model(model: str | None) -> bool: + """Whether ``model`` selects the complexity-router implementation.""" + return classify_strategy_router_model(model or "") == "complexity" + + def _named(value: object, role: StrategyRouterDependencyRole) -> tuple[StrategyRouterDependency, ...]: """One dependency from a scalar field, or none when it is absent or not a name.""" return (StrategyRouterDependency(value, role),) if isinstance(value, str) and value else () @@ -168,20 +173,121 @@ def uses_heuristic_v2_classifier(complexity_router_config: object) -> bool: return _mapping(complexity_router_config).get("classifier_type") == "heuristic_v2" -def is_heuristic_v2_router(litellm_params: Mapping[str, object]) -> bool: - """Whether this deployment is a complexity router that classifies with heuristic_v2.""" - return classify_strategy_router_model(str(litellm_params.get("model") or "")) == "complexity" and ( - uses_heuristic_v2_classifier(litellm_params.get("complexity_router_config")) +def defines_custom_tiers(complexity_router_config: object) -> bool: + """Whether this complexity config replaces the built-in tier ladder with operator-defined tier_definitions. + + Mirrors the SQL spelling on the capability record: only an actual array claims the capability, + so an explicit JSON null or a malformed value does not. + """ + return isinstance(_mapping(complexity_router_config).get("tier_definitions"), (list, tuple)) + + +OPERATOR_CLASSIFIER_PROMPT_FIELDS: Final = ("classification_prompt", "classification_examples") + + +def defines_custom_classifier_prompt(complexity_router_config: object) -> bool: + """Whether an operator wrote any part of this router's classifier prompt themselves. + + Three spellings, all metered: a whole replacement prompt (``classifier_llm_config.system_prompt``), + replacement opening instructions (``classification_prompt``), and replacement calibration examples + (``classification_examples``). Choosing a shipped ``classification_rubric`` preset is not authoring. + Scoped to the classifier types that actually call an LLM, which is also where the config validator + accepts these fields: the heuristic scorers never read them. + """ + config: Final = _mapping(complexity_router_config) + if config.get("classifier_type") not in LLM_CLASSIFIER_TYPES: + return False + return _mapping(config.get("classifier_llm_config")).get("system_prompt") is not None or any( + config.get(field) is not None for field in OPERATOR_CLASSIFIER_PROMPT_FIELDS ) -def count_heuristic_v2_routers(deployments: Iterable[Mapping[str, object]]) -> int: - """How many of ``deployments`` (router model_list entries or config.yaml rows) are heuristic_v2 routers.""" - return sum(1 for deployment in deployments if is_heuristic_v2_router(_mapping(deployment.get("litellm_params")))) +def uses_custom_tier_or_classifier_prompt(complexity_router_config: object) -> bool: + """Whether this router replaces shipped tiers or its shipped classifier prompt.""" + return defines_custom_tiers(complexity_router_config) or defines_custom_classifier_prompt(complexity_router_config) -def heuristic_v2_limit_violation(*, held: int, limit: int | None) -> str | None: - """Why holding ``held`` heuristic_v2 routers exceeds ``limit``, or None when it fits. +_LLM_CLASSIFIER_TYPES_SQL: Final = ", ".join(f"'{name}'" for name in sorted(LLM_CLASSIFIER_TYPES)) + + +@dataclass(frozen=True, slots=True) +class GatedAutoRouterCapability: + """A complexity-router capability the license meters, in every spelling an enforcement point needs. + + ``uses`` and ``sql_config_predicate`` answer the same question, in process and in a DB count over + stored ``litellm_params`` (``{config}`` is the caller's expression for the normalized + ``complexity_router_config`` jsonb, substituted as many times as the predicate needs); they live + on one record so they cannot drift apart. ``subject`` and ``remedy`` build the shared refusal + message. A validated config claims at most one capability, and the validator is what makes that + true: tier_definitions rejects every heuristic classifier_type, and it also rejects the + classifier system_prompt, which in turn only applies to the classifier types heuristic_v2 is not. + """ + + key: str + subject: str + remedy: str + uses: Callable[[object], bool] + sql_config_predicate: str + + +HEURISTIC_V2_CAPABILITY: Final = GatedAutoRouterCapability( + key="heuristic_v2", + subject="with classifier_type 'heuristic_v2'", + remedy="Use classifier_type 'heuristic' for this router or remove an existing heuristic_v2 router.", + uses=uses_heuristic_v2_classifier, + sql_config_predicate="{config} ->> 'classifier_type' = 'heuristic_v2'", +) + +_OPERATOR_PROMPT_FIELDS_SQL: Final = " OR ".join( + f"{{config}} ->> '{field}' IS NOT NULL" for field in OPERATOR_CLASSIFIER_PROMPT_FIELDS +) + +CUSTOMIZATION_CAPABILITY: Final = GatedAutoRouterCapability( + key="tier_or_classifier_prompt", + subject="with operator-defined tier_definitions or an operator-written classifier prompt", + remedy=( + "Use the shipped tiers and classifier prompt for this router or remove an existing router " + "with tier_definitions or its own classifier prompt." + ), + uses=uses_custom_tier_or_classifier_prompt, + sql_config_predicate=( + "jsonb_typeof({config} -> 'tier_definitions') = 'array' OR " + f"({{config}} ->> 'classifier_type' IN ({_LLM_CLASSIFIER_TYPES_SQL}) AND (" + "{config} -> 'classifier_llm_config' ->> 'system_prompt' IS NOT NULL OR " + f"{_OPERATOR_PROMPT_FIELDS_SQL}))" + ), +) + +GATED_AUTO_ROUTER_CAPABILITIES: Final = (HEURISTIC_V2_CAPABILITY, CUSTOMIZATION_CAPABILITY) + + +def claimed_capability(complexity_router_config: object) -> GatedAutoRouterCapability | None: + """The licensed capability this complexity config claims, or None.""" + return next( + (capability for capability in GATED_AUTO_ROUTER_CAPABILITIES if capability.uses(complexity_router_config)), + None, + ) + + +def gated_capability_of(litellm_params: Mapping[str, object]) -> GatedAutoRouterCapability | None: + """The licensed capability this deployment claims, or None unless it is a complexity router.""" + model: Final = litellm_params.get("model") + if not is_complexity_router_model(model if isinstance(model, str) else None): + return None + return claimed_capability(litellm_params.get("complexity_router_config")) + + +def count_capability_routers( + deployments: Iterable[Mapping[str, object]], *, capability: GatedAutoRouterCapability +) -> int: + """How many of ``deployments`` (router model_list entries or config.yaml rows) claim ``capability``.""" + return sum( + 1 for deployment in deployments if gated_capability_of(_mapping(deployment.get("litellm_params"))) is capability + ) + + +def capability_limit_violation(*, capability: GatedAutoRouterCapability, held: int, limit: int | None) -> str | None: + """Why holding ``held`` routers claiming ``capability`` exceeds ``limit``, or None when it fits. ``limit`` None means unlimited. The message is shared by every enforcement point (config load, model writes, router registration) and stays SDK-neutral: it names the cap and what @@ -190,8 +296,8 @@ def heuristic_v2_limit_violation(*, held: int, limit: int | None) -> str | None: if limit is None or held <= limit: return None return ( - f"At most {limit} auto-router(s) with classifier_type 'heuristic_v2' can be registered but this would make " - f"{held}. Use classifier_type 'heuristic' for this router or remove an existing heuristic_v2 router." + f"At most {limit} auto-router(s) {capability.subject} can be registered but this would make " + f"{held}. {capability.remedy}" ) @@ -237,9 +343,7 @@ def carries_complexity_router_settings(model: str | None, present_fields: frozen ``validate_strategy_router_model_write`` is judged on, so a router named only by its default model is in scope, and a field added to the table above is covered here for free. """ - return classify_strategy_router_model(model or "") == "complexity" or bool( - present_fields & _COMPLEXITY_ROUTER_FIELDS - ) + return is_complexity_router_model(model) or bool(present_fields & _COMPLEXITY_ROUTER_FIELDS) def validate_complexity_router_config_placement(litellm_params: Mapping[str, object] | None) -> str | None: diff --git a/litellm/types/router.py b/litellm/types/router.py index 267e8853db1..728d1037f3d 100644 --- a/litellm/types/router.py +++ b/litellm/types/router.py @@ -887,9 +887,9 @@ class FallbackAccessCheck(Protocol): async def __call__(self, *, model: str, request_kwargs: Mapping[str, object], llm_router: "Router") -> bool: ... -class HeuristicV2RouterLimit(Protocol): +class AutoRouterCapabilityLimit(Protocol): """ - Resolves how many heuristic_v2 complexity routers the Router may hold right now; None means unlimited. + Resolves how many complexity routers may claim each licensed capability right now; None means unlimited. The Router calls it on every registration and limit query instead of caching the answer, so the proxy can keep the limit on its license object (re-verified on config load) rather than hand diff --git a/tests/test_litellm/proxy/auth/test_litellm_license.py b/tests/test_litellm/proxy/auth/test_litellm_license.py index 1db53638070..d3f80982c7a 100644 --- a/tests/test_litellm/proxy/auth/test_litellm_license.py +++ b/tests/test_litellm/proxy/auth/test_litellm_license.py @@ -34,27 +34,27 @@ def test_is_over_limit(): assert license_check.is_over_limit(99) is False -def test_heuristic_v2_router_limit() -> None: +def test_auto_router_capability_limit() -> None: """Only the signed license's auto_router feature lifts the one-router limit; an API-verified license (no airgapped data) and an airgapped license without the feature keep it.""" license_check = LicenseCheck() license_check.airgapped_license_data = {"expiration_date": "2999-01-01", "allowed_features": ["auto_router"]} - assert license_check.heuristic_v2_router_limit() is None + assert license_check.auto_router_capability_limit() is None license_check.airgapped_license_data = { "expiration_date": "2999-01-01", "allowed_features": ["sso", "auto_router", "audit_logs"], } - assert license_check.heuristic_v2_router_limit() is None + assert license_check.auto_router_capability_limit() is None license_check.airgapped_license_data = {"expiration_date": "2999-01-01", "allowed_features": ["sso"]} - assert license_check.heuristic_v2_router_limit() == 1 + assert license_check.auto_router_capability_limit() == 1 license_check.airgapped_license_data = {"expiration_date": "2999-01-01"} - assert license_check.heuristic_v2_router_limit() == 1 + assert license_check.auto_router_capability_limit() == 1 license_check.airgapped_license_data = None - assert license_check.heuristic_v2_router_limit() == 1 + assert license_check.auto_router_capability_limit() == 1 def _signed_license(expiration_date: str) -> tuple[RSAPublicKey, str]: @@ -81,12 +81,12 @@ def test_expired_or_unreadable_license_grants_no_features() -> None: license_check = LicenseCheck() public_key, valid_key = _signed_license("2999-01-01") assert license_check.verify_license_without_api_request(public_key=public_key, license_key=valid_key) is True - assert license_check.heuristic_v2_router_limit() is None + assert license_check.auto_router_capability_limit() is None _, expired_key = _signed_license("2000-01-01") assert license_check.verify_license_without_api_request(public_key=public_key, license_key=expired_key) is not True assert license_check.airgapped_license_data is None - assert license_check.heuristic_v2_router_limit() == 1 + assert license_check.auto_router_capability_limit() == 1 assert license_check.verify_license_without_api_request(public_key=public_key, license_key=valid_key) is True assert license_check.verify_license_without_api_request(public_key=public_key, license_key="not-a-license") is not True @@ -98,4 +98,4 @@ def test_valid_signed_license_with_auto_router_lifts_the_limit() -> None: public_key, license_key = _signed_license("2999-01-01") assert license_check.verify_license_without_api_request(public_key=public_key, license_key=license_key) is True - assert license_check.heuristic_v2_router_limit() is None + assert license_check.auto_router_capability_limit() is None 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 3edeeedbae9..33de2a09626 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 @@ -2,6 +2,7 @@ import inspect import asyncio import contextlib import json +from collections.abc import Mapping from typing import Dict, Optional from unittest.mock import AsyncMock, MagicMock, patch @@ -4048,6 +4049,72 @@ class TestStrategyRouterWriteValidation: ) assert _strategy_router_write_violation(incoming_params=None, existing_params=None) is None + @pytest.mark.parametrize( + "config", + [ + {"classifier_type": "heuristic_v2", "tiers": {"SIMPLE": "gpt-4o-mini"}}, + { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini"}, + "tier_definitions": [ + {"name": "routine", "description": "routine drafting"}, + {"name": "hard", "description": "hard reasoning"}, + ], + "tiers": {"routine": "gpt-4o-mini", "hard": "gpt-4o"}, + "fallback_tier": "routine", + }, + ], + ) + def test_model_less_patch_cannot_attach_router_config_to_a_regular_model(self, config: dict[str, object]) -> None: + """The license gate applies only to complexity routers, so a partial PATCH cannot poison a regular + model with a capability-shaped config and make it occupy a slot.""" + from litellm.proxy.management_endpoints.model_management_endpoints import ( + _strategy_router_write_violation, + ) + from litellm.types.router import updateLiteLLMParams + + violation = _strategy_router_write_violation( + incoming_params=updateLiteLLMParams(complexity_router_config=config), + existing_params=LiteLLM_Params(model="openai/gpt-4o-mini"), + ) + + assert violation is not None + assert "does not start with 'auto_router/'" in violation + assert "complexity_router_config" in violation + + def test_effective_params_decrypts_a_stored_complexity_router_model(self, monkeypatch) -> None: + """A database row encrypts model, so the model-aware gate must not accidentally rely on plaintext mocks.""" + from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper + from litellm.proxy.management_endpoints.model_management_endpoints import ( + _effective_complexity_router_params, + ) + from litellm.types.router import updateLiteLLMParams + + monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt") + encrypted_model = encrypt_value_helper("auto_router/complexity_router") + effective_params = _effective_complexity_router_params( + updateLiteLLMParams(complexity_router_config={"tiers": {"SIMPLE": "gpt-4o-mini"}}), + LiteLLM_Params(model=encrypted_model), + ) + + assert effective_params["model"] == "auto_router/complexity_router" + + def test_model_less_patch_keeps_a_complexity_router_in_scope(self) -> None: + from litellm.proxy.management_endpoints.model_management_endpoints import ( + _strategy_router_write_violation, + ) + from litellm.types.router import updateLiteLLMParams + + assert ( + _strategy_router_write_violation( + incoming_params=updateLiteLLMParams( + complexity_router_config={"classifier_type": "heuristic_v2", "tiers": {"SIMPLE": "gpt-4o-mini"}} + ), + existing_params=self._stored_complexity_params(), + ) + is None + ) + def test_restore_of_corrupted_row_is_allowed(self): from litellm.proxy.management_endpoints.model_management_endpoints import ( _strategy_router_write_violation, @@ -4354,33 +4421,33 @@ class TestStrategyRouterWriteValidation: ) @staticmethod - def _live_router_holding_one_heuristic_v2(limit: int | None) -> Router: + def _live_router_holding_one_capability(limit: int | None, config: Mapping[str, object]) -> Router: return Router( model_list=[ {"model_name": "gpt-4o-mini", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "k"}}, { - "model_name": "held-v2", + "model_name": "held", "litellm_params": { "model": "auto_router/complexity_router", - "complexity_router_config": {"classifier_type": "heuristic_v2", "tiers": {"SIMPLE": "gpt-4o-mini"}}, + "complexity_router_config": config, }, "model_info": {"id": "held-id"}, }, ], - heuristic_v2_router_limit=lambda: limit, + auto_router_capability_limit=lambda: limit, ) class _FakeTx: - """Stands in for a prisma transaction: records the raw statements and exposes the model table.""" + """Stands in for a prisma transaction: records raw statements and returns encrypted-model candidates.""" - def __init__(self, db_held: int) -> None: - self.db_held = db_held + def __init__(self, db_models: list[str]) -> None: + self.db_models = db_models self.raw_calls: list[tuple[str, tuple[object, ...]]] = [] self.litellm_proxymodeltable = MagicMock(create=AsyncMock(), update=AsyncMock()) async def query_raw(self, sql: str, *args: object) -> list[dict[str, object]]: self.raw_calls.append((sql, args)) - return [{"held": self.db_held}] if "count(*)" in sql else [] + return [{"model": model} for model in self.db_models] if "AS model" in sql else [] async def __aenter__(self) -> "TestStrategyRouterWriteValidation._FakeTx": return self @@ -4391,9 +4458,9 @@ class TestStrategyRouterWriteValidation: class _FakeDb: """Stands in for prisma_client: the plain client and the transaction it opens are told apart by identity.""" - def __init__(self, db_held: int, existing_row: object = None) -> None: + def __init__(self, db_models: list[str], existing_row: object = None) -> None: self.db = self - self.tx_obj = TestStrategyRouterWriteValidation._FakeTx(db_held) + self.tx_obj = TestStrategyRouterWriteValidation._FakeTx(db_models) self.litellm_proxymodeltable = MagicMock( create=AsyncMock(), update=AsyncMock(), find_unique=AsyncMock(return_value=existing_row) ) @@ -4403,6 +4470,43 @@ class TestStrategyRouterWriteValidation: _V2 = {"classifier_type": "heuristic_v2", "tiers": {"SIMPLE": "gpt-4o-mini"}} _V1 = {"classifier_type": "heuristic", "tiers": {"SIMPLE": "gpt-4o-mini"}} + _CUSTOM_TIERS = { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini"}, + "tier_definitions": [ + {"name": "routine", "description": "routine drafting"}, + {"name": "hard", "description": "hard reasoning"}, + ], + "tiers": {"routine": "gpt-4o-mini", "hard": "gpt-4o"}, + "fallback_tier": "routine", + } + _TIER_LABELS_ONLY = { + "classifier_type": "heuristic", + "tiers": {"SIMPLE": "gpt-4o-mini"}, + "tier_labels": {"SIMPLE": "Cheap"}, + } + _CUSTOM_PROMPT = { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini", "system_prompt": "judge it my way"}, + "tiers": {"SIMPLE": "gpt-4o-mini"}, + } + _OPERATOR_EXAMPLES = { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini"}, + "tiers": {"SIMPLE": "gpt-4o-mini"}, + "classification_examples": '- "reset my password" -> SIMPLE', + } + _OPERATOR_OPENING_PROMPT = { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini"}, + "tiers": {"SIMPLE": "gpt-4o-mini"}, + "classification_prompt": "Grade by data sensitivity", + } + _SHIPPED_RUBRIC = { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini", "classification_rubric": "agentic"}, + "tiers": {"SIMPLE": "gpt-4o-mini"}, + } @pytest.mark.parametrize( "incoming,existing,expected", @@ -4431,41 +4535,55 @@ class TestStrategyRouterWriteValidation: @pytest.mark.asyncio @pytest.mark.parametrize( - "limit,effective_config,db_held,config_holds_one,model_id,expected", + "limit,effective_params,db_models,config_config,model_id,expected", [ - (1, _V2, 1, False, None, "refused"), - (1, _V2, 0, True, None, "refused"), - (1, _V2, 0, False, None, "reserved"), - (1, _V2, 0, False, "held-id", "reserved"), - (2, _V2, 1, False, None, "reserved"), - (1, _V1, 5, True, None, "plain"), - (1, None, 5, True, None, "plain"), - (None, _V2, 5, True, None, "plain"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _V2}, ["auto_router/complexity_router"], None, None, "refused"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _V2}, [], _V2, None, "refused"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _V2}, [], None, None, "reserved"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _V2}, [], None, "held-id", "reserved"), + (2, {"model": "auto_router/complexity_router", "complexity_router_config": _V2}, ["auto_router/complexity_router"], None, None, "reserved"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _CUSTOM_TIERS}, ["openai/gpt-4o"], None, None, "reserved"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _CUSTOM_TIERS}, [], _CUSTOM_PROMPT, None, "refused"), + (1, {"model": "openai/gpt-4o", "complexity_router_config": _CUSTOM_TIERS}, ["auto_router/complexity_router"], None, None, "plain"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _V1}, ["auto_router/complexity_router"], _V2, None, "plain"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": None}, ["auto_router/complexity_router"], _V2, None, "plain"), + (None, {"model": "auto_router/complexity_router", "complexity_router_config": _V2}, ["auto_router/complexity_router"], _V2, None, "plain"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _TIER_LABELS_ONLY}, ["auto_router/complexity_router"], _CUSTOM_TIERS, None, "plain"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _CUSTOM_PROMPT}, ["auto_router/complexity_router"], None, None, "refused"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _OPERATOR_EXAMPLES}, [], _CUSTOM_TIERS, None, "refused"), + (1, {"model": "auto_router/complexity_router", "complexity_router_config": _OPERATOR_OPENING_PROMPT}, [], _CUSTOM_PROMPT, None, "refused"), + (None, {"model": "auto_router/complexity_router", "complexity_router_config": _OPERATOR_EXAMPLES}, ["auto_router/complexity_router"], _CUSTOM_TIERS, None, "plain"), ], ) - async def test_heuristic_v2_slot_matrix( + async def test_auto_router_capability_slot_matrix( self, limit: int | None, - effective_config: object, - db_held: int, - config_holds_one: bool, + effective_params: Mapping[str, object], + db_models: list[str], + config_config: Mapping[str, object] | None, model_id: str | None, expected: str, ) -> None: - """The slot is claimed inside a locked transaction only for a heuristic_v2 write under a limit; the DB rows - (other pods included) plus config.yaml routers decide, the row being edited is excluded through the SQL - parameter, and every other write runs on the plain client with no lock.""" + """The slot is claimed inside a locked transaction only for a write that claims a licensed capability + under a limit; the DB rows (other pods included) plus config.yaml routers decide, the row being edited + is excluded through the SQL parameter, and every other write runs on the plain client with no lock. + + heuristic_v2 has its own slot, while custom tier definitions and custom prompts count into one shared + customization slot. Renaming built-in tiers through tier_labels claims nothing at all.""" from fastapi import HTTPException from litellm.proxy.management_endpoints.model_management_endpoints import ( - HEURISTIC_V2_SLOT_LOCK_KEY, - _heuristic_v2_slot, + AUTO_ROUTER_CAPABILITY_SLOT_LOCK_KEY, + _auto_router_capability_slot, ) + from litellm.router_utils.auto_router_model_naming import gated_capability_of - fake = self._FakeDb(db_held) - live_router = self._live_router_holding_one_heuristic_v2(limit) if config_holds_one else None + capability = gated_capability_of(effective_params) + + fake = self._FakeDb(db_models) + live_router = self._live_router_holding_one_capability(limit, config_config) if config_config is not None else None with ( - patch("litellm.proxy.proxy_server._license_check.heuristic_v2_router_limit", lambda: limit), # test-quality-ok: the guard reads the proxy license singleton with no injection seam + patch("litellm.proxy.proxy_server._license_check.auto_router_capability_limit", lambda: limit), # test-quality-ok: the guard reads the proxy license singleton with no injection seam patch("litellm.proxy.proxy_server.llm_router", live_router), # test-quality-ok: the guard reads the proxy router global with no injection seam patch( # test-quality-ok: the cross-pod publish is the side effect under test; redis is not configured here "litellm.proxy.management_endpoints.model_management_endpoints.publish_config_change", @@ -4474,13 +4592,15 @@ class TestStrategyRouterWriteValidation: ): if expected == "refused": with pytest.raises(HTTPException) as exc_info: - async with _heuristic_v2_slot(fake, effective_config=effective_config, model_id=model_id): + async with _auto_router_capability_slot(fake, effective_params=effective_params, model_id=model_id): pass assert exc_info.value.status_code == 403 + assert capability is not None assert "At most 1 auto-router" in str(exc_info.value.detail) + assert capability.subject in str(exc_info.value.detail) assert "'auto_router' feature lifts the limit" in str(exc_info.value.detail) return - async with _heuristic_v2_slot(fake, effective_config=effective_config, model_id=model_id) as tables: + async with _auto_router_capability_slot(fake, effective_params=effective_params, model_id=model_id) as tables: handle = tables if expected == "plain": await handle.create(data={}) @@ -4489,10 +4609,13 @@ class TestStrategyRouterWriteValidation: return assert handle is fake.tx_obj.litellm_proxymodeltable published.assert_awaited_once_with(redis_cache=None, object_type="litellm_proxymodeltable") - (lock_sql, lock_params), (_count_sql, count_params) = fake.tx_obj.raw_calls + (lock_sql, lock_params), (count_sql, count_params) = fake.tx_obj.raw_calls assert "pg_advisory_xact_lock($1)" in lock_sql and "count" not in lock_sql - assert lock_params == (HEURISTIC_V2_SLOT_LOCK_KEY,) + assert lock_params == (AUTO_ROUTER_CAPABILITY_SLOT_LOCK_KEY,) assert count_params == (model_id or "",) + assert "AS model" in count_sql + assert capability is not None + assert capability.sql_config_predicate.split("{config}")[-1].strip() in count_sql @pytest.mark.asyncio async def test_team_model_bookkeeping_runs_after_the_slot_is_released(self) -> None: @@ -4549,14 +4672,14 @@ class TestStrategyRouterWriteValidation: ) admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) - fake = self._FakeDb(db_held=1) + fake = self._FakeDb(["auto_router/complexity_router"]) with ( patch("litellm.proxy.proxy_server.prisma_client", fake), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.llm_router", None), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam - patch("litellm.proxy.proxy_server._license_check.heuristic_v2_router_limit", lambda: 1), # test-quality-ok: endpoint reads proxy server globals with no injection seam + patch("litellm.proxy.proxy_server._license_check.auto_router_capability_limit", lambda: 1), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch( # test-quality-ok: prior auth check needs a live DB; only the license limit is under test "litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call", new=AsyncMock(return_value=None), @@ -4579,6 +4702,93 @@ class TestStrategyRouterWriteValidation: fake.tx_obj.litellm_proxymodeltable.create.assert_not_awaited() fake.litellm_proxymodeltable.create.assert_not_awaited() + @pytest.mark.asyncio + async def test_model_less_patch_rejects_router_config_on_a_regular_model(self) -> None: + """PATCH rejects the poison before its row write or the capability slot.""" + from litellm.proxy._types import ProxyException + from litellm.proxy.management_endpoints.model_management_endpoints import patch_model + from litellm.types.router import updateLiteLLMParams + + model_id = "regular-model" + admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + regular = Deployment( + model_name="regular-model", + litellm_params=LiteLLM_Params(model="openai/gpt-4o-mini"), + model_info={"id": model_id}, + ) + fake = self._FakeDb([]) + with ( + patch("litellm.proxy.proxy_server.prisma_client", fake), # test-quality-ok: endpoint reads proxy globals with no injection seam + patch("litellm.proxy.proxy_server.llm_router", None), # test-quality-ok: config-based lookup must be absent to drive the stored-row branch + patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reaches its DB-write branch only with this process setting + patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: authorization branch reads the proxy-wide premium flag + patch( # test-quality-ok: inject stored regular row without a database + "litellm.proxy.management_endpoints.model_management_endpoints.get_db_model", + new=AsyncMock(return_value=regular), + ), + patch( # test-quality-ok: endpoint must reject before database authorization needs a live store + "litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call", + new=AsyncMock(return_value=None), + ), + ): + with pytest.raises(ProxyException) as exc_info: + await patch_model( + model_id=model_id, + patch_data=updateDeployment( + litellm_params=updateLiteLLMParams(complexity_router_config=self._CUSTOM_TIERS) + ), + user_api_key_dict=admin, + ) + + assert exc_info.value.code == "400" + assert "does not start with 'auto_router/'" in str(exc_info.value.message) + assert fake.tx_obj.raw_calls == [] + assert fake.tx_obj.litellm_proxymodeltable.update.await_count == 0 + assert fake.litellm_proxymodeltable.update.await_count == 0 + + @pytest.mark.asyncio + async def test_model_less_legacy_update_rejects_router_config_on_a_regular_model(self) -> None: + """The legacy update endpoint enforces the same boundary before its row write or slot.""" + from litellm.proxy._types import ProxyException + from litellm.proxy.management_endpoints.model_management_endpoints import update_model + from litellm.types.router import ModelInfo, updateLiteLLMParams + + model_id = "regular-model" + admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) + regular = Deployment( + model_name="regular-model", + litellm_params=LiteLLM_Params(model="openai/gpt-4o-mini"), + model_info={"id": model_id}, + ) + existing_row = MagicMock() + existing_row.model_dump.return_value = regular.model_dump() + existing_row.litellm_params = regular.litellm_params.model_dump() + fake = self._FakeDb([], existing_row=existing_row) + with ( + patch("litellm.proxy.proxy_server.prisma_client", fake), # test-quality-ok: endpoint reads proxy globals with no injection seam + patch("litellm.proxy.proxy_server.llm_router", None), # test-quality-ok: config-based lookup must be absent to drive the stored-row branch + patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reaches its DB-write branch only with this process setting + patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: authorization branch reads the proxy-wide premium flag + patch( # test-quality-ok: endpoint must reject before database authorization needs a live store + "litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call", + new=AsyncMock(return_value=None), + ), + ): + with pytest.raises(ProxyException) as exc_info: + await update_model( + model_params=updateDeployment( + litellm_params=updateLiteLLMParams(complexity_router_config=self._CUSTOM_TIERS), + model_info=ModelInfo(id=model_id), + ), + user_api_key_dict=admin, + ) + + assert exc_info.value.code == "400" + assert "does not start with 'auto_router/'" in str(exc_info.value.message) + assert fake.tx_obj.raw_calls == [] + assert fake.tx_obj.litellm_proxymodeltable.update.await_count == 0 + assert fake.litellm_proxymodeltable.update.await_count == 0 + @pytest.mark.asyncio async def test_patch_model_refuses_switching_another_router_to_heuristic_v2(self) -> None: """patch_model relays HTTPException as-is, so the license refusal reaches the client as a plain 403.""" @@ -4591,14 +4801,14 @@ class TestStrategyRouterWriteValidation: model_id = "other-id" admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN) - fake = self._FakeDb(db_held=1) + fake = self._FakeDb(["auto_router/complexity_router"]) with ( patch("litellm.proxy.proxy_server.prisma_client", fake), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.llm_router", None), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam - patch("litellm.proxy.proxy_server._license_check.heuristic_v2_router_limit", lambda: 1), # test-quality-ok: endpoint reads proxy server globals with no injection seam + patch("litellm.proxy.proxy_server._license_check.auto_router_capability_limit", lambda: 1), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch( # test-quality-ok: the write must be refused before this DB step runs "litellm.proxy.management_endpoints.model_management_endpoints.get_db_model", new=AsyncMock(return_value=self._db_complexity_router(model_id)), @@ -4643,14 +4853,14 @@ class TestStrategyRouterWriteValidation: "model_info": {"id": model_id}, } existing_row.litellm_params = existing_row.model_dump.return_value["litellm_params"] - fake = self._FakeDb(db_held=1, existing_row=existing_row) + fake = self._FakeDb(["auto_router/complexity_router"], existing_row=existing_row) with ( patch("litellm.proxy.proxy_server.prisma_client", fake), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.llm_router", None), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.store_model_in_db", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch("litellm.proxy.proxy_server.premium_user", True), # test-quality-ok: endpoint reads proxy server globals with no injection seam - patch("litellm.proxy.proxy_server._license_check.heuristic_v2_router_limit", lambda: 1), # test-quality-ok: endpoint reads proxy server globals with no injection seam + patch("litellm.proxy.proxy_server._license_check.auto_router_capability_limit", lambda: 1), # test-quality-ok: endpoint reads proxy server globals with no injection seam patch( # test-quality-ok: prior auth check needs a live DB; only the license limit is under test "litellm.proxy.management_endpoints.model_management_endpoints.ModelManagementAuthChecks.can_user_make_model_call", new=AsyncMock(return_value=None), diff --git a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py index dcfad8f6815..2babfe432f3 100644 --- a/tests/test_litellm/proxy/proxy_server/test_proxy_config.py +++ b/tests/test_litellm/proxy/proxy_server/test_proxy_config.py @@ -28,7 +28,7 @@ from litellm.proxy.proxy_server import ( resolve_routing_plugins, validate_deployment_complexity_router_placement, validate_deployment_max_agentic_loops, - validate_heuristic_v2_router_limit, + validate_auto_router_capability_limits, ) from .conftest import normalize @@ -204,13 +204,71 @@ def _heuristic_v2_row(model_name: str, classifier_type: str = "heuristic_v2") -> } -def test_validate_heuristic_v2_router_limit_refuses_to_start_over_the_limit() -> None: +def _custom_tier_row(model_name: str) -> dict[str, object]: + return { + "model_name": model_name, + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": { + "classifier_type": "llm", + "tier_definitions": [ + {"name": "routine", "description": "routine drafting"}, + {"name": "hard", "description": "hard reasoning"}, + ], + "tiers": {"routine": "gpt-4o-mini", "hard": "gpt-4o"}, + "fallback_tier": "routine", + }, + }, + } + + +def _operator_examples_row(model_name: str) -> dict[str, object]: + return { + "model_name": model_name, + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini"}, + "tiers": {"SIMPLE": "gpt-4o-mini"}, + "classification_examples": '- "reset my password" -> SIMPLE', + }, + }, + } + + +def _custom_prompt_row(model_name: str) -> dict[str, object]: + return { + "model_name": model_name, + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_config": { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini", "system_prompt": "judge it my way"}, + "tiers": {"SIMPLE": "gpt-4o-mini"}, + }, + }, + } + + +@pytest.mark.parametrize( + "over_limit_rows,subject", + [ + ([_heuristic_v2_row("a"), _heuristic_v2_row("b"), _heuristic_v2_row("c", "heuristic")], "heuristic_v2"), + ([_custom_tier_row("a"), _custom_tier_row("b"), _heuristic_v2_row("c", "heuristic")], "tier_definitions"), + ([_custom_prompt_row("a"), _custom_prompt_row("b"), _heuristic_v2_row("c", "heuristic")], "operator-written classifier prompt"), + ([_custom_tier_row("a"), _custom_prompt_row("b"), _heuristic_v2_row("c", "heuristic")], "operator-written classifier prompt"), + ([_operator_examples_row("a"), _custom_tier_row("b"), _heuristic_v2_row("c", "heuristic")], "operator-written classifier prompt"), + ], +) +def test_validate_auto_router_capability_limits_refuses_to_start_over_the_limit( + over_limit_rows: list[dict[str, object]], subject: str +) -> None: """Same reason as the two validators above: the proxy router swallows registration errors, so an over-limit config.yaml must fail here instead of booting with a silently missing router.""" with pytest.raises(ValueError, match=re.escape("At most 1 auto-router")) as exc_info: - validate_heuristic_v2_router_limit( - [_heuristic_v2_row("a"), _heuristic_v2_row("b"), _heuristic_v2_row("c", "heuristic")], limit=1 - ) + validate_auto_router_capability_limits(over_limit_rows, limit=1) + assert subject in str(exc_info.value) assert "'auto_router' feature lifts the limit" in str(exc_info.value) @@ -220,12 +278,15 @@ def test_validate_heuristic_v2_router_limit_refuses_to_start_over_the_limit() -> ([_heuristic_v2_row("a"), _heuristic_v2_row("b")], None), ([_heuristic_v2_row("a"), _heuristic_v2_row("c", "heuristic")], 1), ([{"model_name": "gpt-4o", "litellm_params": {"model": "gpt-4o"}}], 1), + ([_custom_tier_row("a"), _custom_tier_row("b")], None), + ([_custom_tier_row("a"), _heuristic_v2_row("b")], 1), ], ) -def test_validate_heuristic_v2_router_limit_leaves_configs_within_the_limit_alone( +def test_validate_auto_router_capability_limits_leaves_configs_within_the_limit_alone( model_list: list[dict[str, object]], limit: int | None ) -> None: - assert validate_heuristic_v2_router_limit(model_list, limit=limit) is None + """The last case is the separate-ceiling invariant: one router of each capability fits under a limit of one.""" + assert validate_auto_router_capability_limits(model_list, limit=limit) is None _TWO_HEURISTIC_V2_ROUTERS_YAML = ( @@ -247,7 +308,7 @@ _TWO_HEURISTIC_V2_ROUTERS_YAML = ( " classifier_type: heuristic_v2\n" " tiers: {SIMPLE: gpt-4o-mini}\n" "router_settings:\n" - " heuristic_v2_router_limit: 99\n" + " auto_router_capability_limit: 99\n" ) @@ -256,7 +317,7 @@ _TWO_HEURISTIC_V2_ROUTERS_YAML = ( async def test_ProxyConfig_load_config_takes_the_heuristic_v2_limit_from_the_license_only( tmp_path, monkeypatch, license_limit: int | None ) -> None: - """`router_settings.heuristic_v2_router_limit` is managed outside config.yaml: an operator + """`router_settings.auto_router_capability_limit` is managed outside config.yaml: an operator cannot grant the entitlement by editing the config, and a licensed proxy boots both routers.""" f = tmp_path / "c.yaml" f.write_text(_TWO_HEURISTIC_V2_ROUTERS_YAML) @@ -264,15 +325,15 @@ async def test_ProxyConfig_load_config_takes_the_heuristic_v2_limit_from_the_lic monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False) monkeypatch.setattr( - "litellm.proxy.proxy_server._license_check.heuristic_v2_router_limit", lambda: license_limit + "litellm.proxy.proxy_server._license_check.auto_router_capability_limit", lambda: license_limit ) if license_limit is None: router, _model_list, _general_settings = await ProxyConfig().load_config( router=None, config_file_path=str(f) ) - assert router.heuristic_v2_router_limit is not None - assert router.heuristic_v2_router_limit() is None + assert router.auto_router_capability_limit is not None + assert router.auto_router_capability_limit() is None assert sorted(router.complexity_routers) == ["v2-a", "v2-b"] return @@ -296,12 +357,12 @@ async def test_ProxyConfig_load_config_router_refuses_a_db_heuristic_v2_router_b monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", None) monkeypatch.setattr("litellm.proxy.proxy_server.store_model_in_db", False) monkeypatch.delenv("LITELLM_CONFIG_BUCKET_NAME", raising=False) - monkeypatch.setattr("litellm.proxy.proxy_server._license_check.heuristic_v2_router_limit", lambda: 1) + monkeypatch.setattr("litellm.proxy.proxy_server._license_check.auto_router_capability_limit", lambda: 1) router, _model_list, _general_settings = await ProxyConfig().load_config(router=None, config_file_path=str(f)) - assert router.heuristic_v2_router_limit is not None - assert router.heuristic_v2_router_limit() == 1 + assert router.auto_router_capability_limit is not None + assert router.auto_router_capability_limit() == 1 assert sorted(router.complexity_routers) == ["v1-b", "v2-a"] db_row = Deployment(**_heuristic_v2_row("v2-from-db"), model_info={"id": "db-id"}) assert router.upsert_deployment(db_row) is None diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 52e58304476..918ec7bc100 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -15,6 +15,12 @@ from pydantic import ValidationError import litellm from litellm import Router +from litellm.router_utils.auto_router_model_naming import ( + CUSTOMIZATION_CAPABILITY, + GATED_AUTO_ROUTER_CAPABILITIES, + HEURISTIC_V2_CAPABILITY, + count_capability_routers, +) from litellm._logging import verbose_router_logger from litellm.caching.dual_cache import DualCache from litellm.constants import RETURN_RAW_MODEL_NAME_METADATA_KEY, SESSION_ID_GENERATED_METADATA_KEY @@ -46,7 +52,6 @@ from litellm.router_strategy.complexity_router.tier_predictor import ( TierGlobalStatistic, TrainedTierArtifact, ) -from litellm.router_utils.auto_router_model_naming import count_heuristic_v2_routers from litellm.types.router import ( Deployment, LiteLLM_Params, @@ -1124,7 +1129,7 @@ class TestRouterComplexityDeploymentMethods: self._router_row("v2-b", "id-b", "heuristic_v2"), self._router_row("v1-c", "id-c", "heuristic"), ], - heuristic_v2_router_limit=lambda: 1, + auto_router_capability_limit=lambda: 1, ignore_invalid_deployments=True, ) @@ -1139,7 +1144,7 @@ class TestRouterComplexityDeploymentMethods: self._router_row("v2-a", "id-a", "heuristic_v2"), self._router_row("v2-b", "id-b", "heuristic_v2"), ], - heuristic_v2_router_limit=lambda: 1, + auto_router_capability_limit=lambda: 1, ) def test_heuristic_v2_limit_is_resolved_on_every_registration(self) -> None: @@ -1152,14 +1157,14 @@ class TestRouterComplexityDeploymentMethods: self._router_row("v2-a", "id-a", "heuristic_v2"), self._router_row("v2-b", "id-b", "heuristic_v2"), ], - heuristic_v2_router_limit=lambda: limits["value"], + auto_router_capability_limit=lambda: limits["value"], ignore_invalid_deployments=True, ) assert sorted(router.complexity_routers) == ["v2-a", "v2-b"] - assert router.heuristic_v2_router_limit_violation() is None + assert router.auto_router_capability_violation(HEURISTIC_V2_CAPABILITY) is None limits["value"] = 1 - assert router.heuristic_v2_router_limit_violation() is not None + assert router.auto_router_capability_violation(HEURISTIC_V2_CAPABILITY) is not None assert router.upsert_deployment(Deployment(**self._router_row("v2-c", "id-c", "heuristic_v2"))) is None assert sorted(router.complexity_routers) == ["v2-a", "v2-b"] @@ -1174,7 +1179,7 @@ class TestRouterComplexityDeploymentMethods: self._router_row("v2-a", "id-a", "heuristic_v2"), self._router_row("v2-b", "id-b", "heuristic_v2"), ], - heuristic_v2_router_limit=lambda: limits["value"], + auto_router_capability_limit=lambda: limits["value"], ignore_invalid_deployments=True, ) limits["value"] = 1 @@ -1195,7 +1200,7 @@ class TestRouterComplexityDeploymentMethods: assert router.upsert_deployment(Deployment(**db_row)) is not None assert sorted(str(row["model_name"]) for row in router.config_deployments()) == ["gpt-4o-mini", "v2-a"] - assert count_heuristic_v2_routers(router.config_deployments()) == 1 + assert count_capability_routers(router.config_deployments(), capability=HEURISTIC_V2_CAPABILITY) == 1 def test_failed_edit_of_a_live_v2_router_rolls_back_without_the_ceiling(self) -> None: """A rollback after a failed upsert re-admits state that was already serving, so it must not be @@ -1208,7 +1213,7 @@ class TestRouterComplexityDeploymentMethods: self._router_row("v2-a", "id-a", "heuristic_v2"), self._router_row("v2-b", "id-b", "heuristic_v2"), ], - heuristic_v2_router_limit=lambda: limits["value"], + auto_router_capability_limit=lambda: limits["value"], ignore_invalid_deployments=True, ) limits["value"] = 1 @@ -1220,7 +1225,7 @@ class TestRouterComplexityDeploymentMethods: assert sorted(router.complexity_routers) == ["v2-a", "v2-b"] live = router.get_deployment(model_id="id-a") assert live is not None and live.litellm_params.complexity_router_config["classifier_type"] == "heuristic_v2" - assert router.heuristic_v2_router_limit_violation() is not None + assert router.auto_router_capability_violation(HEURISTIC_V2_CAPABILITY) is not None def test_heuristic_v2_routers_are_unlimited_by_default(self) -> None: router = Router( @@ -1232,18 +1237,18 @@ class TestRouterComplexityDeploymentMethods: ) assert sorted(router.complexity_routers) == ["v2-a", "v2-b"] - assert router.heuristic_v2_router_limit_violation() is None + assert router.auto_router_capability_violation(HEURISTIC_V2_CAPABILITY) is None - def test_heuristic_v2_router_limit_violation_frees_the_slot_of_the_router_being_edited(self) -> None: + def test_auto_router_capability_violation_frees_the_slot_of_the_router_being_edited(self) -> None: """A DB reload upserts the existing heuristic_v2 router again; that edit must keep its own slot while a different deployment switching to heuristic_v2 is refused.""" router = Router( model_list=[self._POOL, self._router_row("v2-a", "id-a", "heuristic_v2")], - heuristic_v2_router_limit=lambda: 1, + auto_router_capability_limit=lambda: 1, ignore_invalid_deployments=True, ) - assert router.heuristic_v2_router_limit_violation() is not None + assert router.auto_router_capability_violation(HEURISTIC_V2_CAPABILITY) is not None edited = self._router_row("v2-a-renamed", "id-a", "heuristic_v2") assert router.upsert_deployment(Deployment(**edited)) is not None @@ -1254,6 +1259,205 @@ class TestRouterComplexityDeploymentMethods: assert router.upsert_deployment(Deployment(**self._router_row("v1-c", "id-c", "heuristic"))) is not None assert sorted(router.complexity_routers) == ["v1-c", "v2-a-renamed"] + @staticmethod + def _custom_tier_row(model_name: str, model_id: str) -> dict[str, object]: + return { + "model_name": model_name, + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_default_model": "gpt-4o-mini", + "complexity_router_config": { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini"}, + "tier_definitions": [ + {"name": "routine", "description": "routine drafting and lookups"}, + {"name": "hard", "description": "multi-step reasoning under tradeoffs"}, + ], + "tiers": {"routine": "gpt-4o-mini", "hard": "gpt-4o"}, + "fallback_tier": "routine", + }, + }, + "model_info": {"id": model_id}, + } + + @staticmethod + def _custom_prompt_row(model_name: str, model_id: str) -> dict[str, object]: + return { + "model_name": model_name, + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_default_model": "gpt-4o-mini", + "complexity_router_config": { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini", "system_prompt": "judge it my way"}, + "tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o"}, + }, + }, + } | {"model_info": {"id": model_id}} + + def test_a_second_custom_prompt_router_is_refused_under_the_ceiling(self) -> None: + """An operator-written classifier system_prompt is metered like the other licensed capabilities.""" + with pytest.raises(ValueError, match="operator-written classifier prompt"): + Router( + model_list=[ + self._POOL, + self._custom_prompt_row("prompt-a", "id-a"), + self._custom_prompt_row("prompt-b", "id-b"), + ], + auto_router_capability_limit=lambda: 1, + ) + + def test_the_shipped_rubric_and_default_prompt_stay_free(self) -> None: + """Only an operator-written prompt is gated: picking a shipped rubric preset, or writing no + prompt at all, leaves a router unmetered, so several of them register under a ceiling of one.""" + def rubric(model_name: str, model_id: str, preset: str | None) -> dict[str, object]: + llm_config: dict[str, object] = {"model": "gpt-4o-mini"} + if preset is not None: + llm_config["classification_rubric"] = preset + return { + "model_name": model_name, + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_default_model": "gpt-4o-mini", + "complexity_router_config": { + "classifier_type": "llm", + "classifier_llm_config": llm_config, + "tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o"}, + }, + }, + "model_info": {"id": model_id}, + } + + router = Router( + model_list=[ + self._POOL, + rubric("default-a", "id-a", None), + rubric("preset-b", "id-b", "agentic"), + rubric("preset-c", "id-c", "chat"), + ], + auto_router_capability_limit=lambda: 1, + ) + + assert sorted(router.complexity_routers) == ["default-a", "preset-b", "preset-c"] + + def test_a_second_custom_tier_router_is_refused_under_the_ceiling(self) -> None: + """Operator-defined tier sets are metered like heuristic_v2: one per proxy without the license.""" + with pytest.raises(ValueError, match="tier_definitions"): + Router( + model_list=[ + self._POOL, + self._custom_tier_row("tiers-a", "id-a"), + self._custom_tier_row("tiers-b", "id-b"), + ], + auto_router_capability_limit=lambda: 1, + ) + + def test_custom_tier_routers_are_unlimited_with_the_license_feature(self) -> None: + router = Router( + model_list=[ + self._POOL, + self._custom_tier_row("tiers-a", "id-a"), + self._custom_tier_row("tiers-b", "id-b"), + ], + auto_router_capability_limit=lambda: None, + ) + + assert sorted(router.complexity_routers) == ["tiers-a", "tiers-b"] + assert router.auto_router_capability_violation(CUSTOMIZATION_CAPABILITY) is None + + def test_each_capability_holds_its_own_slot(self) -> None: + """heuristic_v2 has its own slot, while custom tiers and custom prompts share one customization + slot: one v2 plus EITHER customization fits, but a second customization of any form is refused.""" + router = Router( + model_list=[ + self._POOL, + self._router_row("v2-a", "id-a", "heuristic_v2"), + self._custom_tier_row("tiers-a", "id-t"), + ], + auto_router_capability_limit=lambda: 1, + ignore_invalid_deployments=True, + ) + + assert sorted(router.complexity_routers) == ["tiers-a", "v2-a"] + assert router.auto_router_capability_violation(HEURISTIC_V2_CAPABILITY) is not None + assert router.auto_router_capability_violation(CUSTOMIZATION_CAPABILITY) is not None + + assert router.upsert_deployment(Deployment(**self._custom_tier_row("tiers-b", "id-t2"))) is None + assert router.upsert_deployment(Deployment(**self._custom_prompt_row("prompt-b", "id-p2"))) is None + assert router.upsert_deployment(Deployment(**self._router_row("v2-b", "id-b", "heuristic_v2"))) is None + assert sorted(router.complexity_routers) == ["tiers-a", "v2-a"] + + @staticmethod + def _operator_prompt_row(model_name: str, model_id: str, field: str) -> dict[str, object]: + return { + "model_name": model_name, + "litellm_params": { + "model": "auto_router/complexity_router", + "complexity_router_default_model": "gpt-4o-mini", + "complexity_router_config": { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini"}, + "tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o"}, + field: '- "reset my password" -> SIMPLE', + }, + }, + "model_info": {"id": model_id}, + } + + @pytest.mark.parametrize("field", ["classification_prompt", "classification_examples"]) + def test_operator_written_prompt_sections_claim_the_customization_slot(self, field: str) -> None: + """The dashboard prompt editor writes opening instructions and calibration examples as their own + fields on a BUILT-IN tier router, so each must claim the slot on its own.""" + with pytest.raises(ValueError, match="operator-written classifier prompt"): + Router( + model_list=[ + self._POOL, + self._operator_prompt_row("prompt-a", "id-a", field), + self._operator_prompt_row("prompt-b", "id-b", field), + ], + auto_router_capability_limit=lambda: 1, + ) + + @pytest.mark.parametrize("field", ["classification_prompt", "classification_examples"]) + def test_an_operator_prompt_section_claims_the_slot_held_by_custom_tiers(self, field: str) -> None: + """Switching the FORM of customization cannot buy a second unlicensed router.""" + with pytest.raises(ValueError, match="operator-written classifier prompt"): + Router( + model_list=[ + self._POOL, + self._custom_tier_row("tiers-a", "id-a"), + self._operator_prompt_row("prompt-b", "id-b", field), + ], + auto_router_capability_limit=lambda: 1, + ) + + def test_a_custom_prompt_claims_the_slot_held_by_custom_tiers(self) -> None: + """The customization ceiling is shared: changing its form cannot get a second unlicensed router.""" + with pytest.raises(ValueError, match="operator-written classifier prompt"): + Router( + model_list=[ + self._POOL, + self._custom_tier_row("tiers-a", "id-a"), + self._custom_prompt_row("prompt-b", "id-b"), + ], + auto_router_capability_limit=lambda: 1, + ) + + def test_renaming_built_in_tiers_is_not_a_custom_tier_set(self) -> None: + """tier_labels renames the built-in ladder without defining one, so it stays ungated: two such + routers register under a ceiling of one.""" + def labeled(model_name: str, model_id: str) -> dict[str, object]: + row = self._router_row(model_name, model_id, "heuristic") + row["litellm_params"]["complexity_router_config"]["tier_labels"] = {"SIMPLE": "Cheap", "MEDIUM": "Standard"} + return row + + router = Router( + model_list=[self._POOL, labeled("labels-a", "id-a"), labeled("labels-b", "id-b")], + auto_router_capability_limit=lambda: 1, + ) + + assert sorted(router.complexity_routers) == ["labels-a", "labels-b"] + def test_hybrid_initialization_waits_for_later_pool_deployments(self): router = Router( model_list=[ diff --git a/tests/test_litellm/router_utils/test_auto_router_model_naming.py b/tests/test_litellm/router_utils/test_auto_router_model_naming.py index 238d0546518..8dede941a14 100644 --- a/tests/test_litellm/router_utils/test_auto_router_model_naming.py +++ b/tests/test_litellm/router_utils/test_auto_router_model_naming.py @@ -5,9 +5,11 @@ import pytest from litellm.router_utils.auto_router_model_naming import ( carries_complexity_router_settings, classify_strategy_router_model, - count_heuristic_v2_routers, - heuristic_v2_limit_violation, - is_heuristic_v2_router, + GATED_AUTO_ROUTER_CAPABILITIES, + capability_limit_violation, + claimed_capability, + count_capability_routers, + gated_capability_of, strategy_router_dependencies, validate_complexity_router_config_placement, validate_complexity_router_config_write, @@ -376,38 +378,122 @@ def test_placement_is_scoped_to_complexity_router_deployments(model, present_fie assert carries_complexity_router_settings(model, present_fields) is scoped +_HV2_CONFIG: Mapping[str, object] = {"classifier_type": "heuristic_v2"} +_CUSTOM_TIER_CONFIG: Mapping[str, object] = { + "classifier_type": "llm", + "tier_definitions": [{"name": "routine", "description": "easy"}, {"name": "hard", "description": "hard"}], +} +_CUSTOM_PROMPT_CONFIG: Mapping[str, object] = { + "classifier_type": "llm", + "classifier_llm_config": {"model": "gpt-4o-mini", "system_prompt": "judge it my way"}, +} + + @pytest.mark.parametrize( - "litellm_params,expected", + "config,expected_key", [ - ({"model": "auto_router/complexity_router", "complexity_router_config": {"classifier_type": "heuristic_v2"}}, True), - ({"model": "auto_router/complexity_router-eu", "complexity_router_config": {"classifier_type": "heuristic_v2"}}, True), - ({"model": "auto_router/complexity_router", "complexity_router_config": {"classifier_type": "heuristic"}}, False), - ({"model": "auto_router/complexity_router", "complexity_router_config": {"tiers": {"SIMPLE": "a"}}}, False), - ({"model": "auto_router/complexity_router"}, False), - ({"model": "auto_router/quality_router", "complexity_router_config": {"classifier_type": "heuristic_v2"}}, False), - ({"model": "openai/gpt-4o", "complexity_router_config": {"classifier_type": "heuristic_v2"}}, False), - ({"model": "auto_router/complexity_router", "complexity_router_config": "heuristic_v2"}, False), - ({}, False), + (_CUSTOM_PROMPT_CONFIG, "tier_or_classifier_prompt"), + ({"classifier_type": "llm", "classifier_llm_config": {"model": "m"}, "classification_prompt": "grade it"}, "tier_or_classifier_prompt"), + ({"classifier_type": "llm", "classifier_llm_config": {"model": "m"}, "classification_examples": '- "x" -> SIMPLE'}, "tier_or_classifier_prompt"), + ({"classifier_type": "hybrid", "classification_examples": "- y -> MEDIUM"}, "tier_or_classifier_prompt"), + ({"classifier_type": "llm", "classifier_llm_config": {"model": "m"}, "classification_prompt": None, "classification_examples": None}, None), + ({"classifier_type": "heuristic", "classification_examples": "- x -> SIMPLE"}, None), + ({"classifier_type": "hybrid", "classifier_llm_config": {"system_prompt": "p"}}, "tier_or_classifier_prompt"), + ({"classifier_type": "heuristic_first", "classifier_llm_config": {"system_prompt": "p"}}, "tier_or_classifier_prompt"), + ({"classifier_type": "llm", "classifier_llm_config": {"model": "m", "classification_rubric": "chat"}}, None), + ({"classifier_type": "llm", "classifier_llm_config": {"model": "m"}}, None), + ({"classifier_type": "llm", "classifier_llm_config": {"model": "m", "system_prompt": None}}, None), + ({"classifier_type": "heuristic", "classifier_llm_config": {"system_prompt": "p"}}, None), + ({"classifier_type": "heuristic_v2", "classifier_llm_config": {"system_prompt": "p"}}, "heuristic_v2"), + ({"classifier_type": "llm", "classifier_llm_config": "not a mapping"}, None), ], ) -def test_is_heuristic_v2_router(litellm_params: Mapping[str, object], expected: bool) -> None: - """Only a complexity router whose config selects heuristic_v2 counts toward the license limit.""" - assert is_heuristic_v2_router(litellm_params) is expected +def test_custom_classifier_prompt_capability(config: Mapping[str, object], expected_key: str | None) -> None: + """Every operator-written part of the classifier prompt claims the customization slot: a whole + replacement system_prompt, replacement opening instructions (classification_prompt), or replacement + calibration examples (classification_examples). + + A shipped rubric preset stays free, and the heuristic scorers never read system_prompt, so a + value sitting on one is inert and claims nothing (heuristic_v2 still claims its own capability). + """ + claimed = claimed_capability(config) + assert (None if claimed is None else claimed.key) == expected_key -def test_count_heuristic_v2_routers_reads_model_list_rows_and_ignores_malformed_ones() -> None: - v2 = {"model": "auto_router/complexity_router", "complexity_router_config": {"classifier_type": "heuristic_v2"}} +@pytest.mark.parametrize( + "model,expected", + [ + ("auto_router/complexity_router", True), + ("auto_router/complexity_router-eu", True), + ("auto_router/semantic_router", False), + ("auto_router/adaptive_router", False), + ("auto_router/quality_router", False), + ("openai/gpt-4o", False), + (None, False), + ], +) +def test_is_complexity_router_model(model: str | None, expected: bool) -> None: + from litellm.router_utils.auto_router_model_naming import is_complexity_router_model + + assert is_complexity_router_model(model) is expected + + +@pytest.mark.parametrize( + "litellm_params,expected_key", + [ + ({"model": "auto_router/complexity_router", "complexity_router_config": _HV2_CONFIG}, "heuristic_v2"), + ({"model": "auto_router/complexity_router-eu", "complexity_router_config": _HV2_CONFIG}, "heuristic_v2"), + ({"model": "auto_router/complexity_router", "complexity_router_config": _CUSTOM_TIER_CONFIG}, "tier_or_classifier_prompt"), + ({"model": "auto_router/complexity_router-eu", "complexity_router_config": _CUSTOM_TIER_CONFIG}, "tier_or_classifier_prompt"), + ({"model": "auto_router/complexity_router", "complexity_router_config": {"classifier_type": "heuristic"}}, None), + ({"model": "auto_router/complexity_router", "complexity_router_config": {"tiers": {"SIMPLE": "a"}}}, None), + ({"model": "auto_router/complexity_router", "complexity_router_config": {"tier_definitions": None}}, None), + ({"model": "auto_router/complexity_router", "complexity_router_config": {"tier_labels": {"SIMPLE": "Cheap"}}}, None), + ({"model": "auto_router/complexity_router"}, None), + ({"model": "auto_router/quality_router", "complexity_router_config": _HV2_CONFIG}, None), + ({"model": "auto_router/quality_router", "complexity_router_config": _CUSTOM_TIER_CONFIG}, None), + ({"model": "openai/gpt-4o", "complexity_router_config": _HV2_CONFIG}, None), + ({"model": "openai/gpt-4o", "complexity_router_config": _CUSTOM_TIER_CONFIG}, None), + ({"model": "auto_router/complexity_router", "complexity_router_config": "heuristic_v2"}, None), + ({}, None), + ], +) +def test_gated_capability_of(litellm_params: Mapping[str, object], expected_key: str | None) -> None: + """Only a complexity router claiming a licensed capability counts toward that capability's limit. + + Renaming the built-in tiers through tier_labels is not a custom tier set, so it stays ungated. + """ + capability = gated_capability_of(litellm_params) + assert (None if capability is None else capability.key) == expected_key + + +@pytest.mark.parametrize("capability", GATED_AUTO_ROUTER_CAPABILITIES, ids=lambda c: c.key) +def test_count_capability_routers_counts_only_its_own_capability(capability) -> None: + """Each capability has its own ceiling, so a router claiming the sibling capability never counts, + while a custom tier set and a custom classifier prompt count into the SAME customization slot.""" + def row(name: str, config: Mapping[str, object] | None) -> Mapping[str, object]: + params = {"model": "auto_router/complexity_router"} | ({} if config is None else {"complexity_router_config": config}) + return {"model_name": name, "litellm_params": params} + + by_key = { + "heuristic_v2": (_HV2_CONFIG, _HV2_CONFIG), + "tier_or_classifier_prompt": (_CUSTOM_TIER_CONFIG, _CUSTOM_PROMPT_CONFIG), + } + mine_first, mine_second = by_key[capability.key] + theirs = next(configs[0] for key, configs in by_key.items() if key != capability.key) rows: list[Mapping[str, object]] = [ - {"model_name": "a", "litellm_params": v2}, - {"model_name": "b", "litellm_params": {"model": "openai/gpt-4o"}}, - {"model_name": "c", "litellm_params": v2}, - {"model_name": "d"}, - {"model_name": "e", "litellm_params": "not a mapping"}, + row("a", mine_first), + row("b", theirs), + {"model_name": "c", "litellm_params": {"model": "openai/gpt-4o"}}, + row("d", mine_second), + {"model_name": "e"}, + {"model_name": "f", "litellm_params": "not a mapping"}, ] - assert count_heuristic_v2_routers(rows) == 2 - assert count_heuristic_v2_routers(()) == 0 + assert count_capability_routers(rows, capability=capability) == 2 + assert count_capability_routers((), capability=capability) == 0 +@pytest.mark.parametrize("capability", GATED_AUTO_ROUTER_CAPABILITIES, ids=lambda c: c.key) @pytest.mark.parametrize( "held,limit,violates", [ @@ -419,10 +505,42 @@ def test_count_heuristic_v2_routers_reads_model_list_rows_and_ignores_malformed_ (4, 3, True), ], ) -def test_heuristic_v2_limit_violation(held: int, limit: int | None, violates: bool) -> None: - violation = heuristic_v2_limit_violation(held=held, limit=limit) +def test_capability_limit_violation(held: int, limit: int | None, violates: bool, capability) -> None: + violation = capability_limit_violation(capability=capability, held=held, limit=limit) assert (violation is not None) is violates if violation is not None: assert f"At most {limit} auto-router" in violation assert f"would make {held}" in violation + assert capability.subject in violation + assert capability.remedy in violation assert "license" not in violation + + +def test_every_gated_capability_has_a_distinct_predicate_and_sql_spelling() -> None: + """The in-process and SQL halves of a capability must stay paired, and no two capabilities may collide.""" + keys = tuple(capability.key for capability in GATED_AUTO_ROUTER_CAPABILITIES) + assert len(set(keys)) == len(keys) + for capability in GATED_AUTO_ROUTER_CAPABILITIES: + assert "{config}" in capability.sql_config_predicate + assert capability.uses is not None + + +@pytest.mark.parametrize( + "config", + [ + _HV2_CONFIG, + _CUSTOM_TIER_CONFIG, + _CUSTOM_PROMPT_CONFIG, + {"classifier_type": "heuristic"}, + {"classifier_type": "heuristic_v2", "classifier_llm_config": {"system_prompt": "p"}}, + {"classifier_type": "llm", "classifier_llm_config": {"model": "m", "system_prompt": "p"}, "tier_labels": {"SIMPLE": "Cheap"}}, + ], +) +def test_capabilities_are_mutually_exclusive_on_one_config(config: Mapping[str, object]) -> None: + """No config claims two capabilities, which is what lets one lock and one count serve them all. + + The config validator is what makes this true and is pinned separately in test_complexity_router: + tier_definitions rejects every heuristic classifier_type and rejects the classifier system_prompt, + and system_prompt only counts for the classifier types heuristic_v2 is not one of. + """ + assert sum(1 for capability in GATED_AUTO_ROUTER_CAPABILITIES if capability.uses(config)) <= 1 From 8284208af261bd32d78ff3fb43040117894fd358 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 5 Sep 2026 09:51:51 -0700 Subject: [PATCH 226/410] fix(auto-router compression): restrict both hops to real compression guardrails The two policy fields are operator-supplied names and nothing else constrained them. The routing hop calls apply_guardrail directly, which hands the guardrail the conversation and POSTs it to whatever service backs that guardrail, and the model hop is added to metadata["guardrails"], which runs it even when it is not default_on. So naming an ordinary guardrail turned either hop into a way to invoke it and ship prompt content to it. Both hops now refuse a name that does not resolve to an active compression guardrail, and say so in the log rather than failing quietly. --- .../guardrails/auto_router_compression.py | 52 ++++++++++++++--- .../test_auto_router_compression.py | 56 +++++++++++++++++-- tests/test_litellm/test_router.py | 7 ++- 3 files changed, 103 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py index e7f58662249..b6f32c1c46d 100644 --- a/litellm/proxy/guardrails/auto_router_compression.py +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -135,19 +135,36 @@ def team_id_from_request(request_kwargs: Mapping[str, object]) -> str | None: return None +def _compression_guardrail_classes() -> tuple[type, ...]: + """The registered guardrail classes whose provider compresses prompts.""" + from litellm.proxy.guardrails.guardrail_registry import guardrail_class_registry + + return tuple(cls for name, cls in guardrail_class_registry.items() if name in COMPRESSION_GUARDRAIL_PROVIDERS) + + +def is_compression_guardrail(guardrail: object) -> bool: + """Whether `guardrail` is an instance of a compression guardrail provider. + + Both hops are validated through here. The two policy fields are operator-supplied + names and nothing else constrains them, so without this a name that resolves to an + ordinary guardrail would be handed the conversation and invoked: the routing hop + calls `apply_guardrail` directly, which POSTs the content wherever that guardrail + sends it, and the model hop is added to `metadata["guardrails"]`, which runs it even + when it is not `default_on`. + """ + classes: Final = _compression_guardrail_classes() + return bool(classes) and isinstance(guardrail, classes) + + def _active_compression_guardrails() -> tuple["CustomGuardrail", ...]: """Every currently-active guardrail whose type is a compression guardrail.""" import litellm from litellm.integrations.custom_guardrail import CustomGuardrail - from litellm.proxy.guardrails.guardrail_registry import guardrail_class_registry - compression_classes: Final = tuple( - cls for name, cls in guardrail_class_registry.items() if name in COMPRESSION_GUARDRAIL_PROVIDERS - ) - if not compression_classes: + if not _compression_guardrail_classes(): return () active: Final = litellm.logging_callback_manager.get_custom_loggers_for_type(callback_type=CustomGuardrail) - return tuple(cb for cb in active if isinstance(cb, compression_classes) and cb.guardrail_name) + return tuple(cb for cb in active if is_compression_guardrail(cb) and cb.guardrail_name) async def arm_pre_call( @@ -192,7 +209,18 @@ async def arm_pre_call( ) ) - if policy.model is not None: + # Only a name that resolves to a real compression guardrail may be armed: this adds + # it to `metadata["guardrails"]`, which runs it even when it is not `default_on`. + armed_model_hop: Final = policy.model is not None and any( + guardrail.guardrail_name == policy.model for guardrail in _active_compression_guardrails() + ) + if policy.model is not None and not armed_model_hop: + verbose_proxy_logger.warning( + "AutoRouter compression: '%s' is not an active compression guardrail; the model hop is uncompressed", + policy.model, + ) + + if armed_model_hop: _model_hop_armed.set(True) _, metadata = get_or_create_metadata_bucket(data) requested: Final = metadata.get("guardrails") @@ -249,6 +277,16 @@ async def messages_for_routing( ) return _as_routing_messages(messages) + # apply_guardrail below hands this guardrail the conversation and it POSTs the + # content to whatever service backs it, so the name has to be a compression + # guardrail rather than any guardrail the operator happened to name. + if not is_compression_guardrail(guardrail): + verbose_proxy_logger.warning( + "AutoRouter compression: guardrail '%s' is not a compression guardrail; routing on uncompressed messages", + policy.routing, + ) + return _as_routing_messages(messages) + inputs: Final[GenericGuardrailAPIInputs] = { "structured_messages": _as_routing_messages(messages) # pyright: ignore[reportAssignmentType] # plain dicts, not AllMessageValues; see headroom.py's own use of this shape } diff --git a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py index 0b47e56cb02..676b3ba2967 100644 --- a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py +++ b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py @@ -186,15 +186,33 @@ class _RecordingCompressionGuardrail(CustomGuardrail): @pytest.fixture -def registered_guardrail(): +def registered_guardrail(monkeypatch): import litellm + from litellm.proxy.guardrails import guardrail_registry + # Registered under a compression provider name: both hops refuse a name that does + # not resolve to one, so a bare callback would (correctly) never be used. + monkeypatch.setitem(guardrail_registry.guardrail_class_registry, "headroom", _RecordingCompressionGuardrail) guardrail = _RecordingCompressionGuardrail(guardrail_name="fake-compress") litellm.logging_callback_manager.add_litellm_callback(guardrail) yield guardrail litellm.logging_callback_manager.remove_callback_from_all_lists(guardrail) +class _NonCompressionGuardrail(CustomGuardrail): + """A guardrail that is not a compression provider, e.g. a PII or content filter.""" + + def __init__(self, guardrail_name: str): + super().__init__(guardrail_name=guardrail_name) + self.called = False + + async def apply_guardrail( + self, inputs: GenericGuardrailAPIInputs, request_data: dict, input_type: str, logging_obj=None + ) -> GenericGuardrailAPIInputs: + self.called = True + return inputs + + class TestArmPreCall: @pytest.mark.asyncio async def test_no_router_is_noop(self): @@ -266,7 +284,13 @@ class TestArmPreCall: litellm.logging_callback_manager.remove_callback_from_all_lists(guardrail) @pytest.mark.asyncio - async def test_model_side_guardrail_is_requested_even_when_not_default_on(self): + async def test_model_side_guardrail_is_requested_even_when_not_default_on(self, monkeypatch): + import litellm + from litellm.proxy.guardrails import guardrail_registry + + monkeypatch.setitem(guardrail_registry.guardrail_class_registry, "headroom", _RecordingCompressionGuardrail) + active = _RecordingCompressionGuardrail(guardrail_name="headroom-b") + litellm.logging_callback_manager.add_litellm_callback(active) router = _FakeRouter( [ { @@ -280,8 +304,11 @@ class TestArmPreCall: ] ) data = {"model": "smart-router", "messages": [{"role": "user", "content": "hi"}]} - await arm_pre_call(data=data, llm_router=router) - assert data["metadata"]["guardrails"] == ["headroom-b"] + try: + await arm_pre_call(data=data, llm_router=router) + assert data["metadata"]["guardrails"] == ["headroom-b"] + finally: + litellm.logging_callback_manager.remove_callback_from_all_lists(active) @pytest.mark.asyncio async def test_arm_pre_call_keeps_no_copy_of_the_prompt(self): @@ -347,6 +374,27 @@ class TestMessagesForRouting: assert result == [{"role": "user", "content": "[COMPRESSED] my ssn is [REDACTED]"}] assert registered_guardrail.request_data_seen[0]["messages"] == masked + @pytest.mark.asyncio + async def test_a_non_compression_guardrail_is_never_invoked_for_routing(self, monkeypatch): + """Regression (security): the policy fields are operator-supplied names that + nothing else constrains. apply_guardrail hands the guardrail the conversation + and it POSTs that content to whatever service backs it, so naming an ordinary + guardrail must not turn the routing hop into a way to ship prompts there.""" + import litellm + + other = _NonCompressionGuardrail(guardrail_name="pii-filter") + litellm.logging_callback_manager.add_litellm_callback(other) + try: + policy = AutoRouterCompressionPolicy(routing="pii-filter", model=None) + messages = [{"role": "user", "content": "my ssn is 123-45-6789"}] + + result = await messages_for_routing(policy=policy, messages=messages, request_kwargs={}) + + assert other.called is False + assert result == messages + finally: + litellm.logging_callback_manager.remove_callback_from_all_lists(other) + @pytest.mark.asyncio async def test_guardrail_receives_a_throwaway_request_data_not_the_real_request_kwargs(self, registered_guardrail): """Regression: a real compression guardrail writes its stats onto whatever diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 9e6a88d3433..6279c8a5404 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -9686,7 +9686,12 @@ class TestAutoRouterCompressionDecoupling: return router, strategy @pytest.fixture - def registered_guardrail(self): + def registered_guardrail(self, monkeypatch): + from litellm.proxy.guardrails import guardrail_registry + + # Registered under a compression provider name: both hops refuse a name that + # does not resolve to one, so a bare callback would never be used. + monkeypatch.setitem(guardrail_registry.guardrail_class_registry, "headroom", self._CompressingGuardrail) guardrail = self._CompressingGuardrail(guardrail_name="fake-compress") litellm.logging_callback_manager.add_litellm_callback(guardrail) yield guardrail From 88985d00e2a1de43c616893141934cd0f444ce04 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 09:58:44 -0700 Subject: [PATCH 227/410] bump: litellm-enterprise 0.1.64 -> 0.1.65, litellm-proxy-extras 0.4.93 -> 0.4.94 --- enterprise/pyproject.toml | 4 ++-- litellm-proxy-extras/pyproject.toml | 4 ++-- pyproject.toml | 4 ++-- uv.lock | 6 +++--- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index b6f482ccd86..3699087dbfa 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-enterprise" -version = "0.1.64" +version = "0.1.65" description = "Package for LiteLLM Enterprise features" readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.1.64" +version = "0.1.65" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-enterprise==", diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 97e9eb66bf2..82d31fec373 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-proxy-extras" -version = "0.4.93" +version = "0.4.94" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.4.93" +version = "0.4.94" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-proxy-extras==", diff --git a/pyproject.toml b/pyproject.toml index c1fde4af3f5..b889a3a0e60 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,8 +67,8 @@ proxy = [ "azure-identity>=1.25.2,<2.0", "azure-storage-blob>=12.28.0,<13.0", "mcp>=1.28.1,<2.0", - "litellm-proxy-extras==0.4.93", - "litellm-enterprise==0.1.64", + "litellm-proxy-extras==0.4.94", + "litellm-enterprise==0.1.65", "RestrictedPython>=8.5,<9.0", "rich>=13.9.4,<14.0", "InquirerPy>=0.3.4,<1.0", diff --git a/uv.lock b/uv.lock index 9e6343ff375..89205cd9527 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-09-01T21:00:02.682921Z" +exclude-newer = "2026-09-02T16:58:34.594994Z" exclude-newer-span = "P3D" [manifest] @@ -4771,12 +4771,12 @@ proxy-dev = [ [[package]] name = "litellm-enterprise" -version = "0.1.64" +version = "0.1.65" source = { editable = "enterprise" } [[package]] name = "litellm-proxy-extras" -version = "0.4.93" +version = "0.4.94" source = { editable = "litellm-proxy-extras" } [[package]] From 1c16a5910b07415b2ab9cb6a54622f0296117f93 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 5 Sep 2026 10:31:56 -0700 Subject: [PATCH 228/410] fix(tests): undo a stray whole-file reformat and arm a real guardrail test_router.py is not ruff-formatted on staging and CI's format check only scopes litellm/*.py, so running ruff format over the whole file rewrote ~900 lines of unrelated code. That reflow split long single-line patch() calls into multi-line form, which the test-quality gate counts individually, pushing TQ008 four over its ceiling. The file is back to staging's formatting with only the compression test class added. test_common_request_processing.py armed a model-side guardrail name with no such guardrail registered, which stopped working once both hops began requiring the name to resolve to an active compression guardrail. --- .../proxy/test_common_request_processing.py | 33 +- tests/test_litellm/test_router.py | 919 +++++++++++++----- 2 files changed, 675 insertions(+), 277 deletions(-) diff --git a/tests/test_litellm/proxy/test_common_request_processing.py b/tests/test_litellm/proxy/test_common_request_processing.py index c0809e53d2e..96d9b0c5a26 100644 --- a/tests/test_litellm/proxy/test_common_request_processing.py +++ b/tests/test_litellm/proxy/test_common_request_processing.py @@ -383,6 +383,18 @@ class TestProxyBaseLLMRequestProcessing: """arm_pre_call must run before pre_call_hook: an auto router's own compression policy has to be in `data["metadata"]` (naming the model-side guardrail so it runs even if it isn't default_on) by the time guardrails see the request.""" + from litellm.integrations.custom_guardrail import CustomGuardrail + from litellm.proxy.guardrails import guardrail_registry + + # The model hop is only armed for a name that resolves to an active compression + # guardrail, so arming it has to have a real one to resolve to. + class _FakeCompressionGuardrail(CustomGuardrail): + pass + + monkeypatch.setitem(guardrail_registry.guardrail_class_registry, "headroom", _FakeCompressionGuardrail) + active_guardrail = _FakeCompressionGuardrail(guardrail_name="headroom-model") + litellm.logging_callback_manager.add_litellm_callback(active_guardrail) + processing_obj = ProxyBaseLLMRequestProcessing(data={}) mock_request = MagicMock(spec=Request) mock_request.headers = {} @@ -418,15 +430,18 @@ class TestProxyBaseLLMRequestProcessing: mock_proxy_config = MagicMock(spec=ProxyConfig) mock_proxy_config._get_hierarchical_router_settings = AsyncMock(return_value=None) - await processing_obj.common_processing_pre_call_logic( - request=mock_request, - general_settings={}, - user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"), - proxy_logging_obj=mock_proxy_logging_obj, - proxy_config=mock_proxy_config, - route_type="acompletion", - llm_router=fake_llm_router, - ) + try: + await processing_obj.common_processing_pre_call_logic( + request=mock_request, + general_settings={}, + user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-test"), + proxy_logging_obj=mock_proxy_logging_obj, + proxy_config=mock_proxy_config, + route_type="acompletion", + llm_router=fake_llm_router, + ) + finally: + litellm.logging_callback_manager.remove_callback_from_all_lists(active_guardrail) assert seen_metadata.get("guardrails") == ["headroom-model"] diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 6279c8a5404..d97fa7f2912 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -15,6 +15,7 @@ import pytest import respx + import litellm from litellm import Router from litellm.exceptions import MidStreamFallbackError @@ -136,18 +137,31 @@ def test_router_model_group_encrypted_content_affinity_callback_registration(): num_retries=0, ) callbacks = router.optional_callbacks or [] - encrypted_content_callbacks = [cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck)] - deployment_callback = next(cb for cb in callbacks if isinstance(cb, DeploymentAffinityCheck)) + encrypted_content_callbacks = [ + cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck) + ] + deployment_callback = next( + cb for cb in callbacks if isinstance(cb, DeploymentAffinityCheck) + ) assert len(encrypted_content_callbacks) == 1 assert encrypted_content_callbacks[0].enable_global_affinity is False - assert encrypted_content_callbacks[0].model_group_affinity_config == model_group_affinity_config - assert callbacks.index(encrypted_content_callbacks[0]) < callbacks.index(deployment_callback) - assert litellm.callbacks.index(encrypted_content_callbacks[0]) < (litellm.callbacks.index(deployment_callback)) + assert ( + encrypted_content_callbacks[0].model_group_affinity_config + == model_group_affinity_config + ) + assert callbacks.index(encrypted_content_callbacks[0]) < callbacks.index( + deployment_callback + ) + assert litellm.callbacks.index(encrypted_content_callbacks[0]) < ( + litellm.callbacks.index(deployment_callback) + ) router._add_encrypted_content_affinity_check(enable_global_affinity=True) callbacks = router.optional_callbacks or [] - encrypted_content_callbacks = [cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck)] + encrypted_content_callbacks = [ + cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck) + ] assert len(encrypted_content_callbacks) == 1 assert encrypted_content_callbacks[0].enable_global_affinity is True assert encrypted_content_callbacks[0].router is router @@ -178,9 +192,13 @@ async def test_encrypted_content_affinity_model_group_config_is_additive(): }, target_deployment, ] - encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id("deployment-b", "rs_test") + encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id( + "deployment-b", "rs_test" + ) - assert EncryptedContentAffinityCheck.has_model_group_affinity_enabled({model_group: ["encrypted_content_affinity"]}) + assert EncryptedContentAffinityCheck.has_model_group_affinity_enabled( + {model_group: ["encrypted_content_affinity"]} + ) assert not EncryptedContentAffinityCheck.has_model_group_affinity_enabled(None) per_group_check = EncryptedContentAffinityCheck( @@ -221,7 +239,10 @@ async def test_encrypted_content_affinity_model_group_config_is_additive(): ) assert unfiltered == healthy_deployments - assert "encrypted_content_affinity_enabled" not in disabled_request_kwargs["litellm_metadata"] + assert ( + "encrypted_content_affinity_enabled" + not in disabled_request_kwargs["litellm_metadata"] + ) global_check = EncryptedContentAffinityCheck( enable_global_affinity=True, @@ -241,7 +262,9 @@ async def test_encrypted_content_affinity_model_group_config_is_additive(): ) assert globally_filtered == [target_deployment] - assert global_request_kwargs["litellm_metadata"]["encrypted_content_affinity_enabled"] + assert global_request_kwargs["litellm_metadata"][ + "encrypted_content_affinity_enabled" + ] @pytest.mark.asyncio @@ -288,10 +311,18 @@ async def test_encrypted_content_affinity_takes_priority_over_user_key_affinity( num_retries=0, ) callbacks = router.optional_callbacks or [] - deployment_callback = next(cb for cb in callbacks if isinstance(cb, DeploymentAffinityCheck)) - encrypted_content_callback = next(cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck)) - assert callbacks.index(encrypted_content_callback) < callbacks.index(deployment_callback) - assert litellm.callbacks.index(encrypted_content_callback) < (litellm.callbacks.index(deployment_callback)) + deployment_callback = next( + cb for cb in callbacks if isinstance(cb, DeploymentAffinityCheck) + ) + encrypted_content_callback = next( + cb for cb in callbacks if isinstance(cb, EncryptedContentAffinityCheck) + ) + assert callbacks.index(encrypted_content_callback) < callbacks.index( + deployment_callback + ) + assert litellm.callbacks.index(encrypted_content_callback) < ( + litellm.callbacks.index(deployment_callback) + ) cache_key = DeploymentAffinityCheck.get_affinity_cache_key( model_group=model_group, @@ -302,7 +333,9 @@ async def test_encrypted_content_affinity_takes_priority_over_user_key_affinity( value={"model_id": "deployment-a"}, ttl=60, ) - encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id("deployment-b", "rs_test") + encoded_id = ResponsesAPIRequestUtils._build_encrypted_item_id( + "deployment-b", "rs_test" + ) request_kwargs = { "input": [{"type": "reasoning", "id": encoded_id}], "litellm_metadata": {"user_api_key_hash": user_api_key_hash}, @@ -906,7 +939,9 @@ async def test_arouter_aretrieve_batch(): ], ) - with patch.object(litellm, "aretrieve_batch", return_value=AsyncMock()) as mock_aretrieve_batch: + with patch.object( + litellm, "aretrieve_batch", return_value=AsyncMock() + ) as mock_aretrieve_batch: try: response = await router.aretrieve_batch( model="gpt-3.5-turbo", @@ -927,7 +962,9 @@ async def test_arouter_aretrieve_file_content(): Test that router.acreate_file with JSONL file returns the correct response """ - with patch.object(litellm, "afile_content", return_value=AsyncMock()) as mock_afile_content: + with patch.object( + litellm, "afile_content", return_value=AsyncMock() + ) as mock_afile_content: router = litellm.Router( model_list=[ { @@ -988,7 +1025,7 @@ async def test_arouter_filter_team_based_models(): assert result is not None # FAILS - with pytest.raises(Exception, match="No deployments available for selected model, Try again in") as e: + with pytest.raises(Exception, match='No deployments available for selected model, Try again in') as e: result = await router.acompletion( model="gpt-3.5-turbo", messages=[{"role": "user", "content": "Hello, world!"}], @@ -1072,7 +1109,9 @@ def test_arouter_should_include_deployment(): model=deployment_with_team_and_public_name, team_id="test-team", ) - assert result is True, "Should return True when team_id and team_public_model_name match" + assert ( + result is True + ), "Should return True when team_id and team_public_model_name match" # Test Case 2: Team-specific deployment - team_id matches but model_name doesn't match team_public_model_name result = router.should_include_deployment( @@ -1080,9 +1119,9 @@ def test_arouter_should_include_deployment(): model=deployment_with_team_and_public_name, team_id="test-team", ) - assert result is False, ( - "Should return False when team_id matches but model_name doesn't match team_public_model_name" - ) + assert ( + result is False + ), "Should return False when team_id matches but model_name doesn't match team_public_model_name" # Test Case 3: Team-specific deployment - team_id doesn't match result = router.should_include_deployment( @@ -1098,18 +1137,30 @@ def test_arouter_should_include_deployment(): model=deployment_with_team_no_public_name, team_id="test-team", ) - assert result is True, "Should return True when team deployment has no team_public_model_name to match" + assert ( + result is True + ), "Should return True when team deployment has no team_public_model_name to match" # Test Case 5: Non-team deployment - model_name matches and no team_id - result = router.should_include_deployment(model_name="gpt-4", model=deployment_without_team, team_id=None) - assert result is True, "Should return True when model_name matches and deployment has no team_id" + result = router.should_include_deployment( + model_name="gpt-4", model=deployment_without_team, team_id=None + ) + assert ( + result is True + ), "Should return True when model_name matches and deployment has no team_id" # Test Case 6: Non-team deployment - model_name matches but team_id provided (should still work) - result = router.should_include_deployment(model_name="gpt-4", model=deployment_without_team, team_id="any-team") - assert result is True, "Should return True when model_name matches non-team deployment, regardless of team_id param" + result = router.should_include_deployment( + model_name="gpt-4", model=deployment_without_team, team_id="any-team" + ) + assert ( + result is True + ), "Should return True when model_name matches non-team deployment, regardless of team_id param" # Test Case 7: Non-team deployment - model_name doesn't match - result = router.should_include_deployment(model_name="different-model", model=deployment_without_team, team_id=None) + result = router.should_include_deployment( + model_name="different-model", model=deployment_without_team, team_id=None + ) assert result is False, "Should return False when model_name doesn't match" # Test Case 8: Team deployment accessed without matching team_id @@ -1118,7 +1169,9 @@ def test_arouter_should_include_deployment(): model=deployment_with_team_and_public_name, team_id=None, ) - assert result is True, "Should return True when matching model with exact model_name" + assert ( + result is True + ), "Should return True when matching model with exact model_name" def test_arouter_responses_api_bridge(): @@ -1168,7 +1221,9 @@ def test_arouter_responses_api_bridge(): "status": "completed", "output": [], } - mock_response.text = '{"id": "resp_test", "object": "response", "status": "completed", "output": []}' + mock_response.text = ( + '{"id": "resp_test", "object": "response", "status": "completed", "output": []}' + ) with patch.object(client, "post", return_value=mock_response) as mock_post: try: @@ -1242,7 +1297,7 @@ def test_add_invalid_provider_to_router(): ], ) - with pytest.raises(Exception, match="Unsupported provider - vertex_ai_eu") as e: + with pytest.raises(Exception, match='Unsupported provider - vertex_ai_eu') as e: router.add_deployment( Deployment( model_name="vertex_ai/*", @@ -1294,9 +1349,15 @@ async def test_router_ageneric_api_call_with_fallbacks_helper(): }, } - with patch.object(router, "_update_kwargs_with_deployment") as mock_update_kwargs: - with patch.object(router, "async_routing_strategy_pre_call_checks") as mock_pre_call_checks: - with patch.object(router, "_get_client", return_value=None) as mock_get_client: + with patch.object( + router, "_update_kwargs_with_deployment" + ) as mock_update_kwargs: + with patch.object( + router, "async_routing_strategy_pre_call_checks" + ) as mock_pre_call_checks: + with patch.object( + router, "_get_client", return_value=None + ) as mock_get_client: result = await router._ageneric_api_call_with_fallbacks_helper( model="gpt-3.5-turbo", original_generic_function=mock_generic_function, @@ -1331,7 +1392,7 @@ async def test_router_ageneric_api_call_with_fallbacks_helper(): with patch.object(router, "async_get_available_deployment") as mock_get_deployment: mock_get_deployment.side_effect = Exception("No deployment available") - with pytest.raises(Exception, match="No deployment available") as exc_info: + with pytest.raises(Exception, match='No deployment available') as exc_info: await router._ageneric_api_call_with_fallbacks_helper( model="gpt-3.5-turbo", original_generic_function=mock_generic_function, @@ -1359,9 +1420,15 @@ async def test_router_ageneric_api_call_with_fallbacks_helper(): mock_semaphore = asyncio.Semaphore(1) - with patch.object(router, "_update_kwargs_with_deployment") as mock_update_kwargs: - with patch.object(router, "_get_client", return_value=mock_semaphore) as mock_get_client: - with patch.object(router, "async_routing_strategy_pre_call_checks") as mock_pre_call_checks: + with patch.object( + router, "_update_kwargs_with_deployment" + ) as mock_update_kwargs: + with patch.object( + router, "_get_client", return_value=mock_semaphore + ) as mock_get_client: + with patch.object( + router, "async_routing_strategy_pre_call_checks" + ) as mock_pre_call_checks: result = await router._ageneric_api_call_with_fallbacks_helper( model="gpt-3.5-turbo", original_generic_function=mock_semaphore_function, @@ -1390,10 +1457,16 @@ async def test_router_ageneric_api_call_with_fallbacks_helper(): }, } - with patch.object(router, "_update_kwargs_with_deployment") as mock_update_kwargs: - with patch.object(router, "_get_client", return_value=None) as mock_get_client: - with patch.object(router, "async_routing_strategy_pre_call_checks") as mock_pre_call_checks: - with pytest.raises(Exception, match="Mock failure") as exc_info: + with patch.object( + router, "_update_kwargs_with_deployment" + ) as mock_update_kwargs: + with patch.object( + router, "_get_client", return_value=None + ) as mock_get_client: + with patch.object( + router, "async_routing_strategy_pre_call_checks" + ) as mock_pre_call_checks: + with pytest.raises(Exception, match='Mock failure') as exc_info: await router._ageneric_api_call_with_fallbacks_helper( model="gpt-3.5-turbo", original_generic_function=mock_failing_function, @@ -1461,9 +1534,9 @@ async def test_ageneric_api_call_deployment_model_overrides_alias(): original_generic_function=capture_model, ) - assert captured["model"] == "vertex_ai/gemini-2.5-flash", ( - f"Expected deployment model 'vertex_ai/gemini-2.5-flash', got '{captured['model']}'" - ) + assert ( + captured["model"] == "vertex_ai/gemini-2.5-flash" + ), f"Expected deployment model 'vertex_ai/gemini-2.5-flash', got '{captured['model']}'" @pytest.mark.asyncio @@ -1569,10 +1642,14 @@ def test_router_get_model_access_groups_team_only_models(): ] ) - access_groups = router.get_model_access_groups(model_name="gpt-3.5-turbo", team_id=None) + access_groups = router.get_model_access_groups( + model_name="gpt-3.5-turbo", team_id=None + ) assert len(access_groups) == 0 - access_groups = router.get_model_access_groups(model_name="gpt-3.5-turbo", team_id="team_1") + access_groups = router.get_model_access_groups( + model_name="gpt-3.5-turbo", team_id="team_1" + ) assert list(access_groups.keys()) == ["default-models"] @@ -1667,7 +1744,9 @@ def test_model_group_info_cost_from_db_model_info(): ] ) - with patch.object(router, "get_deployment_model_info", side_effect=Exception("not found")): + with patch.object( + router, "get_deployment_model_info", side_effect=Exception("not found") + ): result = router._cached_get_model_group_info("my-custom-model") assert result is not None assert result.input_cost_per_token == 0.0001 @@ -1695,7 +1774,9 @@ def test_model_group_info_cost_none_when_db_model_info_has_no_cost(): ] ) - with patch.object(router, "get_deployment_model_info", side_effect=Exception("not found")): + with patch.object( + router, "get_deployment_model_info", side_effect=Exception("not found") + ): result = router._cached_get_model_group_info("my-custom-model-no-cost") assert result is not None assert result.input_cost_per_token is None @@ -1765,7 +1846,9 @@ def test_model_group_info_with_stringified_cost_values(): } return None - with patch.object(router, "get_deployment_model_info", side_effect=_model_info_with_str_costs): + with patch.object( + router, "get_deployment_model_info", side_effect=_model_info_with_str_costs + ): result = router._set_model_group_info( model_group="my-custom-model", user_facing_model_group_name="my-custom-model", @@ -1811,7 +1894,9 @@ def test_model_group_info_db_fallback_with_stringified_cost_values(): ] ) - with patch.object(router, "get_deployment_model_info", side_effect=Exception("not found")): + with patch.object( + router, "get_deployment_model_info", side_effect=Exception("not found") + ): result = router._set_model_group_info( model_group="my-custom-model", user_facing_model_group_name="my-custom-model", @@ -2071,7 +2156,6 @@ async def test_acompletion_streaming_iterator(): # Collect streamed chunks — the first chunk succeeds, then the error re-raises collected_chunks = [] - async def _drain(): async for chunk in result: collected_chunks.append(chunk) @@ -2765,7 +2849,11 @@ def _make_responses_iterator( BaseResponsesAPIStreamingIterator, ) - base = LiteLLMCompletionStreamingIterator if bridge else BaseResponsesAPIStreamingIterator + base = ( + LiteLLMCompletionStreamingIterator + if bridge + else BaseResponsesAPIStreamingIterator + ) class _Iter(base): def __init__(self): @@ -2845,7 +2933,9 @@ async def test_aresponses_streaming_iterator_fallback(): BaseResponsesAPIStreamingIterator, ) - router = _make_router_with_fallback("anthropic/claude-sonnet-4-6", "vertex_ai/claude-sonnet-4-6") + router = _make_router_with_fallback( + "anthropic/claude-sonnet-4-6", "vertex_ai/claude-sonnet-4-6" + ) src = _make_responses_iterator( chunks=[MagicMock(type="response.created")], error=MidStreamFallbackError( @@ -2928,9 +3018,9 @@ async def test_aresponses_streaming_iterator_writes_litellm_metadata_on_fallback fbk = mock_fallback_utils.call_args.kwargs["kwargs"] assert "litellm_metadata" in fbk, "wrong metadata_variable_name" assert fbk["litellm_metadata"]["model_group"] == "gpt-4" - assert "model_group" not in fbk.get("metadata", {}), ( - "model_group leaked into 'metadata' instead of 'litellm_metadata'" - ) + assert "model_group" not in fbk.get( + "metadata", {} + ), "model_group leaked into 'metadata' instead of 'litellm_metadata'" @pytest.mark.asyncio @@ -3048,7 +3138,9 @@ async def test_aresponses_streaming_iterator_combines_partial_usage(): fallback_response_object = ResponsesAPIResponse( id="resp_test", created_at=0, model="gpt-4", object="response", output=[] ) - fallback_response_object.usage = ResponseAPIUsage(input_tokens=20, output_tokens=15, total_tokens=35) + fallback_response_object.usage = ResponseAPIUsage( + input_tokens=20, output_tokens=15, total_tokens=35 + ) fallback_event = ResponseCompletedEvent( type=ResponsesAPIStreamEvents.RESPONSE_COMPLETED, response=fallback_response_object, @@ -3057,7 +3149,9 @@ async def test_aresponses_streaming_iterator_combines_partial_usage(): with ( patch( "litellm.main.stream_chunk_builder", - return_value=SimpleNamespace(usage=SimpleNamespace(prompt_tokens=10, completion_tokens=4)), + return_value=SimpleNamespace( + usage=SimpleNamespace(prompt_tokens=10, completion_tokens=4) + ), ), patch.object( router, @@ -3358,7 +3452,9 @@ def test_pre_call_checks_skips_token_count_without_max_input_tokens(monkeypatch) monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {}) calls = [] - monkeypatch.setattr(litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000) + monkeypatch.setattr( + litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000 + ) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3386,10 +3482,14 @@ def test_pre_call_checks_counts_once_and_filters_on_max_input_tokens(monkeypatch ], enable_pre_call_checks=True, ) - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} + ) calls = [] - monkeypatch.setattr(litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000) + monkeypatch.setattr( + litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000 + ) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3416,10 +3516,14 @@ def test_pre_call_checks_uses_precounted_tokens(monkeypatch): ], enable_pre_call_checks=True, ) - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} + ) calls = [] - monkeypatch.setattr(litellm, "token_counter", lambda *a, **k: calls.append(1) or 1) + monkeypatch.setattr( + litellm, "token_counter", lambda *a, **k: calls.append(1) or 1 + ) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3446,7 +3550,9 @@ async def test_async_get_healthy_deployments_counts_tokens_off_the_event_loop(mo ], enable_pre_call_checks=True, ) - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1_000_000}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1_000_000} + ) counting_threads = [] monkeypatch.setattr( @@ -3542,10 +3648,14 @@ def test_pre_call_checks_does_not_recount_inline_after_an_off_loop_failure(monke ], enable_pre_call_checks=True, ) - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} + ) calls = [] - monkeypatch.setattr(litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000) + monkeypatch.setattr( + litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000 + ) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3573,7 +3683,9 @@ async def test_async_get_healthy_deployments_never_recounts_on_the_loop(monkeypa ], enable_pre_call_checks=True, ) - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} + ) counting_threads = [] @@ -3608,7 +3720,9 @@ async def test_acount_pre_call_check_tokens_leaves_the_event_loop_free(monkeypat ], enable_pre_call_checks=True, ) - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} + ) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3644,7 +3758,9 @@ async def test_acount_pre_call_check_tokens_skips_without_max_input_tokens(monke monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {}) calls = [] - monkeypatch.setattr(litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000) + monkeypatch.setattr( + litellm, "token_counter", lambda *a, **k: calls.append(1) or 1000 + ) count = await router._acount_pre_call_check_tokens( model="m", @@ -3672,7 +3788,9 @@ def test_pre_call_checks_counts_tokens_from_responses_input_string(monkeypatch): ], enable_pre_call_checks=True, ) - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1} + ) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3697,7 +3815,9 @@ def test_pre_call_checks_counts_tokens_from_responses_input_list(monkeypatch): ], enable_pre_call_checks=True, ) - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 1} + ) deployments = [ {"litellm_params": {"model": "gpt-3.5-turbo"}, "model_info": {"id": "d1"}}, @@ -3739,7 +3859,9 @@ def test_pre_call_checks_counts_responses_instructions_tokens(monkeypatch): ) assert with_instructions_tokens > input_only_tokens - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": input_only_tokens}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": input_only_tokens} + ) with pytest.raises(litellm.ContextWindowExceededError): router._pre_call_checks( model="m", @@ -3808,7 +3930,9 @@ def test_pre_call_checks_counts_tool_definition_tokens(monkeypatch, prompt_kwarg prompt_only_tokens = router._count_pre_call_check_tokens( messages=prompt_kwargs.get("messages"), input=prompt_kwargs.get("input") ) - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": prompt_only_tokens}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": prompt_only_tokens} + ) assert len(router._pre_call_checks(model="m", healthy_deployments=deployments, **prompt_kwargs)) == 1 with pytest.raises(litellm.ContextWindowExceededError): @@ -3928,7 +4052,7 @@ def test_count_pre_call_check_tokens_across_api_surfaces(): assert string_input_tokens > 0 assert list_input_tokens > 0 - with pytest.raises(ValueError, match="Either messages or input must be provided to count tokens"): + with pytest.raises(ValueError, match='Either messages or input must be provided to count tokens'): router._count_pre_call_check_tokens(messages=None, input=None) @@ -3943,7 +4067,9 @@ def test_pre_call_checks_no_messages_or_input_does_not_crash(monkeypatch): ], enable_pre_call_checks=True, ) - monkeypatch.setattr(router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5}) + monkeypatch.setattr( + router, "get_router_model_info", lambda **kwargs: {"max_input_tokens": 5} + ) counted: list[dict] = [] original = router._count_pre_call_check_tokens @@ -4033,7 +4159,9 @@ def test_get_deployment_model_info_base_model_flow(): } # Test Case 1: Base model flow with custom model info that has base_model - with patch.object(litellm, "model_cost", {"test-custom-model": mock_custom_model_info}): + with patch.object( + litellm, "model_cost", {"test-custom-model": mock_custom_model_info} + ): with patch.object(litellm, "get_model_info") as mock_get_model_info: # Configure mock returns mock_get_model_info.side_effect = lambda model: { @@ -4041,11 +4169,15 @@ def test_get_deployment_model_info_base_model_flow(): "test-model": mock_litellm_model_name_info, }.get(model) - result = router.get_deployment_model_info(model_id="test-custom-model", model_name="test-model") + result = router.get_deployment_model_info( + model_id="test-custom-model", model_name="test-model" + ) # Verify that get_model_info was called for both base model and model name assert mock_get_model_info.call_count == 2 - mock_get_model_info.assert_any_call(model="gpt-3.5-turbo") # base model call + mock_get_model_info.assert_any_call( + model="gpt-3.5-turbo" + ) # base model call mock_get_model_info.assert_any_call(model="test-model") # model name call # Verify the result contains merged information @@ -4056,18 +4188,26 @@ def test_get_deployment_model_info_base_model_flow(): # 2. The result of step 1 gets merged into litellm_model_name_info (custom+base override litellm) # Fields from custom model (should override base model values) - assert result["input_cost_per_token"] == 0.001 # From custom model (overrides base 0.0015) - assert result["output_cost_per_token"] == 0.002 # From custom model (same as base) + assert ( + result["input_cost_per_token"] == 0.001 + ) # From custom model (overrides base 0.0015) + assert ( + result["output_cost_per_token"] == 0.002 + ) # From custom model (same as base) assert result["custom_field"] == "custom_value" # From custom model # Fields from base model that weren't overridden by custom assert result["max_tokens"] == 4096 # From base model assert result["litellm_provider"] == "openai" # From base model - assert result["mode"] == "chat" # From base model (overrides litellm "completion") + assert ( + result["mode"] == "chat" + ) # From base model (overrides litellm "completion") # The key field comes from base model since both base and litellm have it # and base model info overrides litellm model name info in final merge - assert result["key"] == "gpt-3.5-turbo" # From base model (overrides litellm key) + assert ( + result["key"] == "gpt-3.5-turbo" + ) # From base model (overrides litellm key) # Test Case 2: Custom model info without base_model mock_custom_model_info_no_base = { @@ -4086,7 +4226,9 @@ def test_get_deployment_model_info_base_model_flow(): "test-model": mock_litellm_model_name_info, }.get(model) - result = router.get_deployment_model_info(model_id="test-custom-model-no-base", model_name="test-model") + result = router.get_deployment_model_info( + model_id="test-custom-model-no-base", model_name="test-model" + ) # Should only call get_model_info once for model name (no base model) assert mock_get_model_info.call_count == 1 @@ -4106,7 +4248,9 @@ def test_get_deployment_model_info_base_model_flow(): "test-model": mock_litellm_model_name_info, }.get(model) - result = router.get_deployment_model_info(model_id="non-existent-model", model_name="test-model") + result = router.get_deployment_model_info( + model_id="non-existent-model", model_name="test-model" + ) # Should only call get_model_info once for model name assert mock_get_model_info.call_count == 1 @@ -4139,7 +4283,9 @@ def test_get_deployment_model_info_base_model_flow(): mock_get_model_info.side_effect = mock_get_model_info_side_effect - result = router.get_deployment_model_info(model_id="test-custom-model-invalid", model_name="test-model") + result = router.get_deployment_model_info( + model_id="test-custom-model-invalid", model_name="test-model" + ) # Should handle exception gracefully and still return merged result assert result is not None @@ -4148,8 +4294,12 @@ def test_get_deployment_model_info_base_model_flow(): # Test Case 5: Both model_cost.get() and get_model_info() return None with patch.object(litellm, "model_cost", {}): - with patch.object(litellm, "get_model_info", side_effect=Exception("Not found")): - result = router.get_deployment_model_info(model_id="non-existent", model_name="non-existent") + with patch.object( + litellm, "get_model_info", side_effect=Exception("Not found") + ): + result = router.get_deployment_model_info( + model_id="non-existent", model_name="non-existent" + ) # Should return None when no model info is found assert result is None @@ -4172,7 +4322,9 @@ def test_get_deployment_model_info_base_model_flow(): # Model NOT in built-in cost map — raise exception mock_get_model_info.side_effect = Exception("Model not in cost map") - result = router.get_deployment_model_info(model_id="custom-model-id", model_name="unknown-model") + result = router.get_deployment_model_info( + model_id="custom-model-id", model_name="unknown-model" + ) # Should return custom_model_info even when litellm_model_name_model_info is None assert result is not None @@ -4208,11 +4360,15 @@ def test_get_deployment_model_info_base_model_flow(): mock_get_model_info.side_effect = get_info_side_effect - result = router.get_deployment_model_info(model_id="custom-with-base", model_name="unknown-model") + result = router.get_deployment_model_info( + model_id="custom-with-base", model_name="unknown-model" + ) # Should return custom_model_info merged with base model info assert result is not None - assert result["input_cost_per_token"] == 0.01 # From custom (overrides base) + assert ( + result["input_cost_per_token"] == 0.01 + ) # From custom (overrides base) assert result["max_tokens"] == 8192 # From base model assert result["litellm_provider"] == "openai" # From base model @@ -4259,14 +4415,18 @@ def test_get_deployment_model_info_base_model_merge_priority(): "litellm_only_field": "litellm_value", } - with patch.object(litellm, "model_cost", {"custom-model-id": mock_custom_model_info}): + with patch.object( + litellm, "model_cost", {"custom-model-id": mock_custom_model_info} + ): with patch.object(litellm, "get_model_info") as mock_get_model_info: mock_get_model_info.side_effect = lambda model: { "gpt-4": mock_base_model_info, "test-model": mock_litellm_model_name_info, }.get(model) - result = router.get_deployment_model_info(model_id="custom-model-id", model_name="test-model") + result = router.get_deployment_model_info( + model_id="custom-model-id", model_name="test-model" + ) assert result is not None @@ -4276,17 +4436,29 @@ def test_get_deployment_model_info_base_model_merge_priority(): # 3. Result from steps 1-2 overrides litellm_model_name_info # Fields that should come from custom model info (highest priority) - assert result["input_cost_per_token"] == 0.01 # From custom model (overrides base 0.03) - assert result["max_tokens"] == 8000 # From custom model (overrides base 4096) + assert ( + result["input_cost_per_token"] == 0.01 + ) # From custom model (overrides base 0.03) + assert ( + result["max_tokens"] == 8000 + ) # From custom model (overrides base 4096) assert result["custom_only_field"] == "custom_value" # From custom model # Fields that should come from base model (not overridden by custom) - assert result["output_cost_per_token"] == 0.06 # From base model (not in custom) - assert result["litellm_provider"] == "openai" # From base model (not in custom) - assert result["base_only_field"] == "base_value" # From base model (not in custom) + assert ( + result["output_cost_per_token"] == 0.06 + ) # From base model (not in custom) + assert ( + result["litellm_provider"] == "openai" + ) # From base model (not in custom) + assert ( + result["base_only_field"] == "base_value" + ) # From base model (not in custom) # Fields that should come from litellm model name info (not overridden by custom+base) - assert result["mode"] == "completion" # From litellm model name info (not in custom or base) + assert ( + result["mode"] == "completion" + ) # From litellm model name info (not in custom or base) assert ( result["litellm_only_field"] == "litellm_value" ) # From litellm model name info (not in custom or base) @@ -4323,9 +4495,10 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): model="special-bedrock-model", model_name="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", ) - assert result["endpoint"] == "/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke", ( - f"Expected '/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke', got '{result['endpoint']}'" - ) + assert ( + result["endpoint"] + == "/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke" + ), f"Expected '/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke', got '{result['endpoint']}'" # Test Case 2: Bedrock invoke-with-response-stream endpoint kwargs = { @@ -4337,9 +4510,10 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): model="special-bedrock-model", model_name="bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0", ) - assert result["endpoint"] == "/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream", ( - f"Expected streaming endpoint with stripped prefix, got '{result['endpoint']}'" - ) + assert ( + result["endpoint"] + == "/model/us.anthropic.claude-haiku-4-5-20251001-v1:0/invoke-with-response-stream" + ), f"Expected streaming endpoint with stripped prefix, got '{result['endpoint']}'" # Test Case 3: Bedrock converse endpoint kwargs = { @@ -4351,9 +4525,9 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): model="bedrock-model", model_name="bedrock/us.meta.llama3-8b-instruct-v1:0", ) - assert result["endpoint"] == "/model/us.meta.llama3-8b-instruct-v1:0/converse", ( - f"Expected '/model/us.meta.llama3-8b-instruct-v1:0/converse', got '{result['endpoint']}'" - ) + assert ( + result["endpoint"] == "/model/us.meta.llama3-8b-instruct-v1:0/converse" + ), f"Expected '/model/us.meta.llama3-8b-instruct-v1:0/converse', got '{result['endpoint']}'" # Test Case 4: Bedrock provider prefix auto-detected from model_name kwargs = { @@ -4364,9 +4538,9 @@ def test_add_deployment_model_to_endpoint_for_llm_passthrough_route(): model="router-model", model_name="bedrock/us.meta.llama3-8b-instruct-v1:0", ) - assert result["endpoint"] == "/model/us.meta.llama3-8b-instruct-v1:0/invoke", ( - f"Expected '/model/us.meta.llama3-8b-instruct-v1:0/invoke', got '{result['endpoint']}'" - ) + assert ( + result["endpoint"] == "/model/us.meta.llama3-8b-instruct-v1:0/invoke" + ), f"Expected '/model/us.meta.llama3-8b-instruct-v1:0/invoke', got '{result['endpoint']}'" def test_update_kwargs_with_deployment_uses_pass_through_request_timeout(): @@ -4418,10 +4592,14 @@ async def test_router_acompletion_with_unknown_model_and_default_fallback(): # Initialize the router with a default fallback router = litellm.Router(model_list=model_list, default_fallbacks=["gpt-4o"]) - messages = [{"role": "user", "content": "This call should succeed by falling back."}] + messages = [ + {"role": "user", "content": "This call should succeed by falling back."} + ] # Call completion with a model name that is NOT in the model_list - response = await router.acompletion(model="completely-unknown-model", messages=messages) + response = await router.acompletion( + model="completely-unknown-model", messages=messages + ) # Check that the call did not fail and we received a valid response object. assert response is not None @@ -4513,10 +4691,15 @@ def test_get_deployment_credentials_with_provider_aws_bedrock_runtime_endpoint() ], ) - credentials = router.get_deployment_credentials_with_provider(model_id="bedrock-claude-model") + credentials = router.get_deployment_credentials_with_provider( + model_id="bedrock-claude-model" + ) assert credentials is not None - assert credentials["aws_bedrock_runtime_endpoint"] == "https://bedrock-runtime.us-east-1.amazonaws.com" + assert ( + credentials["aws_bedrock_runtime_endpoint"] + == "https://bedrock-runtime.us-east-1.amazonaws.com" + ) assert credentials["aws_access_key_id"] == "test-access-key" assert credentials["aws_secret_access_key"] == "test-secret-key" assert credentials["aws_region_name"] == "us-east-1" @@ -4543,7 +4726,9 @@ def test_get_deployment_credentials_with_provider_includes_bucket_name(): ], ) - credentials = router.get_deployment_credentials_with_provider(model_id="vertex-gemini") + credentials = router.get_deployment_credentials_with_provider( + model_id="vertex-gemini" + ) assert credentials is not None assert credentials["gcs_bucket_name"] == "my-batch-bucket" @@ -4583,7 +4768,9 @@ def test_get_deployment_credentials_with_provider_resolves_credential_name(): ], ) - credentials = router.get_deployment_credentials_with_provider(model_id="azure-gpt-4") + credentials = router.get_deployment_credentials_with_provider( + model_id="azure-gpt-4" + ) assert credentials is not None assert credentials["api_key"] == "resolved-api-key" @@ -4619,7 +4806,9 @@ def test_get_deployment_credentials_with_provider_bedrock_batch_fields(): ], ) - credentials = router.get_deployment_credentials_with_provider(model_id="bedrock-batch-model") + credentials = router.get_deployment_credentials_with_provider( + model_id="bedrock-batch-model" + ) assert credentials is not None assert credentials["custom_llm_provider"] == "bedrock" @@ -4662,7 +4851,9 @@ def test_get_deployment_credentials_with_provider_preserves_aws_auth_params(): ], ) - credentials = router.get_deployment_credentials_with_provider(model_id="bedrock-batch-model") + credentials = router.get_deployment_credentials_with_provider( + model_id="bedrock-batch-model" + ) assert credentials is not None for key, value in aws_auth_params.items(): @@ -4697,11 +4888,15 @@ def test_get_deployment_credentials_with_provider_team_wildcard_priority(): ], ) - team_credentials = router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-1") + team_credentials = router.get_deployment_credentials_with_provider( + model_id="openai/gpt-5.2", team_id="team-1" + ) assert team_credentials is not None assert team_credentials["api_key"] == "team-key" - global_credentials = router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2") + global_credentials = router.get_deployment_credentials_with_provider( + model_id="openai/gpt-5.2" + ) assert global_credentials is not None assert global_credentials["api_key"] == "global-key" @@ -4742,11 +4937,15 @@ def test_get_deployment_credentials_with_provider_skips_other_team_deployment(): assert other_team_credentials is not None assert other_team_credentials["vertex_project"] == "shared-project" - unscoped_credentials = router.get_deployment_credentials_with_provider(model_id="gemini-2.5-pro") + unscoped_credentials = router.get_deployment_credentials_with_provider( + model_id="gemini-2.5-pro" + ) assert unscoped_credentials is not None assert unscoped_credentials["vertex_project"] == "shared-project" - owner_credentials = router.get_deployment_credentials_with_provider(model_id="gemini-2.5-pro", team_id="team-b") + owner_credentials = router.get_deployment_credentials_with_provider( + model_id="gemini-2.5-pro", team_id="team-b" + ) assert owner_credentials is not None assert owner_credentials["vertex_project"] == "team-b-project" @@ -4773,8 +4972,16 @@ def test_get_deployment_credentials_with_provider_no_fallback_to_other_team_only ], ) - assert router.get_deployment_credentials_with_provider(model_id="gemini-2.5-pro", team_id="team-a") is None - assert router.get_deployment_credentials_with_provider(model_id="gemini-2.5-pro") is None + assert ( + router.get_deployment_credentials_with_provider( + model_id="gemini-2.5-pro", team_id="team-a" + ) + is None + ) + assert ( + router.get_deployment_credentials_with_provider(model_id="gemini-2.5-pro") + is None + ) def test_deployment_usable_by_team_helpers(): @@ -4814,7 +5021,9 @@ def test_deployment_usable_by_team_helpers(): assert router._deployment_usable_by_team(shared, "team-a") is True assert router._deployment_usable_by_team(shared, None) is True - picked = router._get_model_group_deployment_usable_by_team(model_group_name="gemini-2.5-pro", team_id="team-a") + picked = router._get_model_group_deployment_usable_by_team( + model_group_name="gemini-2.5-pro", team_id="team-a" + ) assert picked is not None assert picked.litellm_params.vertex_project == "shared-project" @@ -4824,7 +5033,12 @@ def test_deployment_usable_by_team_helpers(): assert owner_picked is not None assert owner_picked.litellm_params.vertex_project == "team-b-project" - assert router._get_model_group_deployment_usable_by_team(model_group_name="unknown-model", team_id="team-a") is None + assert ( + router._get_model_group_deployment_usable_by_team( + model_group_name="unknown-model", team_id="team-a" + ) + is None + ) def test_get_deployment_credentials_with_provider_skips_other_team_wildcard(): @@ -4856,7 +5070,9 @@ def test_get_deployment_credentials_with_provider_skips_other_team_wildcard(): assert other_team_credentials is not None assert other_team_credentials["api_key"] == "global-key" - owner_credentials = router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-b") + owner_credentials = router.get_deployment_credentials_with_provider( + model_id="openai/gpt-5.2", team_id="team-b" + ) assert owner_credentials is not None assert owner_credentials["api_key"] == "team-b-key" @@ -4868,11 +5084,21 @@ def test_team_wildcard_credentials_not_usable_after_delete_deployment(): """ router = litellm.Router(model_list=[_team_wildcard_model(api_key="old-key")]) - assert router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-1") is not None + assert ( + router.get_deployment_credentials_with_provider( + model_id="openai/gpt-5.2", team_id="team-1" + ) + is not None + ) router.delete_deployment(id="team-wildcard-id") - assert router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-1") is None + assert ( + router.get_deployment_credentials_with_provider( + model_id="openai/gpt-5.2", team_id="team-1" + ) + is None + ) def test_global_wildcard_pattern_router_evicts_stale_entry_on_upsert_and_delete(): @@ -4945,13 +5171,22 @@ def test_team_wildcard_credentials_refreshed_on_upsert_and_set_model_list(): router = litellm.Router(model_list=[_team_wildcard_model(api_key="old-key")]) - router.upsert_deployment(deployment=Deployment(**_team_wildcard_model(api_key="new-key"))) - credentials = router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-1") + router.upsert_deployment( + deployment=Deployment(**_team_wildcard_model(api_key="new-key")) + ) + credentials = router.get_deployment_credentials_with_provider( + model_id="openai/gpt-5.2", team_id="team-1" + ) assert credentials is not None assert credentials["api_key"] == "new-key" router.set_model_list(model_list=[]) - assert router.get_deployment_credentials_with_provider(model_id="openai/gpt-5.2", team_id="team-1") is None + assert ( + router.get_deployment_credentials_with_provider( + model_id="openai/gpt-5.2", team_id="team-1" + ) + is None + ) def test_get_available_guardrail_single_deployment(): @@ -5140,7 +5375,9 @@ async def test_anthropic_messages_call_type_is_cached(): startTime=1234567890.0, endTime=1234567891.0, completionStartTime=1234567890.5, - model_map_information=StandardLoggingModelInformation(model_map_key="gpt-3.5-turbo", model_map_value=None), + model_map_information=StandardLoggingModelInformation( + model_map_key="gpt-3.5-turbo", model_map_value=None + ), model="gpt-3.5-turbo", model_id="model-123", model_group="openai-gpt", @@ -5219,8 +5456,12 @@ async def test_anthropic_messages_call_type_is_cached(): ) # This assertion will FAIL if anthropic_messages is filtered out - assert cached_result is not None, "Model ID should be cached for anthropic_messages call type" - assert cached_result["model_id"] == test_model_id, f"Expected {test_model_id}, got {cached_result['model_id']}" + assert ( + cached_result is not None + ), "Model ID should be cached for anthropic_messages call type" + assert ( + cached_result["model_id"] == test_model_id + ), f"Expected {test_model_id}, got {cached_result['model_id']}" def test_update_kwargs_with_deployment_propagates_model_tags(): @@ -5245,7 +5486,9 @@ def test_update_kwargs_with_deployment_propagates_model_tags(): ) kwargs: dict = {"metadata": {}} - deployment = router.get_deployment_by_model_group_name(model_group_name="gpt-4o-mini") + deployment = router.get_deployment_by_model_group_name( + model_group_name="gpt-4o-mini" + ) router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) # Deployment tags should be propagated to kwargs metadata @@ -5274,7 +5517,9 @@ def test_update_kwargs_with_deployment_merges_tags_without_duplicates(): # Simulate request that already has tags (from request body or key/team level) kwargs: dict = {"metadata": {"tags": ["user-tag", "shared-tag"]}} - deployment = router.get_deployment_by_model_group_name(model_group_name="gpt-4o-mini") + deployment = router.get_deployment_by_model_group_name( + model_group_name="gpt-4o-mini" + ) router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) # Both sources should be merged, no duplicates @@ -5301,7 +5546,9 @@ def test_update_kwargs_with_deployment_no_tags(): ) kwargs: dict = {"metadata": {}} - deployment = router.get_deployment_by_model_group_name(model_group_name="gpt-4o-mini") + deployment = router.get_deployment_by_model_group_name( + model_group_name="gpt-4o-mini" + ) router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) # No tags key should be added if deployment has no tags @@ -5339,7 +5586,9 @@ def test_update_kwargs_with_deployment_merges_tools(): }, ], } - deployment = router.get_deployment_by_model_group_name(model_group_name="o3-deep-research") + deployment = router.get_deployment_by_model_group_name( + model_group_name="o3-deep-research" + ) router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) # Tools should be merged: deployment first, then request @@ -5370,7 +5619,9 @@ def test_update_kwargs_with_deployment_merge_tools_deployment_only(): ) kwargs: dict = {"metadata": {}} - deployment = router.get_deployment_by_model_group_name(model_group_name="o3-deep-research") + deployment = router.get_deployment_by_model_group_name( + model_group_name="o3-deep-research" + ) router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) assert kwargs["tools"] == [{"type": "web_search"}] @@ -5399,7 +5650,9 @@ def test_update_kwargs_with_deployment_merge_tools_request_overrides_tool_choice "metadata": {}, "tool_choice": "none", } - deployment = router.get_deployment_by_model_group_name(model_group_name="o3-deep-research") + deployment = router.get_deployment_by_model_group_name( + model_group_name="o3-deep-research" + ) router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs) # Request tool_choice should be preserved (merged tools still applied) @@ -5501,8 +5754,12 @@ def test_update_kwargs_with_deployment_model_info_in_litellm_metadata(): ) kwargs: dict = {} - deployment = router.get_deployment_by_model_group_name(model_group_name="claude-sonnet-4") - router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name="generic_api_call") + deployment = router.get_deployment_by_model_group_name( + model_group_name="claude-sonnet-4" + ) + router._update_kwargs_with_deployment( + deployment=deployment, kwargs=kwargs, function_name="generic_api_call" + ) assert "litellm_metadata" in kwargs model_info = kwargs["litellm_metadata"]["model_info"] @@ -5534,8 +5791,12 @@ def test_update_kwargs_with_deployment_model_info_in_metadata(): ) kwargs: dict = {} - deployment = router.get_deployment_by_model_group_name(model_group_name="claude-sonnet-4") - router._update_kwargs_with_deployment(deployment=deployment, kwargs=kwargs, function_name=None) + deployment = router.get_deployment_by_model_group_name( + model_group_name="claude-sonnet-4" + ) + router._update_kwargs_with_deployment( + deployment=deployment, kwargs=kwargs, function_name=None + ) assert "metadata" in kwargs model_info = kwargs["metadata"]["model_info"] @@ -5648,7 +5909,6 @@ async def test_acompletion_streaming_iterator_does_not_log_success_on_terminal_f initial_kwargs=dict(initial_kwargs), ) collected = [] - async def _drain(): async for chunk in result: collected.append(chunk) @@ -5675,7 +5935,6 @@ async def test_acompletion_streaming_iterator_does_not_log_success_on_terminal_f initial_kwargs=dict(initial_kwargs), ) collected = [] - async def _drain(): async for chunk in result: collected.append(chunk) @@ -5892,17 +6151,23 @@ def test_multiregion_team_deployments_unique_model_names(): assert len(deployments) == 0 # With team_id: O(n) scan finds BOTH regional deployments - deployments = router._get_all_deployments(model_name="claude-sonnet", team_id="metis-team") + deployments = router._get_all_deployments( + model_name="claude-sonnet", team_id="metis-team" + ) assert len(deployments) == 2 deployment_names = {d["model_name"] for d in deployments} assert deployment_names == {"metis-claude-us-east-1", "metis-claude-us-west-2"} # Each deployment has a unique ID (critical for cooldown/retry to work) deployment_ids = {d["model_info"]["id"] for d in deployments} - assert len(deployment_ids) == 2, "Each deployment must have a unique ID for cooldown tracking" + assert ( + len(deployment_ids) == 2 + ), "Each deployment must have a unique ID for cooldown tracking" # Wrong team: returns nothing - deployments = router._get_all_deployments(model_name="claude-sonnet", team_id="other-team") + deployments = router._get_all_deployments( + model_name="claude-sonnet", team_id="other-team" + ) assert len(deployments) == 0 @@ -5947,8 +6212,12 @@ async def test_multiregion_team_failover_between_regions(): ) # Verify the router finds both deployments for the team - deployments = router._get_all_deployments(model_name="claude-sonnet", team_id="metis-team") - assert len(deployments) == 2, "Router must find both regional deployments by team_public_model_name" + deployments = router._get_all_deployments( + model_name="claude-sonnet", team_id="metis-team" + ) + assert ( + len(deployments) == 2 + ), "Router must find both regional deployments by team_public_model_name" # Make a normal request — should succeed from one of the regions response = await router.acompletion( @@ -6073,7 +6342,9 @@ def test_explicit_model_access_does_not_force_access_group_filtering(): }, ) - deployment_groups = [d.get("model_info", {}).get("access_groups") for d in deployments] + deployment_groups = [ + d.get("model_info", {}).get("access_groups") for d in deployments + ] assert ["AG1"] in deployment_groups assert ["AG2"] in deployment_groups @@ -6118,7 +6389,9 @@ def test_access_group_filter_empty_does_not_bypass_via_litellm_model_fallback( orig_groups = router.get_model_access_groups - def fake_get_model_access_groups(model_name=None, model_access_group=None, team_id=None): + def fake_get_model_access_groups( + model_name=None, model_access_group=None, team_id=None + ): if model_name == "gpt-5" and model_access_group is None: return {"AG1": ["gpt-5"], "AG2": ["gpt-5"]} return orig_groups( @@ -6193,7 +6466,9 @@ def test_access_group_block_does_not_silently_use_default_fallback_model( orig_groups = router.get_model_access_groups - def fake_get_model_access_groups(model_name=None, model_access_group=None, team_id=None): + def fake_get_model_access_groups( + model_name=None, model_access_group=None, team_id=None + ): if model_name == "gpt-5" and model_access_group is None: return {"AG1": ["gpt-5"], "AG2": ["gpt-5"]} return orig_groups( @@ -6260,7 +6535,9 @@ def test_access_group_block_via_litellm_model_branch_does_not_use_default_fallba orig_groups = router.get_model_access_groups - def fake_get_model_access_groups(model_name=None, model_access_group=None, team_id=None): + def fake_get_model_access_groups( + model_name=None, model_access_group=None, team_id=None + ): if model_name == "gpt-5" and model_access_group is None: return {"AG1": ["gpt-5"], "AG2": ["gpt-5"]} return orig_groups( @@ -6315,7 +6592,9 @@ def test_try_early_resolve_deployments_for_model_not_in_names(): ) assert ( - router_in_names._try_early_resolve_deployments_for_model_not_in_names(model="gpt-5", request_team_id=None) + router_in_names._try_early_resolve_deployments_for_model_not_in_names( + model="gpt-5", request_team_id=None + ) is None ) assert ( @@ -6337,8 +6616,10 @@ def test_try_early_resolve_deployments_for_model_not_in_names(): ] ) - pattern_result = pattern_router._try_early_resolve_deployments_for_model_not_in_names( - model="openai/gpt-4o-mini", request_team_id=None + pattern_result = ( + pattern_router._try_early_resolve_deployments_for_model_not_in_names( + model="openai/gpt-4o-mini", request_team_id=None + ) ) assert pattern_result is not None resolved_model, pattern_deployments = pattern_result @@ -6364,8 +6645,10 @@ def test_try_early_resolve_deployments_for_model_not_in_names(): }, } - default_result = default_router._try_early_resolve_deployments_for_model_not_in_names( - model="brand-new-model", request_team_id=None + default_result = ( + default_router._try_early_resolve_deployments_for_model_not_in_names( + model="brand-new-model", request_team_id=None + ) ) assert default_result is not None resolved_model, default_deployment = default_result @@ -6373,7 +6656,10 @@ def test_try_early_resolve_deployments_for_model_not_in_names(): assert isinstance(default_deployment, dict) assert default_deployment["litellm_params"]["model"] == "brand-new-model" # The original default_deployment must not be mutated. - assert default_router.default_deployment["litellm_params"]["model"] == "openai/will-be-overridden" + assert ( + default_router.default_deployment["litellm_params"]["model"] + == "openai/will-be-overridden" + ) def _router_with_two_deployments(blocked_flags): @@ -6421,7 +6707,10 @@ def _seed_unhealthy_states(router, unhealthy_ids, timestamp=None): ts = timestamp if timestamp is not None else time.time() router.health_state_cache.set_deployment_health_states( - {uid: {"is_healthy": False, "timestamp": ts, "reason": "test_unhealthy"} for uid in unhealthy_ids} + { + uid: {"is_healthy": False, "timestamp": ts, "reason": "test_unhealthy"} + for uid in unhealthy_ids + } ) @@ -6492,7 +6781,9 @@ async def test_async_get_fully_unhealthy_model_names_noop_with_allowed_fails_pol @pytest.mark.asyncio async def test_async_get_healthy_deployments_skips_blocked_deployment(): router = _router_with_two_deployments([True, False]) - healthy, all_dep = await router._async_get_healthy_deployments(model="gpt-4o", parent_otel_span=None) + healthy, all_dep = await router._async_get_healthy_deployments( + model="gpt-4o", parent_otel_span=None + ) healthy_ids = [d["model_info"]["id"] for d in healthy] assert "dep-0" not in healthy_ids assert "dep-1" in healthy_ids @@ -6501,7 +6792,9 @@ async def test_async_get_healthy_deployments_skips_blocked_deployment(): def test_get_healthy_deployments_sync_skips_blocked_deployment(): router = _router_with_two_deployments([False, True]) - healthy, all_dep = router._get_healthy_deployments(model="gpt-4o", parent_otel_span=None) + healthy, all_dep = router._get_healthy_deployments( + model="gpt-4o", parent_otel_span=None + ) healthy_ids = [d["model_info"]["id"] for d in healthy] assert "dep-0" in healthy_ids assert "dep-1" not in healthy_ids @@ -6518,7 +6811,9 @@ def test_filter_blocked_deployments_drops_blocked_keeps_unblocked(): @pytest.mark.asyncio async def test_public_async_get_healthy_deployments_skips_blocked_on_primary_path(): router = _router_with_two_deployments([True, False]) - deployments = await router.async_get_healthy_deployments(model="gpt-4o", request_kwargs={}) + deployments = await router.async_get_healthy_deployments( + model="gpt-4o", request_kwargs={} + ) assert isinstance(deployments, list) ids = [d["model_info"]["id"] for d in deployments] assert "dep-0" not in ids @@ -6560,7 +6855,9 @@ def _router_with_two_pass_through_deployments(blocked_flags): def test_get_available_deployment_for_pass_through_skips_blocked(): router = _router_with_two_pass_through_deployments([True, False]) - deployment = router.get_available_deployment_for_pass_through(model="gpt-4o", request_kwargs={}) + deployment = router.get_available_deployment_for_pass_through( + model="gpt-4o", request_kwargs={} + ) assert deployment["model_info"]["id"] == "pt-1" @@ -6569,7 +6866,9 @@ def test_get_available_deployment_for_pass_through_raises_when_dict_blocked(): router = _router_with_two_pass_through_deployments([True, True]) with pytest.raises(litellm.ServiceUnavailableError): - router.get_available_deployment_for_pass_through(model="pt-0", request_kwargs={}) + router.get_available_deployment_for_pass_through( + model="pt-0", request_kwargs={} + ) def test_initialize_deployment_for_pass_through_keeps_bedrock_iam_deployment(): @@ -6593,7 +6892,9 @@ def test_initialize_deployment_for_pass_through_keeps_bedrock_iam_deployment(): } ] ) - assert [m["model_info"]["id"] for m in router.get_model_list()] == ["bedrock-iam-pt"] + assert [m["model_info"]["id"] for m in router.get_model_list()] == [ + "bedrock-iam-pt" + ] def test_pass_through_deployment_api_key_resolves_via_get_credentials(): @@ -6604,7 +6905,12 @@ def test_pass_through_deployment_api_key_resolves_via_get_credentials(): router = _router_with_two_pass_through_deployments([False, False]) passthrough_router = PassthroughEndpointRouter(llm_router_getter=lambda: router) assert len(router.get_model_list()) == 2 - assert passthrough_router.get_credentials(custom_llm_provider="openai", region_name=None) == "sk-fake-for-tests" + assert ( + passthrough_router.get_credentials( + custom_llm_provider="openai", region_name=None + ) + == "sk-fake-for-tests" + ) def test_get_deployment_credentials_returns_none_for_blocked_deployment(): @@ -6638,9 +6944,16 @@ def test_is_deployment_blocked_static_helper_reflects_blocked_flag(): # No model_info on deployment object → treated as not blocked assert litellm.Router._is_deployment_blocked(object()) is False missing_blocked = types.SimpleNamespace() - assert litellm.Router._is_deployment_blocked(types.SimpleNamespace(model_info=missing_blocked)) is False assert ( - litellm.Router._is_deployment_blocked(types.SimpleNamespace(model_info=types.SimpleNamespace(blocked=True))) + litellm.Router._is_deployment_blocked( + types.SimpleNamespace(model_info=missing_blocked) + ) + is False + ) + assert ( + litellm.Router._is_deployment_blocked( + types.SimpleNamespace(model_info=types.SimpleNamespace(blocked=True)) + ) is True ) @@ -6680,7 +6993,9 @@ class TestRouterRequestTimeoutPropagation: litellm.request_timeout = original_value litellm.request_timeout_explicitly_set = original_flag - def test_request_timeout_stored_independently_when_both_set(self, explicit_request_timeout): + def test_request_timeout_stored_independently_when_both_set( + self, explicit_request_timeout + ): router = self._make_router(timeout=330) assert router.timeout == 330 assert router.request_timeout == 300 @@ -6698,16 +7013,22 @@ class TestRouterRequestTimeoutPropagation: litellm.request_timeout = original_value litellm.request_timeout_explicitly_set = original_flag - def test_non_stream_prefers_request_timeout_over_router_timeout(self, explicit_request_timeout): + def test_non_stream_prefers_request_timeout_over_router_timeout( + self, explicit_request_timeout + ): router = self._make_router(timeout=330) assert router._get_non_stream_timeout(kwargs={}, data={}) == 300 - def test_stream_prefers_request_timeout_over_router_timeout(self, explicit_request_timeout): + def test_stream_prefers_request_timeout_over_router_timeout( + self, explicit_request_timeout + ): router = self._make_router(timeout=330) # stream=True resolves through _get_stream_timeout; request_timeout must win. assert router._get_timeout(kwargs={"stream": True}, data={}) == 300 - def test_explicit_stream_timeout_still_wins_over_request_timeout(self, explicit_request_timeout): + def test_explicit_stream_timeout_still_wins_over_request_timeout( + self, explicit_request_timeout + ): router = self._make_router(timeout=330, stream_timeout=45) assert router._get_stream_timeout(kwargs={}, data={}) == 45 @@ -6723,13 +7044,22 @@ class TestRouterRequestTimeoutPropagation: litellm.request_timeout = original_value litellm.request_timeout_explicitly_set = original_flag - def test_per_deployment_timeout_overrides_request_timeout(self, explicit_request_timeout): + def test_per_deployment_timeout_overrides_request_timeout( + self, explicit_request_timeout + ): router = self._make_router(timeout=330) assert router._get_non_stream_timeout(kwargs={}, data={"timeout": 120}) == 120 - def test_per_request_timeout_overrides_request_timeout(self, explicit_request_timeout): + def test_per_request_timeout_overrides_request_timeout( + self, explicit_request_timeout + ): router = self._make_router(timeout=330) - assert router._get_non_stream_timeout(kwargs={"timeout": 60}, data={"timeout": 120}) == 60 + assert ( + router._get_non_stream_timeout( + kwargs={"timeout": 60}, data={"timeout": 120} + ) + == 60 + ) # --------------------------------------------------------------------------- @@ -7038,7 +7368,9 @@ class TestAdvisorSubCallCooldown: ) def _cooled_down_ids(self, router): - active = router.cooldown_cache.get_active_cooldowns(model_ids=["dep-1"], parent_otel_span=None) + active = router.cooldown_cache.get_active_cooldowns( + model_ids=["dep-1"], parent_otel_span=None + ) return [entry[0] for entry in active] @pytest.mark.asyncio @@ -7047,7 +7379,12 @@ class TestAdvisorSubCallCooldown: router = self._router() now = datetime.now() - assert router.deployment_callback_on_failure(self._kwargs(self._auth_error()), None, now, now) is True + assert ( + router.deployment_callback_on_failure( + self._kwargs(self._auth_error()), None, now, now + ) + is True + ) assert "dep-1" in self._cooled_down_ids(router) def test_advisor_orchestration_failure_does_not_cool_down_deployment(self): @@ -7062,7 +7399,12 @@ class TestAdvisorSubCallCooldown: mark_advisor_orchestration_failure(exception) now = datetime.now() - assert router.deployment_callback_on_failure(self._kwargs(exception), None, now, now) is False + assert ( + router.deployment_callback_on_failure( + self._kwargs(exception), None, now, now + ) + is False + ) assert "dep-1" not in self._cooled_down_ids(router) @@ -7119,13 +7461,13 @@ def test_stream_chunks_have_generated_content_detects_text_and_non_text(): audio_chunk = _chunk(audio_delta) assert _stream_chunks_have_generated_content([audio_chunk]) is True - images_delta = Delta( - images=[{"image_url": {"url": "https://example.com/img.png"}, "index": 0, "type": "image_url"}] - ) + images_delta = Delta(images=[{"image_url": {"url": "https://example.com/img.png"}, "index": 0, "type": "image_url"}]) images_chunk = _chunk(images_delta) assert _stream_chunks_have_generated_content([images_chunk]) is True - annotations_delta = Delta(annotations=[{"type": "url_citation", "url_citation": {"url": "https://example.com"}}]) + annotations_delta = Delta( + annotations=[{"type": "url_citation", "url_citation": {"url": "https://example.com"}}] + ) annotations_chunk = _chunk(annotations_delta) assert _stream_chunks_have_generated_content([annotations_chunk]) is True @@ -7169,8 +7511,12 @@ def test_get_configured_token_limits_skips_wildcard_pattern_matching(): ] ) - with patch.object(router.pattern_router, "route", side_effect=AssertionError("pattern route called")): - assert router.get_configured_token_limits("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") == (None, None) + with patch.object( + router.pattern_router, "route", side_effect=AssertionError("pattern route called") + ): + assert router.get_configured_token_limits( + "bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0" + ) == (None, None) def test_get_configured_token_limits_treats_malformed_values_as_absent(): @@ -7243,8 +7589,13 @@ def test_get_configured_mode_skips_wildcard_pattern_matching(): ] ) - with patch.object(router.pattern_router, "route", side_effect=AssertionError("pattern route called")): - assert router.get_configured_mode("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") is None + with patch.object( + router.pattern_router, "route", side_effect=AssertionError("pattern route called") + ): + assert ( + router.get_configured_mode("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") + is None + ) def test_get_configured_mode_treats_malformed_values_as_absent(): @@ -7303,8 +7654,13 @@ def test_get_configured_display_name_skips_wildcard_pattern_matching(): ] ) - with patch.object(router.pattern_router, "route", side_effect=AssertionError("pattern route called")): - assert router.get_configured_display_name("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") is None + with patch.object( + router.pattern_router, "route", side_effect=AssertionError("pattern route called") + ): + assert ( + router.get_configured_display_name("bedrock/anthropic.claude-3-5-sonnet-20240620-v1:0") + is None + ) def test_get_configured_display_name_treats_malformed_values_as_absent(): @@ -7537,16 +7893,13 @@ async def test_acreate_batch_request_bedrock_tags_override_deployment_tags(): mock_client = MagicMock() mock_client.post = AsyncMock(side_effect=lambda *args, **kwargs: fake_response()) - with ( - patch.object( - CommonBatchFilesUtils, - "sign_aws_request", - return_value=({"Authorization": "signed"}, b"{}"), - ) as mock_sign, - patch( - "litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client", - return_value=mock_client, - ), + with patch.object( + CommonBatchFilesUtils, + "sign_aws_request", + return_value=({"Authorization": "signed"}, b"{}"), + ) as mock_sign, patch( + "litellm.llms.custom_httpx.llm_http_handler.get_async_httpx_client", + return_value=mock_client, ): await router.acreate_batch( model="bedrock-batch-model", @@ -7575,13 +7928,13 @@ async def test_avector_store_search_injects_router(): """ from litellm.types.vector_stores import VectorStoreSearchResponse - expected_response = VectorStoreSearchResponse(object="vector_store.search_results.page", search_query="q", data=[]) + expected_response = VectorStoreSearchResponse( + object="vector_store.search_results.page", search_query="q", data=[] + ) mock_asearch = AsyncMock(return_value=expected_response) # Router.__init__ binds asearch via a local import, so patch the module # attribute before constructing the Router. - with patch( - "litellm.vector_stores.main.asearch", new=mock_asearch - ): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable + with patch("litellm.vector_stores.main.asearch", new=mock_asearch): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable router = litellm.Router( model_list=[ { @@ -7615,9 +7968,7 @@ async def test_avector_store_create_does_not_inject_router(): } ] ) - with patch( - "litellm.vector_stores.main.acreate", new=mock_acreate - ): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface + with patch("litellm.vector_stores.main.acreate", new=mock_acreate): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface create_response = await router.avector_store_create(model=None, custom_llm_provider="openai") assert create_response is expected_response @@ -7633,13 +7984,13 @@ def test_vector_store_search_injects_router(): """ from litellm.types.vector_stores import VectorStoreSearchResponse - expected_response = VectorStoreSearchResponse(object="vector_store.search_results.page", search_query="q", data=[]) + expected_response = VectorStoreSearchResponse( + object="vector_store.search_results.page", search_query="q", data=[] + ) mock_search = MagicMock(return_value=expected_response) # Router.__init__ binds search via a local import, so patch the module # attribute before constructing the Router. - with patch( - "litellm.vector_stores.main.search", new=mock_search - ): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable + with patch("litellm.vector_stores.main.search", new=mock_search): # test-quality-ok: the SDK call is the only place the injected router kwarg is observable router = litellm.Router( model_list=[ { @@ -7648,7 +7999,9 @@ def test_vector_store_search_injects_router(): } ] ) - search_response = router.vector_store_search(vector_store_id="v", query="q", custom_llm_provider="s3_vectors") + search_response = router.vector_store_search( + vector_store_id="v", query="q", custom_llm_provider="s3_vectors" + ) assert search_response is expected_response mock_search.assert_called_once() @@ -7662,9 +8015,7 @@ def test_vector_store_create_does_not_inject_router(): mock_create = MagicMock(return_value=expected_response) # Router.__init__ binds create via a local import, so patch the module # attribute before constructing the Router. - with patch( - "litellm.vector_stores.main.create", new=mock_create - ): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface + with patch("litellm.vector_stores.main.create", new=mock_create): # test-quality-ok: the SDK call is the only place a leaked router kwarg would surface router = litellm.Router( model_list=[ { @@ -7699,7 +8050,9 @@ class TestPreRoutingStrategyRegistryLifecycle: def _complexity_router_params(default_model: str, tags=None) -> dict: return { "model": "auto_router/complexity_router", - "complexity_router_config": {"tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o"}}, + "complexity_router_config": { + "tiers": {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o"} + }, "complexity_router_default_model": default_model, **({"tags": tags} if tags else {}), } @@ -8004,7 +8357,9 @@ class TestPreRoutingStrategyRegistryLifecycle: deployment=Deployment( model_name="hybrid-router", litellm_params=LiteLLM_Params( - **self._hybrid_router_params({"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o"}) + **self._hybrid_router_params( + {"SIMPLE": "gpt-4o-mini", "MEDIUM": "gpt-4o", "COMPLEX": "gpt-4o"} + ) ), model_info=ModelInfo(id="router-1", db_model=True), ) @@ -8113,7 +8468,9 @@ class TestPreRoutingStrategyRegistryLifecycle: ({"model": "openai/gpt-4o"}, False), ] for params, expected in cases: - actual = router._deployment_participates_in_adaptive_routing(litellm_params=LiteLLM_Params(**params)) + actual = router._deployment_participates_in_adaptive_routing( + litellm_params=LiteLLM_Params(**params) + ) assert actual is expected, params["model"] @@ -8300,16 +8657,22 @@ class TestUpsertDeploymentRollback: router.delete_deployment(id="prod-1") assert router.has_model_id("prod-1") is False - router._restore_deployment_after_failed_upsert(previous_deployment=previous, model_id="prod-1") + router._restore_deployment_after_failed_upsert( + previous_deployment=previous, model_id="prod-1" + ) restored = router.get_deployment(model_id="prod-1") assert restored is not None assert restored.litellm_params.model == "gpt-4o" - router._restore_deployment_after_failed_upsert(previous_deployment=previous, model_id="prod-1") + router._restore_deployment_after_failed_upsert( + previous_deployment=previous, model_id="prod-1" + ) assert len(router.model_list) == 1 - router._restore_deployment_after_failed_upsert(previous_deployment=None, model_id="prod-1") + router._restore_deployment_after_failed_upsert( + previous_deployment=None, model_id="prod-1" + ) assert len(router.model_list) == 1 @@ -9075,14 +9438,18 @@ class TestAutoRouterSharedModelNameConnectionParams: return httpx.Response( status_code=200, json={ - "candidates": [{"content": {"parts": [{"text": "Paris"}], "role": "model"}, "finishReason": "STOP"}], + "candidates": [ + {"content": {"parts": [{"text": "Paris"}], "role": "model"}, "finishReason": "STOP"} + ], "usageMetadata": {"promptTokenCount": 5, "candidatesTokenCount": 1, "totalTokenCount": 6}, "modelVersion": "gemini-3.6-flash", }, request=httpx.Request("POST", "https://generativelanguage.googleapis.com"), ) - @pytest.mark.parametrize("plain_entry_first", [True, False], ids=["plain_entry_first", "marker_entry_first"]) + @pytest.mark.parametrize( + "plain_entry_first", [True, False], ids=["plain_entry_first", "marker_entry_first"] + ) async def test_routed_tier_call_goes_out_on_its_own_endpoint_and_credentials(self, plain_entry_first): """The outbound provider request for the routed tier hits the tier's own Gemini host with the tier's own key, never the plain sibling's api_base or api_key.""" @@ -9205,7 +9572,9 @@ async def _drive_cyclic_fallback(router, capture, recorder=None, **request_kwarg litellm.callbacks.append(recorder) try: with pytest.raises(litellm.InternalServerError): - await router.acompletion(model="group-a", messages=[{"role": "user", "content": "hi"}], **request_kwargs) + await router.acompletion( + model="group-a", messages=[{"role": "user", "content": "hi"}], **request_kwargs + ) finally: router_logger.removeHandler(capture) router_logger.setLevel(previous_level) @@ -9244,9 +9613,9 @@ async def test_retry_breadcrumbs_do_not_carry_the_walk_state(): breadcrumbs = [breadcrumb for hop in recorder.breadcrumbs_per_target for breadcrumb in hop] assert breadcrumbs, "no retry breadcrumbs were recorded" - assert any("fallback_depth" in breadcrumb for breadcrumb in breadcrumbs), ( - "no breadcrumb carried router walk state, so this test cannot see the leak" - ) + assert any( + "fallback_depth" in breadcrumb for breadcrumb in breadcrumbs + ), "no breadcrumb carried router walk state, so this test cannot see the leak" for breadcrumb in breadcrumbs: assert "attempted_targets" not in breadcrumb @@ -9292,9 +9661,7 @@ async def test_retry_breadcrumbs_never_carry_a_forwarded_credential(container_ke breadcrumbs = metadata["previous_models"] assert breadcrumbs, "no retry breadcrumbs were recorded" dumped = json.dumps(breadcrumbs, default=str) - assert container_key in dumped, ( - "the credential-bearing kwarg never reached the breadcrumb, so this test cannot see the leak" - ) + assert container_key in dumped, "the credential-bearing kwarg never reached the breadcrumb, so this test cannot see the leak" assert _BREADCRUMB_CREDENTIAL_CANARY not in dumped @@ -9399,7 +9766,9 @@ async def test_fallback_failure_detail_from_upstream_is_bounded(): await _drive_cyclic_fallback( _cyclic_fallback_router(), capture, - mock_response=litellm.InternalServerError(message=huge_message, llm_provider="openai", model="group-a"), + mock_response=litellm.InternalServerError( + message=huge_message, llm_provider="openai", model="group-a" + ), ) assert capture.messages, "the fallback failure path did not log at ERROR" @@ -9465,7 +9834,9 @@ def test_ensure_deployment_affinity_callback_is_idempotent(): try: router._ensure_deployment_affinity_callback() router._ensure_deployment_affinity_callback() - affinity_callbacks = [cb for cb in router.optional_callbacks or [] if isinstance(cb, DeploymentAffinityCheck)] + affinity_callbacks = [ + cb for cb in router.optional_callbacks or [] if isinstance(cb, DeploymentAffinityCheck) + ] assert len(affinity_callbacks) == 1 finally: for cb in router.optional_callbacks or []: @@ -9607,7 +9978,9 @@ class TestModelGroupAliasReachesPreRoutingStrategies: router = self._router("auto_routers") metadata: dict = {} - response = await router.acompletion(model="smart-alias", messages=self._messages(), metadata=metadata) + response = await router.acompletion( + model="smart-alias", messages=self._messages(), metadata=metadata + ) assert response.choices[0].message.content == "routed by the tier" assert metadata["model_group"] == "smart-alias" @@ -9829,6 +10202,8 @@ class TestAutoRouterCompressionDecoupling: assert registered_guardrail.call_count == 0 +@pytest.mark.usefixtures("local_model_cost_map") + @pytest.mark.usefixtures("local_model_cost_map") class TestAzureBaseModelFallbackLogging: """When an azure deployment has no base_model but its model name is a known @@ -9855,14 +10230,17 @@ class TestAzureBaseModelFallbackLogging: def test_map_known_deployment_name_resolves_without_error_log(self): router = self._router_with_azure_deployment("azure/gpt-4o") - with patch("litellm.router.verbose_router_logger.error") as mock_error: + with patch( + "litellm.router.verbose_router_logger.error" + ) as mock_error: model_info = router.get_router_model_info( deployment=None, received_model_name="my-group", id="azure-base-model-test-id" ) - assert not any("Could not identify azure model" in str(call) for call in mock_error.call_args_list), ( - f"unexpected error log: {mock_error.call_args_list}" - ) + assert not any( + "Could not identify azure model" in str(call) + for call in mock_error.call_args_list + ), f"unexpected error log: {mock_error.call_args_list}" # the fallback resolution must actually surface the map values assert model_info["max_input_tokens"] == litellm.model_cost["azure/gpt-4o"]["max_input_tokens"] assert model_info["input_cost_per_token"] == litellm.model_cost["azure/gpt-4o"]["input_cost_per_token"] @@ -9870,14 +10248,17 @@ class TestAzureBaseModelFallbackLogging: def test_unmappable_deployment_name_still_logs_error(self): router = self._router_with_azure_deployment("azure/my-custom-deployment-name") - with patch("litellm.router.verbose_router_logger.error") as mock_error: + with patch( + "litellm.router.verbose_router_logger.error" + ) as mock_error: model_info = router.get_router_model_info( deployment=None, received_model_name="my-group", id="azure-base-model-test-id" ) - assert any("Could not identify azure model" in str(call) for call in mock_error.call_args_list), ( - "expected the error log for an unmappable azure deployment name" - ) + assert any( + "Could not identify azure model" in str(call) + for call in mock_error.call_args_list + ), "expected the error log for an unmappable azure deployment name" # unmappable names resolve to a zeroed stub — unchanged behavior assert model_info.get("max_input_tokens") is None @@ -9904,7 +10285,6 @@ class TestAzureBaseModelFallbackLogging: ) assert model_info["max_input_tokens"] == litellm.model_cost["azure/gpt-4o-mini"]["max_input_tokens"] - def test_model_group_info_intersects_supported_reasoning_efforts(): router = litellm.Router( model_list=[ @@ -9995,6 +10375,7 @@ def test_model_group_info_reasoning_efforts_are_unknown_when_any_deployment_is_o assert result.supported_reasoning_efforts is None + def test_model_group_info_surfaces_supports_parallel_function_calling(local_model_cost_map): """``/model_group/info`` folds each deployment's registry flags into the group; a deployment whose registry entry declares parallel function calling must flip the group to True instead of False.""" @@ -10227,7 +10608,6 @@ class TestAddDeploymentApiBaseProviderResolution: assert deployment is not None assert deployment.litellm_params.custom_llm_provider == "openai" - # ===================================================================== # anthropic_messages mid-stream-fallback helpers, added for #24004 # (mid-stream fallback not supported for anthropic_messages route type). @@ -10338,7 +10718,10 @@ class _AnthropicMessagesFallbackByteStream: def _anthropic_messages_overloaded_error_chunk() -> bytes: - return b'event: error\ndata: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}\n\n' + return ( + b"event: error\n" + b'data: {"type": "error", "error": {"type": "overloaded_error", "message": "Overloaded"}}\n\n' + ) def _anthropic_messages_invalid_request_error_chunk() -> bytes: @@ -10383,7 +10766,9 @@ async def test_anthropic_messages_streaming_iterator_passthrough(): [_anthropic_messages_content_chunk("hi"), _anthropic_messages_content_chunk(" there")] ) - wrapped = await router._aanthropic_messages_streaming_iterator(response=source, initial_kwargs={"model": "primary"}) + wrapped = await router._aanthropic_messages_streaming_iterator( + response=source, initial_kwargs={"model": "primary"} + ) collected = [chunk async for chunk in wrapped] assert collected == [_anthropic_messages_content_chunk("hi"), _anthropic_messages_content_chunk(" there")] @@ -10402,14 +10787,12 @@ async def test_anthropic_messages_streaming_iterator_flushes_buffered_lifecycle_ [_anthropic_messages_message_start_chunk(), _anthropic_messages_content_chunk("hi"), message_stop] ) - wrapped = await router._aanthropic_messages_streaming_iterator(response=source, initial_kwargs={"model": "primary"}) + wrapped = await router._aanthropic_messages_streaming_iterator( + response=source, initial_kwargs={"model": "primary"} + ) collected = [chunk async for chunk in wrapped] - assert collected == [ - _anthropic_messages_message_start_chunk(), - _anthropic_messages_content_chunk("hi"), - message_stop, - ] + assert collected == [_anthropic_messages_message_start_chunk(), _anthropic_messages_content_chunk("hi"), message_stop] @pytest.mark.asyncio @@ -10421,7 +10804,9 @@ async def test_anthropic_messages_streaming_iterator_flushes_buffered_frames_on_ message_stop = b'event: message_stop\ndata: {"type": "message_stop"}\n\n' source = _AnthropicMessagesFakeByteStream([_anthropic_messages_message_start_chunk(), message_stop]) - wrapped = await router._aanthropic_messages_streaming_iterator(response=source, initial_kwargs={"model": "primary"}) + wrapped = await router._aanthropic_messages_streaming_iterator( + response=source, initial_kwargs={"model": "primary"} + ) collected = [chunk async for chunk in wrapped] assert collected == [_anthropic_messages_message_start_chunk(), message_stop] @@ -10492,9 +10877,7 @@ async def test_anthropic_messages_leading_ping_keepalive_is_forwarded_live(): 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"} - ) + 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() @@ -12295,9 +12678,9 @@ class TestTierParamsTheTargetAccepts: def test_declared_param_allowlist_ignores_malformed_declarations(self): """A str is iterable, so without the type guard a YAML scalar mistake like allowed_openai_params: reasoning_effort would allowlist single characters.""" - assert litellm.Router._declared_param_allowlist( - {"allowed_openai_params": ["reasoning_effort", 3]} - ) == frozenset({"reasoning_effort"}) + assert litellm.Router._declared_param_allowlist({"allowed_openai_params": ["reasoning_effort", 3]}) == frozenset( + {"reasoning_effort"} + ) assert litellm.Router._declared_param_allowlist({"allowed_openai_params": "reasoning_effort"}) == frozenset() assert litellm.Router._declared_param_allowlist({}) == frozenset() @@ -12377,11 +12760,7 @@ class TestTierParamsTheTargetAccepts: @pytest.mark.parametrize( "deployment", - [ - {"model_name": "x"}, - {"model_name": "x", "litellm_params": {}}, - {"model_name": "x", "litellm_params": {"model": "not-a-real-provider/nope"}}, - ], + [{"model_name": "x"}, {"model_name": "x", "litellm_params": {}}, {"model_name": "x", "litellm_params": {"model": "not-a-real-provider/nope"}}], ) def test_deployment_accepts_param_fails_open(self, deployment): """An unresolvable deployment must not be the reason a param is dropped.""" @@ -12610,7 +12989,9 @@ class TestPreRoutingTierDrivesFallbacks: async def test_the_selected_tier_fallback_chain_runs(self): router = self._router([{"tier1": ["backup-a"]}]) - response = await router.acompletion(model="smart-router", messages=[{"role": "user", "content": "hi"}]) + response = await router.acompletion( + model="smart-router", messages=[{"role": "user", "content": "hi"}] + ) assert response.choices[0].message.content == "from backup-a" @@ -12643,7 +13024,9 @@ class TestPreRoutingTierDrivesFallbacks: async def test_a_request_without_a_pre_routing_hook_still_uses_its_own_group(self): router = self._router([{"tier1": ["backup-a"]}]) - response = await router.acompletion(model="tier1", messages=[{"role": "user", "content": "hi"}]) + response = await router.acompletion( + model="tier1", messages=[{"role": "user", "content": "hi"}] + ) assert response.choices[0].message.content == "from backup-a" From 98784360e85186f798c7ffac797aba4020c964fe Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 10:37:14 -0700 Subject: [PATCH 229/410] test(e2e): cover Anthropic /chat/completions streaming and tool calls Adds TestAnthropicChatCompletions to the chat completions regression suite, registering a claude-haiku-4-5 deployment via /model/new and asserting the streamed call delivers real content deltas and a tool-forced call returns a well-formed get_weather tool_call on both the non-streamed and streamed paths. Covers three P0 registry cells that had no e2e test. --- .../test_chat_completions_regression_e2e.py | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/tests/e2e/llm_translation/test_chat_completions_regression_e2e.py b/tests/e2e/llm_translation/test_chat_completions_regression_e2e.py index 68c0dfab897..87bd32d8dab 100644 --- a/tests/e2e/llm_translation/test_chat_completions_regression_e2e.py +++ b/tests/e2e/llm_translation/test_chat_completions_regression_e2e.py @@ -11,7 +11,7 @@ fails that provider's row here. The per-provider classes below cover the OpenAI-compatible /chat/completions translation for providers customers reach by registering their own deployment -via /model/new (Cohere, Gemini, hosted_vllm), each deleted on teardown. +via /model/new (Cohere, Gemini, hosted_vllm, Anthropic), each deleted on teardown. """ from __future__ import annotations @@ -46,6 +46,7 @@ pytestmark = pytest.mark.e2e COHERE_BACKEND = "cohere/command-r-08-2024" GEMINI_BACKEND = "gemini/gemini-2.5-flash" OPENAI_BACKEND = "openai/gpt-5.6" +ANTHROPIC_BACKEND = "anthropic/claude-haiku-4-5-20251001" BEDROCK_CONVERSE_BACKEND = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" @@ -746,3 +747,98 @@ class TestBedrockConverseChatCompletions: response = unwrap(client.proxy.chat(key, ChatBody(model=model, messages=_vision_messages(), max_tokens=32))) _assert_describes_cat(response) + + +class TestAnthropicChatCompletions: + """Anthropic via the OpenAI-compatible /chat/completions path, the translation + customers on the OpenAI SDK rely on when they route to Claude. The streamed call + must deliver real content deltas, and a tool-forced call must come back as a + well-formed tool_call on both the non-streamed and streamed paths. + """ + + def _register(self, client: PassthroughClient, resources: ResourceManager, prefix: str) -> str: + model = f"{prefix}-{unique_marker()}" + model_id = client.proxy.create_model( + model, LiteLLMParamsBody(model=ANTHROPIC_BACKEND, api_key="os.environ/ANTHROPIC_API_KEY") + ) + resources.defer(lambda: client.proxy.delete_model(model_id)) + return model + + @pytest.mark.covers( + "llm.chat_completions.anthropic.basic.stream.works", + exercised_on=["chat_completions"], + ) + def test_anthropic_chat_streams_real_content( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = self._register(client, resources, "e2e-anthropic-stream") + key = resources.key() + + result = client.proxy.chat_stream( + key, + ChatBody( + model=model, + messages=[ + ChatMessage(role="user", content=f"Count from 1 to 5, one number per line. {unique_marker()}") + ], + max_tokens=64, + stream=True, + ), + ) + _assert_streamed_completion(result) + + @pytest.mark.covers( + "llm.chat_completions.anthropic.tool_use.nonstream.works", + exercised_on=["chat_completions"], + ) + def test_anthropic_chat_returns_tool_call( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = self._register(client, resources, "e2e-anthropic-tool") + key = resources.key() + + response = unwrap( + client.proxy.chat( + key, + ChatBody( + model=model, + messages=[ + ChatMessage(role="user", content="What is the weather in San Francisco? Use the get_weather tool.") + ], + tools=[_WEATHER_TOOL], + tool_choice="required", + max_tokens=128, + ), + ) + ) + _assert_weather_tool_call(response) + + @pytest.mark.covers( + "llm.chat_completions.anthropic.tool_use.stream.works", + exercised_on=["chat_completions"], + ) + def test_anthropic_chat_streams_tool_call( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = self._register(client, resources, "e2e-anthropic-tool-stream") + key = resources.key() + + result = client.proxy.chat_stream( + key, + ChatBody( + model=model, + messages=[ + ChatMessage(role="user", content="What is the weather in San Francisco? Use the get_weather tool.") + ], + tools=[_WEATHER_TOOL], + tool_choice="required", + max_tokens=128, + stream=True, + ), + ) + assert result.ok and result.is_streaming, f"tool stream was not established: {result}" + assert result.stream_error is None, f"tool stream carried an error event: {result.stream_error}" + name, arguments = _streamed_tool_call(result.stream_events) + assert name == "get_weather", f"streamed tool call named {name!r}: {result.stream_events[:5]}" + args = _WeatherArgs.model_validate_json(arguments) + assert args.location.strip(), f"streamed tool call arguments missing location: {arguments!r}" From df544fcc532179e3ff388a41032a514ce41c5020 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 10:46:05 -0700 Subject: [PATCH 230/410] test(e2e): cover key spend reset, regenerate grace period, and the llm_api_routes grant Three deterministic proxy-only cells from the coverage registry that had no e2e test. A key over its max_budget is reset to 0 through /key/{key}/reset_spend and must both read back 0 on /key/info and serve traffic again. /key/regenerate with grace_period keeps the old key valid until the period elapses and rejects it 401 afterwards. A key whose allowed_routes is the llm_api_routes group must reach /chat/completions and /embeddings while /model/new stays 403. KeyRegenerateBody gains grace_period and the management client gains reset_key_spend so the tests stay on the shared typed transport. --- .../access_control/test_access_control_e2e.py | 28 ++++++++- tests/e2e/management/management_client.py | 16 ++++- .../e2e/management/test_key_management_e2e.py | 59 ++++++++++++++++++- tests/e2e/management/test_management_e2e.py | 34 +++++++++++ tests/e2e/models.py | 10 ++++ 5 files changed, 143 insertions(+), 4 deletions(-) diff --git a/tests/e2e/access_control/test_access_control_e2e.py b/tests/e2e/access_control/test_access_control_e2e.py index af7e9a099fd..c30dadc49ae 100644 --- a/tests/e2e/access_control/test_access_control_e2e.py +++ b/tests/e2e/access_control/test_access_control_e2e.py @@ -24,7 +24,7 @@ from access_control_client import ( from e2e_config import unique_marker from e2e_http import Success, UnauthorizedError, UnknownApiError, unwrap from lifecycle import ResourceManager -from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody +from models import ChatBody, ChatMessage, ChatResponse, EmbedBody, LiteLLMParamsBody from proxy_client import ProxyClient pytestmark = pytest.mark.e2e @@ -32,6 +32,7 @@ pytestmark = pytest.mark.e2e ALLOWED_MODEL = "gemini-2.5-flash" DISALLOWED_MODEL = "gpt-5.5" VIRTUAL_KEY_BACKEND = "anthropic/claude-haiku-4-5-20251001" +EMBEDDING_MODEL = "openai-text-embedding-3-small" class TestAccessControl: @@ -71,6 +72,31 @@ class TestAccessControl: f"403 body must be a model-access denial, got: {result.body[:300]}" ) + @pytest.mark.covers("other.auth.virtual_key.route_group_allowed") + def test_llm_api_routes_group_grants_every_llm_endpoint( + self, client: AccessControlClient, resources: ResourceManager + ) -> None: + """allowed_routes=["llm_api_routes"] names a route group, not a path: one + entry must open every LLM endpoint while the management routes stay shut.""" + key = client.llm_only_key() + resources.defer(lambda: client.delete_key(key)) + + chat = client.chat_status(key, ALLOWED_MODEL, f"capital of France? {unique_marker()}") + assert chat.status_code == 200, ( + f"llm_api_routes key must reach /chat/completions, got {chat.status_code}: {chat.body[:300]}" + ) + assert ChatResponse.model_validate_json(chat.body).choices, ( + f"200 must carry a real completion, not an error envelope: {chat.body[:300]}" + ) + + embedding = unwrap(client.proxy.embed(key, EmbedBody(model=EMBEDDING_MODEL, input=f"route group {unique_marker()}"))) + assert embedding.model, f"llm_api_routes key reached /embeddings but got no model back: {embedding}" + + denied = client.create_model_status(key, f"e2e-route-group-{unique_marker()}") + assert denied.status_code == 403 and ROUTE_NOT_ALLOWED_MARKER in denied.body, ( + f"the same key must still be shut out of /model/new, got {denied.status_code}: {denied.body[:300]}" + ) + def test_llm_only_key_forbidden_from_management_route_403( self, client: AccessControlClient, resources: ResourceManager ) -> None: diff --git a/tests/e2e/management/management_client.py b/tests/e2e/management/management_client.py index 387280c8023..2b897f5f07f 100644 --- a/tests/e2e/management/management_client.py +++ b/tests/e2e/management/management_client.py @@ -40,6 +40,8 @@ from models import ( KeyListParams, KeyListResponse, KeyRegenerateBody, + KeyResetSpendBody, + KeyResetSpendResponse, KeyUpdateBody, ModelDeleteBody, OrgDeleteBody, @@ -191,16 +193,26 @@ class ManagementClient: response_type=NoBody, ) ) - def regenerate_key(self, key: str) -> str: + def regenerate_key(self, key: str, *, grace_period: str | None = None) -> str: return unwrap( self.proxy.transport.post( "/key/regenerate", headers=self.proxy.transport.master, - json=KeyRegenerateBody(key=key), + json=KeyRegenerateBody(key=key, grace_period=grace_period), response_type=KeyGenerateResponse, ) ).key + def reset_key_spend(self, key: str, reset_to: float) -> KeyResetSpendResponse: + return unwrap( + self.proxy.transport.post( + f"/key/{key}/reset_spend", + headers=self.proxy.transport.master, + json=KeyResetSpendBody(reset_to=reset_to), + response_type=KeyResetSpendResponse, + ) + ) + def key_list(self, key_alias: str, *, caller_key: str | None = None) -> Result[KeyListResponse]: """GET /key/list, the Virtual Keys page's own inventory call. `caller_key` is who is asking: the master key by default, or a virtual key.""" diff --git a/tests/e2e/management/test_key_management_e2e.py b/tests/e2e/management/test_key_management_e2e.py index 711175abb0d..d4347b8c0e7 100644 --- a/tests/e2e/management/test_key_management_e2e.py +++ b/tests/e2e/management/test_key_management_e2e.py @@ -18,7 +18,7 @@ from typing import Literal import pytest from e2e_config import unique_marker -from e2e_http import NoBody, unwrap +from e2e_http import NoBody, StreamingResponse, unwrap from lifecycle import ResourceManager from management_client import ManagementClient from models import KeyDeleteBody, KeyGenerateBody, KeyUpdateBody @@ -26,6 +26,9 @@ from pydantic import BaseModel pytestmark = pytest.mark.e2e +TINY_BUDGET = 3e-6 +SPEND_MODEL = "claude-haiku-4-5" + class KeyToggleBlockBody(BaseModel): key: str @@ -82,6 +85,34 @@ def _generate_key(client: ManagementClient, resources: ResourceManager, body: Ke return key +def _is_budget_block(outcome: StreamingResponse) -> bool: + return not outcome.ok and "budget_exceeded" in outcome.body + + +def _spend_until_budget_blocks(client: ManagementClient, key: str) -> None: + """Drive paid calls until the key's max_budget refuses one. The first call spends, + the reservation counter trips the cap, and the next call is the 429.""" + for _ in range(40): + outcome = client.chat_status(key, SPEND_MODEL, f"spend {unique_marker()}") + if _is_budget_block(outcome): + assert outcome.status_code == 429, ( + f"budget refusal must be 429, got {outcome.status_code}: {outcome.body[:200]}" + ) + return + assert outcome.ok, f"paid call failed before the budget tripped ({outcome.status_code}): {outcome.body[:300]}" + time.sleep(2) + pytest.fail(f"max_budget={TINY_BUDGET} never blocked a call on the key") + + +def _settled_spend(client: ManagementClient, key: str) -> float | None: + """The key's recorded spend once it is positive and unchanged across two reads a + poll interval apart, so no batched spend write is still in flight when we reset.""" + first = client.proxy.key_info(key).spend or 0.0 + time.sleep(client.proxy.poll_interval) + second = client.proxy.key_info(key).spend or 0.0 + return second if first > 0 and first == second else None + + def _block(client: ManagementClient, key: str) -> None: _ = unwrap( client.proxy.transport.post( @@ -197,6 +228,32 @@ class TestKeyManagementRoutes: "/key/info never reported max_budget 42.0 after /key/bulk_update before the deadline", ) + @pytest.mark.covers("other.key_mgmt.spend_reset.resets_to_value") + def test_reset_spend_zeroes_recorded_spend_and_lifts_the_budget_block( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + key = _generate_key(client, resources, KeyGenerateBody(models=[SPEND_MODEL], max_budget=TINY_BUDGET)) + _spend_until_budget_blocks(client, key) + recorded = _poll( + client, lambda: _settled_spend(client, key), "key spend never landed in /key/info before the deadline" + ) + + reset = client.reset_key_spend(key, reset_to=0.0) + assert reset.previous_spend == recorded, ( + f"reset_spend reported previous_spend {reset.previous_spend}, /key/info had recorded {recorded}" + ) + assert reset.spend == 0.0, f"reset_spend to 0 reported spend {reset.spend}" + assert client.proxy.key_info(key).spend == 0.0, "/key/info still reports spend after the reset to 0" + + def call_allowed_again() -> bool | None: + outcome = client.chat_status(key, SPEND_MODEL, f"after reset {unique_marker()}") + if _is_budget_block(outcome): + return None + assert outcome.ok, f"post-reset call failed ({outcome.status_code}): {outcome.body[:300]}" + return True + + _ = _poll(client, call_allowed_again, "the key stayed budget-blocked after its spend was reset to 0") + @pytest.mark.covers("mgmt.key.generate.admin_only") def test_generate_forbidden_for_non_admin_key( self, client: ManagementClient, resources: ResourceManager diff --git a/tests/e2e/management/test_management_e2e.py b/tests/e2e/management/test_management_e2e.py index a56eb853823..eace8f2e3b4 100644 --- a/tests/e2e/management/test_management_e2e.py +++ b/tests/e2e/management/test_management_e2e.py @@ -42,6 +42,10 @@ from models import ( pytestmark = pytest.mark.e2e +REGENERATE_GRACE_PERIOD = "15s" +REGENERATE_GRACE_SECONDS = 15.0 + + def _poll[T](client: ManagementClient, attempt: Callable[[], T | None], failure: str) -> T: deadline = time.monotonic() + client.proxy.poll_timeout while time.monotonic() < deadline: @@ -365,6 +369,36 @@ class TestKeyRegeneration: client, old_rejected, "old key was still accepted after regeneration (never rejected 401) at the deadline" ) + @pytest.mark.covers("other.key_mgmt.regenerate.grace_period_honored") + def test_regenerate_with_grace_period_keeps_old_key_until_revoked( + self, client: ManagementClient, resources: ResourceManager + ) -> None: + old_key = _generate_key(client, resources, KeyGenerateBody(models=["gpt-5.5"])) + + new_key = client.regenerate_key(old_key, grace_period=REGENERATE_GRACE_PERIOD) + resources.defer(lambda: client.proxy.delete_key(new_key)) + revoke_at = time.monotonic() + REGENERATE_GRACE_SECONDS + assert new_key != old_key, "regenerate returned the same key string, so no rotation happened" + + def old_accepted() -> bool | None: + outcome = client.chat_status(old_key, "gpt-5.5", f"say hi {unique_marker()}") + return True if outcome.status_code != 401 else None + + _ = _poll(client, old_accepted, "old key was rejected 401 inside its grace period at the deadline") + assert time.monotonic() < revoke_at, ( + f"old key was only accepted after its {REGENERATE_GRACE_PERIOD} grace period had elapsed" + ) + + def old_rejected() -> bool | None: + outcome = client.chat_status(old_key, "gpt-5.5", f"say hi {unique_marker()}") + return True if outcome.status_code == 401 else None + + _ = _poll( + client, + old_rejected, + f"old key was still accepted past its {REGENERATE_GRACE_PERIOD} grace period (never 401) at the deadline", + ) + class TestTeamRoutes: @pytest.mark.covers("mgmt.team.new.persists") diff --git a/tests/e2e/models.py b/tests/e2e/models.py index b5229744d6f..5de49ead3ed 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -83,6 +83,16 @@ class KeyGenerateResponse(BaseModel): class KeyRegenerateBody(BaseModel): key: str + grace_period: str | None = None + + +class KeyResetSpendBody(BaseModel): + reset_to: float + + +class KeyResetSpendResponse(BaseModel): + spend: float + previous_spend: float class KeyDeleteBody(BaseModel): From 88c46fb1defa968348b9e780bd63453f5f8f3e94 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 10:52:46 -0700 Subject: [PATCH 231/410] test(e2e): cover Anthropic and OpenAI prompt caching, Cohere embeddings, and costed /openai chat passthrough Four registry cells that had no e2e test. The cache_control suite gains a direct Anthropic case (the same cache_control prefix the Bedrock and Vertex rows send) and an OpenAI case, where caching is automatic so the prefix goes out as a plain system string with a prompt_cache_key; both assert the second identical call reports cache-read tokens. The shared second-call helper now takes the send callable so the OpenAI shape fits without a second copy of the retry loop. The embeddings suite gains a cohere/embed-v4.0 deployment that must return a non-zero vector, and the passthrough suite gains an OpenAI-format chat through the raw /openai/v1/chat/completions prefix that must relay a real completion and log a costed pass_through_endpoint row whose token counts match the usage the caller was served. --- .../e2e/llm_translation/test_cache_control.py | 76 +++++++++++++++++-- .../test_embeddings_endpoint_e2e.py | 22 +++++- .../llm_translation/test_passthrough_e2e.py | 34 ++++++++- 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/tests/e2e/llm_translation/test_cache_control.py b/tests/e2e/llm_translation/test_cache_control.py index a2c17b0fb66..0d224061381 100644 --- a/tests/e2e/llm_translation/test_cache_control.py +++ b/tests/e2e/llm_translation/test_cache_control.py @@ -10,6 +10,11 @@ Each case asserts the feature actually happened, not just a 200. Coverage matrix intentionally not covered here. - Vertex (gemini-2.5-flash): prompt caching via ``cache_control`` context caching; the second identical call must report cached prompt tokens > 0. +- Anthropic (claude-haiku-4-5, direct): the same ``cache_control`` prefix over + the OpenAI-compatible route; the second call must report cache-read tokens > 0. +- OpenAI (gpt-5.6): automatic prompt caching needs no request marker, so the + cacheable prefix goes out as a plain system string with a ``prompt_cache_key`` + and the second call must report ``prompt_tokens_details.cached_tokens`` > 0. service_tier lives in test_provider_features_e2e.py. @@ -21,6 +26,7 @@ built from the typed content blocks shared in ``endpoints_client.py``. from __future__ import annotations import time +from collections.abc import Callable import pytest from pydantic import BaseModel @@ -29,7 +35,7 @@ from e2e_config import unique_marker from e2e_http import Result, unwrap from endpoints_client import CacheControl, RichMessage, TextBlock from lifecycle import ResourceManager -from models import ChatResponse, LiteLLMParamsBody, Usage +from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody, Usage from passthrough_client import PassthroughClient import os @@ -37,6 +43,8 @@ pytestmark = pytest.mark.e2e BEDROCK_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" VERTEX_MODEL = "vertex_ai/gemini-2.5-flash" +ANTHROPIC_MODEL = "anthropic/claude-haiku-4-5-20251001" +OPENAI_MODEL = "openai/gpt-5.6" class CacheChatBody(BaseModel): @@ -89,17 +97,36 @@ def _cache_chat( ) +def _plain_cache_chat( + client: PassthroughClient, key: str, model: str, prefix: str, cache_key: str +) -> Result[ChatResponse]: + """The same cacheable prefix as a plain system string, for providers that cache + automatically and take no per-block marker (OpenAI).""" + return client.proxy.chat( + key, + ChatBody( + model=model, + messages=[ + ChatMessage(role="system", content=prefix), + ChatMessage(role="user", content="Reply with one word."), + ], + max_tokens=64, + prompt_cache_key=cache_key, + ), + ) + + def _assert_cache_read_on_second_call( - client: PassthroughClient, key: str, model: str + model: str, send: Callable[[str], Result[ChatResponse]] ) -> None: prefix = _cacheable_prefix() - first = unwrap(_cache_chat(client, key, model, prefix)) + first = unwrap(send(prefix)) assert first.choices, f"{model}: first cache-priming call returned no choices: {first}" deadline = time.monotonic() + 30.0 while True: - second = unwrap(_cache_chat(client, key, model, prefix)) + second = unwrap(send(prefix)) read_tokens = _cached_read_tokens(second.usage) if read_tokens > 0 or time.monotonic() >= deadline: break @@ -125,7 +152,8 @@ class TestCacheControl: LiteLLMParamsBody(model=BEDROCK_MODEL, aws_region_name="us-east-1"), ) resources.defer(lambda: client.proxy.delete_model(model_id)) - _assert_cache_read_on_second_call(client, resources.key(), model) + key = resources.key() + _assert_cache_read_on_second_call(model, lambda prefix: _cache_chat(client, key, model, prefix)) @pytest.mark.covers( "llm.chat_completions.vertex.prompt_cache_5m.nonstream.works", @@ -145,4 +173,40 @@ class TestCacheControl: ), ) resources.defer(lambda: client.proxy.delete_model(model_id)) - _assert_cache_read_on_second_call(client, resources.key(), model) + key = resources.key() + _assert_cache_read_on_second_call(model, lambda prefix: _cache_chat(client, key, model, prefix)) + + @pytest.mark.covers( + "llm.chat_completions.anthropic.prompt_cache_5m.nonstream.works", + exercised_on=[], + ) + def test_anthropic_prompt_caching_reads_cache( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = f"e2e-anthropic-cache-{unique_marker()}" + model_id = client.proxy.create_model( + model, + LiteLLMParamsBody(model=ANTHROPIC_MODEL, api_key="os.environ/ANTHROPIC_API_KEY"), + ) + resources.defer(lambda: client.proxy.delete_model(model_id)) + key = resources.key() + _assert_cache_read_on_second_call(model, lambda prefix: _cache_chat(client, key, model, prefix)) + + @pytest.mark.covers( + "llm.chat_completions.openai.prompt_cache_5m.nonstream.works", + exercised_on=[], + ) + def test_openai_prompt_caching_reads_cache( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = f"e2e-openai-cache-{unique_marker()}" + model_id = client.proxy.create_model( + model, + LiteLLMParamsBody(model=OPENAI_MODEL, api_key="os.environ/OPENAI_API_KEY"), + ) + resources.defer(lambda: client.proxy.delete_model(model_id)) + key = resources.key() + cache_key = f"e2e-openai-cache-{unique_marker()}" + _assert_cache_read_on_second_call( + model, lambda prefix: _plain_cache_chat(client, key, model, prefix, cache_key) + ) diff --git a/tests/e2e/llm_translation/test_embeddings_endpoint_e2e.py b/tests/e2e/llm_translation/test_embeddings_endpoint_e2e.py index f951eb328f5..5520ca0cee5 100644 --- a/tests/e2e/llm_translation/test_embeddings_endpoint_e2e.py +++ b/tests/e2e/llm_translation/test_embeddings_endpoint_e2e.py @@ -1,4 +1,4 @@ -"""Live e2e: POST /embeddings returns a real vector across OpenAI, Bedrock, Vertex. +"""Live e2e: POST /embeddings returns a real vector across OpenAI, Bedrock, Vertex, Cohere. Each test registers the deployment it needs at runtime (deleted on teardown) and asserts a non-empty, non-zero vector came back. The LIT-3167 guard in @@ -86,6 +86,26 @@ class TestEmbeddingsEndpoint: f"embedding vector is all zeros: {result.body[:300]}" ) + @pytest.mark.covers("llm.embeddings.cohere.basic.nonstream.works") + def test_cohere_embeddings_returns_vector( + self, endpoints_client: EndpointsClient, resources: ResourceManager + ) -> None: + model = f"e2e-embeddings-cohere-{unique_marker()}" + model_id = endpoints_client.create_model( + model, + LiteLLMParamsBody(model="cohere/embed-v4.0", api_key="os.environ/COHERE_API_KEY"), + ) + resources.defer(lambda: endpoints_client.delete_model(model_id)) + key = resources.key() + + result = endpoints_client.embeddings(key, model, "Say this is a test!") + require_successful_call(result) + parsed = EmbeddingsResult.model_validate_json(result.body) + assert parsed.first_vector, f"/embeddings returned no vector: {result.body[:300]}" + assert any(component != 0.0 for component in parsed.first_vector), ( + f"embedding vector is all zeros: {result.body[:300]}" + ) + @pytest.mark.covers("llm.embeddings.vertex.basic.nonstream.works") def test_vertex_embeddings_returns_vector( self, endpoints_client: EndpointsClient, resources: ResourceManager diff --git a/tests/e2e/llm_translation/test_passthrough_e2e.py b/tests/e2e/llm_translation/test_passthrough_e2e.py index 50ea8f4b4df..e50e83eaf77 100644 --- a/tests/e2e/llm_translation/test_passthrough_e2e.py +++ b/tests/e2e/llm_translation/test_passthrough_e2e.py @@ -17,7 +17,7 @@ import pytest from e2e_config import CHEAP_OPENAI_MODEL, unique_marker from e2e_http import require_successful_call, unwrap from lifecycle import ResourceManager -from models import KeyGenerateBody, SpendLogRow +from models import ChatResponse, KeyGenerateBody, SpendLogRow from passthrough_client import ( AnthropicTool, GeminiFunctionDeclaration, @@ -344,6 +344,38 @@ class TestOpenAIPassthroughSpend: ) +class TestOpenAIProviderPrefixChat: + """OpenAI-format chat through the raw `/openai/{endpoint}` passthrough (LIT-4752). + + The body goes to OpenAI untranslated with the proxy's own OPENAI_API_KEY swapped + in, so the customer gets OpenAI's real completion back, and the gateway must + still write a costed pass_through_endpoint row for it. + """ + + @pytest.mark.covers("llm.chat_completions.openai.passthrough.nonstream.cost_logged") + def test_openai_prefix_chat_returns_completion_and_logs_its_cost( + self, client: PassthroughClient, scoped_key: str + ) -> None: + result = client.openai_chat(scoped_key, CHEAP_OPENAI_MODEL, f"Say hi in one word. {unique_marker()}") + require_successful_call(result) + + completion = ChatResponse.model_validate_json(result.body) + assert completion.id, f"/openai/v1/chat/completions relayed no completion id: {result.body[:300]}" + content = completion.choices[0].message.content if completion.choices and completion.choices[0].message else None + assert content and content.strip(), f"/openai/v1/chat/completions relayed an empty completion: {result.body[:300]}" + assert completion.usage is not None, f"the completion carried no usage to price from: {completion}" + + row = _fetch_cost_breakdown(client, completion.id) + assert row.prompt_tokens == completion.usage.prompt_tokens, ( + f"logged {row.prompt_tokens} prompt tokens, the completion the customer read " + f"reported {completion.usage.prompt_tokens}" + ) + assert row.completion_tokens == completion.usage.completion_tokens, ( + f"logged {row.completion_tokens} completion tokens, the completion the customer read " + f"reported {completion.usage.completion_tokens}" + ) + + class TestOpenAIPassthroughWebsocket: """The OpenAI passthrough prefixes must answer a websocket upgrade, not only a POST. From ff942c3a74e3d5a40e44f97111fffae56f29c06e Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 5 Sep 2026 11:05:50 -0700 Subject: [PATCH 232/410] fix(auto-router compression): surface a stored model-only policy in the edit form The backend treats either compression key on its own as an authoritative policy, but hydrate returned the untouched inherit state whenever the routing key was absent. A config carrying only auto_router_model_compression was therefore invisible in the form, and picking a routing value then overwrote the stored model hop. Only neither key set now reads as untouched, and an absent key on either hop hydrates as no compression for that hop rather than same-as-the-other. --- .../buildAutoRouterCompression.test.ts | 15 ++++++++++++++ .../add_model/buildAutoRouterCompression.ts | 20 ++++++++++++------- 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts index ea6eaf99106..20d6af50d18 100644 --- a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts +++ b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.test.ts @@ -96,6 +96,21 @@ describe("hydrateAutoRouterCompression", () => { expect(rebuilt.auto_router_model_compression).not.toBe("headroom-a"); }); + it("surfaces a stored model-only policy instead of reading as untouched", () => { + // Regression: the backend treats either key alone as an authoritative policy, so a + // model-only config that hydrated to the inherit state was invisible in the form, + // and the next save overwrote the stored model hop with the routing value. + const state = hydrateAutoRouterCompression({ auto_router_model_compression: "headroom-b" }); + expect(state).toEqual({ routing: "none", sameAsRouting: false, model: "headroom-b" }); + }); + + it("round-trips a model-only policy without changing either hop", () => { + const stored = { auto_router_model_compression: "headroom-b" }; + const rebuilt = buildAutoRouterCompressionParams(hydrateAutoRouterCompression(stored)); + expect(rebuilt.auto_router_model_compression).toBe("headroom-b"); + expect(rebuilt.auto_router_routing_compression).toBe("none"); + }); + it("round-trips through buildAutoRouterCompressionParams", () => { const original = { auto_router_routing_compression: "headroom-a", auto_router_model_compression: "none" }; const rebuilt = buildAutoRouterCompressionParams(hydrateAutoRouterCompression(original)); diff --git a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts index 47d0e3db7e4..6f401a12865 100644 --- a/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts +++ b/ui/litellm-dashboard/src/components/add_model/buildAutoRouterCompression.ts @@ -50,14 +50,20 @@ export const hydrateAutoRouterCompression = (litellmParams: { auto_router_routing_compression?: string | null; auto_router_model_compression?: string | null; }): AutoRouterCompressionState => { - const routing = litellmParams.auto_router_routing_compression ?? undefined; - if (routing === undefined) return DEFAULT_AUTO_ROUTER_COMPRESSION; + const storedRouting = litellmParams.auto_router_routing_compression ?? undefined; + const storedModel = litellmParams.auto_router_model_compression ?? undefined; - // An absent model key is no model-hop compression, not same-as-routing: the backend - // reads it as None (policy_from_litellm_params). Hydrating it as same-as-routing - // would make re-saving an unrelated edit write the routing guardrail onto the model - // hop and silently start compressing the model call. - const model = litellmParams.auto_router_model_compression ?? NO_COMPRESSION; + // Only neither key set means the section was never touched. The backend treats + // either key on its own as an authoritative policy (policy_from_litellm_params), so + // reading a model-only config as untouched would hide it from the form and let the + // next save overwrite the stored model hop. + if (storedRouting === undefined && storedModel === undefined) return DEFAULT_AUTO_ROUTER_COMPRESSION; + + // An absent key on either hop is no compression for that hop, not same-as-the-other: + // the backend reads it as None. Hydrating it as same-as-routing would make re-saving + // an unrelated edit write one hop's guardrail onto the other. + const routing = storedRouting ?? NO_COMPRESSION; + const model = storedModel ?? NO_COMPRESSION; const sameAsRouting = model === routing; return { routing, sameAsRouting, model: sameAsRouting ? undefined : model }; }; From 723bc2140fb55dc7f7fdf56cac184763e092c8ae Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 5 Sep 2026 11:19:39 -0700 Subject: [PATCH 233/410] refactor(auto-router compression): resolve the policy without a loop-local rebind The marker walk rebound a loop-local on each iteration, which is the mutation the repository's convention exists to discourage, but a `: Final` cannot express that inside a loop body: basedpyright rejects it outright with 'A Final variable cannot be assigned within a loop'. A lazy generator binds the name once per item and never rebinds it, so the first marker carrying a policy still wins and the rest are never read. --- litellm/proxy/guardrails/auto_router_compression.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py index b6f32c1c46d..ab3ba4011c7 100644 --- a/litellm/proxy/guardrails/auto_router_compression.py +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -117,11 +117,11 @@ def policy_for_model( # request does not carry describes a different slice of traffic, so falling back # to it would apply, say, an "eu" policy to a "us" request purely on config order. untagged: Final = tuple(params for params in markers if not params.get("tags")) - for params in (*tag_matched, *untagged): - policy = policy_from_litellm_params(params) - if policy is not None: - return policy - return None + # Lazily, so the first marker carrying a policy still wins and the rest are never + # read. A generator rather than a loop-local: the name is bound once per item and + # never rebound, which `: Final` cannot express inside a loop body. + candidates: Final = (policy_from_litellm_params(params) for params in (*tag_matched, *untagged)) + return next((policy for policy in candidates if policy is not None), None) def team_id_from_request(request_kwargs: Mapping[str, object]) -> str | None: From a0b07b47912caf10488f0e7926807cc83f5611d7 Mon Sep 17 00:00:00 2001 From: moe-berri Date: Sat, 5 Sep 2026 11:33:21 -0700 Subject: [PATCH 234/410] docs(auto-router compression): cut the explanatory comments back The module, its routing hook and its tests carried long prose rationale where the repository allows only concise comments for genuinely complex logic. Trimmed to the non-obvious reasons and dropped the rest; no logic or test behaviour changes. --- .../guardrails/auto_router_compression.py | 89 ++++++------------- litellm/router.py | 27 ++---- .../test_auto_router_compression.py | 59 ++++-------- 3 files changed, 49 insertions(+), 126 deletions(-) diff --git a/litellm/proxy/guardrails/auto_router_compression.py b/litellm/proxy/guardrails/auto_router_compression.py index ab3ba4011c7..98707e7ddca 100644 --- a/litellm/proxy/guardrails/auto_router_compression.py +++ b/litellm/proxy/guardrails/auto_router_compression.py @@ -1,15 +1,10 @@ """ -Decouples prompt compression between an auto router's routing decision and the -model it routes to. An auto router marker deployment may set -``auto_router_routing_compression`` and/or ``auto_router_model_compression`` in its -``litellm_params`` to name the compression guardrail that hop should use, or -``"none"`` to run no compression on that hop. Neither key set means the request's -own compression guardrails (key/team/model-level, or an "Always on" guardrail) -apply to both hops unchanged, exactly as before this feature existed. +Decouples prompt compression between an auto router's routing decision and the model +it routes to, via ``auto_router_routing_compression`` / ``auto_router_model_compression`` +on the marker deployment: a guardrail name, or ``"none"``. -Once either key is set, this auto router is authoritative: every other compression -guardrail is suppressed for that request, and only these two settings decide what -each hop sees. +Neither key set inherits today's behaviour. Either key set makes the auto router +authoritative and suppresses every other compression guardrail for that request. """ import contextvars @@ -29,12 +24,8 @@ if TYPE_CHECKING: COMPRESSION_GUARDRAIL_PROVIDERS: Final = frozenset({"headroom", "compresr"}) _NO_COMPRESSION: Final = "none" -# Compression guardrails this request's auto router has switched off. Deliberately a -# ContextVar rather than a metadata key: `refresh_proxy_server_request_body_snapshot` -# copies metadata into `proxy_server_request.body`, which deployments persist to spend -# logs. A suppression list that reaches a log the caller can read is a list the caller -# can replay, which would let any request switch off a PII or content-filter guardrail. -# Nothing here is caller-supplied, so there is no marker to forge in the first place. +# A ContextVar, not metadata: metadata reaches spend logs the caller can read, and a +# suppression list they can read is one they can replay to disable any guardrail. _suppressed_compression_guardrails: Final[contextvars.ContextVar[frozenset[str]]] = contextvars.ContextVar( "litellm_auto_router_suppressed_compression_guardrails", default=frozenset() ) @@ -45,9 +36,8 @@ def suppressed_compression_guardrails() -> frozenset[str]: return _suppressed_compression_guardrails.get() -# Whether `arm_pre_call` actually armed a model-side compression guardrail for this -# request. Only the proxy calls `arm_pre_call`, so on the SDK path nothing arms and -# nothing compresses; the router must not assume the model hop already ran. +# Only the proxy calls `arm_pre_call`, so on the SDK path nothing arms and nothing +# compresses; the router must not assume the model hop already ran. _model_hop_armed: Final[contextvars.ContextVar[bool]] = contextvars.ContextVar( "litellm_auto_router_model_hop_armed", default=False ) @@ -95,10 +85,8 @@ def policy_for_model( ) -> AutoRouterCompressionPolicy | None: """The compression policy of the auto router marker `model_alias` resolves to. - Both the proxy's pre-call arming and the router's routing hook resolve the policy - through here, with the same tag rule, so an alias carrying several tag-scoped - markers can never suppress one marker's guardrail and then route under another - marker's policy. + Pre-call arming and the routing hook both resolve through here, so an alias with + several tag-scoped markers cannot suppress under one and then route under another. """ if llm_router is None: return None @@ -113,13 +101,9 @@ def policy_for_model( tag_matched: Final = tuple( params for params in markers if (tags := params.get("tags")) and requested.issuperset(frozenset(tags)) ) - # Only untagged markers may serve as the fallback. A marker scoped to tags this - # request does not carry describes a different slice of traffic, so falling back - # to it would apply, say, an "eu" policy to a "us" request purely on config order. + # Untagged only: a marker scoped to tags this request lacks describes other traffic. untagged: Final = tuple(params for params in markers if not params.get("tags")) - # Lazily, so the first marker carrying a policy still wins and the rest are never - # read. A generator rather than a loop-local: the name is bound once per item and - # never rebound, which `: Final` cannot express inside a loop body. + # Lazy, so the first marker carrying a policy wins and the rest are never read. candidates: Final = (policy_from_litellm_params(params) for params in (*tag_matched, *untagged)) return next((policy for policy in candidates if policy is not None), None) @@ -145,12 +129,8 @@ def _compression_guardrail_classes() -> tuple[type, ...]: def is_compression_guardrail(guardrail: object) -> bool: """Whether `guardrail` is an instance of a compression guardrail provider. - Both hops are validated through here. The two policy fields are operator-supplied - names and nothing else constrains them, so without this a name that resolves to an - ordinary guardrail would be handed the conversation and invoked: the routing hop - calls `apply_guardrail` directly, which POSTs the content wherever that guardrail - sends it, and the model hop is added to `metadata["guardrails"]`, which runs it even - when it is not `default_on`. + Both hops validate through here: the policy fields are operator-supplied names, and + an unvalidated one would get handed the conversation and invoked. """ classes: Final = _compression_guardrail_classes() return bool(classes) and isinstance(guardrail, classes) @@ -185,9 +165,6 @@ async def arm_pre_call( if not isinstance(model_alias, str) or not model_alias: return - # Read-only until a policy is confirmed: creating the metadata bucket for every - # request, including the vast majority with no auto-router compression policy, - # would be an unwanted side effect of merely checking for one. from litellm.router_strategy.tag_based_routing import ( _get_tags_from_request_kwargs, # pyright: ignore[reportPrivateUsage] # used in router.py and budget_limiter.py too ) @@ -209,8 +186,7 @@ async def arm_pre_call( ) ) - # Only a name that resolves to a real compression guardrail may be armed: this adds - # it to `metadata["guardrails"]`, which runs it even when it is not `default_on`. + # Arming adds the name to `metadata["guardrails"]`, which runs it even if not default_on. armed_model_hop: Final = policy.model is not None and any( guardrail.guardrail_name == policy.model for guardrail in _active_compression_guardrails() ) @@ -226,8 +202,7 @@ async def arm_pre_call( requested: Final = metadata.get("guardrails") existing: Final = tuple(requested) if isinstance(requested, (list, tuple)) else () if policy.model not in existing: - # A list, not a tuple: litellm_pre_call_utils tests this key with - # isinstance(..., list) and extends it, and would drop a tuple on the floor. + # A list: litellm_pre_call_utils isinstance-checks this key and drops a tuple. metadata["guardrails"] = [*existing, policy.model] # mutable-ok: this key's contract is a list @@ -240,25 +215,17 @@ def _as_routing_messages( async def messages_for_routing( policy: AutoRouterCompressionPolicy | None, - # list[dict], not Sequence[Mapping]: the async_pre_routing_hook protocol in - # litellm/types/router.py types `messages` as list[dict[str, Any]]. + # list[dict], not Sequence[Mapping]: fixed by the async_pre_routing_hook protocol. messages: list[dict[str, object]] | None, # mutable-ok: shape fixed by the pre-routing hook protocol request_kwargs: Mapping[str, object], ) -> list[dict[str, object]] | None: # mutable-ok: shape fixed by the pre-routing hook protocol - """Messages to use for a routing decision, per `policy.routing`. + """Messages to use for a routing decision, per `policy.routing`. None means the + caller should route on whatever it already has. - Returns None when the caller should route on whatever messages it already has. - - Always reads the live messages, never a pre-guardrail copy of them. The routing - hop compresses through a real guardrail, which POSTs the text to an external - compression service, so it must see what every other guardrail has already done - to the request. Routing on a snapshot taken before the pre-call hook would send - a masking guardrail's own input straight back out of the proxy. - - The consequence, when the model hop compressed and the two hops differ: the - messages in hand are that guardrail's output, and there is no un-compressed copy - left to route on. The routing decision reads the compressed text in that one - combination rather than leaking the original. + Reads the live messages, never a pre-guardrail copy: this compresses through a real + guardrail that POSTs the text out, so routing on a pre-masking snapshot would leak + what the masking guardrail stripped. When the model hop already compressed and the + hops differ, routing therefore reads the compressed text rather than the original. """ if policy is None or policy.routing is None: return None @@ -277,9 +244,6 @@ async def messages_for_routing( ) return _as_routing_messages(messages) - # apply_guardrail below hands this guardrail the conversation and it POSTs the - # content to whatever service backs it, so the name has to be a compression - # guardrail rather than any guardrail the operator happened to name. if not is_compression_guardrail(guardrail): verbose_proxy_logger.warning( "AutoRouter compression: guardrail '%s' is not a compression guardrail; routing on uncompressed messages", @@ -291,9 +255,8 @@ async def messages_for_routing( "structured_messages": _as_routing_messages(messages) # pyright: ignore[reportAssignmentType] # plain dicts, not AllMessageValues; see headroom.py's own use of this shape } model: Final = request_kwargs.get("model") - # A throwaway request_data: apply_guardrail writes its stats onto this dict, not the - # real request's metadata, so routing-side compression never double-counts against - # extract_compression_saved_tokens's model-savings accounting. + # Throwaway: apply_guardrail writes stats here, so routing never double-counts into + # extract_compression_saved_tokens. stats_sink: Final = {"messages": messages, "model": model} # mutable-ok: apply_guardrail writes its stats here result: Final = await guardrail.apply_guardrail( inputs=inputs, diff --git a/litellm/router.py b/litellm/router.py index 81de4572af8..51e2dbe38e4 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -13047,25 +13047,17 @@ class Router: team_id_from_request, ) - # Resolved through the same tag-aware lookup the proxy's pre-call arming used, - # so an alias carrying several tag-scoped markers cannot suppress one marker's - # guardrail and then route under a different marker's policy. + # Same tag-aware lookup the proxy's pre-call arming used, so an alias with + # several tag-scoped markers cannot suppress under one and route under another. compression_policy: Final = policy_for_model( llm_router=self, model_alias=registered_model_name, team_id=team_id_from_request(request_kwargs), request_tags=_get_tags_from_request_kwargs(request_kwargs), ) - # When both hops share the same compression, the model-side guardrail already - # ran in the proxy's ordinary pre-call hook and compressed `messages` in place - # (arm_pre_call armed it whether or not it is `default_on`); reuse that result - # for routing too instead of paying for a second compression call against the - # same content. - # - # Only the proxy calls arm_pre_call, so that reuse is conditional on it having - # actually run: on the SDK path nothing arms the model hop and nothing has - # compressed anything, and taking the shortcut there would skip both hops and - # silently serve the request with no compression at all. + # Shared compression already ran in the pre-call hook, so reuse it rather than + # compressing twice. Conditional on arming having actually happened: only the + # proxy arms, and on the SDK path the shortcut would skip both hops entirely. needs_independent_routing_compression: Final = compression_policy is not None and not ( compression_policy.is_same and compression_policy.model is not None and model_hop_compression_armed() ) @@ -13082,12 +13074,9 @@ class Router: input=input, specific_deployment=specific_deployment, ) - # The strategy only echoes back whatever `messages` it was handed, so a - # routing-only compression must not leak into the response: the model call - # and downstream deployment-context filtering both key off this field. - # Compared by value, not identity: PreRoutingHookResponse is a pydantic model, - # and pydantic reconstructs a validated list field rather than keeping the - # exact object passed in, even when nothing about it changed. + # Routing-only compression must not leak into the response: the model call and + # deployment-context filtering key off this field. Compared by value, since + # pydantic rebuilds the list rather than keeping the object passed in. pre_routing_hook_response: Final = ( routed.model_copy(update={"messages": messages}) # mutable-ok: pydantic's model_copy takes a dict if routed is not None and routing_messages is not None and routed.messages == routing_messages diff --git a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py index 676b3ba2967..db2f94306fb 100644 --- a/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py +++ b/tests/test_litellm/proxy/guardrails/test_auto_router_compression.py @@ -1,20 +1,4 @@ -""" -Unit tests for litellm.proxy.guardrails.auto_router_compression. - -Covers: -- policy_from_litellm_params: absent keys mean no policy; the "none" sentinel - normalizes to explicit no-compression within an active policy; is_same -- policy_for_model: finds the auto-router marker deployment for an alias, picks the - tag-scoped marker the request's tags actually match, and never falls back to a - marker scoped to tags the request does not carry -- arm_pre_call: no-op without a policy; suppresses active compression guardrails - through request-scoped state rather than metadata, which reaches spend logs a - caller can read; arms the model-side guardrail even when it isn't default_on -- messages_for_routing: no-op without a policy; compresses the live messages every - earlier guardrail has already rewritten, never a pre-guardrail copy of them; - never writes stats onto the caller's own request_kwargs (regression for - double-counted compression savings) -""" +"""Unit tests for litellm.proxy.guardrails.auto_router_compression.""" import json from typing import Any @@ -137,8 +121,7 @@ class TestPolicyForModel: assert policy == AutoRouterCompressionPolicy(routing="headroom-a", model=None) def test_a_marker_scoped_to_other_tags_is_never_the_fallback(self): - """Regression: an "eu" marker describes a different slice of traffic, so a "us" - request must not fall back to its policy just because it is configured first.""" + """Regression: a "us" request must not fall back to an "eu" marker's policy.""" router = _FakeRouter( [ _marker({"auto_router_routing_compression": "headroom-eu"}, tags=["eu"]), @@ -149,8 +132,7 @@ class TestPolicyForModel: assert policy == AutoRouterCompressionPolicy(routing="headroom-default", model=None) def test_no_untagged_fallback_means_no_policy(self): - """With only tag-scoped markers and none matching, there is no policy to apply: - inheriting an unrelated slice's compression is worse than inheriting nothing.""" + """No matching marker means no policy, not an unrelated slice's compression.""" router = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-eu"}, tags=["eu"])]) assert ( policy_for_model(llm_router=router, model_alias="smart-router", team_id=None, request_tags=("us",)) is None @@ -268,9 +250,8 @@ class TestArmPreCall: @pytest.mark.asyncio async def test_suppression_state_never_enters_request_metadata(self): - """Regression (security): a suppression list written to metadata is copied into - proxy_server_request.body and persisted to spend logs, so a caller could read it - back and replay it to switch off a PII or content-filter guardrail.""" + """Regression (security): metadata reaches spend logs, so a suppression list + there is one a caller could read back and replay to disable a guardrail.""" guardrail = _RecordingCompressionGuardrail(guardrail_name="always-on-compression") import litellm @@ -312,9 +293,8 @@ class TestArmPreCall: @pytest.mark.asyncio async def test_arm_pre_call_keeps_no_copy_of_the_prompt(self): - """Regression (security): arm_pre_call runs before the pre-call guardrails, so - any copy of the messages it retained would be the pre-masking text. Routing-side - compression POSTs its input to an external service, so that copy must not exist.""" + """Regression (security): arm_pre_call runs before the guardrails, so any copy it + kept would be pre-masking text that routing then POSTs to an external service.""" router = _FakeRouter([_marker({"auto_router_routing_compression": "headroom-a"})]) data = {"model": "smart-router", "messages": [{"role": "user", "content": "my ssn is 123-45-6789"}]} @@ -337,10 +317,8 @@ class TestMessagesForRouting: @pytest.mark.asyncio async def test_routing_none_never_reaches_for_a_pre_guardrail_copy(self): - """Routing asked for no compression while the model hop compressed, so the - messages in hand are that guardrail's output and no uncompressed copy survives. - Routing reads them as-is: the alternative is keeping a pre-guardrail copy, which - is the text a masking guardrail exists to remove.""" + """No uncompressed copy survives the model hop, and keeping one would mean + retaining the pre-masking text. Routing reads what it has.""" policy = AutoRouterCompressionPolicy(routing=None, model="headroom-a") model_compressed = [{"role": "user", "content": "[COMPRESSED] the full original conversation"}] @@ -362,10 +340,8 @@ class TestMessagesForRouting: @pytest.mark.asyncio async def test_routing_compresses_what_the_other_guardrails_left_behind(self, registered_guardrail): - """Regression (security): routing-side compression POSTs its input to an external - service, so it must read the live messages every earlier guardrail has already - rewritten. Routing on a pre-guardrail copy would send a masking guardrail's own - input straight back out of the proxy.""" + """Regression (security): routing POSTs its input out, so it must read what the + earlier guardrails left behind, not a pre-masking copy.""" policy = AutoRouterCompressionPolicy(routing="fake-compress", model="headroom-b") masked = [{"role": "user", "content": "my ssn is [REDACTED]"}] @@ -376,10 +352,8 @@ class TestMessagesForRouting: @pytest.mark.asyncio async def test_a_non_compression_guardrail_is_never_invoked_for_routing(self, monkeypatch): - """Regression (security): the policy fields are operator-supplied names that - nothing else constrains. apply_guardrail hands the guardrail the conversation - and it POSTs that content to whatever service backs it, so naming an ordinary - guardrail must not turn the routing hop into a way to ship prompts there.""" + """Regression (security): naming an ordinary guardrail must not turn the routing + hop into a way to ship prompts to whatever service backs it.""" import litellm other = _NonCompressionGuardrail(guardrail_name="pii-filter") @@ -397,11 +371,8 @@ class TestMessagesForRouting: @pytest.mark.asyncio async def test_guardrail_receives_a_throwaway_request_data_not_the_real_request_kwargs(self, registered_guardrail): - """Regression: a real compression guardrail writes its stats onto whatever - `request_data` dict it's given (`add_standard_logging_guardrail_information_to_ - request_data`). If that were the caller's own `request_kwargs`, routing-side - compression would double-count into extract_compression_saved_tokens, which - sums every guardrail_information entry on the real request's metadata.""" + """Regression: a guardrail writes stats onto the request_data it is given, so + passing the caller's own would double-count into extract_compression_saved_tokens.""" policy = AutoRouterCompressionPolicy(routing="fake-compress", model=None) messages = [{"role": "user", "content": "hi"}] request_kwargs = {"metadata": {}} From 4df284e16dcc02ceabad4576df6f2c976f20d839 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:39:24 -0700 Subject: [PATCH 235/410] fix(guardrails): record guardrail information for undecorated custom apply_guardrail overrides (#39727) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 3 + litellm/integrations/custom_guardrail.py | 9 + .../integrations/test_custom_guardrail.py | 168 ++++++++++++++++++ .../test_openai_guardrail_handler.py | 31 ++++ .../test_bedrock_guardrails.py | 6 +- 5 files changed, 216 insertions(+), 1 deletion(-) diff --git a/litellm/constants.py b/litellm/constants.py index 7d6de612349..e4fb2a00297 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -215,6 +215,9 @@ MAX_CALLBACKS: Final = get_env_int("LITELLM_MAX_CALLBACKS", 100) # so the deployment-level hook does not re-run them for the same request PRE_CALL_EXECUTED_GUARDRAILS_KEY: Final = "_pre_call_executed_guardrails" +# Attribute stamped on log_guardrail_information wrappers so __init_subclass__ does not wrap them again +LOGS_GUARDRAIL_INFORMATION_MARKER: Final = "_litellm_logs_guardrail_information" + # Generic fallback for unknown models DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET: Final = int( os.getenv("DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET", 128) diff --git a/litellm/integrations/custom_guardrail.py b/litellm/integrations/custom_guardrail.py index 9bb613654e4..c462b1edb98 100644 --- a/litellm/integrations/custom_guardrail.py +++ b/litellm/integrations/custom_guardrail.py @@ -46,6 +46,7 @@ dc: Final = DualCache() from litellm.constants import ( GUARDRAIL_SCANNED_MESSAGES_CACHE_TTL_SECONDS, + LOGS_GUARDRAIL_INFORMATION_MARKER, PRE_CALL_EXECUTED_GUARDRAILS_KEY, ) from litellm.exceptions import ( @@ -151,6 +152,13 @@ class CustomGuardrail(CustomLogger): records_own_guardrail_information: ClassVar[bool] = False + def __init_subclass__(cls, **kwargs: object) -> None: # kwargs-ok: forwarded to cooperative __init_subclass__ hooks + super().__init_subclass__(**kwargs) + own_apply_guardrail: Final = cls.__dict__.get("apply_guardrail") + if own_apply_guardrail is None or LOGS_GUARDRAIL_INFORMATION_MARKER in vars(own_apply_guardrail): + return + cls.apply_guardrail = log_guardrail_information(own_apply_guardrail) + def __init__( self, guardrail_name: str | None = None, @@ -1559,4 +1567,5 @@ def log_guardrail_information(func): return async_wrapper(*args, **kwargs) return sync_wrapper(*args, **kwargs) + vars(wrapper)[LOGS_GUARDRAIL_INFORMATION_MARKER] = True # rebind-ok: stamps the wrapper this call just built return wrapper diff --git a/tests/test_litellm/integrations/test_custom_guardrail.py b/tests/test_litellm/integrations/test_custom_guardrail.py index 6edc2c9bf77..49a52157c8a 100644 --- a/tests/test_litellm/integrations/test_custom_guardrail.py +++ b/tests/test_litellm/integrations/test_custom_guardrail.py @@ -1,4 +1,5 @@ import asyncio +from typing import TYPE_CHECKING, Literal, Optional from unittest.mock import AsyncMock import pytest @@ -11,6 +12,9 @@ from litellm.integrations.custom_guardrail import ( from litellm.proxy._types import CallTypes, UserAPIKeyAuth from litellm.types.utils import GenericGuardrailAPIInputs, GuardrailTracingDetail +if TYPE_CHECKING: + from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj + class TestCustomGuardrailDeploymentHook: @@ -2239,6 +2243,170 @@ class TestRecordsOwnGuardrailInformation: assert _guardrail_entries(request_data) == [] +class _UndecoratedGuardrail(CustomGuardrail): + """apply_guardrail written like the docs example: no @log_guardrail_information.""" + + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + from litellm.exceptions import GuardrailRaisedException + + if any("forbidden" in text for text in inputs.get("texts") or []): + raise GuardrailRaisedException(guardrail_name=self.guardrail_name, message="Content blocked") + return inputs + + +class _UndecoratedSelfRecordingGuardrail(CustomGuardrail): + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={"custom": True}, + request_data=request_data, + guardrail_status="success", + start_time=0.0, + end_time=0.0, + duration=0.0, + ) + return inputs + + +class _InheritedApplyGuardrail(_UndecoratedGuardrail): + pass + + +class TestUndecoratedApplyGuardrailIsLogged: + """LIT-5983 regression: a custom guardrail that overrides apply_guardrail without the + @log_guardrail_information decorator must still record guardrail information, and the + auto-wrap must not double-record decorated or self-recording implementations.""" + + @pytest.mark.asyncio + async def test_undecorated_success_is_recorded(self): + from litellm.types.guardrails import GuardrailEventHooks + + guardrail = _UndecoratedGuardrail(guardrail_name="docs-style", event_hook=GuardrailEventHooks.pre_call) + request_data: dict = {"model": "gpt-4o"} + + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=["hello"]), + request_data=request_data, + input_type="request", + ) + + entries = _guardrail_entries(request_data) + assert len(entries) == 1 + assert entries[0]["guardrail_name"] == "docs-style" + assert entries[0]["guardrail_mode"] == "pre_call" + assert entries[0]["guardrail_status"] == "success" + + @pytest.mark.asyncio + async def test_undecorated_block_is_recorded_and_reraised(self): + from litellm.exceptions import GuardrailRaisedException + + guardrail = _UndecoratedGuardrail(guardrail_name="docs-style") + request_data: dict = {"model": "gpt-4o"} + + with pytest.raises(GuardrailRaisedException): + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=["forbidden"]), + request_data=request_data, + input_type="request", + ) + + entries = _guardrail_entries(request_data) + assert len(entries) == 1 + assert entries[0]["guardrail_name"] == "docs-style" + assert entries[0]["guardrail_status"] == "guardrail_intervened" + + @pytest.mark.asyncio + async def test_undecorated_bare_exception_is_recorded_as_failed_to_respond(self): + class _BareExceptionGuardrail(CustomGuardrail): + async def apply_guardrail( + self, + inputs: GenericGuardrailAPIInputs, + request_data: dict, + input_type: Literal["request", "response"], + logging_obj: Optional["LiteLLMLoggingObj"] = None, + ) -> GenericGuardrailAPIInputs: + raise Exception("Content blocked: policy violation") + + guardrail = _BareExceptionGuardrail(guardrail_name="docs-style") + request_data: dict = {"model": "gpt-4o"} + + with pytest.raises(Exception, match="Content blocked"): + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=["x"]), + request_data=request_data, + input_type="request", + ) + + entries = _guardrail_entries(request_data) + assert len(entries) == 1 + assert entries[0]["guardrail_status"] == "guardrail_failed_to_respond" + + @pytest.mark.asyncio + async def test_inherited_apply_guardrail_is_recorded_once(self): + guardrail = _InheritedApplyGuardrail(guardrail_name="child") + request_data: dict = {"model": "gpt-4o"} + + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=["hello"]), + request_data=request_data, + input_type="request", + ) + + assert len(_guardrail_entries(request_data)) == 1 + + @pytest.mark.asyncio + async def test_undecorated_self_recording_apply_guardrail_is_recorded_once(self): + guardrail = _UndecoratedSelfRecordingGuardrail(guardrail_name="self-recording") + request_data: dict = {"model": "gpt-4o"} + + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=["hello"]), + request_data=request_data, + input_type="request", + ) + + entries = _guardrail_entries(request_data) + assert len(entries) == 1 + assert entries[0]["guardrail_response"] == {"custom": True} + + @pytest.mark.asyncio + async def test_base_apply_guardrail_is_not_recorded(self): + guardrail = CustomGuardrail(guardrail_name="base") + request_data: dict = {"model": "gpt-4o"} + + await guardrail.apply_guardrail( + inputs=GenericGuardrailAPIInputs(texts=["hello"]), + request_data=request_data, + input_type="request", + ) + + assert _guardrail_entries(request_data) == [] + + def test_subclass_keywords_reach_cooperative_init_subclass(self): + class _LabelMixin: + seen_label: str = "" + + def __init_subclass__(cls, label: str = "", **kwargs: object) -> None: + super().__init_subclass__(**kwargs) + cls.seen_label = label + + class _Labelled(CustomGuardrail, _LabelMixin, label="docs-style"): + pass + + assert _Labelled.seen_label == "docs-style" + + class _ApplyOnlyObserver(CustomGuardrail): """Overrides only apply_guardrail, like panw_prisma_airs; inherits async_logging_hook.""" diff --git a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py index cebab2512d0..36e715d5804 100644 --- a/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/chat/guardrail_translation/test_openai_guardrail_handler.py @@ -1075,6 +1075,37 @@ class TestOpenAIChatCompletionsHandlerStreamingOutput: assert result == responses_so_far +class TestUndecoratedGuardrailIsRecorded: + """LIT-5983 regression: the handler calls apply_guardrail bare, so a custom guardrail + without @log_guardrail_information must still end up in the request's guardrail + information on both the request and response paths.""" + + @pytest.mark.asyncio + async def test_request_path_records_undecorated_guardrail(self): + handler = OpenAIChatCompletionsHandler() + guardrail = MockGuardrail(guardrail_name="docs-style") + data = {"messages": [{"role": "user", "content": "hello"}], "metadata": {}} + + await handler.process_input_messages(data, guardrail) + + entries = data["metadata"]["standard_logging_guardrail_information"] + assert [(e["guardrail_name"], e["guardrail_status"]) for e in entries] == [("docs-style", "success")] + + @pytest.mark.asyncio + async def test_response_path_records_undecorated_guardrail(self): + handler = OpenAIChatCompletionsHandler() + guardrail = MockGuardrail(guardrail_name="docs-style") + response = ModelResponse( + choices=[Choices(finish_reason="stop", index=0, message=Message(content="hi", role="assistant"))] + ) + request_data: dict = {"metadata": {}} + + await handler.process_output_response(response, guardrail, request_data=request_data) + + entries = request_data["metadata"]["standard_logging_guardrail_information"] + assert [(e["guardrail_name"], e["guardrail_status"]) for e in entries] == [("docs-style", "success")] + + class TestGetStructuredMessages: """Test the get_structured_messages method.""" diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py index 9842d88e8d1..7da55f22bda 100644 --- a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_bedrock_guardrails.py @@ -1137,7 +1137,11 @@ async def test_bedrock_apply_guardrail_response_uses_OUTPUT_source(): mock_api.assert_called_once() kwargs = mock_api.call_args.kwargs assert kwargs["source"] == "OUTPUT" - assert kwargs["request_data"] == {"model": "gpt-4o"} + assert kwargs["request_data"]["model"] == "gpt-4o" + recorded = kwargs["request_data"]["metadata"]["standard_logging_guardrail_information"] + assert [(e["guardrail_name"], e["guardrail_status"]) for e in recorded] == [ + (guardrail.guardrail_name, "success") + ] synthetic = kwargs["response"] assert isinstance(synthetic, ModelResponse) assert len(synthetic.choices) == 2 From f66b3ebe0dda8e25c47739c1eb15637dce2d11ad Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:40:00 -0700 Subject: [PATCH 236/410] feat(responses): honor supported_endpoints /v1/responses opt-in for OpenAI-compatible deployments (#39725) * feat(responses): honor supported_endpoints /v1/responses opt-in for OpenAI-compatible deployments custom_openai and other generic OpenAI-compatible deployments have no native Responses API config, so every /v1/responses call is bridged through /v1/chat/completions. When model_info.supported_endpoints lists /v1/responses, resolve OpenAILikeResponsesConfig instead so the request is forwarded to {api_base}/responses, for streaming, non-streaming and mode: responses deployments alike. Providers with their own Responses config are unchanged. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(responses): drop deployment supported_endpoints opt-in after cross-provider prompt swap A prompt manager that moves the request to another provider leaves kwargs['model_info'] describing the original deployment; without this the swapped provider was sent an OpenAI-like /responses request it does not serve. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(responses): carry prompt-swap deployment metadata as a return value instead of a kwargs marker Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/responses/main.py | 93 +++++-- ...sponses_supported_endpoints_passthrough.py | 254 ++++++++++++++++++ 2 files changed, 327 insertions(+), 20 deletions(-) create mode 100644 tests/test_litellm/responses/test_responses_supported_endpoints_passthrough.py diff --git a/litellm/responses/main.py b/litellm/responses/main.py index ed2d6a216fd..5e74b7324b4 100644 --- a/litellm/responses/main.py +++ b/litellm/responses/main.py @@ -2,6 +2,7 @@ import asyncio import contextvars from collections.abc import Coroutine, Generator, Iterable, Mapping from contextlib import contextmanager +from dataclasses import dataclass from functools import partial from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast @@ -23,6 +24,7 @@ from litellm.litellm_core_utils.prompt_templates.common_utils import ( ) from litellm.llms.base_llm.responses.transformation import BaseResponsesAPIConfig from litellm.llms.custom_httpx.llm_http_handler import BaseLLMHTTPHandler +from litellm.llms.openai_like.responses.transformation import OpenAILikeResponsesConfig from litellm.responses.litellm_completion_transformation.handler import ( LiteLLMCompletionTransformationHandler, ) @@ -403,8 +405,40 @@ def _bridges_to_chat_completions( return responses_api_provider_config is None or use_chat_completions_api is True +def _deployment_passes_through_responses(model_info: object) -> bool: + """Whether ``model_info.supported_endpoints`` opts the deployment into native ``{api_base}/responses``.""" + if not isinstance(model_info, dict): + return False + supported_endpoints: Final = model_info.get("supported_endpoints") + return isinstance(supported_endpoints, (list, tuple)) and "/v1/responses" in supported_endpoints + + +def _deployment_model_info_after_prompt_swap( + requested_provider: str | None, resolved_provider: str | None, model_info: object +) -> object: + """Deployment metadata only describes the upstream while the prompt manager keeps its provider.""" + return model_info if resolved_provider == requested_provider else None + + +@dataclass(frozen=True, slots=True) +class _AsyncPromptManagementOutcome: + merged_optional_params: Mapping[str, object] + deployment_model_info: object + + +def _resolve_responses_api_provider_config( + model: str, custom_llm_provider: str, model_info: object +) -> BaseResponsesAPIConfig | None: + provider_config: Final = ProviderConfigManager.get_provider_responses_api_config( + model=model, provider=custom_llm_provider + ) + if provider_config is not None or not _deployment_passes_through_responses(model_info): + return provider_config + return OpenAILikeResponsesConfig() + + def _will_bridge_to_chat_completions( - model: str, custom_llm_provider: str | None, use_chat_completions_api: bool + model: str, custom_llm_provider: str | None, use_chat_completions_api: bool, model_info: object ) -> bool: """``_bridges_to_chat_completions`` for callers running before the provider config is resolved. @@ -418,9 +452,7 @@ def _will_bridge_to_chat_completions( if custom_llm_provider is None: return True return _bridges_to_chat_completions( - ProviderConfigManager.get_provider_responses_api_config( - model=normalized_model[0], provider=custom_llm_provider - ), + _resolve_responses_api_provider_config(normalized_model[0], custom_llm_provider, model_info), use_chat_completions_api or normalized_model[1], ) @@ -527,7 +559,10 @@ async def aresponses( with _prompt_management_sees_a_provisional_message_list( kwargs, bridged=_will_bridge_to_chat_completions( - model, custom_llm_provider, bool(kwargs.get("use_chat_completions_api")) + model, + custom_llm_provider, + bool(kwargs.get("use_chat_completions_api")), + kwargs.get("model_info"), ), ): ( @@ -552,6 +587,7 @@ async def aresponses( merged_input=merged_input, ), ) + requested_provider: Final = custom_llm_provider if model != original_model: custom_llm_provider = _resolve_prompt_swapped_provider( original_model=original_model, @@ -561,7 +597,12 @@ async def aresponses( prompt_id=prompt_id, ) kwargs.pop("prompt_id", None) - kwargs["_async_prompt_merged_params"] = merged_optional_params + kwargs["_async_prompt_merged_params"] = _AsyncPromptManagementOutcome( + merged_optional_params=merged_optional_params, + deployment_model_info=_deployment_model_info_after_prompt_swap( + requested_provider, custom_llm_provider, kwargs.get("model_info") + ), + ) func: Final = partial( responses, @@ -666,12 +707,14 @@ def _apply_prompt_management_to_responses_call( kwargs: dict[str, Any], local_vars: dict[str, object], use_chat_completions_api: bool, -) -> tuple[str | ResponseInputParam, str, str | None]: - async_merged: Final[Mapping[str, object] | None] = kwargs.pop("_async_prompt_merged_params", None) - if async_merged is not None: - for key, value in async_merged.items(): +) -> tuple[str | ResponseInputParam, str, str | None, object]: + """Returns the prompt-managed input, model and provider, plus the deployment metadata that still + describes the upstream (``None`` once the prompt manager moved the request to another provider).""" + async_outcome: Final[_AsyncPromptManagementOutcome | None] = kwargs.pop("_async_prompt_merged_params", None) + if async_outcome is not None: + for key, value in async_outcome.merged_optional_params.items(): local_vars[key] = value - return input, model, custom_llm_provider + return input, model, custom_llm_provider, async_outcome.deployment_model_info prompt_id: Final = cast(str | None, kwargs.get("prompt_id", None)) prompt_variables: Final = cast(dict | None, kwargs.get("prompt_variables", None)) @@ -684,7 +727,9 @@ def _apply_prompt_management_to_responses_call( ): with _prompt_management_sees_a_provisional_message_list( kwargs, - bridged=_will_bridge_to_chat_completions(model, custom_llm_provider, use_chat_completions_api), + bridged=_will_bridge_to_chat_completions( + model, custom_llm_provider, use_chat_completions_api, kwargs.get("model_info") + ), ): ( model, @@ -710,19 +755,28 @@ def _apply_prompt_management_to_responses_call( ) local_vars["input"] = input local_vars["model"] = model - if model != original_model: - custom_llm_provider = _resolve_prompt_swapped_provider( + resolved_provider: Final = ( + custom_llm_provider + if model == original_model + else _resolve_prompt_swapped_provider( original_model=original_model, swapped_model=model, custom_llm_provider=custom_llm_provider, kwargs=kwargs, prompt_id=prompt_id, ) - local_vars["custom_llm_provider"] = custom_llm_provider + ) + local_vars["custom_llm_provider"] = resolved_provider for key, value in merged_optional_params.items(): local_vars[key] = value + return ( + input, + model, + resolved_provider, + _deployment_model_info_after_prompt_swap(custom_llm_provider, resolved_provider, kwargs.get("model_info")), + ) - return input, model, custom_llm_provider + return input, model, custom_llm_provider, kwargs.get("model_info") # Opt-in via model id (mirrors the `responses/` prefix pattern on chat completions). @@ -1052,7 +1106,7 @@ def responses( ) local_vars["custom_llm_provider"] = custom_llm_provider - input, model, custom_llm_provider = _apply_prompt_management_to_responses_call( + input, model, custom_llm_provider, deployment_model_info = _apply_prompt_management_to_responses_call( input=input, model=model, custom_llm_provider=custom_llm_provider, @@ -1123,9 +1177,8 @@ def responses( if custom_llm_provider is None: responses_api_provider_config = None else: - responses_api_provider_config = ProviderConfigManager.get_provider_responses_api_config( - model=model, - provider=custom_llm_provider, + responses_api_provider_config = _resolve_responses_api_provider_config( + model, custom_llm_provider, deployment_model_info ) local_vars.update(kwargs) diff --git a/tests/test_litellm/responses/test_responses_supported_endpoints_passthrough.py b/tests/test_litellm/responses/test_responses_supported_endpoints_passthrough.py new file mode 100644 index 00000000000..7cd04b015f9 --- /dev/null +++ b/tests/test_litellm/responses/test_responses_supported_endpoints_passthrough.py @@ -0,0 +1,254 @@ +""" +A deployment with `model_info.supported_endpoints` containing `/v1/responses` forwards +`/v1/responses` natively to `{api_base}/responses`. Without it, generic OpenAI-compatible +providers such as `custom_openai` keep bridging through `/v1/chat/completions`. +""" + +import json +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +import respx + +import litellm +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +from litellm.llms.openai.responses.transformation import OpenAIResponsesAPIConfig +from litellm.llms.openai_like.responses.transformation import OpenAILikeResponsesConfig +from litellm.responses.main import _resolve_responses_api_provider_config +from litellm.types.llms.openai import ResponsesAPIResponse +from litellm.types.utils import ModelResponse + +API_BASE = "https://backend.example/v1" +RESPONSES_URL = f"{API_BASE}/responses" +CHAT_URL = f"{API_BASE}/chat/completions" +OPT_IN = {"supported_endpoints": ["/v1/chat/completions", "/v1/responses"]} + +RESPONSES_BODY = { + "id": "resp_native", + "object": "response", + "created_at": 1741476542, + "status": "completed", + "model": "my-model", + "output": [ + { + "type": "message", + "id": "msg_1", + "status": "completed", + "role": "assistant", + "content": [{"type": "output_text", "text": "native", "annotations": []}], + } + ], + "parallel_tool_calls": True, + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, +} + +CHAT_BODY = { + "id": "chatcmpl_bridged", + "object": "chat.completion", + "created": 1741476542, + "model": "my-model", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "bridged"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 1, "completion_tokens": 1, "total_tokens": 2}, +} + +SSE_BODY = ( + "event: response.created\n" + f"data: {json.dumps({'type': 'response.created', 'response': RESPONSES_BODY})}\n\n" + "event: response.completed\n" + f"data: {json.dumps({'type': 'response.completed', 'response': RESPONSES_BODY})}\n\n" +) + + +def _mock_backend(router: respx.MockRouter) -> tuple[respx.Route, respx.Route]: + responses_route = router.post(RESPONSES_URL).mock(return_value=httpx.Response(200, json=RESPONSES_BODY)) + chat_route = router.post(CHAT_URL).mock(return_value=httpx.Response(200, json=CHAT_BODY)) + return responses_route, chat_route + + +SWAPPED_MODEL = "deepseek/deepseek-chat" +SWAPPED_API_BASE = "https://api.deepseek.com/beta" + + +def _prompt_manager_swapping_to(model: str) -> MagicMock: + """A logging object whose prompt hook rewrites the request's model, as a prompt manager does.""" + prompt_return = (model, [{"role": "user", "content": "hi"}], {}) + logging_obj = MagicMock() + logging_obj.__class__ = LiteLLMLoggingObj + logging_obj.should_run_prompt_management_hooks.return_value = True + logging_obj.get_chat_completion_prompt.return_value = prompt_return + logging_obj.async_get_chat_completion_prompt = AsyncMock(return_value=prompt_return) + logging_obj.model_call_details = {} + return logging_obj + + +def _mock_swap_targets(router: respx.MockRouter, monkeypatch) -> tuple[respx.Route, respx.Route]: + """The swapped provider's chat endpoint, plus the `/responses` it does not serve but a stale + opt-in would send to.""" + monkeypatch.setenv("DEEPSEEK_API_KEY", "sk-deepseek") + swapped_chat_route = router.post(f"{SWAPPED_API_BASE}/chat/completions").mock( + return_value=httpx.Response(200, json=CHAT_BODY) + ) + stale_responses_route = router.post(f"{SWAPPED_API_BASE}/responses").mock( + return_value=httpx.Response(200, json=RESPONSES_BODY) + ) + return swapped_chat_route, stale_responses_route + + +@pytest.fixture(autouse=True) +def _respx_interceptable_httpx_client(monkeypatch): + monkeypatch.setattr(litellm, "num_retries", 0) + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + litellm.in_memory_llm_clients_cache.flush_cache() + yield + litellm.in_memory_llm_clients_cache.flush_cache() + + +@pytest.mark.parametrize( + "model_info, expected_type", + [ + (OPT_IN, OpenAILikeResponsesConfig), + ({"supported_endpoints": ["/v1/chat/completions"]}, type(None)), + ({}, type(None)), + (None, type(None)), + ("/v1/responses", type(None)), + ], +) +def test_resolver_opt_in_gates_openai_like_config(model_info, expected_type): + config = _resolve_responses_api_provider_config("my-model", "custom_openai", model_info) + assert type(config) is expected_type + + +def test_resolver_keeps_native_provider_config(): + """`openai/` already routes /v1/responses natively; the opt-in must not swap its config.""" + config = _resolve_responses_api_provider_config("gpt-4.1", "openai", OPT_IN) + assert type(config) is OpenAIResponsesAPIConfig + + +@respx.mock +async def test_opt_in_forwards_responses_natively(): + responses_route, chat_route = _mock_backend(respx.mock) + + result = await litellm.aresponses( + model="custom_openai/my-model", + input="hi", + api_base=API_BASE, + api_key="sk-backend", + model_info=OPT_IN, + ) + + assert responses_route.call_count == 1 + assert chat_route.call_count == 0 + request = responses_route.calls.last.request + assert request.headers["authorization"] == "Bearer sk-backend" + assert json.loads(request.content)["input"] == "hi" + assert isinstance(result, ResponsesAPIResponse) + assert result.output[0].content[0].text == "native" + + +@respx.mock +async def test_opt_in_forwards_streaming_responses_natively(monkeypatch): + """The router registers each deployment in `litellm.model_cost`; an unregistered model is + treated as non-streaming and would be faked, so mirror that registration here.""" + monkeypatch.setitem(litellm.model_cost, "custom_openai/my-model", {"litellm_provider": "custom_openai"}) + responses_route = respx.post(RESPONSES_URL).mock( + return_value=httpx.Response(200, text=SSE_BODY, headers={"content-type": "text/event-stream"}) + ) + chat_route = respx.post(CHAT_URL).mock(return_value=httpx.Response(200, json=CHAT_BODY)) + + stream = await litellm.aresponses( + model="custom_openai/my-model", + input="hi", + stream=True, + api_base=API_BASE, + api_key="sk-backend", + model_info=OPT_IN, + ) + events = [event async for event in stream] + + assert responses_route.call_count == 1 + assert chat_route.call_count == 0 + assert json.loads(responses_route.calls.last.request.content)["stream"] is True + assert [event.type for event in events] == ["response.created", "response.completed"] + + +@respx.mock +async def test_without_opt_in_still_bridges_through_chat_completions(): + responses_route, chat_route = _mock_backend(respx.mock) + + result = await litellm.aresponses( + model="custom_openai/my-model", + input="hi", + api_base=API_BASE, + api_key="sk-backend", + model_info={"supported_endpoints": ["/v1/chat/completions"]}, + ) + + assert chat_route.call_count == 1 + assert responses_route.call_count == 0 + assert isinstance(result, ResponsesAPIResponse) + assert result.output[0].content[0].text == "bridged" + + +@respx.mock +async def test_prompt_swap_to_other_provider_drops_deployment_opt_in(monkeypatch): + """When a prompt manager moves the request to another provider, the original deployment's + `supported_endpoints` no longer describes the upstream, so the swapped provider bridges.""" + swapped_chat_route, stale_responses_route = _mock_swap_targets(respx.mock, monkeypatch) + + result = await litellm.aresponses( + model="custom_openai/my-model", + input="hi", + prompt_id="p1", + litellm_logging_obj=_prompt_manager_swapping_to(SWAPPED_MODEL), + model_info=OPT_IN, + ) + + assert swapped_chat_route.call_count == 1 + assert stale_responses_route.call_count == 0 + assert isinstance(result, ResponsesAPIResponse) + assert result.output[0].content[0].text == "bridged" + + +@respx.mock +def test_sync_prompt_swap_to_other_provider_drops_deployment_opt_in(monkeypatch): + swapped_chat_route, stale_responses_route = _mock_swap_targets(respx.mock, monkeypatch) + + result = litellm.responses( + model="custom_openai/my-model", + input="hi", + prompt_id="p1", + litellm_logging_obj=_prompt_manager_swapping_to(SWAPPED_MODEL), + model_info=OPT_IN, + ) + + assert swapped_chat_route.call_count == 1 + assert stale_responses_route.call_count == 0 + assert isinstance(result, ResponsesAPIResponse) + assert result.output[0].content[0].text == "bridged" + + +@respx.mock +async def test_mode_responses_chat_completion_reaches_native_responses(monkeypatch): + """A `mode: responses` deployment bridges chat completions into the Responses API; with + the opt-in that inner call must reach `{api_base}/responses` instead of bouncing back + to `/chat/completions`.""" + responses_route, chat_route = _mock_backend(respx.mock) + monkeypatch.setitem( + litellm.model_cost, + "custom_openai/my-model", + {"mode": "responses", "litellm_provider": "custom_openai"}, + ) + + result = await litellm.acompletion( + model="custom_openai/my-model", + messages=[{"role": "user", "content": "hi"}], + api_base=API_BASE, + api_key="sk-backend", + model_info={"mode": "responses", **OPT_IN}, + ) + + assert responses_route.call_count == 1 + assert chat_route.call_count == 0 + assert isinstance(result, ModelResponse) + assert result.choices[0].message.content == "native" From e6705510f82c0c70b274c922210bbdd8edab5379 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:41:35 -0700 Subject: [PATCH 237/410] fix(router): hold max_parallel_requests slot until streaming response is exhausted or closed (#39859) * fix(router): hold max_parallel_requests slot until streaming response is exhausted or closed Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(router): normalize deployment_slot once to keep stream_with_fallbacks under the C901 ceiling Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(router): close upstream stream before releasing max_parallel_requests slot Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/router.py | 108 ++++++++++++----------- tests/test_litellm/test_router.py | 141 ++++++++++++++++++++++++++++++ 2 files changed, 198 insertions(+), 51 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 6943eece90f..490836f5f0a 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -2597,14 +2597,20 @@ class Router: model_response: CustomStreamWrapper, messages: list[dict[str, str]], initial_kwargs: dict, + deployment_slot: contextlib.AsyncExitStack | None = None, ) -> CustomStreamWrapper: """ Helper to iterate over a streaming response. Catches errors for fallbacks using the router's fallback system + + `deployment_slot` holds the deployment's max_parallel_requests semaphore; it is + released when the stream is exhausted, closed, or falls back to another deployment """ from litellm.exceptions import MidStreamFallbackError + held_slot: Final = deployment_slot if deployment_slot is not None else contextlib.AsyncExitStack() + class FallbackStreamWrapper(CustomStreamWrapper): def __init__(self, async_generator: AsyncGenerator): # Copy attributes from the original model_response @@ -2628,12 +2634,26 @@ class Router: async def __anext__(self): return await self._async_generator.__anext__() + async def close_model_response() -> None: + if not hasattr(model_response, "aclose"): + return + try: + await model_response.aclose() + except BaseException as e: + verbose_router_logger.debug( + "stream_with_fallbacks: error closing model_response: %s", + e, + ) + async def stream_with_fallbacks(): fallback_response = None # Track for cleanup in finally try: async for item in model_response: yield item except MidStreamFallbackError as e: + with anyio.CancelScope(shield=True): + await close_model_response() + await held_slot.aclose() if not e.is_pre_first_chunk and ( e.generated_content or _stream_chunks_have_generated_content(model_response.chunks) ): @@ -2707,14 +2727,8 @@ class Router: # (e.g. on client disconnect). # Shield from anyio cancellation so the awaits can complete. with anyio.CancelScope(shield=True): - if hasattr(model_response, "aclose"): - try: - await model_response.aclose() - except BaseException as e: - verbose_router_logger.debug( - "stream_with_fallbacks: error closing model_response: %s", - e, - ) + await close_model_response() + await held_slot.aclose() if fallback_response is not None and hasattr(fallback_response, "aclose"): try: await fallback_response.aclose() @@ -3379,61 +3393,53 @@ class Router: kwargs=kwargs, client_type="max_parallel_requests", ) - if rpm_semaphore is not None and isinstance(rpm_semaphore, asyncio.Semaphore): - async with rpm_semaphore: - """ - - Check rpm limits before making the call - - If allowed, increment the rpm limit (allows global value to be updated, concurrency-safe) - """ - await self.async_routing_strategy_pre_call_checks( - deployment=deployment, - logging_obj=logging_obj, - parent_otel_span=parent_otel_span, - ) - response = await _response - else: + async with contextlib.AsyncExitStack() as deployment_slot: + if isinstance(rpm_semaphore, asyncio.Semaphore): + await deployment_slot.enter_async_context(rpm_semaphore) await self.async_routing_strategy_pre_call_checks( deployment=deployment, logging_obj=logging_obj, parent_otel_span=parent_otel_span, ) - response = await _response - ## CHECK CONTENT FILTER ERROR ## - if isinstance(response, ModelResponse): - _should_raise = self._should_raise_content_policy_error(model=model, response=response, kwargs=kwargs) - if _should_raise: - raise litellm.ContentPolicyViolationError( - message="Response output was blocked.", - model=model, - llm_provider="", + ## CHECK CONTENT FILTER ERROR ## + if isinstance(response, ModelResponse): + _should_raise = self._should_raise_content_policy_error( + model=model, response=response, kwargs=kwargs ) + if _should_raise: + raise litellm.ContentPolicyViolationError( + message="Response output was blocked.", + model=model, + llm_provider="", + ) - if ( - isinstance(response, CustomStreamWrapper) - and response.completion_stream is None - and response.make_call is not None - ): - await response.fetch_stream() + if ( + isinstance(response, CustomStreamWrapper) + and response.completion_stream is None + and response.make_call is not None + ): + await response.fetch_stream() - self.success_calls[model_name] += 1 - verbose_router_logger.info("litellm.acompletion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name) - # debug how often this deployment picked - self._track_deployment_metrics( - deployment=deployment, - response=response, - parent_otel_span=parent_otel_span, - ) - - if isinstance(response, CustomStreamWrapper): - return await self._acompletion_streaming_iterator( - model_response=response, - messages=messages, - initial_kwargs=input_kwargs_for_streaming_fallback, + self.success_calls[model_name] += 1 + verbose_router_logger.info("litellm.acompletion(model=%s)\x1b[32m 200 OK\x1b[0m", model_name) + # debug how often this deployment picked + self._track_deployment_metrics( + deployment=deployment, + response=response, + parent_otel_span=parent_otel_span, ) - return response + if isinstance(response, CustomStreamWrapper): + return await self._acompletion_streaming_iterator( + model_response=response, + messages=messages, + initial_kwargs=input_kwargs_for_streaming_fallback, + deployment_slot=deployment_slot.pop_all(), + ) + + return response except litellm.Timeout as e: deployment_request_timeout_param: Final = _timeout_debug_deployment_dict.get("litellm_params", {}).get( "request_timeout", None diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 31eb46f1458..ffd6c5f97ce 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -12938,3 +12938,144 @@ async def test_router_retry_policy_controls_upstream_attempt_count( await router.acompletion(model="gpt-5.6", messages=[{"role": "user", "content": "hi"}]) assert upstream.call_count == expected_upstream_calls + + +class _InFlightTracker: + def __init__(self) -> None: + self.current = 0 + self.peak = 0 + + def enter(self) -> None: + self.current += 1 + self.peak = max(self.peak, self.current) + + def exit(self) -> None: + self.current -= 1 + + +_SSE_CHUNKS: Final[tuple[bytes, ...]] = tuple( + b'data: {"id":"c","object":"chat.completion.chunk","created":1,"model":"gpt-5.6",' + b'"choices":[{"index":0,"delta":{"content":"x"},"finish_reason":null}]}\n\n' + for _ in range(5) +) + + +class _CountingSSEStream(httpx.AsyncByteStream): + def __init__(self, tracker: _InFlightTracker) -> None: + self._tracker = tracker + self._in_flight = False + + def _finish(self) -> None: + if self._in_flight: + self._in_flight = False + self._tracker.exit() + + async def __aiter__(self): + self._in_flight = True + self._tracker.enter() + try: + for chunk in _SSE_CHUNKS: + await asyncio.sleep(0.02) + yield chunk + finally: + await self.aclose() + yield b"data: [DONE]\n\n" + + async def aclose(self) -> None: + await asyncio.sleep(0.02) + self._finish() + + +def _max_parallel_router(max_parallel_requests: int) -> Router: + return Router( + model_list=[ + { + "model_name": "gpt-5.6", + "litellm_params": { + "model": "openai/gpt-5.6", + "api_key": "sk-fake", + "api_base": "https://max-parallel.local/v1", + "max_parallel_requests": max_parallel_requests, + }, + } + ], + num_retries=0, + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +async def test_router_max_parallel_requests_bounds_in_flight_upstream_calls( + monkeypatch: pytest.MonkeyPatch, stream: bool +): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + tracker: Final = _InFlightTracker() + router: Final = _max_parallel_router(max_parallel_requests=2) + + async def upstream(request: httpx.Request) -> httpx.Response: + if stream: + return httpx.Response( + 200, headers={"content-type": "text/event-stream"}, stream=_CountingSSEStream(tracker) + ) + tracker.enter() + await asyncio.sleep(0.05) + tracker.exit() + return httpx.Response( + 200, + json={ + "id": "c", + "object": "chat.completion", + "created": 1, + "model": "gpt-5.6", + "choices": [{"index": 0, "message": {"role": "assistant", "content": "x"}, "finish_reason": "stop"}], + }, + ) + + async def one_call() -> None: + response = await router.acompletion( + model="gpt-5.6", messages=[{"role": "user", "content": "hi"}], stream=stream + ) + if stream: + async for _ in response: + pass + + with respx.mock(assert_all_called=True) as respx_mock: + respx_mock.post("https://max-parallel.local/v1/chat/completions").mock(side_effect=upstream) + await asyncio.wait_for(asyncio.gather(*(one_call() for _ in range(10))), timeout=10) + + assert tracker.peak <= 2 + assert tracker.current == 0 + + +@pytest.mark.asyncio +async def test_router_max_parallel_requests_slot_released_when_stream_closed_early(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + tracker: Final = _InFlightTracker() + router: Final = _max_parallel_router(max_parallel_requests=1) + + with respx.mock() as respx_mock: + respx_mock.post("https://max-parallel.local/v1/chat/completions").mock( + side_effect=lambda request: httpx.Response( + 200, headers={"content-type": "text/event-stream"}, stream=_CountingSSEStream(tracker) + ) + ) + first: Final = await router.acompletion( + model="gpt-5.6", messages=[{"role": "user", "content": "hi"}], stream=True + ) + await first.__anext__() + + async def second_call() -> None: + second = await router.acompletion( + model="gpt-5.6", messages=[{"role": "user", "content": "hi"}], stream=True + ) + async for _ in second: + pass + + second_task: Final = asyncio.create_task(second_call()) + await asyncio.sleep(0.05) + assert tracker.current == 1 + await first.aclose() + await asyncio.wait_for(second_task, timeout=2) + + assert tracker.peak == 1 + assert tracker.current == 0 From cba3dd58287114588dad0624cd2c8d0d040b902d Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:41:56 -0700 Subject: [PATCH 238/410] fix(proxy): retry deadlocks and requeue spend logs on any DB write error (#39883) * fix(proxy): retry deadlocks and requeue spend logs on any DB write error update_spend_logs dequeued the batch and only retried/requeued on transport errors. A 40P01 deadlock surfaced as a plain prisma DataError and went through poison-row isolation, which dropped every row it hit; every other DB error was re-raised with the batch already gone from the queue. Treat deadlocks as transient (retry, then requeue), keep them out of poison-row isolation, and requeue the batch at the head of the queue on any other prisma error so it lands once the DB is healthy. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(proxy): drop redundant docstrings and tighten test typing for spend-log requeue Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): assert deadlock retries from mock call history instead of mutable lists Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/db/exception_handler.py | 6 + litellm/proxy/utils.py | 17 ++- .../test_proxy_update_spend.py | 109 +++++++++++++++++- 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/db/exception_handler.py b/litellm/proxy/db/exception_handler.py index 19bddee618b..f469587ab8e 100644 --- a/litellm/proxy/db/exception_handler.py +++ b/litellm/proxy/db/exception_handler.py @@ -199,6 +199,12 @@ class PrismaDBExceptionHandler: return True return False + @staticmethod + def is_prisma_error(e: Exception) -> bool: + import prisma + + return isinstance(e, _exception_types(prisma.errors.PrismaError)) + @staticmethod def is_deadlock_error(e: Exception) -> bool: """True iff ``e`` is a Postgres deadlock (P2034 / 40P01) surfaced through prisma.""" diff --git a/litellm/proxy/utils.py b/litellm/proxy/utils.py index 1f453d3b1ba..accf7b720fb 100644 --- a/litellm/proxy/utils.py +++ b/litellm/proxy/utils.py @@ -6386,10 +6386,17 @@ class ProxyUpdateSpend: ) break except Exception as e: - if not PrismaDBExceptionHandler.is_database_transport_error(e): + if not _is_transient_spend_log_write_error(e): + if PrismaDBExceptionHandler.is_prisma_error(e): + await enqueue_spend_logs(prisma_client, logs_to_process, at_head=True) + verbose_proxy_logger.warning( + "Spend tracking - DB error writing spend logs, requeued %d rows for the next flush. error=%s", + len(logs_to_process), + str(e), + ) raise verbose_proxy_logger.warning( - "Spend tracking - DB connection error writing spend logs, retry %d/%d. logs_count=%d, error=%s", + "Spend tracking - transient DB error writing spend logs, retry %d/%d. logs_count=%d, error=%s", i + 1, n_retry_times, len(logs_to_process), @@ -6732,6 +6739,10 @@ async def _monitor_spend_logs_queue( MAX_SPEND_LOG_ISOLATION_FAILURES_PER_BATCH: Final = 256 +def _is_transient_spend_log_write_error(e: Exception) -> bool: + return PrismaDBExceptionHandler.is_database_transport_error(e) or PrismaDBExceptionHandler.is_deadlock_error(e) + + async def _create_spend_logs_with_poison_isolation( repo: SpendLogsRepository, rows: Sequence[Mapping[str, object]], @@ -6767,6 +6778,8 @@ async def _create_spend_logs_with_poison_isolation( raise if PrismaDBExceptionHandler.is_database_service_unavailable_error(e): raise + if PrismaDBExceptionHandler.is_deadlock_error(e): + raise budget_left: Final = max(failure_budget - 1, 0) if len(rows) == 1: request_id: Final = rows[0].get("request_id") diff --git a/tests/test_litellm/proxy/utils/prisma_and_spend/test_proxy_update_spend.py b/tests/test_litellm/proxy/utils/prisma_and_spend/test_proxy_update_spend.py index 048fddb10d6..d671a4ffc1f 100644 --- a/tests/test_litellm/proxy/utils/prisma_and_spend/test_proxy_update_spend.py +++ b/tests/test_litellm/proxy/utils/prisma_and_spend/test_proxy_update_spend.py @@ -473,6 +473,110 @@ async def test_update_spend_logs_retries_and_requeues_batch_on_db_outage( assert [row["request_id"] for row in mock_prisma_client.spend_log_transactions] == ["a", "b", "c"] +def _deadlock_error() -> Exception: + return _data_error( + 'Error occurred during query execution: ConnectorError(ConnectorError { user_facing_error: None, ' + 'kind: QueryError(PostgresError { code: "40P01", message: "deadlock detected", severity: "ERROR" }) })' + ) + + +@pytest.mark.asyncio +async def test_update_spend_logs_retries_deadlock_and_keeps_every_row( + mock_prisma_client: Any, make_spend_log_row: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """A 40P01 deadlock aborts the whole insert, so the same rows succeed on replay. + Before the fix the deadlock surfaced as a plain ``DataError`` and went through + poison-row isolation, which bisected the batch and dropped every row the + deadlock happened to hit as if Postgres had rejected it. + """ + + async def _fake_sleep(_: float) -> None: + return None + + monkeypatch.setattr(utils_mod.asyncio, "sleep", _fake_sleep) + create_many = AsyncMock(side_effect=[_deadlock_error(), _deadlock_error(), None]) + mock_prisma_client.db.litellm_spendlogs.create_many = create_many + proxy_logging = MagicMock() + proxy_logging.failure_handler = AsyncMock() + mock_prisma_client.spend_log_transactions = [] + + await ProxyUpdateSpend.update_spend_logs( + n_retry_times=2, + prisma_client=mock_prisma_client, + db_writer_client=None, + proxy_logging_obj=proxy_logging, + logs_to_process=[make_spend_log_row(request_id="a"), make_spend_log_row(request_id="b")], + ) + + attempts = tuple( + tuple(row["request_id"] for row in call.kwargs["data"]) for call in create_many.await_args_list + ) + assert attempts == (("a", "b"), ("a", "b"), ("a", "b")) + assert mock_prisma_client.spend_log_transactions == [] + + +@pytest.mark.asyncio +async def test_update_spend_logs_requeues_batch_once_deadlock_retries_exhaust( + mock_prisma_client: Any, make_spend_log_row: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + """If every retry deadlocks, the batch goes back to the head of the queue for + the next flush instead of being dropped. + """ + + async def _fake_sleep(_: float) -> None: + return None + + monkeypatch.setattr(utils_mod.asyncio, "sleep", _fake_sleep) + mock_prisma_client.db.litellm_spendlogs.create_many = AsyncMock(side_effect=_deadlock_error()) + proxy_logging = MagicMock() + proxy_logging.failure_handler = AsyncMock() + mock_prisma_client.spend_log_transactions = [make_spend_log_row(request_id="c")] + + with pytest.raises(type(_deadlock_error())): + await ProxyUpdateSpend.update_spend_logs( + n_retry_times=1, + prisma_client=mock_prisma_client, + db_writer_client=None, + proxy_logging_obj=proxy_logging, + logs_to_process=[make_spend_log_row(request_id="a"), make_spend_log_row(request_id="b")], + ) + + assert mock_prisma_client.db.litellm_spendlogs.create_many.await_count == 2 + assert [row["request_id"] for row in mock_prisma_client.spend_log_transactions] == ["a", "b", "c"] + + +@pytest.mark.asyncio +async def test_update_spend_logs_requeues_batch_on_non_transport_db_error( + mock_prisma_client: Any, make_spend_log_row: Any +) -> None: + """A DB error that is neither transport nor deadlock (here P2021, the table is + gone mid-migration) is not retried in place, but the dequeued batch must not + be lost either: it goes back to the head of the queue so it lands once the + DB is healthy again. + """ + from prisma.errors import TableNotFoundError + + err = TableNotFoundError( + {"user_facing_error": {"error_code": "P2021", "message": "The table does not exist", "meta": {}}} + ) + mock_prisma_client.db.litellm_spendlogs.create_many = AsyncMock(side_effect=err) + proxy_logging = MagicMock() + proxy_logging.failure_handler = AsyncMock() + mock_prisma_client.spend_log_transactions = [make_spend_log_row(request_id="c")] + + with pytest.raises(TableNotFoundError): + await ProxyUpdateSpend.update_spend_logs( + n_retry_times=2, + prisma_client=mock_prisma_client, + db_writer_client=None, + proxy_logging_obj=proxy_logging, + logs_to_process=[make_spend_log_row(request_id="a"), make_spend_log_row(request_id="b")], + ) + + assert mock_prisma_client.db.litellm_spendlogs.create_many.await_count == 1 + assert [row["request_id"] for row in mock_prisma_client.spend_log_transactions] == ["a", "b", "c"] + + @pytest.mark.asyncio async def test_requeue_after_outage_drops_oldest_logs_past_the_byte_budget( mock_prisma_client: Any, make_spend_log_row: Any @@ -549,8 +653,9 @@ async def test_flush_returns_the_bytes_it_took_off_the_queue(mock_prisma_client: async def test_update_spend_logs_does_not_requeue_non_transport_failures( mock_prisma_client: Any, make_spend_log_row: Any ) -> None: - """Only transport failures are worth replaying. A rejection the DB will keep - rejecting must not be requeued, or it would wedge the queue forever. + """Only DB failures are worth replaying. A row the proxy itself cannot + serialize would fail the same way on every flush, so requeueing it would + wedge the head of the queue forever. """ mock_prisma_client.db.litellm_spendlogs.create_many = AsyncMock(side_effect=ValueError("bad payload")) proxy_logging = MagicMock() From 86038318ce408b4f63767a2fb753357ae7ed3cdf Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 5 Sep 2026 11:43:52 -0700 Subject: [PATCH 239/410] feat(ui): deep link guardrail detail with ?guardrail= on guardrails pages The Guardrails and Guardrails Monitor pages kept the selected guardrail in local React state, so the detail view could not be shared, reloaded, or reached with the browser back button. Both pages now read and write the selection through the nuqs `guardrail` query param, matching how the keys, teams, orgs, projects, users, models and logs pages deep link their detail views. Opening a guardrail pushes a history entry and closing it replaces the entry so back returns to the page the user came from --- .../GuardrailsMonitorView.test.tsx | 116 ++++++++++++++---- .../_components/GuardrailsMonitorView.tsx | 16 +-- .../_components/GuardrailsPanel.test.tsx | 77 +++++++++--- .../_components/GuardrailsPanel.tsx | 18 ++- 4 files changed, 179 insertions(+), 48 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx index 9f27daab6b3..4e0590df72d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx @@ -1,36 +1,61 @@ -import { render, screen, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { describe, expect, it, vi } from "vitest"; +import { type UrlUpdateEvent } from "nuqs/adapters/testing"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; import GuardrailsMonitorView from "./GuardrailsMonitorView"; import * as networking from "@/components/networking"; +import { renderWithProviders, screen, testQueryClient, waitFor } from "@/../tests/test-utils"; vi.mock("@/components/networking", () => ({ getGuardrailsUsageOverview: vi.fn(), + getGuardrailsUsageDetail: vi.fn(), + getGuardrailsUsageLogs: vi.fn(), formatDate: vi.fn((d: Date) => d.toISOString().slice(0, 10)), })); -const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview); +vi.mock("@/components/GuardrailsMonitor/LogViewer", () => ({ + LogViewer: ({ guardrailName }: { guardrailName: string }) =>
    {guardrailName}
    , +})); -function wrapper({ children }: { children: React.ReactNode }) { - const queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); - return {children}; -} +const mockGetGuardrailsUsageOverview = vi.mocked(networking.getGuardrailsUsageOverview); +const mockGetGuardrailsUsageDetail = vi.mocked(networking.getGuardrailsUsageDetail); +const mockGetGuardrailsUsageLogs = vi.mocked(networking.getGuardrailsUsageLogs); + +const emptyOverview = { rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 }; + +const piiRow = { + id: "gr-pii", + name: "PII Guard", + type: "pii", + provider: "LiteLLM", + requestsEvaluated: 10, + failRate: 10, + status: "healthy" as const, + trend: "stable" as const, +}; + +const piiDetail = { + guardrail_name: "PII Guard", + description: "", + status: "healthy", + provider: "LiteLLM", + type: "pii", + requestsEvaluated: 10, + failRate: 10, + avgScore: 0.5, + avgLatency: 20, +}; describe("GuardrailsMonitorView", () => { - it("should render overview and fetch guardrails usage when accessToken is provided", async () => { - mockGetGuardrailsUsageOverview.mockResolvedValue({ - rows: [], - chart: [], - totalRequests: 0, - totalBlocked: 0, - passRate: 100, - }); + beforeEach(() => { + testQueryClient.clear(); + vi.clearAllMocks(); + mockGetGuardrailsUsageOverview.mockResolvedValue(emptyOverview); + mockGetGuardrailsUsageDetail.mockResolvedValue(piiDetail); + mockGetGuardrailsUsageLogs.mockResolvedValue({ logs: [], total: 0 }); + }); - render(, { wrapper }); + it("should render overview and fetch guardrails usage when accessToken is provided", async () => { + renderWithProviders(); expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument(); await waitFor(() => { @@ -39,7 +64,54 @@ describe("GuardrailsMonitorView", () => { }); it("should render without crashing when accessToken is null", async () => { - render(, { wrapper }); + renderWithProviders(); expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument(); }); + + describe("guardrail detail deep link (?guardrail=)", () => { + it("should open the detail view directly from a ?guardrail= deep link", async () => { + renderWithProviders(, { searchParams: "?guardrail=gr-pii" }); + + expect(await screen.findByRole("heading", { name: "PII Guard" })).toBeInTheDocument(); + expect(mockGetGuardrailsUsageDetail).toHaveBeenCalledWith( + "test-token", + "gr-pii", + expect.any(String), + expect.any(String), + ); + expect(screen.queryByRole("heading", { name: /Guardrails Monitor/i })).not.toBeInTheDocument(); + }); + + it("should push ?guardrail= as a new history entry when a guardrail is selected", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + mockGetGuardrailsUsageOverview.mockResolvedValue({ ...emptyOverview, rows: [piiRow] }); + renderWithProviders(, { onUrlUpdate }); + + await user.click(await screen.findByRole("button", { name: "PII Guard" })); + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0]; + expect(lastUpdate.searchParams.get("guardrail")).toBe("gr-pii"); + expect(lastUpdate.options.history).toBe("push"); + expect(await screen.findByRole("heading", { name: "PII Guard" })).toBeInTheDocument(); + }); + + it("should clear ?guardrail= by replacing history when going back to the overview", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + renderWithProviders(, { + searchParams: "?guardrail=gr-pii", + onUrlUpdate, + }); + + await user.click(await screen.findByRole("button", { name: /back to overview/i })); + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0]; + expect(lastUpdate.searchParams.has("guardrail")).toBe(false); + expect(lastUpdate.options.history).toBe("replace"); + expect(await screen.findByRole("heading", { name: /Guardrails Monitor/i })).toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx index a9acf3e6377..f90a46e19e4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.tsx @@ -1,12 +1,11 @@ import type { DateRangePickerValue } from "@/components/shared/date_picker_types"; +import { parseAsString, useQueryState } from "nuqs"; import React, { useCallback, useMemo, useState } from "react"; import { formatDate } from "@/components/networking"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; import { GuardrailDetail } from "./GuardrailDetail"; import { GuardrailsOverview } from "./GuardrailsOverview"; -type View = { type: "overview" } | { type: "detail"; guardrailId: string }; - interface GuardrailsMonitorViewProps { accessToken?: string | null; } @@ -16,7 +15,10 @@ const defaultStart = new Date(); defaultStart.setDate(defaultStart.getDate() - 7); export default function GuardrailsMonitorView({ accessToken = null }: GuardrailsMonitorViewProps) { - const [view, setView] = useState({ type: "overview" }); + const [selectedGuardrailId, setSelectedGuardrailId] = useQueryState( + "guardrail", + parseAsString.withOptions({ history: "push" }), + ); const initialFrom = useMemo(() => new Date(defaultStart), []); const initialTo = useMemo(() => new Date(defaultEnd), []); @@ -34,11 +36,11 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails }, []); const handleSelectGuardrail = (id: string) => { - setView({ type: "detail", guardrailId: id }); + void setSelectedGuardrailId(id); }; const handleBack = () => { - setView({ type: "overview" }); + void setSelectedGuardrailId(null, { history: "replace" }); }; const dateRangeControl = ( @@ -47,7 +49,7 @@ export default function GuardrailsMonitorView({ accessToken = null }: Guardrails return (
    - {view.type === "overview" ? ( + {!selectedGuardrailId ? (
    {dateRangeControl}
    ({ getGuardrailsList: vi.fn(), @@ -15,16 +16,21 @@ vi.mock("./add_guardrail_form", () => ({ vi.mock("./guardrail_table", () => ({ __esModule: true, - default: ({ guardrailsList, onDeleteClick }: any) => ( + default: ({ guardrailsList, onDeleteClick, onGuardrailClick }: any) => (
    Mock Guardrail Table
    {guardrailsList.length > 0 && ( - + <> + + + )}
    ), @@ -32,7 +38,12 @@ vi.mock("./guardrail_table", () => ({ vi.mock("./guardrail_info", () => ({ __esModule: true, - default: () =>
    Mock Guardrail Info View
    , + default: ({ guardrailId, onClose }: { guardrailId: string; onClose: () => void }) => ( +
    +
    Mock Guardrail Info View {guardrailId}
    + +
    + ), })); vi.mock("./GuardrailTestPlayground", async () => { @@ -112,7 +123,7 @@ describe("GuardrailsPanel", () => { }); it("should render the component", async () => { - render(); + renderWithProviders(); expect(screen.getByText("Guardrails")).toBeInTheDocument(); // Activate the Guardrails tab so its content (including the Add button) is rendered fireEvent.click(screen.getByText("Guardrails")); @@ -120,7 +131,7 @@ describe("GuardrailsPanel", () => { }); it("should delete the clicked guardrail after confirming in the modal", async () => { - render(); + renderWithProviders(); fireEvent.click(screen.getByText("Guardrails")); fireEvent.click(await screen.findByTestId("delete-button")); @@ -139,14 +150,14 @@ describe("GuardrailsPanel", () => { }); it("should mount every tab panel up front so panel state survives tab switches", async () => { - render(); + renderWithProviders(); expect(await screen.findByLabelText("playground draft")).toBeInTheDocument(); expect(screen.getByText("Mock Team Guardrails Tab")).toBeInTheDocument(); }); it("should keep test playground state when switching tabs away and back", async () => { - render(); + renderWithProviders(); fireEvent.click(screen.getByText("Test Playground")); @@ -161,7 +172,7 @@ describe("GuardrailsPanel", () => { }); it("should not delete anything when the modal is cancelled", async () => { - render(); + renderWithProviders(); fireEvent.click(screen.getByText("Guardrails")); fireEvent.click(await screen.findByTestId("delete-button")); @@ -171,4 +182,42 @@ describe("GuardrailsPanel", () => { expect(mockDeleteGuardrailCall).not.toHaveBeenCalled(); }); + + describe("guardrail detail deep link (?guardrail=)", () => { + it("should open the guardrail info view directly from a ?guardrail= deep link", async () => { + renderWithProviders(, { searchParams: "?guardrail=test-guardrail-1" }); + + expect(await screen.findByTestId("guardrail-info-view")).toHaveTextContent("test-guardrail-1"); + expect(screen.queryByText("Mock Guardrail Table")).not.toBeInTheDocument(); + }); + + it("should push ?guardrail= as a new history entry when a guardrail row is clicked", async () => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + renderWithProviders(, { onUrlUpdate }); + + fireEvent.click(await screen.findByTestId("open-button")); + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0]; + expect(lastUpdate.searchParams.get("guardrail")).toBe("test-guardrail-1"); + expect(lastUpdate.options.history).toBe("push"); + expect(await screen.findByTestId("guardrail-info-view")).toHaveTextContent("test-guardrail-1"); + }); + + it("should clear ?guardrail= by replacing history when the info view is closed", async () => { + const onUrlUpdate = vi.fn<(event: UrlUpdateEvent) => void>(); + renderWithProviders(, { + searchParams: "?guardrail=test-guardrail-1", + onUrlUpdate, + }); + + fireEvent.click(await screen.findByRole("button", { name: "Close Guardrail Info" })); + + await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled()); + const lastUpdate = onUrlUpdate.mock.calls.at(-1)![0]; + expect(lastUpdate.searchParams.has("guardrail")).toBe(false); + expect(lastUpdate.options.history).toBe("replace"); + expect(await screen.findByText("Mock Guardrail Table")).toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx index 7e59abf8e3d..901e39004f3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx @@ -1,3 +1,4 @@ +import { parseAsString, useQueryState } from "nuqs"; import React, { useState, useEffect } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ChevronDown, Code, Plus } from "lucide-react"; @@ -40,7 +41,10 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole const [isDeleting, setIsDeleting] = useState(false); const [guardrailToDelete, setGuardrailToDelete] = useState(null); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedGuardrailId, setSelectedGuardrailId] = useState(null); + const [selectedGuardrailId, setSelectedGuardrailId] = useQueryState( + "guardrail", + parseAsString.withOptions({ history: "push" }), + ); const isAdmin = userRole ? isAdminRole(userRole) : false; const fetchGuardrails = async () => { @@ -63,16 +67,20 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole fetchGuardrails(); }, [accessToken]); + const closeGuardrailDetail = () => { + void setSelectedGuardrailId(null, { history: "replace" }); + }; + const handleAddGuardrail = () => { if (selectedGuardrailId) { - setSelectedGuardrailId(null); + closeGuardrailDetail(); } setIsAddModalVisible(true); }; const handleAddCustomCodeGuardrail = () => { if (selectedGuardrailId) { - setSelectedGuardrailId(null); + closeGuardrailDetail(); } setIsCustomCodeModalVisible(true); }; @@ -175,7 +183,7 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole {selectedGuardrailId ? ( setSelectedGuardrailId(null)} + onClose={closeGuardrailDetail} accessToken={accessToken} isAdmin={isAdmin} /> @@ -184,7 +192,7 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole guardrailsList={guardrailsList} isLoading={isLoading} onDeleteClick={handleDeleteClick} - onGuardrailClick={(id) => setSelectedGuardrailId(id)} + onGuardrailClick={(id) => void setSelectedGuardrailId(id)} /> )} From e3b4a82ff9991369f0a79e34f44a5da732506b0f Mon Sep 17 00:00:00 2001 From: tin-berri Date: Sat, 5 Sep 2026 11:44:34 -0700 Subject: [PATCH 240/410] Merge pull request #39926 from BerriAI/litellm_lit6981_none_url_auth fix(mcp): reject URL credentials for none auth --- .../_experimental/mcp_server/exceptions.py | 14 +++++++ .../outbound_credentials/adapter.py | 3 ++ .../outbound_credentials/resolver.py | 11 ++++- .../mcp_server/outbound_credentials/types.py | 12 ++++++ .../mcp_server/rest_endpoints.py | 3 ++ .../outbound_credentials/test_adapter.py | 12 ++++++ .../outbound_credentials/test_resolver.py | 29 +++++++++++++ .../outbound_credentials/test_types.py | 9 ++++ .../mcp_server/test_mcp_server_manager.py | 42 +++++++++++++++++++ .../mcp_server/test_rest_endpoints.py | 30 +++++++++++++ 10 files changed, 164 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_experimental/mcp_server/exceptions.py b/litellm/proxy/_experimental/mcp_server/exceptions.py index a1b3b167a4a..c818f6b05bd 100644 --- a/litellm/proxy/_experimental/mcp_server/exceptions.py +++ b/litellm/proxy/_experimental/mcp_server/exceptions.py @@ -5,6 +5,20 @@ from typing import Final from fastapi import HTTPException +class MCPServerURLCredentialsError(HTTPException): + """A fixed, sanitized URL-credential migration error safe for operator previews.""" + + def __init__(self) -> None: + super().__init__( + status_code=500, + detail=( + "misconfigured: auth_type none cannot be used with credentials embedded in the upstream URL; " + "remove them from the URL and configure Basic Auth with auth_type: basic and " + "auth_value: username:password" + ), + ) + + class MCPUpstreamAuthError(Exception): """Raised when an upstream MCP server returns an authentication failure (typically HTTP 401) and the gateway should surface it transparently to diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index 6a95a93a2a8..ea2318bd6f1 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -19,6 +19,7 @@ from pydantic import SecretStr from typing_extensions import assert_never from litellm.experimental_mcp_client.client import strip_auth_scheme, to_basic_credentials +from litellm.proxy._experimental.mcp_server.exceptions import MCPServerURLCredentialsError from litellm.proxy._experimental.mcp_server.oauth_utils import resolve_upstream_resource from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( DEFAULT_CREDENTIAL_HEADER, @@ -293,6 +294,8 @@ def raise_public(error: CredError) -> NoReturn: ) case "misconfigured": raise HTTPException(status_code=500, detail=error.summary) + case "url_credentials_not_allowed": + raise MCPServerURLCredentialsError() case "upstream_unavailable": raise HTTPException(status_code=503, detail=error.summary) case "unsupported_mode": diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py index 3af7b51f432..404baa14350 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py @@ -134,7 +134,7 @@ class UpstreamCredentialProvider: async def resolve_credentials(self, subject: Subject, server: ServerSpec) -> Result[httpx.Auth, CredError]: match server.config: case NoneConfig(): - return Ok(NoOpAuth()) + return self._none(server) case ApiKeyConfig() as config: return self._api_key(config) case PassthroughConfig(): @@ -151,6 +151,15 @@ class UpstreamCredentialProvider: return _not_implemented(AuthSpecKind.aws_sigv4) assert_never(server.config) + def _none(self, server: ServerSpec) -> Result[httpx.Auth, CredError]: + try: + resource: Final = httpx.URL(server.resource) + except httpx.InvalidURL: + return Ok(NoOpAuth()) + if resource.userinfo: + return Error(CredError.of_url_credentials_not_allowed()) + return Ok(NoOpAuth()) + async def has_user_token(self, subject: Subject, server: ServerSpec) -> bool: """Whether a usable per-user token exists for this server (the preemptive 401's check). diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py index 67aad3e443e..632dc57dcf6 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/types.py @@ -95,6 +95,7 @@ class CredError: tag: Literal[ "unauthorized", "misconfigured", + "url_credentials_not_allowed", "upstream_unavailable", "unsupported_mode", "precondition_required", @@ -103,6 +104,7 @@ class CredError: unauthorized: Unauthorized = case() # no usable credential for this (subject, server) -> 401 challenge misconfigured: str = case() # the declared mode is missing required config -> 5xx (operator) + url_credentials_not_allowed: None = case() upstream_unavailable: str = case() # the IdP / token endpoint could not be reached -> 503 unsupported_mode: str = case() # a raw mode string did not parse into AuthSpecKind (boundary) precondition_required: str = case() # a required per-user value (e.g. an env var) has not been provided -> 412 @@ -129,6 +131,10 @@ class CredError: def of_misconfigured(detail: str) -> CredError: return CredError(misconfigured=detail) + @staticmethod + def of_url_credentials_not_allowed() -> CredError: + return CredError(url_credentials_not_allowed=None) + @staticmethod def of_upstream_unavailable(detail: str) -> CredError: return CredError(upstream_unavailable=detail) @@ -154,6 +160,12 @@ class CredError: return f"unauthorized: {self.unauthorized.detail}" case "misconfigured": return f"misconfigured: {self.misconfigured}" + case "url_credentials_not_allowed": + return ( + "misconfigured: auth_type none cannot be used with credentials embedded in the upstream URL; " + "remove them from the URL and configure Basic Auth with auth_type: basic and " + "auth_value: username:password" + ) case "upstream_unavailable": return f"upstream unavailable: {self.upstream_unavailable}" case "unsupported_mode": diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index b3469da9071..5fbfad54a39 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -18,6 +18,7 @@ from litellm.exceptions import ( ) from litellm.proxy._experimental.mcp_server.exceptions import ( MCPServerListError, + MCPServerURLCredentialsError, MCPUpstreamAuthError, ) from litellm.proxy._experimental.mcp_server.faults.list_outcomes import ( @@ -75,6 +76,8 @@ _MCP_GUARDRAIL_REJECTIONS: Final = ( def _connection_error_message(exc: BaseException, url: str | None, timeout_seconds: float) -> str: + if isinstance(exc, MCPServerURLCredentialsError): + return str(exc.detail) if isinstance(exc, TimeoutError): return ( f"Failed to connect to MCP server: no response from {url or 'the server'} " diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py index c6f3b9cb1f4..d67d0df4d0e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py @@ -13,6 +13,7 @@ from fastapi import HTTPException from pydantic import ValidationError from litellm.experimental_mcp_client.client import MCPClient +from litellm.proxy._experimental.mcp_server.exceptions import MCPServerURLCredentialsError from litellm.proxy._experimental.mcp_server.outbound_credentials.adapter import ( oauth_protected_resource_path, raise_public, @@ -442,6 +443,17 @@ def test_raise_public_maps_each_error_to_its_status(error, status): assert exc_info.value.status_code == status +def test_raise_public_marks_only_url_credentials_error_as_safe_for_preview(): + with pytest.raises(HTTPException) as generic_exc_info: + raise_public(CredError.of_misconfigured("private operator detail")) + assert not isinstance(generic_exc_info.value, MCPServerURLCredentialsError) + + error = CredError.of_url_credentials_not_allowed() + with pytest.raises(MCPServerURLCredentialsError) as url_exc_info: + raise_public(error) + assert url_exc_info.value.detail == error.summary + + def test_raise_public_emits_unauthorized_challenge(): body = {"error": "byok_auth_required", "server_id": "s1"} error = CredError.of_unauthorized("needs key", www_authenticate='Bearer resource_metadata="/x"', body=body) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index 9d63e8c2c1c..5e2f2cf97d7 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -116,6 +116,35 @@ async def test_none_mode_yields_a_no_op_auth(): assert isinstance(result.ok, NoOpAuth) +@pytest.mark.asyncio +async def test_none_mode_rejects_url_userinfo(): + spec = ServerSpec( + server_id="s", + resource="https://lit-user:s3cr3t@upstream.example.com/mcp", + config=NoneConfig(), + ) + + result = await UpstreamCredentialProvider().resolve_credentials(_SUBJECT, spec) + + assert isinstance(result, Error) + assert result.error.tag == "url_credentials_not_allowed" + assert "Basic Auth" in result.error.summary + assert "auth_type: basic" in result.error.summary + assert "auth_value: username:password" in result.error.summary + assert "lit-user" not in result.error.summary + assert "s3cr3t" not in result.error.summary + + +@pytest.mark.asyncio +async def test_none_mode_does_not_validate_non_credential_resource(): + spec = ServerSpec(server_id="s", resource="https://[::1", config=NoneConfig()) + + result = await UpstreamCredentialProvider().resolve_credentials(_SUBJECT, spec) + + assert isinstance(result, Ok) + assert isinstance(result.ok, NoOpAuth) + + @pytest.mark.asyncio async def test_api_key_shared_emits_the_configured_header(): config = ApiKeyConfig( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py index d4b51b08e06..bacbb5c1236 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_types.py @@ -75,6 +75,15 @@ def test_crederror_factory_sets_the_matching_tag(factory, expected_tag): assert "detail text" in err.summary +def test_url_credentials_error_has_a_fixed_actionable_summary(): + err = CredError.of_url_credentials_not_allowed() + + assert err.tag == "url_credentials_not_allowed" + assert "Basic Auth" in err.summary + assert "auth_type: basic" in err.summary + assert "auth_value: username:password" in err.summary + + def test_apikeyconfig_requires_a_key_source(): with pytest.raises(ValidationError): ApiKeyConfig() # type: ignore[call-arg] diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 9745e508703..34dc067e7a3 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -8966,6 +8966,24 @@ class TestCreateMcpClientV2Graft: assert isinstance(client._resolved_auth, NoOpAuth) assert client._mcp_auth_value is None + @pytest.mark.parametrize("auth_type", [None, MCPAuth.none]) + async def test_none_mode_rejects_url_userinfo(self, auth_type): + with pytest.raises(HTTPException) as exc_info: + await MCPServerManager()._create_mcp_client( + self._http_server( + auth_type=auth_type, + url="https://lit-user:s3cr3t@upstream.example.com/mcp", + ) + ) + + detail = str(exc_info.value.detail) + assert exc_info.value.status_code == 500 + assert "Basic Auth" in detail + assert "auth_type: basic" in detail + assert "auth_value: username:password" in detail + assert "lit-user" not in detail + assert "s3cr3t" not in detail + @pytest.mark.parametrize( "auth_type, token, expected_name, expected_value", [ @@ -11369,6 +11387,30 @@ class TestResolveOpenapiToolAuth: assert "Authorization" not in (forwarded or {}) + @pytest.mark.asyncio + async def test_none_mode_without_url_keeps_spec_path_server_unauthenticated(self): + server = MCPServer( + server_id="openapi-only", + name="report_api", + server_name="report_api", + url=None, + transport=MCPTransport.http, + auth_type=MCPAuth.none, + spec_path="https://api.example.com/openapi.json", + ) + + resolved, forwarded = await MCPServerManager().resolve_openapi_upstream_auth( + mcp_server=server, + oauth2_headers=None, + raw_headers=None, + mcp_auth_header=None, + user_api_key_auth=None, + forwarded_headers={"X-Trace": "trace-id"}, + ) + + assert resolved is None + assert forwarded == {"X-Trace": "trace-id"} + class TestOpenApiHandlerRelaysUpstreamAuth: """`_call_openapi_tool_handler` must not flatten a re-auth signal into a generic message. diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index f2c8f8c80c5..c007d22117f 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -177,6 +177,36 @@ class TestExecuteWithMcpClient: assert "https://api.example.com/mcp/" in message assert "30s" in message + def test_connection_error_message_hides_arbitrary_http_exception_detail(self): + message = rest_endpoints._connection_error_message( + HTTPException(status_code=500, detail="secret upstream detail"), + "https://api.example.com/mcp/", + 30.0, + ) + + assert "secret upstream detail" not in message + + @pytest.mark.asyncio + async def test_none_mode_url_credentials_returns_actionable_redacted_error(self): + async def unreached_operation(client): + raise AssertionError("operation must not run for an invalid server configuration") + + payload = NewMCPServerRequest( + server_name="example", + url="https://lit-user:s3cr3t@upstream.example.com/mcp", + auth_type=MCPAuth.none, + ) + + result = await rest_endpoints._execute_with_mcp_client(payload, unreached_operation) + + message = str(result["message"]) + assert result["error"] is True + assert "Basic Auth" in message + assert "auth_type: basic" in message + assert "auth_value: username:password" in message + assert "lit-user" not in message + assert "s3cr3t" not in message + @pytest.mark.asyncio async def test_forwards_static_headers(self, monkeypatch): """Ensure static_headers are forwarded to the MCP client during test calls. From 9ac893df1562f35a56fbac17399481f1fb32f857 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 11:47:15 -0700 Subject: [PATCH 241/410] style(e2e): wrap the openai passthrough content assertion under 120 columns --- tests/e2e/llm_translation/test_passthrough_e2e.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/tests/e2e/llm_translation/test_passthrough_e2e.py b/tests/e2e/llm_translation/test_passthrough_e2e.py index e50e83eaf77..447fe7d30d9 100644 --- a/tests/e2e/llm_translation/test_passthrough_e2e.py +++ b/tests/e2e/llm_translation/test_passthrough_e2e.py @@ -361,8 +361,14 @@ class TestOpenAIProviderPrefixChat: completion = ChatResponse.model_validate_json(result.body) assert completion.id, f"/openai/v1/chat/completions relayed no completion id: {result.body[:300]}" - content = completion.choices[0].message.content if completion.choices and completion.choices[0].message else None - assert content and content.strip(), f"/openai/v1/chat/completions relayed an empty completion: {result.body[:300]}" + content = ( + completion.choices[0].message.content + if completion.choices and completion.choices[0].message + else None + ) + assert content and content.strip(), ( + f"/openai/v1/chat/completions relayed an empty completion: {result.body[:300]}" + ) assert completion.usage is not None, f"the completion carried no usage to price from: {completion}" row = _fetch_cost_breakdown(client, completion.id) From a0058ed15759febc3acb96ab27c823671a232715 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Sat, 5 Sep 2026 11:47:36 -0700 Subject: [PATCH 242/410] fix(hide-secrets): stop redacting benign identifiers (#39879) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(hide-secrets): stop redacting benign identifiers and make redaction deterministic The OpenAI key detector matched `sk-` anywhere inside a word, so `` became ``, and the Base64 entropy limit of 3.0 flagged ordinary quoted identifiers such as `"application/json"` and model ids. Redaction also iterated a hash-seeded set, so the same request produced different bytes on different workers and broke prompt caching. - require a standalone `sk-`/`sk_` token with a digit (still catches sk-proj-/sk-ant-) - raise Base64HighEntropyString limit from 3.0 to the detect-secrets default 4.5 - redact overlapping matches longest-first in a stable order Resolves LIT-7049 * fix(hide-secrets): treat separators as key boundaries and defer sk_live_ to the stripe detector The standalone-token boundary also rejected keys glued to a preceding `_`, `-` or percent-encoded delimiter (`openai_sk-…`, `key-sk-…`, `Bearer%20sk-…`), which the old pattern redacted, and `sk_live_…` was counted by both the OpenAI and the Stripe detector. * fix(hide-secrets): keep the openai key scan linear on repeated sk separators The digit requirement was a lookahead, so every `sk` inside a long `[a-zA-Z0-9_-]` run re-scanned the rest of that run looking for a digit. 100 KB of `-sk-` took over 5s in the worker's event loop and the proxy closed the connection without a response. The check now runs once per match in `analyze_string` instead. * chore(hide-secrets): remove redundant performance test comment * fix(hide-secrets): consume complete openai key tokens * chore(hide-secrets): remove redundant fixture comment * chore(hide-secrets): remove redundant test docstrings * fix(hide-secrets): redact whole stripe live keys * style(hide-secrets): wrap secret sorting key --- .../enterprise_callbacks/secret_detection.py | 27 ++--- .../secrets_plugins/openai_api_key.py | 15 ++- .../test_secret_detection.py | 101 ++++++++++++++++-- 3 files changed, 124 insertions(+), 19 deletions(-) diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py b/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py index 1fddc527ec8..bfbfd7bfb15 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/secret_detection.py @@ -433,9 +433,9 @@ _default_detect_secrets_config = { "name": "ZendeskSecretKeyDetector", "path": _custom_plugins_path + "/zendesk_secret_key.py", }, - {"name": "Base64HighEntropyString", "limit": 3.0}, + {"name": "Base64HighEntropyString", "limit": 4.5}, {"name": "HexHighEntropyString", "limit": 3.0}, - ] + ], } @@ -466,16 +466,19 @@ class _ENTERPRISE_SecretDetection(CustomGuardrail): os.remove(temp_file.name) - detected_secrets = [] - for file in secrets.files: - for found_secret in secrets[file]: - if found_secret.secret_value is None: - continue - detected_secrets.append( - {"type": found_secret.type, "value": found_secret.secret_value} - ) - - return detected_secrets + return [ + {"type": found_secret.type, "value": found_secret.secret_value} + for file in sorted(secrets.files) + for found_secret in sorted( + secrets[file], + key=lambda secret: ( + -len(secret.secret_value or ""), + secret.type, + secret.secret_value or "", + ), + ) + if found_secret.secret_value is not None + ] def redact_text(self, text: str, source: str = "message") -> str: """Replace every detected secret in ``text`` with ``[REDACTED]`` and diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/secrets_plugins/openai_api_key.py b/enterprise/litellm_enterprise/enterprise_callbacks/secrets_plugins/openai_api_key.py index c5d20f75909..32652703326 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/secrets_plugins/openai_api_key.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/secrets_plugins/openai_api_key.py @@ -3,6 +3,7 @@ This plugin searches for OpenAI API Keys. """ import re +from collections.abc import Generator from detect_secrets.plugins.base import RegexBasedDetector @@ -16,4 +17,16 @@ class OpenAIApiKeyDetector(RegexBasedDetector): @property def denylist(self) -> list[re.Pattern]: - return [re.compile(r"""(sk-[a-zA-Z0-9]{5,})""")] + return [ + re.compile( + r"((?:(? Generator[str, None, None]: + # the digit check lives outside the regex: a lookahead re-scans the token + # from every `sk` inside it, which is quadratic on `-sk-sk-sk-...` input + yield from (match for match in super().analyze_string(string) if re.search(r"[0-9]", match)) diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py b/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py index dc1cbb9983e..f46df5baadf 100644 --- a/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py +++ b/tests/test_litellm/enterprise/enterprise_callbacks/test_secret_detection.py @@ -10,6 +10,8 @@ Covers the three defects from the ticket: handling live only on the native path). """ +import time + import pytest from litellm_enterprise.enterprise_callbacks.secret_detection import ( @@ -19,12 +21,16 @@ from litellm.caching.caching import DualCache from litellm.proxy._types import UserAPIKeyAuth AWS_KEY = "AKIAIOSFODNN7EXAMPLE" +OPENAI_KEY = "sk-test-abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGH" +SHORT_OPENAI_KEY = "sk-12345" +UNICODE_DIGIT_SUFFIX = "sk-notification٣" +STRIPE_LIVE_KEY = f"sk_live_{'1234567890' * 3}" +URL_ENCODED_KEY = "Bearer%20sk-Ab3dEf6Gh7Ij8Kl9Mn0Pq2Rs3Tu4Vw5X" +AWS_KEYS = [f"AKIAIOSFODNN7EXAMPL{suffix}" for suffix in "FEDCBA"] def _guardrail() -> _ENTERPRISE_SecretDetection: - return _ENTERPRISE_SecretDetection( - guardrail_name="hide-secrets", event_hook="pre_call", default_on=True - ) + return _ENTERPRISE_SecretDetection(guardrail_name="hide-secrets", event_hook="pre_call", default_on=True) def _recorded(request_data: dict) -> dict: @@ -33,6 +39,91 @@ def _recorded(request_data: dict) -> dict: return entries[0] +def test_scan_message_preserves_benign_identifiers_and_xml_tags(): + guardrail = _guardrail() + content = " model: claude-sonnet-4-5-20250929 " + + assert guardrail.scan_message_for_secrets(content) == [] + assert guardrail.redact_text(content) == content + assert guardrail.redact_text("result = compute(x) ") == ( + "result = compute(x) " + ) + + +def test_scan_message_preserves_quoted_benign_identifiers(): + guardrail = _guardrail() + content = '{"content-type": "application/json", "model": "claude-sonnet-4-5-20250929"}' + + assert guardrail.scan_message_for_secrets(content) == [] + assert guardrail.redact_text(content) == content + + +def test_scan_message_redacts_every_openai_key_occurrence(): + guardrail = _guardrail() + content = f"first {OPENAI_KEY}, second {OPENAI_KEY}" + + assert guardrail.redact_text(content) == "first [REDACTED], second [REDACTED]" + + +def test_scan_message_redacts_short_numeric_openai_like_values(): + guardrail = _guardrail() + + assert guardrail.redact_text(f"value {SHORT_OPENAI_KEY}") == "value [REDACTED]" + + +def test_scan_message_requires_ascii_digits_for_openai_like_values(): + guardrail = _guardrail() + + assert guardrail.scan_message_for_secrets(UNICODE_DIGIT_SUFFIX) == [] + assert guardrail.redact_text(UNICODE_DIGIT_SUFFIX) == UNICODE_DIGIT_SUFFIX + + +def test_scan_message_redacts_openai_key_after_separator(): + guardrail = _guardrail() + + assert guardrail.redact_text(f"openai_{OPENAI_KEY} key-{OPENAI_KEY}") == ( + "openai_[REDACTED] key-[REDACTED]" + ) + assert guardrail.redact_text(URL_ENCODED_KEY) == "Bearer%20[REDACTED]" + + +def test_scan_message_does_not_stop_openai_key_at_token_characters(): + guardrail = _guardrail() + + assert guardrail.redact_text("key sk-proj-abcde12345/extra") == "key [REDACTED]/extra" + + +def test_scan_message_stays_linear_on_repeated_sk_separators(): + guardrail = _guardrail() + content = "-sk-" * 25_000 + + started = time.perf_counter() + assert guardrail.scan_message_for_secrets(content) == [] + assert time.perf_counter() - started < 2.0 + + +def test_scan_message_redacts_whole_stripe_live_key(): + guardrail = _guardrail() + + assert guardrail.redact_text(f"stripe {STRIPE_LIVE_KEY} end") == "stripe [REDACTED] end" + + +def test_scan_message_returns_matches_in_stable_order(): + guardrail = _guardrail() + detected = guardrail.scan_message_for_secrets(" ".join(AWS_KEYS)) + + assert [secret["value"] for secret in detected] == sorted(AWS_KEYS) + + +def test_scan_message_replaces_longest_overlapping_match_first(): + guardrail = _guardrail() + content = f'token = "{OPENAI_KEY}/extra"' + + detected = guardrail.scan_message_for_secrets(content) + assert [secret["value"] for secret in detected] == [f"{OPENAI_KEY}/extra", OPENAI_KEY] + assert guardrail.redact_text(content) == 'token = "[REDACTED]"' + + @pytest.mark.asyncio async def test_apply_guardrail_redacts_secrets(): """Playground path: the returned texts must carry [REDACTED], not the secret.""" @@ -199,9 +290,7 @@ async def test_apply_guardrail_without_texts_records_nothing(): "messages": [ { "role": "user", - "content": [ - {"type": "image_url", "image_url": {"url": "https://x/y.png"}} - ], + "content": [{"type": "image_url", "image_url": {"url": "https://x/y.png"}}], } ], "metadata": {}, From 3c0900b7c5d26aec0b6ed508083dcee20d6501e2 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:51:15 -0700 Subject: [PATCH 243/410] perf(logging): scan large base64 payloads for log truncation off the event loop (#39890) * perf(logging): scan large base64 payloads for log truncation off the event loop Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * perf(logging): make base64 offload threshold a plain constant Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/constants.py | 1 + litellm/litellm_core_utils/litellm_logging.py | 21 ++++-- litellm/litellm_core_utils/logging_utils.py | 40 ++++++++++- .../test_litellm_logging.py | 50 +++++++++++++ .../litellm_core_utils/test_logging_utils.py | 71 +++++++++++++++++++ 5 files changed, 177 insertions(+), 6 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index e4fb2a00297..ce744e9c58a 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -89,6 +89,7 @@ LITELLM_MAX_STREAMING_DURATION_SECONDS: Final = ( # Data URIs exceeding this are replaced with a size placeholder. # Set to 0 to disable truncation. MAX_BASE64_LENGTH_FOR_LOGGING: Final = int(os.getenv("MAX_BASE64_LENGTH_FOR_LOGGING", 64)) +BASE64_TRUNCATION_OFFLOAD_THRESHOLD_CHARS: Final = 256 * 1024 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 = "{}" diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 8f1b2ce1cc2..01b823e51ab 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -78,7 +78,10 @@ from litellm.litellm_core_utils.llm_cost_calc.tool_call_cost_tracking import ( from litellm.litellm_core_utils.llm_cost_calc.usage_object_transformation import ( InteractionsUsageObjectTransformation, ) -from litellm.litellm_core_utils.logging_utils import truncate_base64_in_messages +from litellm.litellm_core_utils.logging_utils import ( + truncate_base64_in_messages, + truncate_base64_in_messages_async, +) from litellm.litellm_core_utils.model_param_helper import ModelParamHelper from litellm.litellm_core_utils.redact_messages import ( redact_message_input_output_from_custom_logger, @@ -538,6 +541,7 @@ class Logging(LiteLLMLoggingBaseClass): self.standard_built_in_tools_params: StandardBuiltInToolsParams = ( self.initialize_standard_built_in_tools_params(kwargs) ) + self.truncated_messages_for_logging: str | list | dict | None = None # mutable-ok: logged messages shape ## TIME TO FIRST TOKEN LOGGING ## self.completion_start_time: datetime.datetime | None = None self._llm_caching_handler: LLMCachingHandler | None = None @@ -2933,6 +2937,11 @@ class Logging(LiteLLMLoggingBaseClass): result._hidden_params["batch_failed_requests"] = batch_result.failed_requests # pyright: ignore[reportPrivateUsage] # rebind-ok: same pattern as above result.usage = batch_result.usage + self.truncated_messages_for_logging = await truncate_base64_in_messages_async( + StandardLoggingPayloadSetup.append_system_prompt_messages( + kwargs=self.model_call_details, messages=self.model_call_details.get("messages") + ) + ) start_time, end_time, result = self._success_handler_helper_fn( start_time=start_time, end_time=end_time, @@ -6202,9 +6211,13 @@ def get_standard_logging_object_payload( model_id=_model_id, requester_ip_address=clean_metadata.get("requester_ip_address", None), user_agent=clean_metadata.get("user_agent", None), - messages=truncate_base64_in_messages( - StandardLoggingPayloadSetup.append_system_prompt_messages( - kwargs=kwargs, messages=kwargs.get("messages") + messages=( + logging_obj.truncated_messages_for_logging + if logging_obj.truncated_messages_for_logging is not None + else truncate_base64_in_messages( + StandardLoggingPayloadSetup.append_system_prompt_messages( + kwargs=kwargs, messages=kwargs.get("messages") + ) ) ), response=final_response_obj, diff --git a/litellm/litellm_core_utils/logging_utils.py b/litellm/litellm_core_utils/logging_utils.py index f3b1b29a9ad..44daef42e14 100644 --- a/litellm/litellm_core_utils/logging_utils.py +++ b/litellm/litellm_core_utils/logging_utils.py @@ -3,12 +3,15 @@ import functools import inspect import re import time -from collections.abc import Mapping +from collections.abc import Iterator, Mapping, Sequence from datetime import datetime from typing import TYPE_CHECKING, Any, Final from litellm._logging import verbose_logger -from litellm.constants import MAX_BASE64_LENGTH_FOR_LOGGING +from litellm.constants import ( + BASE64_TRUNCATION_OFFLOAD_THRESHOLD_CHARS, + MAX_BASE64_LENGTH_FOR_LOGGING, +) from litellm.types.utils import ( ModelResponse, ModelResponseStream, @@ -141,6 +144,39 @@ def truncate_base64_in_messages( return messages +_StringTree = str | Sequence["_StringTree"] | Mapping[str, "_StringTree"] | None + + +def _iter_string_leaves(value: _StringTree) -> Iterator[str]: + stack: Final[list[_StringTree]] = [value] # mutable-ok: explicit stack, recursive functions are banned in litellm/ + while stack: + match stack.pop(): + case str() as text: + yield text + case Mapping() as mapping: + stack.extend(mapping.values()) + case Sequence() as items: + stack.extend(items) + case None: + pass + + +async def truncate_base64_in_messages_async( + messages: str | list | dict | None, # mutable-ok: same contract as truncate_base64_in_messages +) -> str | list | dict | None: # mutable-ok: same contract as truncate_base64_in_messages + """ + Same result as truncate_base64_in_messages, but payloads whose string content + reaches BASE64_TRUNCATION_OFFLOAD_THRESHOLD_CHARS are scanned in a worker + thread so the regex pass over multi-MB base64 images does not block the event loop. + """ + if messages is None or MAX_BASE64_LENGTH_FOR_LOGGING <= 0: + return messages + total_chars: Final = sum(len(leaf) for leaf in _iter_string_leaves(messages)) + if total_chars < BASE64_TRUNCATION_OFFLOAD_THRESHOLD_CHARS: + return truncate_base64_in_messages(messages) + return await asyncio.to_thread(truncate_base64_in_messages, messages) + + # Global service logger instance to avoid recreating it _service_logger = None diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index af75691eb10..31fb4fb55c5 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -1114,6 +1114,56 @@ async def test_logging_non_streaming_request(): litellm.callbacks = original_callbacks +@pytest.mark.asyncio +async def test_async_success_handler_truncates_large_base64_off_the_event_loop(monkeypatch): + """The standard logging payload's base64 scan of a large multimodal request must not run on the loop thread.""" + import threading + + from litellm.litellm_core_utils import logging_utils + + loop_thread = threading.get_ident() + scan_threads: list[int] = [] + original_scan = logging_utils._truncate_base64_in_string + + def recording_scan(value: str) -> str: + scan_threads.append(threading.get_ident()) + return original_scan(value) + + monkeypatch.setattr(logging_utils, "_truncate_base64_in_string", recording_scan) + monkeypatch.setattr(logging_utils, "BASE64_TRUNCATION_OFFLOAD_THRESHOLD_CHARS", 1_000) + + logged = asyncio.Event() + captured: dict = {} + + class CaptureLogger(CustomLogger): + async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): + captured["standard_logging_object"] = kwargs["standard_logging_object"] + logged.set() + + monkeypatch.setattr(litellm, "callbacks", [CaptureLogger()]) + payload = "L" * 20_000 + await litellm.acompletion( + model="openai/gpt-5.6", + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe"}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{payload}"}}, + ], + } + ], + mock_response="ok", + ) + await asyncio.wait_for(logged.wait(), timeout=10) + + logged_url = captured["standard_logging_object"]["messages"][0]["content"][1]["image_url"]["url"] + assert "base64_data truncated" in logged_url + assert payload not in logged_url + assert scan_threads + assert loop_thread not in scan_threads + + @pytest.mark.parametrize( "async_flag", [ diff --git a/tests/test_litellm/litellm_core_utils/test_logging_utils.py b/tests/test_litellm/litellm_core_utils/test_logging_utils.py index b0dad0bf228..f9913f1935d 100644 --- a/tests/test_litellm/litellm_core_utils/test_logging_utils.py +++ b/tests/test_litellm/litellm_core_utils/test_logging_utils.py @@ -2,12 +2,16 @@ Tests for litellm.litellm_core_utils.logging_utils — base64 truncation helpers. """ +import threading + import pytest +from litellm.litellm_core_utils import logging_utils from litellm.litellm_core_utils.logging_utils import ( _format_base64_size, _truncate_base64_in_string, truncate_base64_in_messages, + truncate_base64_in_messages_async, ) # --------------------------------------------------------------------------- @@ -157,3 +161,70 @@ class TestTruncateBase64InMessages: result[0]["content"][0]["image_url"]["url"] == f"data:image/png;base64,{short}" ) + + +# --------------------------------------------------------------------------- +# truncate_base64_in_messages_async +# --------------------------------------------------------------------------- + + +def _image_messages(payload: str) -> list: + return [ + { + "role": "user", + "content": [ + {"type": "text", "text": "describe"}, + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{payload}"}}, + ], + } + ] + + +@pytest.fixture +def scan_threads(monkeypatch): + """Record the thread that runs every base64 regex scan.""" + threads: list[int] = [] + original = logging_utils._truncate_base64_in_string + + def recording_scan(value: str) -> str: + threads.append(threading.get_ident()) + return original(value) + + monkeypatch.setattr(logging_utils, "_truncate_base64_in_string", recording_scan) + return threads + + +class TestTruncateBase64InMessagesAsync: + @pytest.mark.asyncio + async def test_large_payload_is_scanned_off_the_event_loop(self, monkeypatch, scan_threads): + monkeypatch.setattr(logging_utils, "BASE64_TRUNCATION_OFFLOAD_THRESHOLD_CHARS", 1_000) + payload = "I" * 20_000 + messages = _image_messages(payload) + + result = await truncate_base64_in_messages_async(messages) + offload_threads = tuple(scan_threads) + + assert result == truncate_base64_in_messages(messages) + assert payload not in result[0]["content"][1]["image_url"]["url"] + assert payload in messages[0]["content"][1]["image_url"]["url"] + assert offload_threads + assert threading.get_ident() not in offload_threads + + @pytest.mark.asyncio + async def test_small_payload_stays_on_the_calling_thread(self, monkeypatch, scan_threads): + monkeypatch.setattr(logging_utils, "BASE64_TRUNCATION_OFFLOAD_THRESHOLD_CHARS", 1_000) + messages = _image_messages("J" * 200) + + result = await truncate_base64_in_messages_async(messages) + + assert result == truncate_base64_in_messages(messages) + assert scan_threads + assert set(scan_threads) == {threading.get_ident()} + + @pytest.mark.asyncio + async def test_none_and_disabled_truncation_short_circuit(self, monkeypatch, scan_threads): + assert await truncate_base64_in_messages_async(None) is None + monkeypatch.setattr(logging_utils, "MAX_BASE64_LENGTH_FOR_LOGGING", 0) + messages = _image_messages("K" * 20_000) + assert await truncate_base64_in_messages_async(messages) is messages + assert scan_threads == [] From a670a4621e9029b054de86ad28c0fe939d1cfc52 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 11:53:04 -0700 Subject: [PATCH 244/410] fix(proxy): make the invalid-model 403 path cheap under a burst of rejections (#39892) * fix(proxy): make the invalid-model 403 path cheap under a burst of rejections Keep the wildcard pattern registry in specificity order at registration time so route() no longer re-sorts every pattern per lookup, and reuse the standardized failure payload across the async and threaded sync failure handlers regardless of what a callback did to log_event_type. Rejections are still logged and observable. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(router): wrap the filtered pattern tuple the way ruff format wants Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(router,logging): assert registry order and callback awaits instead of patching a class Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(router): inject the pattern sorter so the lookup test observes that route() never sorts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/litellm_logging.py | 3 +- .../router_utils/pattern_match_deployments.py | 17 ++++++---- .../test_litellm_logging.py | 28 ++++++++++++++++ .../test_pattern_match_deployments.py | 32 ++++++++++++++++++- 4 files changed, 70 insertions(+), 10 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 01b823e51ab..22e4dbf3a44 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -3234,8 +3234,7 @@ class Logging(LiteLLMLoggingBaseClass): self.model_call_details = {} if ( - self.model_call_details.get("log_event_type") == "failed_api_call" - and self.model_call_details.get("exception") is exception + self.model_call_details.get("exception") is exception and self.model_call_details.get("standard_logging_object") is not None ): return start_time, self.model_call_details["end_time"] diff --git a/litellm/router_utils/pattern_match_deployments.py b/litellm/router_utils/pattern_match_deployments.py index 0775e0a4039..d5234e27ec6 100644 --- a/litellm/router_utils/pattern_match_deployments.py +++ b/litellm/router_utils/pattern_match_deployments.py @@ -56,8 +56,9 @@ class PatternMatchRouter: This class will store a mapping for regex pattern: List[Deployments] """ - def __init__(self): + def __init__(self, pattern_utils: type[PatternUtils] = PatternUtils): self.patterns: dict[str, list] = {} + self._pattern_utils: Final = pattern_utils def add_pattern(self, pattern: str, llm_deployment: dict): """ @@ -69,9 +70,10 @@ class PatternMatchRouter: """ # Convert the pattern to a regex regex: Final = self._pattern_to_regex(pattern) - if regex not in self.patterns: - self.patterns[regex] = [] - self.patterns[regex].append(llm_deployment) + if regex in self.patterns: + self.patterns[regex].append(llm_deployment) + return + self.patterns = dict(self._pattern_utils.sorted_patterns({**self.patterns, regex: [llm_deployment]})) def remove_deployment(self, model_id: str) -> None: """ @@ -138,11 +140,12 @@ class PatternMatchRouter: if request is None: return None - sorted_patterns: Final = PatternUtils.sorted_patterns(self.patterns) regex_filtered_model_names: Final = ( - [self._pattern_to_regex(m) for m in filtered_model_names] if filtered_model_names is not None else [] + tuple(self._pattern_to_regex(m) for m in filtered_model_names) + if filtered_model_names is not None + else () ) - for pattern, llm_deployments in sorted_patterns: + for pattern, llm_deployments in self.patterns.items(): if filtered_model_names is not None and pattern not in regex_filtered_model_names: continue pattern_match = re.match(pattern, request) diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 31fb4fb55c5..a58d8125010 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -6077,6 +6077,34 @@ def test_failure_handler_helper_fn_builds_payload_once_per_exception(): assert obj.model_call_details["standard_logging_object"] is not first_payload +@pytest.mark.asyncio +async def test_sync_failure_handler_reuses_payload_after_callable_async_callback(): + """Regression for LIT-6886: the proxy runs async_failure_handler, then the threaded + failure_handler, for every rejected request. A plain-function async callback (the + Router registers one) is dispatched through CustomLogger.async_log_event, which + restamps log_event_type on the shared model_call_details; the sync handler then + rebuilt the standardized payload, doubling the redaction and payload cost of a 403.""" + router_style_callback = AsyncMock() + obj = LitellmLogging( + model="gpt-4o", + messages=[{"role": "user", "content": "Hey"}], + stream=False, + call_type="acompletion", + start_time=time.time(), + litellm_call_id="lit-6886-1", + function_id="f", + dynamic_async_failure_callbacks=[router_style_callback], + ) + exc = _raise_and_catch(_ClientError(status_code=403, message="key not allowed to access model")) + await obj.async_failure_handler(exception=exc, traceback_exception="") + first_payload = obj.model_call_details["standard_logging_object"] + assert first_payload is not None + assert router_style_callback.await_count == 1 + + obj.failure_handler(exc, "") + assert obj.model_call_details["standard_logging_object"] is first_payload + + @pytest.mark.asyncio async def test_prompt_hook_injection_marker_recorded_for_every_surface(logging_obj): """The savings gate reads litellm_gateway_injected_cache from the request's diff --git a/tests/test_litellm/router_utils/test_pattern_match_deployments.py b/tests/test_litellm/router_utils/test_pattern_match_deployments.py index 795d448ef5f..f9d9345cd26 100644 --- a/tests/test_litellm/router_utils/test_pattern_match_deployments.py +++ b/tests/test_litellm/router_utils/test_pattern_match_deployments.py @@ -2,8 +2,10 @@ from __future__ import annotations +from unittest.mock import Mock + from litellm.router_utils import pattern_match_deployments -from litellm.router_utils.pattern_match_deployments import PatternMatchRouter +from litellm.router_utils.pattern_match_deployments import PatternMatchRouter, PatternUtils def _wildcard_deployment(model_name: str) -> dict: @@ -76,3 +78,31 @@ def test_get_pattern_still_resolves_unqualified_names(monkeypatch): router = PatternMatchRouter() router.add_pattern("openai/*", _wildcard_deployment("openai/*")) assert _matched_models(router.get_pattern("gpt-4o")) == ["openai/gpt-4o"] + + +class _CountingPatternUtils(PatternUtils): + sorted_patterns = staticmethod(Mock(wraps=PatternUtils.sorted_patterns)) + + +def test_route_never_sorts_and_the_most_specific_pattern_still_wins_after_registry_changes(): + """Regression for LIT-6886: the auth layer walks the wildcard registry for every request, so an + unmatched model name (an invalid-model 403) re-sorted every pattern by specificity per request and + a burst of rejections saturated the worker CPU. Lookups must not sort; adding a pattern or removing + a deployment must still leave the most specific pattern winning.""" + router = PatternMatchRouter(pattern_utils=_CountingPatternUtils) + router.add_pattern("openai/*", _wildcard_deployment("openai/*")) + router.add_pattern("anthropic/*", _wildcard_deployment("anthropic/*")) + router.add_pattern("openai/gpt-*", {"model_name": "openai/gpt-*", "litellm_params": {"model": "azure/gpt-*"}}) + sorts_after_setup = _CountingPatternUtils.sorted_patterns.call_count + + for _ in range(3): + assert router.route("does-not-exist") is None + assert _matched_models(router.route("openai/gpt-4o")) == ["azure/gpt-4o"] + assert _matched_models(router.route("openai/o3")) == ["openai/o3"] + assert _CountingPatternUtils.sorted_patterns.call_count == sorts_after_setup + + router.add_pattern("openai/*", {**_wildcard_deployment("openai/*"), "model_info": {"id": "id-1"}}) + assert len(_matched_models(router.route("openai/o3"))) == 2 + router.remove_deployment("id-1") + assert _matched_models(router.route("openai/gpt-4o")) == ["azure/gpt-4o"] + assert _matched_models(router.route("openai/o3")) == ["openai/o3"] From 45cc2ed08225300536894028624284e6f9eb8baf Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 11:53:22 -0700 Subject: [PATCH 245/410] test(e2e): require a 200 inside the regenerate grace window and drop the helper docstrings --- tests/e2e/access_control/test_access_control_e2e.py | 6 +++--- tests/e2e/management/test_key_management_e2e.py | 4 ---- tests/e2e/management/test_management_e2e.py | 5 +++-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/tests/e2e/access_control/test_access_control_e2e.py b/tests/e2e/access_control/test_access_control_e2e.py index c30dadc49ae..9d01f2915e7 100644 --- a/tests/e2e/access_control/test_access_control_e2e.py +++ b/tests/e2e/access_control/test_access_control_e2e.py @@ -76,8 +76,6 @@ class TestAccessControl: def test_llm_api_routes_group_grants_every_llm_endpoint( self, client: AccessControlClient, resources: ResourceManager ) -> None: - """allowed_routes=["llm_api_routes"] names a route group, not a path: one - entry must open every LLM endpoint while the management routes stay shut.""" key = client.llm_only_key() resources.defer(lambda: client.delete_key(key)) @@ -89,7 +87,9 @@ class TestAccessControl: f"200 must carry a real completion, not an error envelope: {chat.body[:300]}" ) - embedding = unwrap(client.proxy.embed(key, EmbedBody(model=EMBEDDING_MODEL, input=f"route group {unique_marker()}"))) + embedding = unwrap( + client.proxy.embed(key, EmbedBody(model=EMBEDDING_MODEL, input=f"route group {unique_marker()}")) + ) assert embedding.model, f"llm_api_routes key reached /embeddings but got no model back: {embedding}" denied = client.create_model_status(key, f"e2e-route-group-{unique_marker()}") diff --git a/tests/e2e/management/test_key_management_e2e.py b/tests/e2e/management/test_key_management_e2e.py index d4347b8c0e7..8b7d5f0eb6f 100644 --- a/tests/e2e/management/test_key_management_e2e.py +++ b/tests/e2e/management/test_key_management_e2e.py @@ -90,8 +90,6 @@ def _is_budget_block(outcome: StreamingResponse) -> bool: def _spend_until_budget_blocks(client: ManagementClient, key: str) -> None: - """Drive paid calls until the key's max_budget refuses one. The first call spends, - the reservation counter trips the cap, and the next call is the 429.""" for _ in range(40): outcome = client.chat_status(key, SPEND_MODEL, f"spend {unique_marker()}") if _is_budget_block(outcome): @@ -105,8 +103,6 @@ def _spend_until_budget_blocks(client: ManagementClient, key: str) -> None: def _settled_spend(client: ManagementClient, key: str) -> float | None: - """The key's recorded spend once it is positive and unchanged across two reads a - poll interval apart, so no batched spend write is still in flight when we reset.""" first = client.proxy.key_info(key).spend or 0.0 time.sleep(client.proxy.poll_interval) second = client.proxy.key_info(key).spend or 0.0 diff --git a/tests/e2e/management/test_management_e2e.py b/tests/e2e/management/test_management_e2e.py index eace8f2e3b4..476165b715d 100644 --- a/tests/e2e/management/test_management_e2e.py +++ b/tests/e2e/management/test_management_e2e.py @@ -12,6 +12,7 @@ from __future__ import annotations import math import time from collections.abc import Callable +from typing import Final import pytest @@ -377,12 +378,12 @@ class TestKeyRegeneration: new_key = client.regenerate_key(old_key, grace_period=REGENERATE_GRACE_PERIOD) resources.defer(lambda: client.proxy.delete_key(new_key)) - revoke_at = time.monotonic() + REGENERATE_GRACE_SECONDS + revoke_at: Final = time.monotonic() + REGENERATE_GRACE_SECONDS assert new_key != old_key, "regenerate returned the same key string, so no rotation happened" def old_accepted() -> bool | None: outcome = client.chat_status(old_key, "gpt-5.5", f"say hi {unique_marker()}") - return True if outcome.status_code != 401 else None + return True if outcome.ok else None _ = _poll(client, old_accepted, "old key was rejected 401 inside its grace period at the deadline") assert time.monotonic() < revoke_at, ( From ee5d66d030824c34e2bd15a1aecd62581b57dc61 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 5 Sep 2026 11:56:56 -0700 Subject: [PATCH 246/410] fix(ui): keep Back to Guardrails reachable when a ?guardrail= link is stale With the selection in the URL, a mistyped or deleted guardrail id lands on the info view's not-found branch, which rendered only the message and left no way back to the table short of editing the address bar. The not-found branch now shares the Back to Guardrails button with the loaded view --- .../_components/guardrail_info.test.tsx | 24 +++++++++++++++++++ .../guardrails/_components/guardrail_info.tsx | 21 ++++++++-------- 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx index fcffc2122e7..836921104ff 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.test.tsx @@ -338,3 +338,27 @@ describe("Guardrail Info", () => { expect(screen.getByText("Guardrail Settings")).toBeInTheDocument(); }); }); + +describe("Guardrail Info when the guardrail cannot be loaded", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("should keep Back to Guardrails reachable so a stale ?guardrail= link is not a dead end", async () => { + vi.mocked(networking.getGuardrailInfo).mockRejectedValue(new Error("Guardrail stale-id not found")); + vi.mocked(networking.getGuardrailUISettings).mockResolvedValue({ + supported_entities: [], + supported_actions: [], + pii_entity_categories: [], + supported_modes: [], + }); + vi.mocked(networking.getGuardrailProviderSpecificParams).mockResolvedValue({}); + const onClose = vi.fn(); + + render(); + + expect(await screen.findByText("Guardrail not found")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /back to guardrails/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx index aaa656d15bb..c9162d99934 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/guardrail_info.tsx @@ -481,16 +481,18 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, return
    Loading...
    ; } + const backButton = ( + + ); + if (!guardrailData) { - return
    Guardrail not found
    ; + return
    {backButton}Guardrail not found
    ; } - // Format date helper function - const formatDate = (dateString?: string) => { - if (!dateString) return "-"; - const date = new Date(dateString); - return date.toLocaleString(); - }; + const formatDate = (dateString?: string) => (dateString ? new Date(dateString).toLocaleString() : "-"); // Format the provider display name and logo const { logo, displayName } = getGuardrailLogoAndName(guardrailData.litellm_params?.guardrail || ""); @@ -510,10 +512,7 @@ const GuardrailInfoView: React.FC = ({ guardrailId, onClose, return (
    - + {backButton}

    {guardrailData.guardrail_name || "Unnamed Guardrail"}

    {guardrailData.guardrail_id}

    From da5af0cb27aa668527a0e5746e307ab3c1188a24 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 12:02:48 -0700 Subject: [PATCH 247/410] test: repair two CI tests broken by intentional changes test_no_linear_scans_in_router: #39674 renamed heuristic_v2_router_limit_violation to auto_router_capability_violation, so the allowlist entry stopped matching and the same admin-only scan tripped the static check. Rename the entry to follow it. tableScrolling.spec.ts: 9ba6cab889 (LIT-4738) gave the Tags and Model Hub tables client-side pagination at 25 rows, so the 40 seeded rows no longer render on one page. Select 50 rows per page before counting, as the Logs case already does. --- tests/e2e/ui/tests/tables/tableScrolling.spec.ts | 2 ++ tests/router_unit_tests/test_router_index_management.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/e2e/ui/tests/tables/tableScrolling.spec.ts b/tests/e2e/ui/tests/tables/tableScrolling.spec.ts index 5c7438cfd11..0537ba193be 100644 --- a/tests/e2e/ui/tests/tables/tableScrolling.spec.ts +++ b/tests/e2e/ui/tests/tables/tableScrolling.spec.ts @@ -184,6 +184,7 @@ test.describe("Admin tables scroll inside the page", () => { ); try { await navigateToPage(page, Page.TagManagement); + await setRowsPerPage(page, "50"); await expectRowsAtLeast(page, SEED_ROWS); expect(await rowsPaintingPastAnAncestor(page)).toEqual([]); } finally { @@ -207,6 +208,7 @@ test.describe("Admin tables scroll inside the page", () => { ); try { await navigateToPage(page, Page.ModelHubTable); + await setRowsPerPage(page, "50"); await expectRowsAtLeast(page, SEED_ROWS); expect(await rowsPaintingPastAnAncestor(page)).toEqual([]); } finally { diff --git a/tests/router_unit_tests/test_router_index_management.py b/tests/router_unit_tests/test_router_index_management.py index 35d295d581a..291badd91b4 100644 --- a/tests/router_unit_tests/test_router_index_management.py +++ b/tests/router_unit_tests/test_router_index_management.py @@ -241,7 +241,7 @@ class TestRouterIndexManagement: "_get_deployment_by_litellm_model": "lookup by litellm_params.model, which is not indexed", "_finalize_adaptive_router_if_configured": 'init-time prefix scan for "auto_router/adaptive_router"; no index for prefix match', "config_deployments": "filters the whole list on model_info.db_model; admin path only (model add/upsert)", - "heuristic_v2_router_limit_violation": "counts heuristic_v2 routers across the whole list; admin path only (auto-router init/upsert)", + "auto_router_capability_violation": "counts gated auto-routers across the whole list; admin path only (auto-router init/upsert)", } # Get path to router.py From 0ad361a7283498e5f8b0154486e1b9a2a97270cd Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:02:55 +0000 Subject: [PATCH 248/410] fix(router): coordinate async and sync failure handlers at remaining router call sites (#39887) * fix(router): coordinate async and sync failure handlers at remaining router call sites Five router failure paths still scheduled logging_obj.async_failure_handler as a task while starting logging_obj.failure_handler on a raw thread, so both handlers mutated the same logging object concurrently. Route them through dispatch_failure_handlers like the streaming paths already do. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(router): wait on the real logging executor and justify the callbacks global patch Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(logging): submit sync failure handler even when the dispatch task is cancelled Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(logging): justify the executor submit patch Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/litellm_logging.py | 15 +- litellm/router.py | 53 +++---- .../test_litellm_logging.py | 56 ++++++++ tests/test_litellm/test_router.py | 132 ++++++++++++++++++ 4 files changed, 216 insertions(+), 40 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 22e4dbf3a44..83e0b4d84f1 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -1918,7 +1918,9 @@ class Logging(LiteLLMLoggingBaseClass): two paths cannot mutate it at the same time. ``prefer_async_handlers`` only bypasses the sync-SDK-only shortcut (e.g. ``async for`` on a stream from ``completion()``); legacy string callbacks still run via - ``executor.submit(failure_handler)`` when configured. + ``executor.submit(failure_handler)`` when configured, and still get submitted + when the awaiting task is cancelled (e.g. the event loop shuts down right after + the request failed). """ litellm_params: Final = self.model_call_details.get("litellm_params", {}) or {} sync_sdk: Final = self._is_sync_litellm_request(litellm_params) @@ -1927,12 +1929,11 @@ class Logging(LiteLLMLoggingBaseClass): self.failure_handler(exception, traceback_exception) return - await self.async_failure_handler(exception, traceback_exception) - - if not self._should_run_sync_failure_callbacks_for_async_calls(): - return - - executor.submit(self.failure_handler, exception, traceback_exception) + try: + await self.async_failure_handler(exception, traceback_exception) + finally: + if self._should_run_sync_failure_callbacks_for_async_calls(): + executor.submit(self.failure_handler, exception, traceback_exception) def should_run_logging( self, diff --git a/litellm/router.py b/litellm/router.py index 490836f5f0a..76da3a857df 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -8380,17 +8380,12 @@ class Router: ## LOG FAILURE EVENT if logging_obj is not None: asyncio.create_task( - logging_obj.async_failure_handler( + logging_obj.dispatch_failure_handlers( exception=e, traceback_exception=traceback.format_exc(), - end_time=time.time(), + prefer_async_handlers=True, ) ) - ## LOGGING - threading.Thread( - target=logging_obj.failure_handler, - args=(e, traceback.format_exc()), - ).start() # log response _set_cooldown_deployments( litellm_router_instance=self, exception_status=e.status_code, @@ -8403,17 +8398,12 @@ class Router: ## LOG FAILURE EVENT if logging_obj is not None: asyncio.create_task( - logging_obj.async_failure_handler( + logging_obj.dispatch_failure_handlers( exception=e, traceback_exception=traceback.format_exc(), - end_time=time.time(), + prefer_async_handlers=True, ) ) - ## LOGGING - threading.Thread( - target=logging_obj.failure_handler, - args=(e, traceback.format_exc()), - ).start() # log response raise e async def async_callback_filter_deployments( @@ -8451,17 +8441,12 @@ class Router: ## LOG FAILURE EVENT if logging_obj is not None: asyncio.create_task( - logging_obj.async_failure_handler( + logging_obj.dispatch_failure_handlers( exception=e, traceback_exception=traceback.format_exc(), - end_time=time.time(), + prefer_async_handlers=True, ) ) - ## LOGGING - threading.Thread( - target=logging_obj.failure_handler, - args=(e, traceback.format_exc()), - ).start() # log response raise e return returned_healthy_deployments @@ -12643,13 +12628,13 @@ class Router: logging_obj: Final = request_kwargs.get("litellm_logging_obj", None) if logging_obj is not None: - ## LOGGING - threading.Thread( - target=logging_obj.failure_handler, - args=(e, traceback_exception), - ).start() # log response - # Handle any exceptions that might occur during streaming - asyncio.create_task(logging_obj.async_failure_handler(e, traceback_exception)) + asyncio.create_task( + logging_obj.dispatch_failure_handlers( + exception=e, + traceback_exception=traceback_exception, + prefer_async_handlers=True, + ) + ) raise e async def async_get_available_deployment_for_pass_through( @@ -12777,11 +12762,13 @@ class Router: if request_kwargs is not None: logging_obj: Final = request_kwargs.get("litellm_logging_obj", None) if logging_obj is not None: - threading.Thread( - target=logging_obj.failure_handler, - args=(e, traceback_exception), - ).start() - asyncio.create_task(logging_obj.async_failure_handler(e, traceback_exception)) + asyncio.create_task( + logging_obj.dispatch_failure_handlers( + exception=e, + traceback_exception=traceback_exception, + prefer_async_handlers=True, + ) + ) raise e async def _run_routing_plugins( diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index a58d8125010..1991170707d 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -1546,6 +1546,62 @@ async def test_dispatch_failure_handlers_async_completes_before_sync_submit( assert events == ["async_start", "async_end", "sync_submit"] +@pytest.mark.asyncio +async def test_dispatch_failure_handlers_submits_sync_handler_when_task_is_cancelled( + logging_obj, +): + """Cancelling the dispatch task mid-await still submits the sync failure_handler. + + Router failure paths fire the dispatcher with ``asyncio.create_task`` and raise + right away. When the event loop is torn down before the task finishes (a short + ``asyncio.run`` in the SDK), the cancelled task must still hand the sync callbacks + to the executor, as the old raw-thread path did, and only once the async handler + has stopped. + """ + exception = ValueError("boom") + traceback_exception = "traceback" + events: list[str] = [] + async_started = asyncio.Event() + + async def _async_failure(exc, tb, **kwargs): + events.append("async_start") + async_started.set() + await asyncio.sleep(10) + events.append("async_end") + + def _submit(*args, **kwargs): + events.append("sync_submit") + + logging_obj.model_call_details["litellm_params"] = {} + + with ( + patch.object(logging_obj, "async_failure_handler", side_effect=_async_failure), + patch.object(logging_obj, "failure_handler", new_callable=MagicMock), + patch.object( + logging_obj, + "_should_run_sync_failure_callbacks_for_async_calls", + return_value=True, + ), + patch( # test-quality-ok: the executor submit is the observable + "litellm.litellm_core_utils.litellm_logging.executor.submit", + side_effect=_submit, + ), + ): + task = asyncio.create_task( + logging_obj.dispatch_failure_handlers( + exception, + traceback_exception, + prefer_async_handlers=True, + ) + ) + await async_started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + assert events == ["async_start", "sync_submit"] + + @pytest.mark.asyncio async def test_dispatch_failure_handlers_submits_sync_handler_for_failure_only_callbacks( logging_obj, diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index ffd6c5f97ce..8368aa11316 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -5,6 +5,7 @@ import json import logging import os import threading +from datetime import datetime from types import SimpleNamespace from typing import Final from unittest.mock import AsyncMock, MagicMock, patch @@ -20,6 +21,7 @@ import litellm from litellm import Router from litellm.exceptions import MidStreamFallbackError from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLogging from litellm.llms.bedrock.common_utils import BedrockError from litellm.llms.anthropic.experimental_pass_through.messages.agentic_streaming_iterator import ( SERVER_FULFILLED_TOOL_LEAK_ERROR_SSE_BYTES, @@ -12940,6 +12942,136 @@ async def test_router_retry_policy_controls_upstream_attempt_count( assert upstream.call_count == expected_upstream_calls +def _make_failure_logging_obj(): + return LiteLLMLogging( + model="gpt-5.6", + messages=[{"role": "user", "content": "hi"}], + stream=False, + call_type="acompletion", + start_time=datetime.now(), + litellm_call_id="lit-6960", + function_id="f", + ) + + +async def _assert_router_failure_logging_is_coordinated(logging_obj, trigger, expected_exception): + """The sync failure_handler must not start until async_failure_handler has finished on the shared logging_obj.""" + events: list[str] = [] + sync_done = threading.Event() + + async def _async_failure(*args, **kwargs): + events.append("async_start") + await asyncio.sleep(0.05) + events.append("async_end") + + def _sync_failure(*args, **kwargs): + events.append("sync_start") + sync_done.set() + + with ( + patch.object(logging_obj, "async_failure_handler", side_effect=_async_failure), + patch.object(logging_obj, "failure_handler", side_effect=_sync_failure), + patch.object(logging_obj, "_should_run_sync_failure_callbacks_for_async_calls", return_value=True), + ): + with pytest.raises(expected_exception): + await trigger() + pending = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + await asyncio.gather(*pending) + assert await asyncio.to_thread(sync_done.wait, 5), "failure_handler never ran" + + assert events == ["async_start", "async_end", "sync_start"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "hook_error", + [ + litellm.RateLimitError(message="rpm exceeded", llm_provider="openai", model="gpt-5.6"), + RuntimeError("pre call check blew up"), + ], +) +async def test_async_routing_strategy_pre_call_checks_failure_logging_is_coordinated(hook_error): + class _RaisingPreCallCheck(CustomLogger): + async def async_pre_call_check(self, deployment, parent_otel_span): + raise hook_error + + router = litellm.Router( + model_list=[{"model_name": "gpt-5.6", "litellm_params": {"model": "openai/gpt-5.6", "api_key": "sk-fake"}}] + ) + deployment = router.model_list[0] + logging_obj = _make_failure_logging_obj() + + with patch.object(litellm, "callbacks", [_RaisingPreCallCheck()]): # test-quality-ok: router reads this global + await _assert_router_failure_logging_is_coordinated( + logging_obj, + lambda: router.async_routing_strategy_pre_call_checks( + deployment=deployment, parent_otel_span=None, logging_obj=logging_obj + ), + type(hook_error), + ) + + +@pytest.mark.asyncio +async def test_async_callback_filter_deployments_failure_logging_is_coordinated(): + class _RaisingFilter(CustomLogger): + async def async_filter_deployments(self, *args, **kwargs): + raise RuntimeError("filter blew up") + + router = litellm.Router( + model_list=[{"model_name": "gpt-5.6", "litellm_params": {"model": "openai/gpt-5.6", "api_key": "sk-fake"}}] + ) + logging_obj = _make_failure_logging_obj() + + with patch.object(litellm, "callbacks", [_RaisingFilter()]): # test-quality-ok: router reads this global + await _assert_router_failure_logging_is_coordinated( + logging_obj, + lambda: router.async_callback_filter_deployments( + model="gpt-5.6", + healthy_deployments=router.model_list, + messages=None, + parent_otel_span=None, + request_kwargs={}, + logging_obj=logging_obj, + ), + RuntimeError, + ) + + +@pytest.mark.asyncio +async def test_async_get_available_deployment_failure_logging_is_coordinated(): + router = litellm.Router( + model_list=[{"model_name": "gpt-5.6", "litellm_params": {"model": "openai/gpt-5.6", "api_key": "sk-fake"}}] + ) + logging_obj = _make_failure_logging_obj() + + await _assert_router_failure_logging_is_coordinated( + logging_obj, + lambda: router.async_get_available_deployment( + model="model-that-is-not-configured", + request_kwargs={"litellm_logging_obj": logging_obj}, + messages=[{"role": "user", "content": "hi"}], + ), + litellm.BadRequestError, + ) + + +@pytest.mark.asyncio +async def test_async_get_available_deployment_for_pass_through_failure_logging_is_coordinated(): + router = litellm.Router( + model_list=[{"model_name": "gpt-5.6", "litellm_params": {"model": "openai/gpt-5.6", "api_key": "sk-fake"}}] + ) + logging_obj = _make_failure_logging_obj() + + await _assert_router_failure_logging_is_coordinated( + logging_obj, + lambda: router.async_get_available_deployment_for_pass_through( + model="gpt-5.6", + request_kwargs={"litellm_logging_obj": logging_obj}, + ), + litellm.BadRequestError, + ) + + class _InFlightTracker: def __init__(self) -> None: self.current = 0 From 0f59b6fb7a996debcb350f6bf10a18e5ba276a62 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 12:03:42 -0700 Subject: [PATCH 249/410] ci(e2e): refine changed-test selection and runner lifecycle --- .github/e2e-stack/assert_tests_ran.py | 29 +++++-- .github/e2e-stack/secrets_to_env.py | 44 ++++++---- .github/e2e-stack/up.sh | 8 +- .github/workflows/test-e2e-changed.yml | 80 ++++++++++++++++--- .../test_e2e_changed_gate.py | 55 +++++++++++++ tests/e2e/CONTRIBUTING.md | 12 ++- tests/e2e/gateway/stage_mirror_ci_config.yml | 4 - 7 files changed, 186 insertions(+), 46 deletions(-) create mode 100644 tests/code_coverage_tests/test_e2e_changed_gate.py diff --git a/.github/e2e-stack/assert_tests_ran.py b/.github/e2e-stack/assert_tests_ran.py index 092a9db7256..7fd3f7c7c33 100644 --- a/.github/e2e-stack/assert_tests_ran.py +++ b/.github/e2e-stack/assert_tests_ran.py @@ -5,15 +5,28 @@ from typing import Final def main() -> int: - report: Final = ET.parse(Path(sys.argv[1])).getroot() - suites: Final = tuple(report.iter("testsuite")) - collected: Final = sum(int(suite.get("tests", "0")) for suite in suites) - skipped: Final = sum(int(suite.get("skipped", "0")) for suite in suites) - executed: Final = collected - skipped - _ = sys.stdout.write(f"executed {executed} of {collected} collected tests ({skipped} skipped)\n") - if executed > 0: + selected: Final = tuple(sys.argv[2:]) + try: + report: Final = ET.parse(Path(sys.argv[1])).getroot() + except (ET.ParseError, OSError): + _ = sys.stdout.write("::error::could not read the test execution report\n") + return 1 + cases: Final = tuple(report.iter("testcase")) + passed: Final = frozenset( + case.get("file") for case in cases if all(case.find(tag) is None for tag in ("skipped", "failure", "error")) + ) + missing: Final = tuple(path for path in selected if path not in passed) + for path in selected: + collected: Final = sum(case.get("file") == path for case in cases) + skipped: Final = sum(case.get("file") == path and case.find("skipped") is not None for case in cases) + _ = sys.stdout.write(f"{path}: {collected} collected, {skipped} skipped\n") + if ( + selected + and not missing + and not any(case.find(tag) is not None for case in cases for tag in ("failure", "error")) + ): return 0 - _ = sys.stdout.write("::error::every selected test was skipped, so nothing was verified\n") + _ = sys.stdout.write("::error::every selected file must execute a passing test, with no failures or errors\n") return 1 diff --git a/.github/e2e-stack/secrets_to_env.py b/.github/e2e-stack/secrets_to_env.py index d2d6675d690..99e717b271d 100644 --- a/.github/e2e-stack/secrets_to_env.py +++ b/.github/e2e-stack/secrets_to_env.py @@ -1,29 +1,41 @@ +import os import re import sys from pathlib import Path from typing import Final -from pydantic import TypeAdapter +from pydantic import TypeAdapter, ValidationError -secrets_adapter: TypeAdapter[dict[str, str]] = TypeAdapter(dict[str, str]) -SECRET_NAME: Final = re.compile(r"KEY|SECRET|TOKEN|PASS|CREDENTIAL|LICENSE|AUTH") +secrets_adapter: Final[TypeAdapter[dict[str, str]]] = TypeAdapter(dict[str, str]) +ENV_NAME: Final = re.compile(r"[A-Za-z_][A-Za-z0-9_]*") def main() -> int: - env_path = Path(sys.argv[1]) - secrets = {key: value.rstrip("\r\n") for key, value in secrets_adapter.validate_json(sys.stdin.read()).items()} - unwritable = tuple( - key for key, value in secrets.items() if "'" in value or "\n" in value or "\r" in value - ) - if unwritable: - _ = sys.stderr.write(f"values contain characters unsafe for both bash and dotenv: {', '.join(unwritable)}\n") + env_path: Final = Path(sys.argv[1]) + try: + secrets: Final = { + key: value.rstrip("\r\n") for key, value in secrets_adapter.validate_json(sys.stdin.read()).items() + } + except (ValidationError, UnicodeError): + _ = sys.stderr.write("expected a JSON object containing string environment values\n") + return 1 + if any( + ENV_NAME.fullmatch(key) is None or any(char in value for char in "'\n\r\0") for key, value in secrets.items() + ): + _ = sys.stderr.write("environment names or values cannot be represented in both bash and dotenv\n") + return 1 + for value in secrets.values(): + if value: + _ = sys.stdout.write(f"::add-mask::{value.replace('%', '%25')}\n") + sys.stdout.flush() + lines: Final = tuple(f"{key}='{value}'" for key, value in secrets.items() if value) + try: + with os.fdopen(os.open(env_path, os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600), "w") as handle: + os.fchmod(handle.fileno(), 0o600) + _ = handle.write("\n".join(lines) + "\n") + except OSError: + _ = sys.stderr.write("could not write the environment file\n") return 1 - lines = tuple(f"{key}='{value}'" for key, value in secrets.items() if value) - with env_path.open("a") as handle: - _ = handle.write("\n".join(lines) + "\n") - for key, value in secrets.items(): - if value and SECRET_NAME.search(key): - _ = sys.stdout.write(f"::add-mask::{value}\n") return 0 diff --git a/.github/e2e-stack/up.sh b/.github/e2e-stack/up.sh index 06fce35a896..e891add341d 100755 --- a/.github/e2e-stack/up.sh +++ b/.github/e2e-stack/up.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash set -euo pipefail +umask 077 REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" STACK_DIR="${E2E_STACK_DIR:-${RUNNER_TEMP:-/tmp}/litellm-e2e-stack}" @@ -8,9 +9,9 @@ LOGS_DIR="${STACK_DIR}/logs" PIDS_DIR="${STACK_DIR}/pids" POSTGRES_IMAGE="${E2E_POSTGRES_IMAGE:-postgres:16.6}" -VALKEY_IMAGE="${E2E_VALKEY_IMAGE:-valkey/valkey:8.1}" +VALKEY_IMAGE="${E2E_VALKEY_IMAGE:-valkey/valkey:8.1.4@sha256:81db6d39e1bba3b3ff32bd3a1b19a6d69690f94a3954ec131277b9a26b95b3aa}" JAEGER_IMAGE="${E2E_JAEGER_IMAGE:-jaegertracing/jaeger:2.10.0}" -NGINX_IMAGE="${E2E_NGINX_IMAGE:-nginx:1.29-alpine}" +NGINX_IMAGE="${E2E_NGINX_IMAGE:-nginx:1.29.1-alpine@sha256:42a516af16b852e33b7682d5ef8acbd5d13fe08fecadc7ed98605ba5e3b26ab8}" LB_PORT="${E2E_LB_PORT:-4000}" GATEWAY_PORT_1="${E2E_GATEWAY_PORT_1:-4010}" @@ -28,6 +29,8 @@ JAEGER_QUERY_PORT="${E2E_JAEGER_QUERY_PORT:-16686}" MASTER_KEY="${LITELLM_MASTER_KEY:-sk-e2e-$(openssl rand -hex 16)}" mkdir -p "${CERTS_DIR}" "${LOGS_DIR}" "${PIDS_DIR}" +chmod 700 "${STACK_DIR}" "${LOGS_DIR}" "${PIDS_DIR}" +chmod 755 "${CERTS_DIR}" log() { printf 'e2e-stack: %s\n' "$*"; } @@ -38,7 +41,6 @@ wait_for() { until eval "${check}"; do if ((SECONDS >= deadline)); then log "timed out waiting for ${label}" - tail -n 60 "${LOGS_DIR}"/*.log 2>/dev/null || true exit 1 fi sleep 2 diff --git a/.github/workflows/test-e2e-changed.yml b/.github/workflows/test-e2e-changed.yml index e08660d0412..b802e9173f5 100644 --- a/.github/workflows/test-e2e-changed.yml +++ b/.github/workflows/test-e2e-changed.yml @@ -26,27 +26,26 @@ jobs: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR_NUMBER: ${{ github.event.pull_request.number }} - SMOKE_TESTS: tests/e2e/access_control + HEAD_SHA: ${{ github.event.pull_request.head.sha }} OWN_LANE: '^tests/e2e/(ui|claude_code|load)/|^tests/e2e/batches/test_managed_files_enforcement_e2e\.py$' run: | + gh api "repos/${REPO}/pulls/${PR_NUMBER}" \ + --jq 'select(.head.sha == env.HEAD_SHA and .changed_files < 3000) | .head.sha' \ + | grep -Fxq "${HEAD_SHA}" files="$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate \ --jq '.[] | select(.status != "removed") | .filename')" + gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha' | grep -Fxq "${HEAD_SHA}" tests="$(printf '%s\n' "${files}" \ | grep -E '^tests/e2e/([A-Za-z0-9_.-]+/)*test_[A-Za-z0-9_.-]+\.py$' \ | grep -vE "${OWN_LANE}" \ | sort -u | tr '\n' ' ' | sed 's/ $//')" || true - if [ -z "${tests}" ] && printf '%s\n' "${files}" | grep -vE "${OWN_LANE}" \ - | grep -qE '^(tests/e2e/|\.github/e2e-stack/|\.github/workflows/test-e2e-changed\.yml$)'; then - tests="${SMOKE_TESTS}" - echo "harness or stack changed without a test file; running the smoke suite" - fi echo "tests=${tests}" >> "${GITHUB_OUTPUT}" if [ -n "${tests}" ]; then echo "any=true" >> "${GITHUB_OUTPUT}" echo "selected e2e tests: ${tests}" else echo "any=false" >> "${GITHUB_OUTPUT}" - echo "no e2e changes; nothing to run" + echo "no changed e2e test files supported by this stack; nothing to run" fi run: @@ -88,6 +87,7 @@ jobs: uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 with: persist-credentials: false + ref: ${{ github.sha }} - name: Set up Python uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 @@ -120,14 +120,24 @@ jobs: run: uv run --no-sync playwright install --with-deps chromium - name: Configure AWS credentials + id: aws uses: aws-actions/configure-aws-credentials@e7f100cf4c008499ea8adda475de1042d6975c7b # v6.2.0 with: role-to-assume: ${{ vars.E2E_AWS_ROLE_TO_ASSUME }} aws-region: us-east-1 role-session-name: litellm-e2e-changed-${{ github.run_id }} + role-duration-seconds: 900 + output-env-credentials: false + output-credentials: true - name: Fetch provider credentials from AWS Secrets Manager + env: + AWS_ACCESS_KEY_ID: ${{ steps.aws.outputs.aws-access-key-id }} + AWS_SECRET_ACCESS_KEY: ${{ steps.aws.outputs.aws-secret-access-key }} + AWS_SESSION_TOKEN: ${{ steps.aws.outputs.aws-session-token }} + AWS_DEFAULT_REGION: us-east-1 run: | + umask 077 aws secretsmanager get-secret-value --secret-id litellm-e2e-changed-provider-keys \ --query SecretString --output text \ | uv run --no-sync python .github/e2e-stack/secrets_to_env.py tests/e2e/.env @@ -138,7 +148,12 @@ jobs: - name: Boot the stage-mirror stack id: boot - run: bash .github/e2e-stack/up.sh + run: | + umask 077 + if ! bash .github/e2e-stack/up.sh > "${RUNNER_TEMP}/e2e-boot.log" 2>&1; then + echo "::error::stage-mirror stack failed to boot; raw logs are not published" + exit 1 + fi - name: Export stack environment run: | @@ -149,13 +164,16 @@ jobs: - name: Run the selected tests three times with retries off env: TESTS: ${{ needs.detect.outputs.tests }} + E2E_FIXTURE_MODE: live run: | + umask 077 read -r -a test_files <<< "${TESTS}" for pass in 1 2 3; do report="${RUNNER_TEMP}/e2e-pass-${pass}.xml" echo "::group::pass ${pass} of 3" set +e - uv run --no-sync pytest "${test_files[@]}" --reruns 0 -v -rA --tb=short -p no:cacheprovider --junitxml="${report}" + uv run --no-sync pytest "${test_files[@]}" --rootdir=. --reruns 0 -v -p no:cacheprovider \ + -o junit_family=xunit1 --junitxml="${report}" > "${RUNNER_TEMP}/e2e-pass-${pass}.log" 2>&1 status=$? set -e echo "::endgroup::" @@ -163,13 +181,49 @@ jobs: echo "::error::the selected files collected no runnable tests, so nothing was verified" exit 1 fi + if ! uv run --no-sync python .github/e2e-stack/assert_tests_ran.py "${report}" "${test_files[@]}"; then + echo "::error::pass ${pass} of 3 did not verify every selected file" + exit 1 + fi if [ "${status}" != "0" ]; then echo "::error::pass ${pass} of 3 failed with exit code ${status}" exit "${status}" fi - uv run --no-sync python .github/e2e-stack/assert_tests_ran.py "${report}" + echo "pass ${pass} of 3 passed" done - - name: Show stack logs on failure - if: failure() && steps.boot.conclusion != 'skipped' - run: tail -n 200 "${RUNNER_TEMP}/litellm-e2e-stack/logs"/*.log + - name: Stop the stack + if: always() && steps.boot.outcome != 'skipped' + run: bash .github/e2e-stack/down.sh + + - name: Remove credentials and raw output + if: always() + run: | + rm -f tests/e2e/.env "${RUNNER_TEMP}/e2e-boot.log" "${RUNNER_TEMP}"/e2e-pass-*.log "${RUNNER_TEMP}"/e2e-pass-*.xml + rm -rf "${RUNNER_TEMP}/litellm-e2e-stack" + + gate: + name: e2e-changed-tests + needs: [detect, run] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Require three successful passes when tests changed + env: + DETECT_RESULT: ${{ needs.detect.result }} + ANY_TESTS: ${{ needs.detect.outputs.any }} + RUN_RESULT: ${{ needs.run.result }} + run: | + if [ "${DETECT_RESULT}" != "success" ]; then + echo "::error::changed-test detection did not succeed" + exit 1 + fi + if [ "${ANY_TESTS}" = "false" ]; then + echo "no changed e2e test files supported by this stack; nothing to run" + exit 0 + fi + if [ "${ANY_TESTS}" != "true" ] || [ "${RUN_RESULT}" != "success" ]; then + echo "::error::selected e2e tests require an approved, successful run; fork PRs must run from a reviewed same-repository branch" + exit 1 + fi diff --git a/tests/code_coverage_tests/test_e2e_changed_gate.py b/tests/code_coverage_tests/test_e2e_changed_gate.py new file mode 100644 index 00000000000..7a82a8ebb60 --- /dev/null +++ b/tests/code_coverage_tests/test_e2e_changed_gate.py @@ -0,0 +1,55 @@ +import subprocess +import sys +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Final + +import pytest + +GATE: Final = Path(__file__).resolve().parents[2] / ".github/e2e-stack/assert_tests_ran.py" +SELECTED: Final = ("tests/e2e/access_control/test_a.py", "tests/e2e/access_control/test_b.py") + + +@pytest.mark.parametrize( + ("second_outcome", "expected_status"), + (("passed", 0), ("skipped", 1), ("failure", 1), ("error", 1), ("deselected", 1)), +) +def test_each_changed_file_must_run(tmp_path: Path, second_outcome: str, expected_status: int) -> None: + suite: Final = ET.Element("testsuite") + _ = ET.SubElement(suite, "testcase", file=SELECTED[0]) + if second_outcome != "deselected": + second: Final = ET.SubElement(suite, "testcase", file=SELECTED[1]) + if second_outcome != "passed": + _ = ET.SubElement(second, second_outcome) + report: Final = tmp_path / "report.xml" + ET.ElementTree(suite).write(report) + + result: Final = subprocess.run([sys.executable, str(GATE), str(report), *SELECTED], capture_output=True, text=True) + + assert result.returncode == expected_status, result.stdout + + +@pytest.mark.parametrize("outcome", ("failure", "error")) +def test_passing_case_does_not_hide_a_failure_in_the_same_file(tmp_path: Path, outcome: str) -> None: + suite: Final = ET.Element("testsuite") + _ = ET.SubElement(suite, "testcase", file=SELECTED[0]) + failed: Final = ET.SubElement(suite, "testcase", file=SELECTED[0]) + _ = ET.SubElement(failed, outcome) + report: Final = tmp_path / "report.xml" + ET.ElementTree(suite).write(report) + + result: Final = subprocess.run( + [sys.executable, str(GATE), str(report), SELECTED[0]], capture_output=True, text=True + ) + + assert result.returncode == 1 + + +@pytest.mark.parametrize("contents", ("", "')) +def test_missing_execution_evidence_fails(tmp_path: Path, contents: str) -> None: + report: Final = tmp_path / "report.xml" + _ = report.write_text(contents) + + result: Final = subprocess.run([sys.executable, str(GATE), str(report), *SELECTED], capture_output=True, text=True) + + assert result.returncode == 1 diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 1e88f6505cf..d51e729fd1c 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -54,9 +54,17 @@ Some suites need extra services the bare proxy does not start. The `logging/` OT ### The pull request check -Every PR that adds or modifies a `tests/e2e/**/test_*.py` file (outside `ui/`, `claude_code/`, `load/`, and `batches/test_managed_files_enforcement_e2e.py`, which have their own lanes or need a differently configured proxy) runs exactly those files three times, with retries off, against a stage-mirror stack booted on a GitHub Actions runner: migrations, a control-plane backend, two gateway processes behind an nginx load balancer, Postgres, Jaeger, and a TLS cluster-mode Valkey, wired the way stage is deployed. A PR that only touches the harness or the stack itself runs the `access_control/` suite as a smoke instead. Three green passes are the bar because the check exists to catch a flaky test before it reaches the release gate, so a red pass is a failure to fix, not a retry candidate. A pass that executes nothing is also red: each pass writes a JUnit report and fails when every collected test was skipped, so a skipped-out file cannot pass on paper. The same stack boots on a laptop with `bash .github/e2e-stack/up.sh`: it reads provider keys from `tests/e2e/.env`, writes the pytest environment to `${E2E_STACK_DIR:-/tmp/litellm-e2e-stack}/stack.env`, every port is overridable through `E2E_*_PORT` variables, and `down.sh` tears it all down +Every same-repository PR that adds, modifies, or renames a `tests/e2e/**/test_*.py` file runs those changed files three times with pytest retries off. The stage-mirror stack has a control-plane backend, two gateways behind nginx, Postgres, Jaeger, and TLS cluster-mode Valkey. Documentation, harness, configuration, deleted-file, and workflow-only changes do not start the stack or request environment approval. The `ui/`, `claude_code/`, and `load/` directories and `batches/test_managed_files_enforcement_e2e.py` remain outside this check because they use separate tooling or need a differently configured stack -Credentials: everything the runner writes into `tests/e2e/.env` comes from two AWS Secrets Manager secrets in us-east-1, `litellm-e2e-changed-provider-keys` and `litellm-e2e-changed-license`, and the first holds only the names that the tests and `tests/e2e/gateway/stage_mirror_ci_config.yml` read. The cloud credentials in it are dedicated to this lane and can do only what the tests do: the AWS pair belongs to the `litellm-e2e-changed` IAM user, whose inline policy allows Bedrock inference, the one guardrail the tests use, the batch job APIs, the batch S3 bucket, and assuming the batch test's role, and the Vertex key belongs to a service account holding only `roles/aiplatform.user` on the Vertex project. The provider API keys carry no lane-specific spend cap, since a cap that trips mid-month would fail every run until it resets, so the reviewer's approval of the `e2e-changed` environment is the control on how a PR's tests use them. Rotating any value is a single `aws secretsmanager put-secret-value` on the secret, with no workflow change +Every selected file must execute at least one passing test in each pass, and any test failure, collection error, or entirely skipped or deselected file fails the check. A failed pass stops the run without retrying. The final `e2e-changed-tests` job succeeds only when no supported test files changed or the approved run completed all three passes. Fork PRs with selected tests fail this gate until a maintainer brings the reviewed change onto a same-repository branch + +Repository admins must require the `e2e-changed-tests` status check for merging and configure the `e2e-changed` environment with required reviewers, self-review disabled, and admin bypass disabled. Each push cancels the previous run; a new run that selects tests needs a fresh approval. Reviewers must inspect the entire executable PR diff, including application code, dependencies, tests, and workflow helpers, before approving the exact revision. Approved code executes with provider credentials, so environment approval is a trust decision about that code + +Credentials come from the existing AWS Secrets Manager secrets in us-east-1, `litellm-e2e-changed-provider-keys` and `litellm-e2e-changed-license`. The OIDC role must trust only `repo:BerriAI/litellm:environment:e2e-changed` with audience `sts.amazonaws.com` and have read access only to these secrets. The short-lived reader credentials are scoped to the fetch step. Provider credentials must cover the selected suites, including Datadog credentials when logging or MCP tests need them; missing credentials fail the run. Keep provider credentials dedicated to this lane with only the permissions those tests need + +Fetched values are masked before use, and credential files and raw output are private to the runner. Public logs contain selected file names, counts, and pass status; raw pytest output, reports, and stack logs are not uploaded or printed. The workflow removes them and the credential files during cleanup. To diagnose a failed pass, reproduce the selected files locally with the appropriate credentials and inspect the local logs + +To reproduce the CI topology on a dedicated machine, `bash .github/e2e-stack/up.sh` reads `tests/e2e/.env`, writes `stack.env` under `${E2E_STACK_DIR:-/tmp/litellm-e2e-stack}`, and `bash .github/e2e-stack/down.sh` stops it. Keep this directory private and remove its credential files and logs after use ### Record and replay diff --git a/tests/e2e/gateway/stage_mirror_ci_config.yml b/tests/e2e/gateway/stage_mirror_ci_config.yml index 21664c2d0a1..6b3756b5f9f 100644 --- a/tests/e2e/gateway/stage_mirror_ci_config.yml +++ b/tests/e2e/gateway/stage_mirror_ci_config.yml @@ -52,10 +52,6 @@ model_list: litellm_params: model: gemini/gemini-2.5-flash api_key: os.environ/GEMINI_API_KEY - - model_name: gemini-2.5-flash - litellm_params: - model: gemini/gemini-2.5-flash - api_key: os.environ/GEMINI_API_KEY mcp_servers: devin: From 5298deb491ca1485f79a93f337e665421ec42b2e Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 12:04:56 -0700 Subject: [PATCH 250/410] test(e2e/ui): select 50 rows per page before asserting the Tags and Model Hub tables overflow #39680 made every admin table honor the selected page size, so the Tags and Model Hub tables now paginate at 25 by default and the two scroll specs, which seed 40 rows and expect them all on one page, fail on every litellm-e2e-ui run since (builds 206 to 208). Selecting 50 rows first, the way the Request Logs spec already does, keeps the overflow assertion meaningful --- tests/e2e/ui/tests/tables/tableScrolling.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/e2e/ui/tests/tables/tableScrolling.spec.ts b/tests/e2e/ui/tests/tables/tableScrolling.spec.ts index 5c7438cfd11..0537ba193be 100644 --- a/tests/e2e/ui/tests/tables/tableScrolling.spec.ts +++ b/tests/e2e/ui/tests/tables/tableScrolling.spec.ts @@ -184,6 +184,7 @@ test.describe("Admin tables scroll inside the page", () => { ); try { await navigateToPage(page, Page.TagManagement); + await setRowsPerPage(page, "50"); await expectRowsAtLeast(page, SEED_ROWS); expect(await rowsPaintingPastAnAncestor(page)).toEqual([]); } finally { @@ -207,6 +208,7 @@ test.describe("Admin tables scroll inside the page", () => { ); try { await navigateToPage(page, Page.ModelHubTable); + await setRowsPerPage(page, "50"); await expectRowsAtLeast(page, SEED_ROWS); expect(await rowsPaintingPastAnAncestor(page)).toEqual([]); } finally { From 5a22edb6c3e223f1fecd08eeb966b4ce65b7b3ce Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 5 Sep 2026 12:07:48 -0700 Subject: [PATCH 251/410] feat(ui): explain how guardrail usage and cost are calculated Adds a "How is this calculated?" hover to the Guardrail Cost card on the overview and to the Cost and Usage Units cards on the detail page. The overview hint lists each guardrail's cost and the total; the detail cost hint shows units x per-unit price per counter with unpriced units called out, and the units hint shows the per-counter sum. Also moves the Status column to the front of the overview table. Refs LIT-5652 --- .../GuardrailUsageBreakdown.test.tsx | 32 +++++++++ .../_components/GuardrailUsageBreakdown.tsx | 31 +++++++- .../_components/GuardrailsOverview.test.tsx | 14 ++++ .../_components/GuardrailsOverview.tsx | 67 ++++++++++++----- .../GuardrailsMonitor/MetricCard.tsx | 25 ++++++- .../GuardrailsMonitor/usageUnits.test.ts | 71 ++++++++++++++++++- .../GuardrailsMonitor/usageUnits.ts | 32 +++++++++ 7 files changed, 250 insertions(+), 22 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx index 7929b97b000..ba90ca8e6ff 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx @@ -1,4 +1,5 @@ import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { describe, expect, it } from "vitest"; import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; import { GuardrailUsageBreakdown } from "./GuardrailUsageBreakdown"; @@ -84,6 +85,37 @@ describe("GuardrailUsageBreakdown", () => { expect(within(unpricedKey).getByText("7", { selector: ".text-warning" })).toBeInTheDocument(); }); + it("explains the cost math per counter on hover", async () => { + const user = userEvent.setup(); + render(); + + await user.hover( + within(screen.getByRole("group", { name: "Cost" })).getByRole("button", { name: /How is this calculated/ }), + ); + + expect(await screen.findByText("Content Policy: 1,000 × $0.00015 = $0.1500")).toBeInTheDocument(); + expect(screen.getByText("Sensitive Information Policy: 300 × $0.0001 = $0.0300")).toBeInTheDocument(); + expect(screen.getByText("Some Future Counter: 7 units with no known price, left out")).toBeInTheDocument(); + expect(screen.getByText("Total: $0.1800")).toBeInTheDocument(); + }); + + it("explains the units sum on hover", async () => { + const user = userEvent.setup(); + render(); + + await user.hover( + within(screen.getByRole("group", { name: "Usage Units" })).getByRole("button", { + name: /How is this calculated/, + }), + ); + + expect( + await screen.findByText( + "Content Policy 1,000 + Sensitive Information Policy 300 + Some Future Counter 7 = 1,307", + ), + ).toBeInTheDocument(); + }); + it("orders teams and keys by units, largest first", () => { render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx index 6e8b725aa2e..01dd8f79ce4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx @@ -3,7 +3,14 @@ import { CircleDollarSign } from "lucide-react"; import React from "react"; import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; -import { counterLabel, formatCost, totalUnits, unpricedSummary } from "@/components/GuardrailsMonitor/usageUnits"; +import { + counterLabel, + counterMathLine, + formatCost, + totalUnits, + unitsSumLine, + unpricedSummary, +} from "@/components/GuardrailsMonitor/usageUnits"; import { DataTable } from "@/components/shared/DataTable"; import { IdCell } from "@/components/shared/table_cells/id_cell"; import { MoneyCell } from "@/components/shared/table_cells/money_cell"; @@ -104,6 +111,26 @@ const groupColumns = (label: string, emptyLabel: string): ColumnDef[] const teamColumns = groupColumns("Team", "No team"); const keyColumns = groupColumns("Key", "No key"); +const CostMath = ({ counters, total }: { counters: CounterRow[]; total: number | null }) => ( +
    + {counters.map((row) => ( +
    {counterMathLine(row)}
    + ))} +
    Total: {formatCost(total)}
    +
    Per-unit prices come from the bedrock/guardrails entry in the cost map.
    +
    +); + +const UnitsMath = ({ units }: { units: GuardrailUsageDetail["usage_units"] }) => ( +
    +
    {unitsSumLine(units)}
    +
    + Bedrock reports one unit per 1,000 characters of the message for each policy the guardrail has on, on every call, + blocked or not. +
    +
    +); + const TableHeading = ({ title }: { title: string }) => (
    {title}
    ); @@ -132,11 +159,13 @@ export function GuardrailUsageBreakdown({ detail }: { detail: GuardrailUsageDeta valueColor={detail.cost != null ? "text-foreground" : "text-muted-foreground"} icon={} subtitle={unpriced ?? undefined} + hint={} /> } />
    diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx index c52645def70..14e070afc0d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx @@ -211,6 +211,20 @@ describe("GuardrailsOverview", () => { expect(card).toHaveTextContent("250 units unpriced"); }); + it("explains the guardrail cost total on hover", async () => { + const user = userEvent.setup(); + renderOverview(); + + const card = await screen.findByRole("group", { name: "Guardrail Cost" }); + await user.hover(within(card).getByRole("button", { name: /How is this calculated/ })); + + expect(await screen.findByText("High Failure Guardrail: $0.1500")).toBeInTheDocument(); + expect(screen.getByText("Free Bedrock Guardrail: $0.0000")).toBeInTheDocument(); + expect(screen.queryByText(/Low Failure Guardrail: /)).not.toBeInTheDocument(); + expect(screen.getByText("Total: $0.1500")).toBeInTheDocument(); + expect(screen.getByText(/250 units unpriced had no known price and are left out/)).toBeInTheDocument(); + }); + it("shows a dash for guardrail cost when nothing in the window was priced", async () => { useGuardrailsUsageOverviewMock.mockReturnValue({ data: { ...overview, totalCost: null, totalUntrackedUsageUnits: {} }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx index 0bbda6015b4..3df7058baba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx @@ -63,6 +63,34 @@ function UsageUnitsCell({ units }: { units: GuardrailUsageOverviewRow["usageUnit ); } +function TotalCostMath({ + rows, + total, + unpriced, +}: { + rows: GuardrailUsageOverviewRow[]; + total: number | null; + unpriced: string | null; +}) { + return ( +
    + {rows + .filter((row) => row.cost != null) + .map((row) => ( +
    + {row.name}: {formatCost(row.cost)} +
    + ))} +
    Total: {formatCost(total)}
    +
    + {`Each guardrail's cost is its units per policy × that policy's per-unit price from the cost map, added up${ + unpriced ? `; ${unpriced} had no known price and are left out` : "" + }. Open a guardrail for its per-policy math.`} +
    +
    + ); +} + function CostCell({ row }: { row: GuardrailUsageOverviewRow }) { const unpriced = unpricedSummary(row.untrackedUsageUnits); return ( @@ -124,6 +152,25 @@ export function GuardrailsOverview({ const error = guardrailsError; const columns: ColumnDef[] = [ + { + header: "Status", + accessorKey: "status", + enableSorting: false, + cell: ({ row }) => ( + + + {row.original.status} + + ), + }, { header: "Guardrail", accessorKey: "name", @@ -215,25 +262,6 @@ export function GuardrailsOverview({ sortDescFirst: false, cell: ({ row }) => , }, - { - header: "Status", - accessorKey: "status", - enableSorting: false, - cell: ({ row }) => ( - - - {row.original.status} - - ), - }, ]; const sortableKeys: SortKey[] = ["failRate", "requestsEvaluated", "avgLatency", "cost"]; @@ -291,6 +319,7 @@ export function GuardrailsOverview({ valueColor={metrics.totalCost != null ? "text-foreground" : "text-muted-foreground"} icon={} subtitle={metrics.unpriced ?? undefined} + hint={} />
    diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx index c0b5e0a50d1..1805dc797e4 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx @@ -1,4 +1,6 @@ +import { CircleHelp } from "lucide-react"; import React, { type ReactNode } from "react"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; interface MetricCardProps { label: string; @@ -6,9 +8,10 @@ interface MetricCardProps { valueColor?: string; icon?: ReactNode; subtitle?: string; + hint?: ReactNode; } -export function MetricCard({ label, value, valueColor = "text-foreground", icon, subtitle }: MetricCardProps) { +export function MetricCard({ label, value, valueColor = "text-foreground", icon, subtitle, hint }: MetricCardProps) { return (
    @@ -17,6 +20,26 @@ export function MetricCard({ label, value, valueColor = "text-foreground", icon,
    {value}
    {subtitle &&

    {subtitle}

    } + {hint && ( + + + + + How is this calculated? + + } + /> + + {hint} + + + + )}
    ); } diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts index 29362cc3701..560010bd852 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from "vitest"; -import { counterLabel, formatCost, totalUnits, unpricedSummary } from "./usageUnits"; +import { + counterLabel, + counterMathLine, + formatCost, + formatUnitPrice, + totalUnits, + unitPrice, + unitsSumLine, + unpricedSummary, +} from "./usageUnits"; describe("formatCost", () => { it("renders a dash when nothing was priced", () => { @@ -53,3 +62,63 @@ describe("unpricedSummary", () => { expect(unpricedSummary({ someFutureCounter: 1 })).toBe("1 unit unpriced"); }); }); + +describe("unitPrice", () => { + it("backs the per-unit price out of the priced share only", () => { + expect(unitPrice({ counter: "contentPolicyUnits", units: 1200, unpriced: 200, cost: 0.15 })).toBeCloseTo( + 0.00015, + 10, + ); + }); + + it("is null when nothing was priced", () => { + expect(unitPrice({ counter: "someFutureCounter", units: 7, unpriced: 7, cost: null })).toBeNull(); + expect(unitPrice({ counter: "someFutureCounter", units: 7, unpriced: 7, cost: 0 })).toBeNull(); + }); +}); + +describe("formatUnitPrice", () => { + it("keeps the significant decimals and drops trailing zeros", () => { + expect(formatUnitPrice(0.0001)).toBe("$0.0001"); + expect(formatUnitPrice(0.00015)).toBe("$0.00015"); + expect(formatUnitPrice(0)).toBe("$0"); + expect(formatUnitPrice(1)).toBe("$1"); + }); +}); + +describe("counterMathLine", () => { + it("shows units × price = cost for a fully priced counter", () => { + expect(counterMathLine({ counter: "contentPolicyUnits", units: 1000, unpriced: 0, cost: 0.15 })).toBe( + "Content Policy: 1,000 × $0.00015 = $0.1500", + ); + }); + + it("prices only the priced share and calls out the rest", () => { + expect(counterMathLine({ counter: "sensitiveInformationPolicyUnits", units: 8, unpriced: 2, cost: 0.0006 })).toBe( + "Sensitive Information Policy: 6 × $0.0001 = $0.0006 (2 unpriced left out)", + ); + }); + + it("says so when a counter has no known price at all", () => { + expect(counterMathLine({ counter: "someFutureCounter", units: 7, unpriced: 7, cost: null })).toBe( + "Some Future Counter: 7 units with no known price, left out", + ); + expect(counterMathLine({ counter: "someFutureCounter", units: 1, unpriced: 1, cost: null })).toBe( + "Some Future Counter: 1 unit with no known price, left out", + ); + }); + + it("shows a free counter as × $0", () => { + expect(counterMathLine({ counter: "wordPolicyUnits", units: 2, unpriced: 0, cost: 0 })).toBe( + "Word Policy: 2 × $0 = $0.0000", + ); + }); +}); + +describe("unitsSumLine", () => { + it("adds the counters up in order", () => { + expect(unitsSumLine({ contentPolicyUnits: 2, topicPolicyUnits: 2, wordPolicyUnits: 1200 })).toBe( + "Content Policy 2 + Topic Policy 2 + Word Policy 1,200 = 1,204", + ); + }); +}); diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts index f3a5e9d7140..05e046aaace 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts @@ -19,3 +19,35 @@ export const unpricedSummary = (untracked: UsageUnits): string | null => { const total = totalUnits(untracked); return total > 0 ? `${total.toLocaleString()} ${total === 1 ? "unit" : "units"} unpriced` : null; }; + +export interface CounterMath { + readonly counter: string; + readonly units: number; + readonly unpriced: number; + readonly cost: number | null; +} + +export const pricedUnits = ({ units, unpriced }: Pick): number => + Math.max(units - unpriced, 0); + +export const unitPrice = (row: CounterMath): number | null => { + const priced = pricedUnits(row); + return row.cost != null && priced > 0 ? row.cost / priced : null; +}; + +export const formatUnitPrice = (price: number): string => `$${price.toFixed(6).replace(/\.?0+$/, "")}`; + +export const counterMathLine = (row: CounterMath): string => { + const label = counterLabel(row.counter); + const price = unitPrice(row); + if (price == null) { + return `${label}: ${row.units.toLocaleString()} ${row.units === 1 ? "unit" : "units"} with no known price, left out`; + } + const line = `${label}: ${pricedUnits(row).toLocaleString()} × ${formatUnitPrice(price)} = ${formatCost(row.cost)}`; + return row.unpriced > 0 ? `${line} (${row.unpriced.toLocaleString()} unpriced left out)` : line; +}; + +export const unitsSumLine = (units: UsageUnits): string => + `${Object.entries(units) + .map(([counter, n]) => `${counterLabel(counter)} ${n.toLocaleString()}`) + .join(" + ")} = ${totalUnits(units).toLocaleString()}`; From 5051e6d44acb627d912cba11b4afdc91ae5ff8c6 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 12:08:11 -0700 Subject: [PATCH 252/410] ci(e2e): include execution gate checks in code quality --- .github/workflows/test-code-quality.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/test-code-quality.yml b/.github/workflows/test-code-quality.yml index c112bf2bb22..a0e0ce6665a 100644 --- a/.github/workflows/test-code-quality.yml +++ b/.github/workflows/test-code-quality.yml @@ -74,6 +74,9 @@ jobs: - name: check_workflow_startup_safety run: uv run --no-sync python ./tests/code_coverage_tests/check_workflow_startup_safety.py + - name: test_e2e_changed_gate + run: uv run --no-sync pytest -q --noconftest -p no:cacheprovider -c /dev/null tests/code_coverage_tests/test_e2e_changed_gate.py + - name: router_code_coverage run: uv run --no-sync python ./tests/code_coverage_tests/router_code_coverage.py From 73e1cfb378e9d45c0a92266d6a763e61440cc862 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Sat, 5 Sep 2026 12:09:53 -0700 Subject: [PATCH 253/410] fix(cloudzero): infer daily batch schema from every row (#39871) * fix(cloudzero): infer daily batch schema from every row pl.DataFrame defaults to inferring column types from the first 100 rows, so a day whose batch starts with more than 100 rows missing team_alias, api_key_alias or user_email typed that column as Null and then raised a ComputeError on the first row that had a value, failing the whole export with a 500 and sending nothing. Pass infer_schema_length=None when rebuilding each day's DataFrame, the same guard the usage query already uses. * test(cloudzero): cover late tag schema inference Exercise the CloudZero resource tag field after a long run of missing values so a finite inference window fails the regression test. --- .../integrations/cloudzero/cz_stream_api.py | 6 ++++- .../cloudzero/test_cz_stream_api.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/cloudzero/cz_stream_api.py b/litellm/integrations/cloudzero/cz_stream_api.py index 1e2fa318786..2213c5fe275 100644 --- a/litellm/integrations/cloudzero/cz_stream_api.py +++ b/litellm/integrations/cloudzero/cz_stream_api.py @@ -97,7 +97,11 @@ class CloudZeroStreamer: continue # Convert lists back to DataFrames - return {date_key: pl.DataFrame(records) for date_key, records in daily_batches.items() if records} + return { + date_key: pl.DataFrame(records, infer_schema_length=None) + for date_key, records in daily_batches.items() + if records + } def _parse_and_convert_timestamp(self, timestamp_str: str) -> datetime: """Parse timestamp string and convert to UTC.""" diff --git a/tests/test_litellm/integrations/cloudzero/test_cz_stream_api.py b/tests/test_litellm/integrations/cloudzero/test_cz_stream_api.py index 1a95e45b2d5..d4e49a1252f 100644 --- a/tests/test_litellm/integrations/cloudzero/test_cz_stream_api.py +++ b/tests/test_litellm/integrations/cloudzero/test_cz_stream_api.py @@ -69,6 +69,30 @@ class TestCloudZeroStreamer: assert "2025-01-19" in result assert len(result["2025-01-19"]) == 1 + def test_group_by_date_infers_schema_from_every_row(self): + """Test daily batches retain optional string columns that are null for thousands of leading rows.""" + streamer = CloudZeroStreamer("test-key", "test-connection") + leading_nulls = 10_000 + rows = [ + {"time/usage_start": "2025-01-19T10:30:00Z", "resource/tag:team_alias": None} + for _ in range(leading_nulls) + ] + rows.append( + {"time/usage_start": "2025-01-19T10:30:00Z", "resource/tag:team_alias": "team-alias"} + ) + data = pl.DataFrame( + rows, + schema={"time/usage_start": pl.String, "resource/tag:team_alias": pl.String}, + ) + + result = streamer._group_by_date(data) + + batch = result["2025-01-19"] + assert len(batch) == leading_nulls + 1 + assert batch.schema["resource/tag:team_alias"] == pl.String + assert batch["resource/tag:team_alias"].null_count() == leading_nulls + assert batch.tail(1).item(0, "resource/tag:team_alias") == "team-alias" + def test_parse_and_convert_timestamp_utc(self): """Test _parse_and_convert_timestamp method with UTC timestamp.""" streamer = CloudZeroStreamer("test-key", "test-connection") From 877197918bfe7540e714c6ef2acfb24694df5049 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Sat, 5 Sep 2026 12:10:05 -0700 Subject: [PATCH 254/410] fix(cloudzero): preserve late resource tags (#39873) * fix(cloudzero): infer daily batch schema from every row pl.DataFrame defaults to inferring column types from the first 100 rows, so a day whose batch starts with more than 100 rows missing team_alias, api_key_alias or user_email typed that column as Null and then raised a ComputeError on the first row that had a value, failing the whole export with a 500 and sending nothing. Pass infer_schema_length=None when rebuilding each day's DataFrame, the same guard the usage query already uses. * test(cloudzero): cover late tag schema inference Exercise the CloudZero resource tag field after a long run of missing values so a finite inference window fails the regression test. * fix(cloudzero): preserve late resource tags * style(cloudzero): remove redundant test comment --- litellm/integrations/cloudzero/transform.py | 2 +- .../integrations/cloudzero/test_transform.py | 27 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/cloudzero/transform.py b/litellm/integrations/cloudzero/transform.py index ffc8fe1c1f5..12a0ee55fad 100644 --- a/litellm/integrations/cloudzero/transform.py +++ b/litellm/integrations/cloudzero/transform.py @@ -95,7 +95,7 @@ class CBFTransformer: if len(cbf_data) > 0: console.print(f"[green]✓ Successfully transformed {len(cbf_data):,} records[/green]") - return pl.DataFrame(cbf_data) + return pl.DataFrame(cbf_data, infer_schema_length=None) def _create_cbf_record(self, row: dict[str, object]) -> CBFRecord: """Create a single CBF record from LiteLLM daily spend row.""" diff --git a/tests/test_litellm/integrations/cloudzero/test_transform.py b/tests/test_litellm/integrations/cloudzero/test_transform.py index 3ec2fe6779e..cf8d70702f9 100644 --- a/tests/test_litellm/integrations/cloudzero/test_transform.py +++ b/tests/test_litellm/integrations/cloudzero/test_transform.py @@ -86,6 +86,33 @@ class TestCBFTransformer: assert result.is_empty() + def test_transform_keeps_tags_first_seen_after_row_100(self): + transformer = CBFTransformer() + teamless_rows = 101 + team_rows = 2 + total_rows = teamless_rows + team_rows + data = pl.DataFrame( + { + "date": ["2025-01-19"] * total_rows, + "successful_requests": [1] * total_rows, + "spend": [0.5] * total_rows, + "prompt_tokens": [10] * total_rows, + "completion_tokens": [5] * total_rows, + "model": ["gpt-4"] * total_rows, + "custom_llm_provider": ["openai"] * total_rows, + "api_key": ["sk-late-team"] * total_rows, + "team_id": pl.Series([None] * teamless_rows + ["team-late"] * team_rows, dtype=pl.String), + "team_alias": pl.Series([None] * teamless_rows + ["Late Team"] * team_rows, dtype=pl.String), + } + ) + + result = transformer.transform(data) + + assert len(result) == total_rows + assert "resource/tag:team_alias" in result.columns + assert result["resource/tag:team_alias"].to_list() == [None] * teamless_rows + ["Late Team"] * team_rows + assert result["resource/tag:entity_id"].to_list() == [None] * teamless_rows + ["Late Team"] * team_rows + def test_create_cbf_record(self): """Test _create_cbf_record method with valid row data.""" transformer = CBFTransformer() From b290dd410e6bb9c59fc3ac7219a3bb197cf027d8 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 12:22:52 -0700 Subject: [PATCH 255/410] feat(terraform/gcp): dependencies-only mode and bring-your-own-network for GKE (#39695) * feat(terraform/gcp): add dependencies-only mode Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(terraform/gcp): review fixes for dependencies-only mode Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .github/workflows/test-terraform-modules.yml | 31 ++++ terraform/litellm/gcp/README.md | 77 +++++++- terraform/litellm/gcp/bootstrap.tf | 6 +- terraform/litellm/gcp/cloudrun.tf | 109 +++++------ terraform/litellm/gcp/cloudsql.tf | 9 +- .../litellm/gcp/examples/default/main.tf | 5 + .../litellm/gcp/examples/default/outputs.tf | 30 +++ .../examples/default/terraform.tfvars.example | 8 + .../litellm/gcp/examples/default/variables.tf | 24 +++ terraform/litellm/gcp/iam.tf | 11 ++ terraform/litellm/gcp/load_balancer.tf | 58 ++++-- terraform/litellm/gcp/locals.tf | 5 +- terraform/litellm/gcp/network.tf | 20 +- terraform/litellm/gcp/outputs.tf | 58 ++++-- terraform/litellm/gcp/redis.tf | 4 +- .../litellm/gcp/tests/deps_only.tftest.hcl | 175 ++++++++++++++++++ terraform/litellm/gcp/variables.tf | 30 ++- 17 files changed, 540 insertions(+), 120 deletions(-) create mode 100644 terraform/litellm/gcp/tests/deps_only.tftest.hcl diff --git a/.github/workflows/test-terraform-modules.yml b/.github/workflows/test-terraform-modules.yml index 0e3e5330453..52006d9b578 100644 --- a/.github/workflows/test-terraform-modules.yml +++ b/.github/workflows/test-terraform-modules.yml @@ -4,6 +4,7 @@ on: push: paths: - "terraform/litellm/aws/**" + - "terraform/litellm/gcp/**" - ".github/workflows/test-terraform-modules.yml" pull_request: branches: @@ -13,6 +14,7 @@ on: - "litellm_**" paths: - "terraform/litellm/aws/**" + - "terraform/litellm/gcp/**" - ".github/workflows/test-terraform-modules.yml" permissions: @@ -52,3 +54,32 @@ jobs: # Plan-only, mock_provider-backed: no AWS credentials, no API calls. - name: test run: terraform test + + gcp-module: + name: fmt, validate, test (gcp) + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: terraform/litellm/gcp + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd # v3.1.2 + with: + terraform_version: 1.13.3 + terraform_wrapper: false + + - name: fmt + run: terraform fmt -recursive -check -diff + + - name: init + run: terraform init -backend=false -input=false + + - name: validate + run: terraform validate + + - name: test + run: terraform test diff --git a/terraform/litellm/gcp/README.md b/terraform/litellm/gcp/README.md index 88e9979148f..c93e5f6b303 100644 --- a/terraform/litellm/gcp/README.md +++ b/terraform/litellm/gcp/README.md @@ -392,6 +392,63 @@ with its own provider config (one `examples/default`-style root per project), or fork the module to add `configuration_aliases` and pass per-instance `providers = { ... }`. +## Dependencies only (run LiteLLM on GKE) + +Set `create_runtime = false` to provision Cloud SQL, Memorystore, GCS, +Secret Manager, and the runtime service account without Cloud Run or the +load balancer. For a Shared VPC, set the full host-project network ID and +skip PSA creation after the host project has configured it: + +```hcl +create_runtime = false +network_id = "projects//global/networks/" +create_psa_connection = false +``` + +The host project must already have Private Services Access configured on +that network and the Service Networking API enabled; the module cannot set +PSA up from a service project. GKE nodes must sit on the same Shared VPC so +the Cloud SQL and Memorystore private IPs are routable from the pods. Run +the root with its provider pointed at the project that should own the +dependencies. `create_runtime = true` with `network_id` set is also allowed, +but the Serverless VPC Access connector has to live in the same project as +the network, so that combination only works when the VPC is in the +deployment project + +Map the outputs into the Helm values as follows: + +```yaml +database: + writer: + host: + dbname: + passwordSecret: + name: + reader: + host: + dbname: + passwordSecret: + name: +redis: + host: + port: +masterKey: + secretName: +``` + +Create the database Secret with keys `username` (the `db_username` output) +and `password` (read it with `gcloud secrets versions access latest +--secret=`), and the master key Secret from +`master_key_secret_id` the same way. Memorystore only accepts TLS by +default, so store the `redis_server_ca_pem` output in a third Secret, +mount it into the gateway and backend pods via `volumes` / `volumeMounts`, +and add `REDIS_SSL=true` and `REDIS_SSL_CA_CERTS=` to each +component's `extraEnv`. Setting `redis_transit_encryption = false` removes +the CA plumbing at the cost of plaintext Redis traffic inside the VPC + +The chart's pre-install/pre-upgrade migration hook runs the Prisma +migration, so nothing replaces the Cloud Run migrations Job in this mode + ## Storage and database retention Two opt-in tripwires guard against accidental data loss on @@ -409,14 +466,15 @@ Flip `cloudsql_deletion_protection` to `false` or `gcs_force_destroy` to ## Redis encryption -Memorystore runs with `transit_encryption_mode = "SERVER_AUTHENTICATION"`, -so the proxy connects via `rediss://`. The instance's self-signed CA cert -(`server_ca_certs[0].cert`) is shipped to gateway + backend as -`REDIS_CA_PEM_B64`; their entrypoint shell decodes it to `/tmp/redis-ca.pem` -before uvicorn starts and points `REDIS_SSL_CA_CERTS` at that path. No -extra config needed — but if you ever swap Memorystore for an external -Redis, override `REDIS_HOST`/`REDIS_PORT` and either drop these env vars -or point them at your own CA. +By default, Memorystore runs with +`transit_encryption_mode = "SERVER_AUTHENTICATION"`, so Cloud Run connects +via `rediss://`. The instance's self-signed CA cert +(`server_ca_certs[0].cert`) is shipped to gateway and backend as +`REDIS_CA_PEM_B64`; their entrypoint shell decodes it to +`/tmp/redis-ca.pem` before uvicorn starts and points `REDIS_SSL_CA_CERTS` at +that path. Set `redis_transit_encryption = false` to use plaintext Redis. +For GKE, use `redis_server_ca_pem` as described in the dependencies-only +section, or accept the security tradeoff of disabling transit encryption ## Files @@ -434,4 +492,5 @@ or point them at your own CA. | `iam.tf` | Runtime SA + Cloud SQL client + Secret Manager accessor | | `cloudrun.tf` | 3 Cloud Run services + Cloud Run Job for migrations | | `load_balancer.tf`| External HTTPS LB, serverless NEGs, URL map for path routing | -| `outputs.tf` | LB IP, service URLs, secret IDs, migration `execute` command | +| `outputs.tf` | LB IP, service URLs, dependency endpoints, secret IDs, migration command | +| `tests/` | Plan-only mock-provider coverage for deployment modes and Redis encryption | diff --git a/terraform/litellm/gcp/bootstrap.tf b/terraform/litellm/gcp/bootstrap.tf index b929c4d76f3..dead5c41f6b 100644 --- a/terraform/litellm/gcp/bootstrap.tf +++ b/terraform/litellm/gcp/bootstrap.tf @@ -15,15 +15,17 @@ # enough to invoke Cloud Run admin APIs (`gcloud auth login`). resource "terraform_data" "migration" { + count = var.create_runtime ? 1 : 0 + triggers_replace = { - job_id = google_cloud_run_v2_job.migrations.id + job_id = google_cloud_run_v2_job.migrations[0].id job_image = local.migrations_image } provisioner "local-exec" { interpreter = ["bash", "-c"] environment = { - JOB = google_cloud_run_v2_job.migrations.name + JOB = google_cloud_run_v2_job.migrations[0].name REGION = var.region PROJECT = var.project_id } diff --git a/terraform/litellm/gcp/cloudrun.tf b/terraform/litellm/gcp/cloudrun.tf index 5a5c361b832..84ae8b9247f 100644 --- a/terraform/litellm/gcp/cloudrun.tf +++ b/terraform/litellm/gcp/cloudrun.tf @@ -6,25 +6,28 @@ locals { # Memorystore exposes a self-signed CA cert per instance; we ship it as # a base64 env var and decode it to a file at container startup so the # rediss:// connection can validate. Public cert, not sensitive. - redis_ca_pem_b64 = base64encode(google_redis_instance.this.server_ca_certs[0].cert) + redis_ca_pem_b64 = var.redis_transit_encryption ? base64encode(google_redis_instance.this.server_ca_certs[0].cert) : "" - shared_env_kv = [ - { name = "DATABASE_HOST", value = google_sql_database_instance.writer.private_ip_address }, - { name = "DATABASE_PORT", value = "5432" }, - { name = "DATABASE_USER", value = var.db_username }, - { name = "DATABASE_NAME", value = var.db_name }, - { name = "DATABASE_HOST_READ_REPLICA", value = google_sql_database_instance.reader.private_ip_address }, - { name = "DATABASE_PORT_READ_REPLICA", value = "5432" }, - { name = "REDIS_HOST", value = google_redis_instance.this.host }, - { name = "REDIS_PORT", value = tostring(google_redis_instance.this.port) }, - # _redis.get_redis_url_from_environment honors REDIS_SSL to flip the - # scheme to rediss://; REDIS_SSL_CA_CERTS is mapped via - # _get_redis_env_kwarg_mapping → ssl_ca_certs on the redis-py client. - { name = "REDIS_SSL", value = "true" }, - { name = "REDIS_SSL_CA_CERTS", value = "/tmp/redis-ca.pem" }, - { name = "REDIS_CA_PEM_B64", value = local.redis_ca_pem_b64 }, - { name = "GCS_BUCKET_NAME", value = google_storage_bucket.this.name }, - ] + shared_env_kv = concat( + [ + { name = "DATABASE_HOST", value = google_sql_database_instance.writer.private_ip_address }, + { name = "DATABASE_PORT", value = "5432" }, + { name = "DATABASE_USER", value = var.db_username }, + { name = "DATABASE_NAME", value = var.db_name }, + { name = "DATABASE_HOST_READ_REPLICA", value = google_sql_database_instance.reader.private_ip_address }, + { name = "DATABASE_PORT_READ_REPLICA", value = "5432" }, + { name = "REDIS_HOST", value = google_redis_instance.this.host }, + { name = "REDIS_PORT", value = tostring(google_redis_instance.this.port) }, + ], + var.redis_transit_encryption ? [ + { name = "REDIS_SSL", value = "true" }, + { name = "REDIS_SSL_CA_CERTS", value = "/tmp/redis-ca.pem" }, + { name = "REDIS_CA_PEM_B64", value = local.redis_ca_pem_b64 }, + ] : [], + [ + { name = "GCS_BUCKET_NAME", value = google_storage_bucket.this.name }, + ], + ) # OTel v2 is opt-in and gated on otel_endpoint, matching the AWS stack — # nothing OTel-related is added to the container env until an endpoint is @@ -126,9 +129,9 @@ locals { # Decode the Memorystore CA cert (passed as REDIS_CA_PEM_B64) to the # path REDIS_SSL_CA_CERTS points at, so the redis-py client can validate # the rediss:// handshake. - redis_ca_fragment = [ + redis_ca_fragment = var.redis_transit_encryption ? [ "python -c \"import os, base64, pathlib; pathlib.Path(os.environ['REDIS_SSL_CA_CERTS']).write_bytes(base64.b64decode(os.environ['REDIS_CA_PEM_B64']))\"" - ] + ] : [] database_url_fragment = [ "export DATABASE_URL=\"postgresql://$${DATABASE_USER}:$${DATABASE_PASSWORD}@$${DATABASE_HOST}:$${DATABASE_PORT}/$${DATABASE_NAME}\"", @@ -171,29 +174,7 @@ locals { # ---------- Gateway ---------- resource "google_cloud_run_v2_service" "gateway" { - # Metering needs a client certificate AND its key. Each secret is created only - # when its own PEM is supplied, so an endpoint set with a missing key would - # otherwise apply cleanly and leave the proxy logging "missing config" and - # never exporting. ca_cert_pem stays optional: empty means fall back to the - # system trust store. - # - # The guard lives here, on an unconditional resource, rather than on the cert - # secret: that secret is count-gated on the cert itself, so it has zero - # instances in exactly the case this must catch. Adding count or for_each to - # this resource would silently stop the guard from evaluating. - # - # endpoint cert key -> result - # "" any any -> metering off, no secrets created - # set set set -> metering on - # set any-missing -> plan fails here - lifecycle { - precondition { - condition = var.billing_metrics_endpoint == "" || ( - var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" - ) - error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set." - } - } + count = var.create_runtime ? 1 : 0 name = "${local.name}-gateway" location = var.region @@ -206,7 +187,7 @@ resource "google_cloud_run_v2_service" "gateway" { max_instance_request_concurrency = var.gateway_max_instance_request_concurrency vpc_access { - connector = google_vpc_access_connector.this.id + connector = google_vpc_access_connector.this[0].id egress = "PRIVATE_RANGES_ONLY" } @@ -312,17 +293,7 @@ resource "google_cloud_run_v2_service" "gateway" { # ---------- Backend ---------- resource "google_cloud_run_v2_service" "backend" { - # Same guard as the gateway: the backend meters too (it serves the named-server - # MCP transport), and a targeted apply of just this resource must not slip a - # billing endpoint through without the credentials to use it. - lifecycle { - precondition { - condition = var.billing_metrics_endpoint == "" || ( - var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" - ) - error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set." - } - } + count = var.create_runtime ? 1 : 0 name = "${local.name}-backend" location = var.region @@ -335,7 +306,7 @@ resource "google_cloud_run_v2_service" "backend" { max_instance_request_concurrency = var.backend_max_instance_request_concurrency vpc_access { - connector = google_vpc_access_connector.this.id + connector = google_vpc_access_connector.this[0].id egress = "PRIVATE_RANGES_ONLY" } @@ -443,6 +414,8 @@ resource "google_cloud_run_v2_service" "backend" { # with zero IAM bindings, so a compromised UI container can't pivot to # Secret Manager / Cloud SQL via the metadata service. resource "google_cloud_run_v2_service" "ui" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-ui" location = var.region ingress = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER" @@ -450,7 +423,7 @@ resource "google_cloud_run_v2_service" "ui" { deletion_protection = false template { - service_account = google_service_account.ui_runtime.email + service_account = google_service_account.ui_runtime[0].email max_instance_request_concurrency = var.ui_max_instance_request_concurrency scaling { @@ -491,25 +464,31 @@ resource "google_cloud_run_v2_service" "ui" { # (LITELLM_MASTER_KEY); these IAM bindings just open up Cloud Run's invoker # gate so the LB request makes it to the container. resource "google_cloud_run_v2_service_iam_member" "gateway_allusers" { + count = var.create_runtime ? 1 : 0 + project = var.project_id - location = google_cloud_run_v2_service.gateway.location - name = google_cloud_run_v2_service.gateway.name + location = google_cloud_run_v2_service.gateway[0].location + name = google_cloud_run_v2_service.gateway[0].name role = "roles/run.invoker" member = "allUsers" } resource "google_cloud_run_v2_service_iam_member" "backend_allusers" { + count = var.create_runtime ? 1 : 0 + project = var.project_id - location = google_cloud_run_v2_service.backend.location - name = google_cloud_run_v2_service.backend.name + location = google_cloud_run_v2_service.backend[0].location + name = google_cloud_run_v2_service.backend[0].name role = "roles/run.invoker" member = "allUsers" } resource "google_cloud_run_v2_service_iam_member" "ui_allusers" { + count = var.create_runtime ? 1 : 0 + project = var.project_id - location = google_cloud_run_v2_service.ui.location - name = google_cloud_run_v2_service.ui.name + location = google_cloud_run_v2_service.ui[0].location + name = google_cloud_run_v2_service.ui[0].name role = "roles/run.invoker" member = "allUsers" } @@ -519,6 +498,8 @@ resource "google_cloud_run_v2_service_iam_member" "ui_allusers" { # assembles DATABASE_URL from the DATABASE_* env vars and runs `prisma # migrate deploy`. No proxy_config, no master key, no shell wrapper. resource "google_cloud_run_v2_job" "migrations" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-migrations" location = var.region labels = local.labels @@ -529,7 +510,7 @@ resource "google_cloud_run_v2_job" "migrations" { service_account = google_service_account.runtime.email vpc_access { - connector = google_vpc_access_connector.this.id + connector = google_vpc_access_connector.this[0].id egress = "PRIVATE_RANGES_ONLY" } diff --git a/terraform/litellm/gcp/cloudsql.tf b/terraform/litellm/gcp/cloudsql.tf index c9c2d03b2de..777434b4727 100644 --- a/terraform/litellm/gcp/cloudsql.tf +++ b/terraform/litellm/gcp/cloudsql.tf @@ -36,7 +36,7 @@ resource "google_sql_database_instance" "writer" { ip_configuration { ipv4_enabled = false - private_network = google_compute_network.this.id + private_network = local.network_id } insights_config { @@ -55,6 +55,11 @@ resource "google_sql_database_instance" "writer" { # (full data loss). Set the initial size only; let Cloud SQL own it # thereafter. ignore_changes = [settings[0].disk_size] + + precondition { + condition = var.create_psa_connection || var.network_id != "" + error_message = "create_psa_connection must be true unless network_id references an existing VPC with Private Services Access configured." + } } } @@ -76,7 +81,7 @@ resource "google_sql_database_instance" "reader" { ip_configuration { ipv4_enabled = false - private_network = google_compute_network.this.id + private_network = local.network_id } } diff --git a/terraform/litellm/gcp/examples/default/main.tf b/terraform/litellm/gcp/examples/default/main.tf index 8760d445f0c..f44b2a9a001 100644 --- a/terraform/litellm/gcp/examples/default/main.tf +++ b/terraform/litellm/gcp/examples/default/main.tf @@ -31,6 +31,11 @@ module "litellm" { tenant = var.tenant env = var.env + create_runtime = var.create_runtime + network_id = var.network_id + create_psa_connection = var.create_psa_connection + redis_transit_encryption = var.redis_transit_encryption + litellm_master_key = var.litellm_master_key litellm_license = var.litellm_license ui_password = var.ui_password diff --git a/terraform/litellm/gcp/examples/default/outputs.tf b/terraform/litellm/gcp/examples/default/outputs.tf index 3a9343c4850..48cdc1af66e 100644 --- a/terraform/litellm/gcp/examples/default/outputs.tf +++ b/terraform/litellm/gcp/examples/default/outputs.tf @@ -38,6 +38,31 @@ output "redis_endpoint" { value = module.litellm.redis_endpoint } +output "redis_host" { + description = "Memorystore Redis host." + value = module.litellm.redis_host +} + +output "redis_port" { + description = "Memorystore Redis port." + value = module.litellm.redis_port +} + +output "redis_server_ca_pem" { + description = "Memorystore server CA PEM." + value = module.litellm.redis_server_ca_pem +} + +output "db_username" { + description = "Cloud SQL application username." + value = module.litellm.db_username +} + +output "db_name" { + description = "Cloud SQL database name." + value = module.litellm.db_name +} + output "gcs_bucket" { description = "GCS bucket name." value = module.litellm.gcs_bucket @@ -53,6 +78,11 @@ output "db_password_secret_id" { value = module.litellm.db_password_secret_id } +output "runtime_service_account_email" { + description = "Runtime service account email." + value = module.litellm.runtime_service_account_email +} + output "migration_run_command" { description = "Break-glass command to re-run the one-off migration job." value = module.litellm.migration_run_command diff --git a/terraform/litellm/gcp/examples/default/terraform.tfvars.example b/terraform/litellm/gcp/examples/default/terraform.tfvars.example index 4416cf0ee5d..c35206503bb 100644 --- a/terraform/litellm/gcp/examples/default/terraform.tfvars.example +++ b/terraform/litellm/gcp/examples/default/terraform.tfvars.example @@ -8,6 +8,14 @@ region = "us-central1" tenant = "acme" env = "stage" +# Deployment mode. For dependencies only on a Shared VPC, set +# create_runtime = false, network_id to the full host-project network ID, and +# create_psa_connection = false after configuring PSA on that network. +# create_runtime = true +# network_id = "" +# create_psa_connection = true +# redis_transit_encryption = true + # Tenant-supplied secrets. Prefer TF_VAR_litellm_master_key / # TF_VAR_litellm_license / TF_VAR_ui_password env vars so the values don't # end up in a committed tfvars file. All three are optional — when diff --git a/terraform/litellm/gcp/examples/default/variables.tf b/terraform/litellm/gcp/examples/default/variables.tf index 56e5ec88ef8..88b57ce27eb 100644 --- a/terraform/litellm/gcp/examples/default/variables.tf +++ b/terraform/litellm/gcp/examples/default/variables.tf @@ -26,6 +26,30 @@ variable "env" { type = string } +variable "create_runtime" { + description = "Create Cloud Run and load balancer resources." + type = bool + default = true +} + +variable "network_id" { + description = "Existing VPC network resource ID. Empty creates a VPC." + type = string + default = "" +} + +variable "create_psa_connection" { + description = "Create Private Services Access resources." + type = bool + default = true +} + +variable "redis_transit_encryption" { + description = "Enable Memorystore transit encryption." + type = bool + default = true +} + # Sensitive — prefer TF_VAR_litellm_master_key / TF_VAR_litellm_license / # TF_VAR_ui_password so values stay out of any committed tfvars file. variable "litellm_master_key" { diff --git a/terraform/litellm/gcp/iam.tf b/terraform/litellm/gcp/iam.tf index 09df5e7dff0..509e6d48ffd 100644 --- a/terraform/litellm/gcp/iam.tf +++ b/terraform/litellm/gcp/iam.tf @@ -6,6 +6,15 @@ resource "google_service_account" "runtime" { account_id = "${local.name}-runtime" display_name = "LiteLLM Cloud Run runtime" + + lifecycle { + precondition { + condition = !var.create_runtime || var.billing_metrics_endpoint == "" || ( + var.billing_metrics_client_cert_pem != "" && var.billing_metrics_client_key_pem != "" + ) + error_message = "billing_metrics_client_cert_pem and billing_metrics_client_key_pem are both required when billing_metrics_endpoint is set and create_runtime is true." + } + } } # UI runtime SA — no role bindings. The UI is static nginx with no DB, @@ -14,6 +23,8 @@ resource "google_service_account" "runtime" { # project's serverless service agent (not this SA), so it doesn't need # artifactregistry.reader either. resource "google_service_account" "ui_runtime" { + count = var.create_runtime ? 1 : 0 + account_id = "${local.name}-ui-runtime" display_name = "LiteLLM Cloud Run UI runtime (no data-plane access)" } diff --git a/terraform/litellm/gcp/load_balancer.tf b/terraform/litellm/gcp/load_balancer.tf index 11f30d0f944..57e8af8210f 100644 --- a/terraform/litellm/gcp/load_balancer.tf +++ b/terraform/litellm/gcp/load_balancer.tf @@ -14,77 +14,93 @@ locals { } resource "google_compute_global_address" "lb" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-lb-ip" labels = local.labels } # Serverless NEGs — one per Cloud Run service. resource "google_compute_region_network_endpoint_group" "gateway" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-gateway-neg" region = var.region network_endpoint_type = "SERVERLESS" cloud_run { - service = google_cloud_run_v2_service.gateway.name + service = google_cloud_run_v2_service.gateway[0].name } } resource "google_compute_region_network_endpoint_group" "backend" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-backend-neg" region = var.region network_endpoint_type = "SERVERLESS" cloud_run { - service = google_cloud_run_v2_service.backend.name + service = google_cloud_run_v2_service.backend[0].name } } resource "google_compute_region_network_endpoint_group" "ui" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-ui-neg" region = var.region network_endpoint_type = "SERVERLESS" cloud_run { - service = google_cloud_run_v2_service.ui.name + service = google_cloud_run_v2_service.ui[0].name } } # Backend services wrap each NEG. resource "google_compute_backend_service" "gateway" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-gateway-bs" protocol = "HTTP" load_balancing_scheme = "EXTERNAL_MANAGED" backend { - group = google_compute_region_network_endpoint_group.gateway.id + group = google_compute_region_network_endpoint_group.gateway[0].id } } resource "google_compute_backend_service" "backend" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-backend-bs" protocol = "HTTP" load_balancing_scheme = "EXTERNAL_MANAGED" backend { - group = google_compute_region_network_endpoint_group.backend.id + group = google_compute_region_network_endpoint_group.backend[0].id } } resource "google_compute_backend_service" "ui" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-ui-bs" protocol = "HTTP" load_balancing_scheme = "EXTERNAL_MANAGED" backend { - group = google_compute_region_network_endpoint_group.ui.id + group = google_compute_region_network_endpoint_group.ui[0].id } } # URL map. Default → backend (management API). Path matchers route the # gateway and UI prefixes elsewhere. resource "google_compute_url_map" "this" { + count = var.create_runtime ? 1 : 0 + name = local.name - default_service = google_compute_backend_service.backend.id + default_service = google_compute_backend_service.backend[0].id host_rule { hosts = ["*"] @@ -93,13 +109,13 @@ resource "google_compute_url_map" "this" { path_matcher { name = "main" - default_service = google_compute_backend_service.backend.id + default_service = google_compute_backend_service.backend[0].id # UI paths (catch them before any /v1/* gateway rules so /favicon.ico # and / take precedence). path_rule { paths = local.ui_path_prefixes - service = google_compute_backend_service.ui.id + service = google_compute_backend_service.ui[0].id } # Gateway path prefixes. GCP URL maps cap a path_rule at 10 path globs, @@ -108,7 +124,7 @@ resource "google_compute_url_map" "this" { for_each = { for idx, chunk in chunklist(local.gateway_path_prefixes, 10) : idx => chunk } content { paths = path_rule.value - service = google_compute_backend_service.gateway.id + service = google_compute_backend_service.gateway[0].id } } } @@ -118,7 +134,7 @@ resource "google_compute_url_map" "this" { # target proxy when TLS is enabled; otherwise the regular path-routing # URL map is attached to the HTTP proxy and everything stays plaintext. resource "google_compute_url_map" "https_redirect" { - count = local.tls_enabled ? 1 : 0 + count = var.create_runtime && local.tls_enabled ? 1 : 0 name = "${local.name}-redirect" default_url_redirect { @@ -129,8 +145,10 @@ resource "google_compute_url_map" "https_redirect" { } resource "google_compute_target_http_proxy" "this" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-http" - url_map = local.tls_enabled ? google_compute_url_map.https_redirect[0].id : google_compute_url_map.this.id + url_map = local.tls_enabled ? google_compute_url_map.https_redirect[0].id : google_compute_url_map.this[0].id # Default-deny on the HTTP-only path: TLS is the supported posture. # Operators must either supply DNS names or explicitly opt in. @@ -143,12 +161,14 @@ resource "google_compute_target_http_proxy" "this" { } resource "google_compute_global_forwarding_rule" "http" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-http" ip_protocol = "TCP" port_range = "80" load_balancing_scheme = "EXTERNAL_MANAGED" - ip_address = google_compute_global_address.lb.address - target = google_compute_target_http_proxy.this.id + ip_address = google_compute_global_address.lb[0].address + target = google_compute_target_http_proxy.this[0].id labels = local.labels } @@ -161,7 +181,7 @@ resource "google_compute_global_forwarding_rule" "http" { # transitions to ACTIVE. resource "google_compute_managed_ssl_certificate" "this" { - count = local.tls_enabled ? 1 : 0 + count = var.create_runtime && local.tls_enabled ? 1 : 0 # A managed cert's `domains` is immutable, so changing var.lb_domains # forces replacement, and the cert is referenced by the HTTPS target @@ -181,19 +201,19 @@ resource "google_compute_managed_ssl_certificate" "this" { } resource "google_compute_target_https_proxy" "this" { - count = local.tls_enabled ? 1 : 0 + count = var.create_runtime && local.tls_enabled ? 1 : 0 name = "${local.name}-https" - url_map = google_compute_url_map.this.id + url_map = google_compute_url_map.this[0].id ssl_certificates = [google_compute_managed_ssl_certificate.this[0].id] } resource "google_compute_global_forwarding_rule" "https" { - count = local.tls_enabled ? 1 : 0 + count = var.create_runtime && local.tls_enabled ? 1 : 0 name = "${local.name}-https" ip_protocol = "TCP" port_range = "443" load_balancing_scheme = "EXTERNAL_MANAGED" - ip_address = google_compute_global_address.lb.address + ip_address = google_compute_global_address.lb[0].address target = google_compute_target_https_proxy.this[0].id labels = local.labels } diff --git a/terraform/litellm/gcp/locals.tf b/terraform/litellm/gcp/locals.tf index 9a817eba605..3861413d496 100644 --- a/terraform/litellm/gcp/locals.tf +++ b/terraform/litellm/gcp/locals.tf @@ -21,6 +21,9 @@ locals { var.labels, ) + create_network = var.network_id == "" + network_id = local.create_network ? google_compute_network.this[0].id : var.network_id + gateway_path_prefixes = [ "/v1/chat/*", "/chat/*", "/v1/completions*", "/completions*", @@ -74,7 +77,7 @@ locals { "/ui/*", ] - proxy_config_enabled = length(keys(var.proxy_config)) > 0 + proxy_config_enabled = var.create_runtime && length(keys(var.proxy_config)) > 0 proxy_config_yaml = local.proxy_config_enabled ? yamlencode(var.proxy_config) : "" proxy_config_mount_path = "/etc/litellm" diff --git a/terraform/litellm/gcp/network.tf b/terraform/litellm/gcp/network.tf index a1ccaed02f9..47c7bf94a2b 100644 --- a/terraform/litellm/gcp/network.tf +++ b/terraform/litellm/gcp/network.tf @@ -1,13 +1,17 @@ resource "google_compute_network" "this" { + count = local.create_network ? 1 : 0 + name = local.name auto_create_subnetworks = false routing_mode = "REGIONAL" } resource "google_compute_subnetwork" "this" { + count = local.create_network ? 1 : 0 + name = "${local.name}-${var.region}" region = var.region - network = google_compute_network.this.id + network = google_compute_network.this[0].id ip_cidr_range = var.subnet_cidr private_ip_google_access = true } @@ -16,17 +20,21 @@ resource "google_compute_subnetwork" "this" { # managed services peer with the VPC over the connection below using # addresses from this range. resource "google_compute_global_address" "psa" { + count = var.create_psa_connection ? 1 : 0 + name = "${local.name}-psa" purpose = "VPC_PEERING" address_type = "INTERNAL" prefix_length = 16 - network = google_compute_network.this.id + network = local.network_id } resource "google_service_networking_connection" "psa" { - network = google_compute_network.this.id + count = var.create_psa_connection ? 1 : 0 + + network = local.network_id service = "servicenetworking.googleapis.com" - reserved_peering_ranges = [google_compute_global_address.psa.name] + reserved_peering_ranges = [google_compute_global_address.psa[0].name] } # Serverless VPC Access connector — required so Cloud Run can reach @@ -37,9 +45,11 @@ resource "google_service_networking_connection" "psa" { # for low-to-moderate Cloud Run egress; bump max if your services push # heavy private-network traffic. resource "google_vpc_access_connector" "this" { + count = var.create_runtime ? 1 : 0 + name = "${local.name}-conn" region = var.region - network = google_compute_network.this.name + network = local.network_id ip_cidr_range = var.vpc_connector_cidr min_instances = 2 max_instances = 3 diff --git a/terraform/litellm/gcp/outputs.tf b/terraform/litellm/gcp/outputs.tf index 6f1f1d5ccf4..2a4742f42cf 100644 --- a/terraform/litellm/gcp/outputs.tf +++ b/terraform/litellm/gcp/outputs.tf @@ -1,26 +1,26 @@ output "lb_ip" { - description = "Global anycast IP of the external HTTPS load balancer." - value = google_compute_global_address.lb.address + description = "Global anycast IP of the external HTTPS load balancer. Null when create_runtime is false." + value = var.create_runtime ? one(google_compute_global_address.lb[*].address) : null } output "lb_url" { - description = "Proxy URL. Switches scheme based on whether lb_domains is set; when TLS is enabled the URL points at the first listed domain (since managed certs are tied to the hostname, not the anycast IP). The dashboard is served at /, the API at /v1/*." - value = local.tls_enabled ? "https://${var.lb_domains[0]}" : "http://${google_compute_global_address.lb.address}" + description = "Proxy URL, or null when create_runtime is false. Switches scheme based on whether lb_domains is set." + value = var.create_runtime ? (local.tls_enabled ? "https://${var.lb_domains[0]}" : "http://${one(google_compute_global_address.lb[*].address)}") : null } output "gateway_service_url" { - description = "Default Cloud Run URL for the gateway (bypasses the LB)." - value = google_cloud_run_v2_service.gateway.uri + description = "Default Cloud Run URL for the gateway, or null when create_runtime is false." + value = var.create_runtime ? one(google_cloud_run_v2_service.gateway[*].uri) : null } output "backend_service_url" { - description = "Default Cloud Run URL for the backend (bypasses the LB)." - value = google_cloud_run_v2_service.backend.uri + description = "Default Cloud Run URL for the backend, or null when create_runtime is false." + value = var.create_runtime ? one(google_cloud_run_v2_service.backend[*].uri) : null } output "ui_service_url" { - description = "Default Cloud Run URL for the UI (bypasses the LB)." - value = google_cloud_run_v2_service.ui.uri + description = "Default Cloud Run URL for the UI, or null when create_runtime is false." + value = var.create_runtime ? one(google_cloud_run_v2_service.ui[*].uri) : null } output "cloudsql_writer_ip" { @@ -38,6 +38,36 @@ output "redis_endpoint" { value = "${google_redis_instance.this.host}:${google_redis_instance.this.port}" } +output "runtime_service_account_email" { + description = "Runtime service account email for Cloud Run or GKE Workload Identity." + value = google_service_account.runtime.email +} + +output "redis_host" { + description = "Memorystore Redis host." + value = google_redis_instance.this.host +} + +output "redis_port" { + description = "Memorystore Redis port." + value = google_redis_instance.this.port +} + +output "redis_server_ca_pem" { + description = "Memorystore server CA PEM. Mount it in the pod and set REDIS_SSL=true and REDIS_SSL_CA_CERTS= via extraEnv when transit encryption is enabled." + value = var.redis_transit_encryption ? google_redis_instance.this.server_ca_certs[0].cert : null +} + +output "db_username" { + description = "Cloud SQL application username." + value = var.db_username +} + +output "db_name" { + description = "Cloud SQL database name." + value = var.db_name +} + output "gcs_bucket" { description = "GCS bucket name. Exposed to gateway + backend as GCS_BUCKET_NAME. Reference from proxy_config via `os.environ/GCS_BUCKET_NAME`." value = google_storage_bucket.this.name @@ -54,11 +84,11 @@ output "db_password_secret_id" { } output "migration_run_command" { - description = "Shell command that executes the one-off migration job against Cloud SQL. Run this once after the first apply." - value = format( + description = "Shell command that executes the one-off migration job against Cloud SQL, or null when create_runtime is false." + value = var.create_runtime ? format( "gcloud run jobs execute %s --region %s --project %s --wait", - google_cloud_run_v2_job.migrations.name, + one(google_cloud_run_v2_job.migrations[*].name), var.region, var.project_id, - ) + ) : null } diff --git a/terraform/litellm/gcp/redis.tf b/terraform/litellm/gcp/redis.tf index 0e07c416e85..0602758f090 100644 --- a/terraform/litellm/gcp/redis.tf +++ b/terraform/litellm/gcp/redis.tf @@ -4,7 +4,7 @@ resource "google_redis_instance" "this" { memory_size_gb = var.redis_memory_size_gb region = var.region - authorized_network = google_compute_network.this.id + authorized_network = local.network_id connect_mode = "PRIVATE_SERVICE_ACCESS" redis_version = "REDIS_7_0" @@ -16,7 +16,7 @@ resource "google_redis_instance" "this" { # and passed to the proxy as REDIS_CA_PEM_B64); the proxy decodes it to # /tmp/redis-ca.pem at startup and uses it to validate the rediss:// # handshake. Mirrors `transit_encryption_enabled = true` on AWS. - transit_encryption_mode = "SERVER_AUTHENTICATION" + transit_encryption_mode = var.redis_transit_encryption ? "SERVER_AUTHENTICATION" : "DISABLED" depends_on = [google_service_networking_connection.psa] } diff --git a/terraform/litellm/gcp/tests/deps_only.tftest.hcl b/terraform/litellm/gcp/tests/deps_only.tftest.hcl new file mode 100644 index 00000000000..610c49d5b52 --- /dev/null +++ b/terraform/litellm/gcp/tests/deps_only.tftest.hcl @@ -0,0 +1,175 @@ +mock_provider "google" { + mock_resource "google_redis_instance" { + defaults = { + host = "10.0.0.4" + port = 6379 + server_ca_certs = [{ + cert = "-----BEGIN CERTIFICATE-----\nmock\n-----END CERTIFICATE-----" + }] + } + } +} + +mock_provider "google-beta" {} +mock_provider "random" {} + +variables { + project_id = "test-project" + tenant = "tenant" + env = "test" + allow_plaintext_lb = true + image_registry = "us-central1-docker.pkg.dev/test-project/litellm" +} + +run "default_creates_everything" { + command = plan + + assert { + condition = alltrue([ + length(google_compute_network.this) == 1, + length(google_compute_subnetwork.this) == 1, + length(google_compute_global_address.psa) == 1, + length(google_service_networking_connection.psa) == 1, + length(google_vpc_access_connector.this) == 1, + length(google_cloud_run_v2_service.gateway) == 1, + length(google_cloud_run_v2_service.backend) == 1, + length(google_cloud_run_v2_service.ui) == 1, + length(google_cloud_run_v2_job.migrations) == 1, + length(google_compute_global_address.lb) == 1, + length(terraform_data.migration) == 1, + ]) + error_message = "The default mode must create networking, runtime services, the load balancer, and migrations." + } + + assert { + condition = google_redis_instance.this.transit_encryption_mode == "SERVER_AUTHENTICATION" + error_message = "Redis transit encryption must remain enabled by default." + } + + assert { + condition = length(local.shared_env_kv) == 12 + error_message = "The default runtime environment must include GCS and the three Redis TLS entries." + } +} + +run "deps_only_creates_no_runtime" { + command = plan + + variables { + create_runtime = false + proxy_config = { + model_list = [] + } + } + + assert { + condition = alltrue([ + length(google_cloud_run_v2_service.gateway) == 0, + length(google_cloud_run_v2_service.backend) == 0, + length(google_cloud_run_v2_service.ui) == 0, + length(google_cloud_run_v2_job.migrations) == 0, + length(google_cloud_run_v2_service_iam_member.gateway_allusers) == 0, + length(google_cloud_run_v2_service_iam_member.backend_allusers) == 0, + length(google_cloud_run_v2_service_iam_member.ui_allusers) == 0, + length(google_compute_global_address.lb) == 0, + length(google_compute_region_network_endpoint_group.gateway) == 0, + length(google_compute_region_network_endpoint_group.backend) == 0, + length(google_compute_region_network_endpoint_group.ui) == 0, + length(google_compute_backend_service.gateway) == 0, + length(google_compute_backend_service.backend) == 0, + length(google_compute_backend_service.ui) == 0, + length(google_compute_url_map.this) == 0, + length(google_compute_url_map.https_redirect) == 0, + length(google_compute_target_http_proxy.this) == 0, + length(google_compute_global_forwarding_rule.http) == 0, + length(google_compute_managed_ssl_certificate.this) == 0, + length(google_compute_target_https_proxy.this) == 0, + length(google_compute_global_forwarding_rule.https) == 0, + length(terraform_data.migration) == 0, + length(google_vpc_access_connector.this) == 0, + length(google_service_account.ui_runtime) == 0, + length(google_storage_bucket.proxy_config) == 0, + ]) + error_message = "Dependencies-only mode must omit all runtime, load balancer, connector, UI identity, and proxy config resources." + } + + assert { + condition = alltrue([ + google_sql_database_instance.writer.name == "tenant-litellm-test", + google_sql_database_instance.reader.name == "tenant-litellm-test-reader", + google_redis_instance.this.name == "tenant-litellm-test", + google_storage_bucket.this.force_destroy == false, + google_secret_manager_secret.master_key.secret_id == "tenant-litellm-test-master-key", + google_secret_manager_secret.db_password.secret_id == "tenant-litellm-test-db-password", + google_service_account.runtime.account_id == "tenant-litellm-test-runtime", + ]) + error_message = "Dependencies-only mode must retain data stores, secrets, and the runtime service account." + } + + assert { + condition = output.lb_url == null && output.migration_run_command == null + error_message = "Runtime outputs must be null while dependency outputs remain available." + } +} + +run "existing_network_attaches_data_stores" { + command = plan + + variables { + network_id = "projects/host-proj/global/networks/shared" + create_psa_connection = false + create_runtime = false + } + + assert { + condition = alltrue([ + length(google_compute_network.this) == 0, + length(google_compute_subnetwork.this) == 0, + length(google_compute_global_address.psa) == 0, + length(google_service_networking_connection.psa) == 0, + google_sql_database_instance.writer.settings[0].ip_configuration[0].private_network == var.network_id, + google_redis_instance.this.authorized_network == var.network_id, + ]) + error_message = "An existing VPC must receive the Cloud SQL and Memorystore private-network attachments." + } +} + +run "psa_required_without_existing_network" { + command = plan + + variables { + create_psa_connection = false + } + + expect_failures = [ + google_sql_database_instance.writer, + ] +} + +run "redis_plaintext_drops_tls_env" { + command = plan + + variables { + redis_transit_encryption = false + } + + assert { + condition = google_redis_instance.this.transit_encryption_mode == "DISABLED" + error_message = "Redis transit encryption must be disabled when requested." + } + + assert { + condition = length(local.shared_env_kv) == 9 + error_message = "Plaintext Redis mode must include GCS and omit the three Redis TLS entries." + } + + assert { + condition = length([for env in local.shared_env_kv : env if env.name == "REDIS_SSL"]) == 0 + error_message = "Plaintext Redis mode must not set REDIS_SSL." + } + + assert { + condition = length(local.redis_ca_fragment) == 0 + error_message = "Plaintext Redis mode must not decode a Redis CA at startup." + } +} diff --git a/terraform/litellm/gcp/variables.tf b/terraform/litellm/gcp/variables.tf index 1162e100bb2..9c68ed3db76 100644 --- a/terraform/litellm/gcp/variables.tf +++ b/terraform/litellm/gcp/variables.tf @@ -79,16 +79,42 @@ variable "ui_password" { sensitive = true } +# ---------- Deployment mode ---------- + +variable "create_runtime" { + description = "Create Cloud Run, load balancer, VPC connector, runtime support resources, and the migration job. Set false for GKE or another external runtime." + type = bool + default = true +} + +variable "network_id" { + description = "Existing VPC network resource ID (`projects//global/networks/`). When set, no VPC or subnet is created. A VPC connector requires this network to be in the deployment project when create_runtime is true." + type = string + default = "" +} + +variable "create_psa_connection" { + description = "Create the Private Services Access range and connection for Cloud SQL and Memorystore. Set false when the existing network already has PSA configured." + type = bool + default = true +} + +variable "redis_transit_encryption" { + description = "Enable Memorystore transit encryption and inject Redis TLS settings into Cloud Run. Set false to use plaintext Redis." + type = bool + default = true +} + # ---------- Networking ---------- variable "subnet_cidr" { - description = "Primary CIDR block for the LiteLLM subnet." + description = "Primary CIDR block for the LiteLLM subnet. Unused when network_id is set." type = string default = "10.40.0.0/16" } variable "vpc_connector_cidr" { - description = "CIDR for the Serverless VPC Access connector. /28 required." + description = "CIDR for the Serverless VPC Access connector. /28 required. Unused when create_runtime is false." type = string default = "10.41.0.0/28" } From e66ba0533fe43a626b8a157fae59f507894aa13b Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 5 Sep 2026 12:23:07 -0700 Subject: [PATCH 256/410] fix(ui): keep guardrail cost hints provider neutral and link to a pricing request The hint copy described Bedrock's unit semantics and cost map entry even though any provider's units reach this view, so it now explains the math in provider-neutral terms. When units have no known price, the hint says so and links to a prefilled GitHub feature request (provider and counter names filled in) so the reader can ask for pricing. Per-unit prices below $0.000001 now read "< $0.000001" instead of "$0". Refs LIT-5652 --- .../GuardrailUsageBreakdown.test.tsx | 28 +++++++++++++++++++ .../_components/GuardrailUsageBreakdown.tsx | 15 +++++----- .../_components/GuardrailsOverview.test.tsx | 6 +++- .../_components/GuardrailsOverview.tsx | 26 ++++++++++------- .../GuardrailsMonitor/UnpricedNote.tsx | 21 ++++++++++++++ .../GuardrailsMonitor/usageUnits.test.ts | 22 +++++++++++++++ .../GuardrailsMonitor/usageUnits.ts | 15 +++++++++- 7 files changed, 113 insertions(+), 20 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/UnpricedNote.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx index ba90ca8e6ff..3a0a4c38ecb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx @@ -97,6 +97,34 @@ describe("GuardrailUsageBreakdown", () => { expect(screen.getByText("Sensitive Information Policy: 300 × $0.0001 = $0.0300")).toBeInTheDocument(); expect(screen.getByText("Some Future Counter: 7 units with no known price, left out")).toBeInTheDocument(); expect(screen.getByText("Total: $0.1800")).toBeInTheDocument(); + expect(screen.getByText(/7 units with no known price are left out of the cost/)).toBeInTheDocument(); + const issueLink = screen.getByRole("link", { name: "Request pricing on GitHub" }); + expect(issueLink).toHaveAttribute("target", "_blank"); + const issueUrl = new URL(issueLink.getAttribute("href") ?? ""); + expect(issueUrl.searchParams.get("title")).toBe("[Feature]: add Bedrock guardrail pricing to the cost map"); + expect(issueUrl.searchParams.get("the-feature")).toContain("someFutureCounter"); + }); + + it("does not ask for pricing when every unit was priced", async () => { + const user = userEvent.setup(); + render( + , + ); + + await user.hover( + within(screen.getByRole("group", { name: "Cost" })).getByRole("button", { name: /How is this calculated/ }), + ); + + expect(await screen.findByText("Total: $0.1500")).toBeInTheDocument(); + expect(screen.queryByRole("link", { name: "Request pricing on GitHub" })).not.toBeInTheDocument(); }); it("explains the units sum on hover", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx index 01dd8f79ce4..e67425adb10 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx @@ -3,6 +3,7 @@ import { CircleDollarSign } from "lucide-react"; import React from "react"; import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; +import { UnpricedNote } from "@/components/GuardrailsMonitor/UnpricedNote"; import { counterLabel, counterMathLine, @@ -111,23 +112,21 @@ const groupColumns = (label: string, emptyLabel: string): ColumnDef[] const teamColumns = groupColumns("Team", "No team"); const keyColumns = groupColumns("Key", "No key"); -const CostMath = ({ counters, total }: { counters: CounterRow[]; total: number | null }) => ( +const CostMath = ({ counters, detail }: { counters: CounterRow[]; detail: GuardrailUsageDetail }) => (
    {counters.map((row) => (
    {counterMathLine(row)}
    ))} -
    Total: {formatCost(total)}
    -
    Per-unit prices come from the bedrock/guardrails entry in the cost map.
    +
    Total: {formatCost(detail.cost)}
    +
    Each counter is its priced units × the per-unit price LiteLLM has for it in the cost map.
    +
    ); const UnitsMath = ({ units }: { units: GuardrailUsageDetail["usage_units"] }) => (
    {unitsSumLine(units)}
    -
    - Bedrock reports one unit per 1,000 characters of the message for each policy the guardrail has on, on every call, - blocked or not. -
    +
    Units are the billable counters the provider reported for this guardrail, added up over every call.
    ); @@ -159,7 +158,7 @@ export function GuardrailUsageBreakdown({ detail }: { detail: GuardrailUsageDeta valueColor={detail.cost != null ? "text-foreground" : "text-muted-foreground"} icon={} subtitle={unpriced ?? undefined} - hint={} + hint={} /> { expect(screen.getByText("Free Bedrock Guardrail: $0.0000")).toBeInTheDocument(); expect(screen.queryByText(/Low Failure Guardrail: /)).not.toBeInTheDocument(); expect(screen.getByText("Total: $0.1500")).toBeInTheDocument(); - expect(screen.getByText(/250 units unpriced had no known price and are left out/)).toBeInTheDocument(); + expect(screen.getByText(/250 units with no known price are left out of the cost/)).toBeInTheDocument(); + const issueLink = screen.getByRole("link", { name: "Request pricing on GitHub" }); + const issueUrl = new URL(issueLink.getAttribute("href") ?? ""); + expect(issueUrl.searchParams.get("template")).toBe("feature_request.yml"); + expect(issueUrl.searchParams.get("the-feature")).toContain("sensitiveInformationPolicyUnits"); }); it("shows a dash for guardrail cost when nothing in the window was priced", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx index 3df7058baba..33be85f3c81 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx @@ -8,7 +8,14 @@ import { type GuardrailUsageOverviewRow, useGuardrailsUsageOverview, } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; -import { counterLabel, formatCost, totalUnits, unpricedSummary } from "@/components/GuardrailsMonitor/usageUnits"; +import { UnpricedNote } from "@/components/GuardrailsMonitor/UnpricedNote"; +import { + counterLabel, + formatCost, + totalUnits, + unpricedSummary, + type UsageUnits, +} from "@/components/GuardrailsMonitor/usageUnits"; import { Button } from "@/components/ui/button"; import { PageHeader } from "@/components/shared/PageHeader"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; @@ -41,7 +48,7 @@ const EMPTY_METRICS = { avgLatency: 0, count: 0, totalCost: null as number | null, - unpriced: null as string | null, + untracked: {} as UsageUnits, }; function UsageUnitsCell({ units }: { units: GuardrailUsageOverviewRow["usageUnits"] }) { @@ -66,11 +73,11 @@ function UsageUnitsCell({ units }: { units: GuardrailUsageOverviewRow["usageUnit function TotalCostMath({ rows, total, - unpriced, + untracked, }: { rows: GuardrailUsageOverviewRow[]; total: number | null; - unpriced: string | null; + untracked: UsageUnits; }) { return (
    @@ -83,10 +90,9 @@ function TotalCostMath({ ))}
    Total: {formatCost(total)}
    - {`Each guardrail's cost is its units per policy × that policy's per-unit price from the cost map, added up${ - unpriced ? `; ${unpriced} had no known price and are left out` : "" - }. Open a guardrail for its per-policy math.`} + {`Each guardrail's cost is its units per counter × that counter's per-unit price from the cost map, added up. Open a guardrail for its per-counter math.`}
    +
    ); } @@ -135,7 +141,7 @@ export function GuardrailsOverview({ : 0, count: activeData.length, totalCost: guardrailsData.totalCost, - unpriced: unpricedSummary(guardrailsData.totalUntrackedUsageUnits), + untracked: guardrailsData.totalUntrackedUsageUnits, }; }, [guardrailsData, activeData]); const chartData = guardrailsData?.chart; @@ -318,8 +324,8 @@ export function GuardrailsOverview({ value={formatCost(metrics.totalCost)} valueColor={metrics.totalCost != null ? "text-foreground" : "text-muted-foreground"} icon={} - subtitle={metrics.unpriced ?? undefined} - hint={} + subtitle={unpricedSummary(metrics.untracked) ?? undefined} + hint={} />
    diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/UnpricedNote.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/UnpricedNote.tsx new file mode 100644 index 00000000000..174124d18aa --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/UnpricedNote.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { pricingIssueUrl, totalUnits, type UsageUnits } from "./usageUnits"; + +export function UnpricedNote({ unpriced, provider }: { unpriced: UsageUnits; provider?: string }) { + const total = totalUnits(unpriced); + if (total === 0) return null; + const [noun, verb] = total === 1 ? ["unit", "is"] : ["units", "are"]; + return ( +
    + {`${total.toLocaleString()} ${noun} with no known price ${verb} left out of the cost. `} + + Request pricing on GitHub + +
    + ); +} diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts index 560010bd852..9b45eefaf54 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts @@ -4,6 +4,7 @@ import { counterMathLine, formatCost, formatUnitPrice, + pricingIssueUrl, totalUnits, unitPrice, unitsSumLine, @@ -84,6 +85,10 @@ describe("formatUnitPrice", () => { expect(formatUnitPrice(0)).toBe("$0"); expect(formatUnitPrice(1)).toBe("$1"); }); + + it("never shows a positive price as free", () => { + expect(formatUnitPrice(0.0000002)).toBe("< $0.000001"); + }); }); describe("counterMathLine", () => { @@ -122,3 +127,20 @@ describe("unitsSumLine", () => { ); }); }); + +describe("pricingIssueUrl", () => { + it("prefills the feature request with the provider and the unpriced counters", () => { + const url = new URL(pricingIssueUrl({ text_records: 5, someFutureCounter: 7 }, "azure/prompt_shield")); + + expect(url.origin + url.pathname).toBe("https://github.com/BerriAI/litellm/issues/new"); + expect(url.searchParams.get("template")).toBe("feature_request.yml"); + expect(url.searchParams.get("title")).toBe("[Feature]: add azure/prompt_shield guardrail pricing to the cost map"); + expect(url.searchParams.get("the-feature")).toContain("text_records, someFutureCounter"); + }); + + it("stays generic when no provider is known", () => { + const url = new URL(pricingIssueUrl({ text_records: 5 })); + + expect(url.searchParams.get("title")).toBe("[Feature]: add guardrail pricing to the cost map"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts index 05e046aaace..f914a3e9698 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts @@ -35,7 +35,10 @@ export const unitPrice = (row: CounterMath): number | null => { return row.cost != null && priced > 0 ? row.cost / priced : null; }; -export const formatUnitPrice = (price: number): string => `$${price.toFixed(6).replace(/\.?0+$/, "")}`; +export const formatUnitPrice = (price: number): string => { + const fixed = price.toFixed(6).replace(/\.?0+$/, ""); + return price > 0 && Number(fixed) === 0 ? "< $0.000001" : `$${fixed}`; +}; export const counterMathLine = (row: CounterMath): string => { const label = counterLabel(row.counter); @@ -51,3 +54,13 @@ export const unitsSumLine = (units: UsageUnits): string => `${Object.entries(units) .map(([counter, n]) => `${counterLabel(counter)} ${n.toLocaleString()}`) .join(" + ")} = ${totalUnits(units).toLocaleString()}`; + +export const pricingIssueUrl = (unpriced: UsageUnits, provider?: string): string => { + const subject = provider ? `${provider} guardrail` : "guardrail"; + const params = new URLSearchParams({ + template: "feature_request.yml", + title: `[Feature]: add ${subject} pricing to the cost map`, + "the-feature": `LiteLLM has no price for these ${subject} usage units, so the Guardrails Monitor leaves them out of the cost: ${Object.keys(unpriced).join(", ")}`, + }); + return `https://github.com/BerriAI/litellm/issues/new?${params.toString()}`; +}; From 110f654f342897ea438a6c71e91f0078bb4d76fa Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Sat, 5 Sep 2026 12:43:02 -0700 Subject: [PATCH 257/410] feat(mcp): renew the stored SSO identity assertion behind ID-JAG (#35401) * feat(mcp): renew the stored SSO identity assertion behind ID-JAG The oauth2_id_jag arm asserts the id_token captured at the user's last interactive SSO login, and nothing ever renewed it, so an agent holding a brokered LiteLLM key could act for that user only until that token's exp. The assertion already carried the IdP refresh token beside it; this redeems it. RefreshingSSOAssertionStore wraps the database reader and satisfies the same protocol, so the egress arm is unchanged. Renewal is lazy and single-flighted per user through the same RefreshCoordinator the authorization_code arm uses, since an IdP that rotates refresh tokens treats two concurrent redemptions as replay. A refusal leaves the expired assertion in place so the reader still challenges the user; an unreachable IdP surfaces as a store outage instead. * fix(mcp): let a cross-replica loser settle the SSO assertion renewal itself Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp): satisfy type discipline lint budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(ci): rerun checks after docs main added the missing router setting row Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp): answer a cross-replica loser retryable instead of re-electing it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp): bypass stale assertion cache during renewal Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: ratchet type-discipline budget after merge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Yassin Kortam Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../per_user_oauth_store.py | 21 +- .../outbound_credentials/resolver.py | 23 +- .../runtime_refresh_coordinator.py | 41 + .../sso_assertion_refresher.py | 469 +++++++++++ .../sso_assertion_store.py | 39 +- .../outbound_credentials/test_resolver.py | 74 ++ .../test_sso_assertion_refresher.py | 794 ++++++++++++++++++ type-discipline-budget.json | 6 +- 8 files changed, 1427 insertions(+), 40 deletions(-) create mode 100644 litellm/proxy/_experimental/mcp_server/outbound_credentials/runtime_refresh_coordinator.py create mode 100644 litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_refresher.py create mode 100644 tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_refresher.py diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/per_user_oauth_store.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/per_user_oauth_store.py index 9273ddda9cf..5e28396dcfb 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/per_user_oauth_store.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/per_user_oauth_store.py @@ -31,11 +31,8 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_sto TokenCacheBackend, TokenStoreUnavailable, ) -from litellm.proxy._experimental.mcp_server.outbound_credentials.redis_distributed_lock import ( - RedisDistributedLock, -) -from litellm.proxy._experimental.mcp_server.outbound_credentials.redis_refresh_coordinator import ( - RedisRefreshCoordinator, +from litellm.proxy._experimental.mcp_server.outbound_credentials.runtime_refresh_coordinator import ( + runtime_refresh_coordinator, ) from litellm.proxy._experimental.mcp_server.outbound_credentials.token_cache_codec import ( OAuthTokenCacheCodec, @@ -131,23 +128,17 @@ def _runtime_backend_and_coordinator() -> tuple[TokenCacheBackend | None, Refres ) from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 - redis_cache: Final = user_api_key_cache.redis_cache - if redis_cache is None: + coordinator: Final = runtime_refresh_coordinator() + if coordinator is None: return None, None, False codec: Final = OAuthTokenCacheCodec( encrypt_value_helper, lambda blob: decrypt_value_helper(blob, "mcp_per_user_token", exception_type="debug"), ) - # user_api_key_cache satisfies the AsyncCache slice (DualCache types ttl via **kwargs) and the - # Redis client from init_async_client() is partially typed - both are untyped-boundary casts. + # user_api_key_cache satisfies the AsyncCache slice (DualCache types ttl via **kwargs) - an + # untyped-boundary cast. cache: Final[AsyncCache] = user_api_key_cache # pyright: ignore - redis_client: Final = redis_cache.init_async_client() # pyright: ignore - lock: Final = RedisDistributedLock( - redis_client, # pyright: ignore - namespace_key=redis_cache.check_and_fix_namespace, - ) backend: Final = DualCacheTokenCacheBackend(cache, codec) - coordinator: Final = RedisRefreshCoordinator(lock) return backend, coordinator, True diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py index 404baa14350..8328aae01ab 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py @@ -46,11 +46,13 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( Ok, Result, ) +from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_refresher import ( + default_sso_assertion_store, +) from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import ( AssertionStoreUnavailable, - DbSSOAssertionStore, SSOAssertionStore, - SSOIdentityAssertion, + assertion_expired, ) from litellm.proxy._experimental.mcp_server.outbound_credentials.token_endpoint import ( ExchangedToken, @@ -129,7 +131,7 @@ class UpstreamCredentialProvider: self._token_endpoint: TokenEndpointClient = token_endpoint or TokenEndpointClient() self._exchanged_tokens: ExchangedTokenCache = exchanged_tokens or ExchangedTokenCache() self._client_credentials_source = client_credentials_source or ClientCredentialsTokenSource() - self._sso_assertion_store: SSOAssertionStore = sso_assertion_store or DbSSOAssertionStore() + self._sso_assertion_store: SSOAssertionStore = sso_assertion_store or default_sso_assertion_store() async def resolve_credentials(self, subject: Subject, server: ServerSpec) -> Result[httpx.Auth, CredError]: match server.config: @@ -246,7 +248,7 @@ class UpstreamCredentialProvider: "Sign in through LiteLLM SSO so the gateway captures one." ) ) - if _assertion_expired(assertion, datetime.now(timezone.utc)): + if assertion_expired(assertion, datetime.now(timezone.utc)): return Error( CredError.of_precondition_required( "The stored IdP identity assertion for this user has expired. Sign in through " @@ -405,19 +407,6 @@ def _id_jag_slot_key(subject: Subject, server: ServerSpec) -> str: return hashlib.sha256(material.encode()).hexdigest() -def _assertion_expired(assertion: SSOIdentityAssertion, now: datetime) -> bool: - """Whether the stored assertion's ``exp`` has passed. An assertion carrying no expiry is - treated as usable and left for the IdP to reject, since the store records what the id_token - claimed rather than imposing a lifetime of its own. A naive ``expires_at`` is read as UTC so a - stored value that lost its offset compares instead of raising. - """ - expires_at: Final = assertion.expires_at - if expires_at is None: - return False - normalized: Final = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=timezone.utc) - return normalized <= now - - def _id_jag_fingerprint(subject_token: str, server_id: str, config: IdJagConfig) -> str: """What the cached leg-2 bearer was minted from: the subject token, the server, and the config. diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/runtime_refresh_coordinator.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/runtime_refresh_coordinator.py new file mode 100644 index 00000000000..e799838b5b7 --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/runtime_refresh_coordinator.py @@ -0,0 +1,41 @@ +"""The runtime ``RefreshCoordinator``: cross-replica single-flight when Redis is wired. + +Builds ``RedisRefreshCoordinator`` over the proxy's shared Redis so one refresh runs per key +across the fleet, or returns ``None`` when Redis is absent so the caller keeps the foundation's +in-process default (correct for a single replica). The proxy globals it reads are not ready at +import time, so this is called per composition rather than held as module state. + +Shared by every credential arm that renews a stored grant: a rotating refresh token must be +redeemed once across all workers, so each arm electing its own winner with its own lock shape +would be a bug waiting to differ. +""" + +from __future__ import annotations + +from typing import Final + +from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import ( + RefreshCoordinator, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.redis_distributed_lock import ( + RedisDistributedLock, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.redis_refresh_coordinator import ( + RedisRefreshCoordinator, +) + + +def runtime_refresh_coordinator() -> RefreshCoordinator | None: + from litellm.proxy.proxy_server import user_api_key_cache # noqa: PLC0415 # runtime global + + redis_cache: Final = user_api_key_cache.redis_cache + if redis_cache is None: + return None + # The Redis client from init_async_client() is only partially typed; the lock validates every + # reply it depends on, so the untyped boundary is contained here. + redis_client: Final = redis_cache.init_async_client() # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType] # litellm redis wrapper is untyped + lock: Final = RedisDistributedLock( + redis_client, # pyright: ignore[reportArgumentType,reportUnknownArgumentType] # litellm redis wrapper is untyped + namespace_key=redis_cache.check_and_fix_namespace, + ) + return RedisRefreshCoordinator(lock) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_refresher.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_refresher.py new file mode 100644 index 00000000000..7600fd7ab8a --- /dev/null +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_refresher.py @@ -0,0 +1,469 @@ +"""Renew the stored SSO identity assertion so an ID-JAG agent outlives one id_token. + +The ``oauth2_id_jag`` arm asserts the id_token captured at the user's last interactive sign-in, so +without renewal an agent holding a brokered LiteLLM key can act for that user only until that token's +``exp``, typically an hour, and the sole recovery is another interactive login. The assertion already +carries the IdP refresh token beside it; this module is what redeems it. + +``RefreshingSSOAssertionStore`` wraps any ``SSOAssertionStore`` and satisfies the same protocol, so +the egress arm is unchanged: it still reads one assertion and still judges expiry itself. Renewal is +lazy (only a read that finds a near-expiry assertion triggers one, so IdP traffic tracks actual use, +not the size of the user table) and single-flighted per user through the same ``RefreshCoordinator`` +the ``authorization_code`` arm uses, because an IdP that rotates refresh tokens treats two concurrent +redemptions of one token as replay and can revoke the whole grant chain. + +The refresh is redeemed against the generic-OIDC client the login itself used +(``GENERIC_TOKEN_ENDPOINT`` / ``GENERIC_CLIENT_ID`` / ``GENERIC_CLIENT_SECRET``, which the proxy +reconciles from the stored SSO row into the process environment at startup), authenticated the way +that login authenticated: the non-PKCE path always sends HTTP Basic, while the PKCE path sends the +credentials in the body when ``GENERIC_INCLUDE_CLIENT_ID`` is set, and an IdP application may accept +only one of the two. An assertion can only exist if that client minted it, so no other client could +redeem its refresh token, and no other method is known to be accepted. A deployment whose +``GENERIC_SCOPE`` omits ``offline_access`` captures no refresh token at all, which is why that miss +logs the scope by name rather than failing silently. + +Failures are values internally (``Result[_, RefreshFailure]``). At the store boundary they collapse +onto the protocol's existing two-outcome contract: a refusal returns the expired assertion unchanged +so the reader's own guard challenges the user to sign in again, while a transient IdP failure raises +``AssertionStoreUnavailable`` so the reader answers 503 instead of blaming the user for an outage. + +One ambiguity remains under Redis-coordinated renewal across replicas. A cross-replica loser that +finds the row still expiring after the holder finished cannot tell a refused refresh from a renewal +that could not be recorded. Redeeming itself could consume a refresh token the holder may already +have rotated, so it answers retryable 503 rather than guessing a sign-in challenge. The next +uncontended read settles the outcome itself: a refusal challenges, and a successful refresh persists. +If the holder rotated the token but its write failed, that rotation is lost and the next uncontended +read's refusal challenges, which is the only honest answer because the rotated token was never +recorded. On the refusal path, the loser pays for one retry before that challenge. +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Final, Literal, Protocol + +import httpx +from pydantic import SecretStr, TypeAdapter, ValidationError +from typing_extensions import assert_never + +from litellm._logging import verbose_proxy_logger +from litellm.exceptions import Timeout +from litellm.llms.custom_httpx.http_handler import ( + get_async_httpx_client, # pyright: ignore[reportUnknownVariableType] # litellm http handler is untyped +) +from litellm.proxy._experimental.mcp_server.auth.token_endpoint_auth import ( + build_token_endpoint_client_auth, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_store import ( + InProcessRefreshCoordinator, + RefreshCoordinator, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( + Error, + Ok, + Result, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.runtime_refresh_coordinator import ( + runtime_refresh_coordinator, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import ( + AssertionStoreUnavailable, + DbSSOAssertionStore, + SSOAssertionStore, + SSOIdentityAssertion, + assertion_expired, + assertion_from_sso_login, + fetch_sso_identity_assertion, + persist_sso_identity_assertion, +) +from litellm.types.llms.custom_http import httpxSpecialProvider +from litellm.types.mcp import MCPTokenEndpointAuthMethod + +_BODY_ADAPTER: Final[TypeAdapter[Mapping[str, object]]] = TypeAdapter(dict[str, object]) + +_REFRESH_GRANT_TYPE: Final = "refresh_token" +# The lock namespace for the one assertion row a user has; the sibling arm keys the same lock by +# server_id, and no server_id can collide with this literal. +_SINGLE_FLIGHT_KEY: Final = "sso_identity_assertion" +# Renew this far ahead of ``exp`` so a token that would die between resolution and the second leg of +# the exchange is replaced first. Matches the sibling per-user token store's skew. +_DEFAULT_EXPIRY_SKEW_SECONDS: Final = 60.0 + + +class AssertionRead(Protocol): + """Reads the user's stored assertion row.""" + + async def __call__(self, user_id: str) -> SSOIdentityAssertion | None: ... + + +class AssertionWrite(Protocol): + """Replaces the user's stored assertion row.""" + + async def __call__(self, user_id: str, assertion: SSOIdentityAssertion) -> None: ... + + +class CoordinatorFactory(Protocol): + """Builds the cross-replica coordinator, or ``None`` when there is no shared lock to build on.""" + + def __call__(self) -> RefreshCoordinator | None: ... + + +class FormPost(Protocol): + """POSTs an OAuth form and hands back the raw response.""" + + async def __call__( + self, url: str, form: Mapping[str, str], headers: Mapping[str, str] + ) -> httpx.Response | None: ... + + +@dataclass(frozen=True, slots=True) +class SSOClientConfig: + """The generic-OIDC client credentials a refresh_token grant has to authenticate as, and how.""" + + token_endpoint: str + client_id: str + client_secret: SecretStr + auth_method: MCPTokenEndpointAuthMethod + + +def sso_client_config(env: Mapping[str, str]) -> SSOClientConfig | None: + """The configured generic-OIDC client, or ``None`` when the deployment has none. + + Read from the process environment because that is where the login path reads it + (``_setup_generic_sso_env_vars``) and where the proxy materializes the stored ``sso_config`` row + at startup, so this resolves to the same client that minted the assertion. ``None`` is an + ordinary state, not an error: a deployment signing in through a provider that captures no + assertion has nothing here to renew, and a client with no secret is not a confidential client + that could redeem one. + + ``auth_method`` is derived from the same ``GENERIC_INCLUDE_CLIENT_ID`` the login reads, because + the two login paths do not agree: the non-PKCE path always authenticates with HTTP Basic, while + the PKCE path puts the credentials in the body when that flag is set. Both capture assertions, so + a constant here would authenticate the renewal differently from the sign-in that produced the + refresh token and 401 against an IdP application registered for only one of the two. + """ + token_endpoint: Final = env.get("GENERIC_TOKEN_ENDPOINT") + client_id: Final = env.get("GENERIC_CLIENT_ID") + client_secret: Final = env.get("GENERIC_CLIENT_SECRET") + if not token_endpoint or not client_id or not client_secret: + return None + includes_client_id: Final = env.get("GENERIC_INCLUDE_CLIENT_ID", "false").lower() == "true" + return SSOClientConfig( + token_endpoint=token_endpoint, + client_id=client_id, + client_secret=SecretStr(client_secret), + auth_method="client_secret_post" if includes_client_id else "client_secret_basic", + ) + + +@dataclass(frozen=True, slots=True) +class RefreshFailure: + """Why a renewal produced nothing, split by what the caller can do about it. + + ``rejected`` is settled: this refresh token will never work again, so the user has to sign in. + ``unavailable`` is transient: the same attempt may succeed in a minute, so telling the user to + sign in again would be a lie about whose problem it is. Both arms carry the same payload, so + this is a ``Literal`` discriminant rather than a ``tagged_union``; consumers still ``match`` on + ``kind`` with an ``assert_never`` tail. + """ + + kind: Literal["rejected", "unavailable"] + detail: str + + @staticmethod + def of_rejected(detail: str) -> RefreshFailure: + return RefreshFailure(kind="rejected", detail=detail) + + @staticmethod + def of_unavailable(detail: str) -> RefreshFailure: + return RefreshFailure(kind="unavailable", detail=detail) + + +class TokenEndpointTransport(Protocol): + """One form POST to the IdP token endpoint, with the refusal/outage split preserved. + + That split is the whole reason this is not the resolver's ``TokenEndpointClient``: that + collaborator maps every non-2xx to ``upstream_unavailable``, which is right for an exchange leg + and wrong here, where a 400 ``invalid_grant`` means the stored refresh token is dead and the user + must act. + """ + + async def post( + self, url: str, form: Mapping[str, str], headers: Mapping[str, str] + ) -> Result[Mapping[str, object], RefreshFailure]: ... + + +async def post_form(url: str, form: Mapping[str, str], headers: Mapping[str, str]) -> httpx.Response | None: + # litellm's httpx handler is only partially typed; nothing but the response object crosses back, + # and the transport below validates its body, so the untyped boundary is contained here. + client: Final = get_async_httpx_client(llm_provider=httpxSpecialProvider.MCP) # pyright: ignore[reportUnknownVariableType] # litellm http handler is untyped + return await client.post(url, data=form, headers=headers) # pyright: ignore[reportUnknownMemberType,reportUnknownVariableType,reportReturnType,reportArgumentType] # litellm http handler is untyped and its stub narrows data=/headers= to dict, which httpx itself does not require + + +class HttpxTokenEndpointTransport: + """The live transport. 4xx is the IdP refusing this grant; anything else is an outage. + + The POST itself is injected so that split, which decides whether the user is challenged or told + to wait, is testable without a live IdP. + """ + + def __init__(self, post: FormPost = post_form) -> None: + self._post = post + + async def post( + self, url: str, form: Mapping[str, str], headers: Mapping[str, str] + ) -> Result[Mapping[str, object], RefreshFailure]: + try: + response: Final = await self._post(url, form, headers) + if response is None: + return Error(RefreshFailure.of_unavailable("the IdP token endpoint returned no response")) + response.raise_for_status() + body: Final = _BODY_ADAPTER.validate_python(response.json()) # pyright: ignore[reportAny] # untyped JSON; the adapter is the type gate + except httpx.HTTPStatusError as exc: + status: Final = exc.response.status_code + if 400 <= status < 500: + return Error(RefreshFailure.of_rejected(f"the IdP refused the refresh with status {status}")) + return Error(RefreshFailure.of_unavailable(f"the IdP token endpoint answered with status {status}")) + except (httpx.RequestError, Timeout) as exc: + return Error(RefreshFailure.of_unavailable(f"the IdP token endpoint is unreachable ({type(exc).__name__})")) + except json.JSONDecodeError: + return Error(RefreshFailure.of_unavailable("the IdP token endpoint returned a non-JSON response")) + except ValidationError: + return Error(RefreshFailure.of_unavailable("the IdP token endpoint returned a non-object response")) + return Ok(body) + + +class SSOAssertionRefresher: + """Redeems the stored refresh token for a current id_token and writes the rotation back. + + Collaborators are injected so the orchestration, the untyped response parsing and the + write-back race are all testable without an IdP or a database. + """ + + def __init__( + self, + transport: TokenEndpointTransport, + *, + client_config: Callable[[], SSOClientConfig | None] = lambda: sso_client_config(os.environ), + read: AssertionRead = fetch_sso_identity_assertion, + write: AssertionWrite = persist_sso_identity_assertion, + ) -> None: + self._transport = transport + self._client_config = client_config + self._read = read + self._write = write + + async def refresh( + self, user_id: str, assertion: SSOIdentityAssertion + ) -> Result[SSOIdentityAssertion, RefreshFailure]: + if assertion.refresh_token is None: + verbose_proxy_logger.warning( + "ID-JAG: the stored IdP identity assertion for user_id=%s has expired and no refresh token was " + "captured with it, so it cannot be renewed without another interactive sign-in. Add " + "'offline_access' to GENERIC_SCOPE so the SSO login captures one.", + user_id, + ) + return Error(RefreshFailure.of_rejected("no refresh token was captured at sign-in")) + config: Final = self._client_config() + if config is None: + verbose_proxy_logger.warning( + "ID-JAG: the stored IdP identity assertion for user_id=%s has expired and cannot be renewed " + "because the generic SSO client is not configured (GENERIC_TOKEN_ENDPOINT, GENERIC_CLIENT_ID, " + "GENERIC_CLIENT_SECRET).", + user_id, + ) + return Error(RefreshFailure.of_rejected("the generic SSO client is not configured")) + + carried_refresh_token: Final = assertion.refresh_token.get_secret_value() + # Whichever method the SSO login used for this client, since that is the one the IdP + # application is known to accept: an assertion only exists to renew because a sign-in already + # authenticated this client that way. + client_auth: Final = build_token_endpoint_client_auth( + auth_method=config.auth_method, + client_id=config.client_id, + client_secret=config.client_secret.get_secret_value(), + ) + form: Final = { # mutable-ok: the RFC 6749 form body is a wire format the HTTP client takes as a mapping + "grant_type": _REFRESH_GRANT_TYPE, + "refresh_token": carried_refresh_token, + **client_auth.body, + } + match await self._transport.post(config.token_endpoint, form, client_auth.headers): + case Error(failure): + return Error(failure) + case Ok(body): + return await self._renewed_from(user_id, assertion, body, carried_refresh_token) + + async def _renewed_from( + self, + user_id: str, + previous: SSOIdentityAssertion, + body: Mapping[str, object], + carried_refresh_token: str, + ) -> Result[SSOIdentityAssertion, RefreshFailure]: + """The renewed assertion, built by the same validator the login path uses. + + A rotated refresh token replaces the stored one; an omitted one carries forward, since an + IdP that does not rotate expects the original to keep working. + """ + rotated: Final = body.get("refresh_token") + renewed: Final = assertion_from_sso_login( + body.get("id_token"), + rotated if isinstance(rotated, str) and rotated else carried_refresh_token, + ) + if renewed is None: + verbose_proxy_logger.warning( + "ID-JAG: the IdP accepted the refresh for user_id=%s but returned no usable id_token, so there " + "is nothing to assert upstream. The SSO client's grant needs the 'openid' scope for the token " + "endpoint to return one on a refresh.", + user_id, + ) + return Error(RefreshFailure.of_rejected("the IdP's refresh response carried no usable id_token")) + failure: Final = await self._store_renewal(user_id, previous, renewed) + if failure is not None: + return Error(failure) + return Ok(renewed) + + async def _store_renewal( + self, user_id: str, previous: SSOIdentityAssertion, renewed: SSOIdentityAssertion + ) -> RefreshFailure | None: + """Write the renewal back, unless the row moved on while this renewal was in flight. + + The row is one per user and last-write-wins, so an interactive sign-in landing mid-renewal + would otherwise be overwritten with a refresh token the IdP has already rotated away, costing + that user a sign-in later. Comparing against the id_token this renewal started from is what + detects that; skipping is safe because the newer row is the one the reader wants anyway. + + A failed write is transient, not settled. The store, not this return value, is what every + caller reads, so a renewal that could not be recorded is a renewal nobody will see; saying so + keeps a database problem answering 503 rather than telling the user to sign in again over it. + """ + try: + current: Final = await self._read(user_id) + if current is not None and current.id_token.get_secret_value() != previous.id_token.get_secret_value(): + verbose_proxy_logger.info( + "ID-JAG: a newer IdP identity assertion for user_id=%s was stored while this renewal was in " + "flight; keeping the stored one.", + user_id, + ) + return None + await self._write(user_id, renewed) + except Exception as exc: # noqa: BLE001 # any storage failure is transient here, never the user's fault + verbose_proxy_logger.warning( + "ID-JAG: could not persist the renewed IdP identity assertion for user_id=%s, so the rotated " + "refresh token is lost and this user will have to sign in again once the renewed token expires: %s", + user_id, + exc, + ) + return RefreshFailure.of_unavailable("the renewed IdP identity assertion could not be persisted") + return None + + +class RefreshingSSOAssertionStore: + """An ``SSOAssertionStore`` that renews a near-expiry assertion before handing it back. + + Reads the inner store; an assertion still comfortably inside its lifetime is returned untouched, + so the common path costs exactly what it did before. Otherwise one renewal runs per user through + the injected ``RefreshCoordinator`` and every caller then re-reads the inner store, which is the + authority: the winner's write is what they all observe, and a renewal the write-back guard + skipped yields the newer assertion that displaced it rather than a private copy. + + A refusal leaves the expired assertion in place for the reader's own guard to reject, so the user + sees the same sign-in-again challenge as before this store existed. A transient IdP failure + raises ``AssertionStoreUnavailable``, the protocol's existing signal for "this is not the user's + fault"; concurrent in-process callers share that outcome, while a cross-replica loser answers 503 + when its re-read still finds the row expiring. On the refusal path that costs the loser one retry, + which then challenges. If the holder rotated the token but its write failed, the rotation is lost + and the next uncontended read's refusal challenges, the only honest answer because that token was + never recorded. + """ + + def __init__( + self, + inner: SSOAssertionStore, + refresher: SSOAssertionRefresher, + *, + fresh_read: AssertionRead, + coordinator_factory: CoordinatorFactory = runtime_refresh_coordinator, + expiry_skew_seconds: float = _DEFAULT_EXPIRY_SKEW_SECONDS, + clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc), + ) -> None: + self._inner = inner + self._refresher = refresher + self._fresh_read = fresh_read + self._coordinator_factory = coordinator_factory + self._in_process_coordinator = InProcessRefreshCoordinator() + self._distributed_coordinator: RefreshCoordinator | None = None + self._skew = timedelta(seconds=expiry_skew_seconds) + self._clock = clock + + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: + assertion: Final = await self._inner.fetch(user_id) + if not self._expiring(assertion): + return assertion + await self._coordinator().run( + user_id, + _SINGLE_FLIGHT_KEY, + refresh=lambda: self._renew(user_id), + reread=lambda: self._reread_renewed(user_id), + ) + return await self._fresh_read(user_id) + + def _expiring(self, assertion: SSOIdentityAssertion | None) -> bool: + return assertion is not None and assertion_expired(assertion, self._clock() + self._skew) + + def _coordinator(self) -> RefreshCoordinator: + """The cross-replica coordinator once Redis is reachable, else the in-process one. + + Built on first use and kept, because the proxy's Redis client is not wired at import time; + retried while it is absent so a proxy that gains Redis later stops electing per-worker. + """ + if self._distributed_coordinator is None: + self._distributed_coordinator = self._coordinator_factory() + return self._distributed_coordinator or self._in_process_coordinator + + async def _renew(self, user_id: str) -> None: + """The elected renewal, judged from a fresh read so a rotation another replica just landed is + never redeemed again. Returns nothing: the inner store, not this return value, is what every + caller reads afterwards, so the winner and the losers cannot disagree.""" + latest: Final = await self._fresh_read(user_id) + if latest is None or not self._expiring(latest): + return + match await self._refresher.refresh(user_id, latest): + case Ok(_): + return + case Error(failure): + match failure.kind: + case "rejected": + return + case "unavailable": + raise AssertionStoreUnavailable(failure.detail) + assert_never(failure.kind) + + async def _reread_renewed(self, user_id: str) -> None: + """A loser cannot distinguish refusal from an unrecorded renewal without risking token replay. + + It answers retryable 503 instead of guessing a sign-in challenge; the retry runs uncontended + and settles the outcome itself. + """ + latest: Final = await self._fresh_read(user_id) + if self._expiring(latest): + raise AssertionStoreUnavailable( + f"the IdP identity assertion for user_id={user_id} was being renewed by another replica " + "and is not yet current; retry shortly" + ) + + +def default_sso_assertion_store() -> SSOAssertionStore: + """The live read seam for the ``id_jag`` arm: the stored assertion, renewed when it is stale.""" + db_store: Final = DbSSOAssertionStore() + fresh_read: Final = db_store.fetch_uncached + return RefreshingSSOAssertionStore( + db_store, + SSOAssertionRefresher(HttpxTokenEndpointTransport(), read=fresh_read), + fresh_read=fresh_read, + ) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py index 6552008ca54..f7b92df5ba3 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/sso_assertion_store.py @@ -127,6 +127,23 @@ def assertion_from_sso_login(id_token: object, refresh_token: object) -> SSOIden ) +def assertion_expired(assertion: SSOIdentityAssertion, now: datetime) -> bool: + """Whether the assertion's ``exp`` has passed at ``now``. An assertion carrying no expiry is + treated as usable and left for the IdP to reject, since the store records what the id_token + claimed rather than imposing a lifetime of its own. A naive ``expires_at`` is read as UTC so a + stored value that lost its offset compares instead of raising. + + Lives beside the model rather than in either reader so the egress guard and the renewal + trigger judge the same field the same way; passing a ``now`` in the future is how a caller + asks "is this about to expire" without a second, driftable predicate. + """ + expires_at: Final = assertion.expires_at + if expires_at is None: + return False + normalized: Final = expires_at if expires_at.tzinfo is not None else expires_at.replace(tzinfo=timezone.utc) + return normalized <= now + + async def ema_assertion_retention_enabled() -> bool: """Whether any MCP server uses ``oauth2_id_jag``, evaluated per login so the gateway only retains bearer material while an EMA upstream exists to spend it on. Judged against the two @@ -146,7 +163,9 @@ async def ema_assertion_retention_enabled() -> bool: return True if prisma_client is None: return False - row = await prisma_client.db.litellm_mcpservertable.find_first(where={"auth_type": MCPAuth.oauth2_id_jag.value}) + row: Final = await prisma_client.db.litellm_mcpservertable.find_first( + where={"auth_type": MCPAuth.oauth2_id_jag.value} + ) return row is not None @@ -158,7 +177,7 @@ async def persist_sso_identity_assertion( if prisma_client is None: return - payload: Final[dict[str, str]] = { + payload: Final = { "id_token": assertion.id_token.get_secret_value(), **({"refresh_token": assertion.refresh_token.get_secret_value()} if assertion.refresh_token else {}), **({"issuer": assertion.issuer} if assertion.issuer else {}), @@ -220,11 +239,13 @@ async def fetch_sso_identity_assertion( class AssertionStoreUnavailable(Exception): - """Raised by ``fetch`` when the backing store is unreachable (e.g. the DB is down). + """Raised by ``fetch`` when the assertion cannot be read for a transient reason: the DB is + down, or the IdP behind a renewing store could not be reached. Distinct from returning ``None`` for "this user has no captured assertion": an outage must not read as a definite absence, which would tell the user to sign in again over a transient failure, - and it must not escape as an unhandled error on the egress or retry path. Mirrors + and it must not escape as an unhandled error on the egress or retry path. The message names the + real component for the operator log; callers get the reader's generic 503. Mirrors ``TokenStoreUnavailable`` on the sibling per-user OAuth store. """ @@ -257,6 +278,12 @@ class DbSSOAssertionStore: except Exception as exc: # noqa: BLE001 # any driver/storage failure is an outage, not an absence raise AssertionStoreUnavailable(str(exc)) from exc + async def fetch_uncached(self, user_id: str) -> SSOIdentityAssertion | None: + try: + return await _read_assertion_from_db(user_id) + except Exception as exc: # noqa: BLE001 # any driver/storage failure is an outage, not an absence + raise AssertionStoreUnavailable(str(exc)) from exc + async def rotate_sso_identity_assertions_master_key(prisma_client: PrismaClient, new_master_key: str) -> None: """Re-encrypt every stored assertion under ``new_master_key`` during a salt-key rotation, @@ -280,7 +307,9 @@ async def rotate_sso_identity_assertions_master_key(prisma_client: PrismaClient, row.user_id, ) return False - re_encrypted = _STR_ADAPTER.validate_python(encrypt_value_helper(plaintext, new_encryption_key=new_master_key)) + re_encrypted: Final = _STR_ADAPTER.validate_python( + encrypt_value_helper(plaintext, new_encryption_key=new_master_key) + ) await prisma_client.db.litellm_ssoidentityassertion.update( where={"user_id": row.user_id}, data={"assertion_b64": re_encrypted}, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index 5e2f2cf97d7..6b3098d9e60 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -9,9 +9,11 @@ returning the stub. import asyncio import logging +import time from datetime import datetime, timedelta, timezone import httpx +import jwt as pyjwt import pytest from pydantic import SecretStr @@ -42,6 +44,11 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.oauth_token_sto OAuthToken, TokenStoreUnavailable, ) +from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_refresher import ( + RefreshingSSOAssertionStore, + SSOAssertionRefresher, + SSOClientConfig, +) from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import ( AssertionStoreUnavailable, SSOIdentityAssertion, @@ -589,6 +596,73 @@ async def test_id_jag_refuses_an_expired_stored_assertion_without_calling_the_id assert endpoint.calls == [] +@pytest.mark.asyncio +async def test_id_jag_renews_an_expired_stored_assertion_instead_of_challenging(): + """The unattended-agent case end to end: the user last signed in more than an id_token lifetime + ago, so without renewal this is the 412 above. With the renewing store wired the arm resolves, + and leg 1 asserts the renewed token rather than the one that ran out.""" + renewed_id_token = pyjwt.encode( + {"iss": "https://idp.example.com", "sub": "alice", "exp": int(time.time()) + 3600}, + "test-idp-signing-key-32-bytes-long-xxxx", + algorithm="HS256", + ) + expired = SSOIdentityAssertion( + id_token=SecretStr("stale-id-token"), + refresh_token=SecretStr("rt_1"), + expires_at=datetime.now(timezone.utc) - timedelta(seconds=1), + ) + rows = {"alice": expired} + + async def _read(user_id: str) -> SSOIdentityAssertion | None: + return rows.get(user_id) + + async def _write(user_id: str, assertion: SSOIdentityAssertion) -> None: + rows[user_id] = assertion + + class _Inner: + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: + return await _read(user_id) + + class _Transport: + async def post(self, url, form, headers): + return Ok({"access_token": "at", "id_token": renewed_id_token}) + + refresher = SSOAssertionRefresher( + _Transport(), + client_config=lambda: SSOClientConfig( + token_endpoint="https://idp.example.com/token", + client_id="litellm", + client_secret=SecretStr("s"), + auth_method="client_secret_basic", + ), + read=_read, + write=_write, + ) + endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access")) + provider = UpstreamCredentialProvider( + token_endpoint=endpoint, + sso_assertion_store=RefreshingSSOAssertionStore( + _Inner(), refresher, fresh_read=_read, coordinator_factory=lambda: None + ), + ) + + result = await provider.resolve_credentials( + Subject(tenant_id="", subject_id="alice"), _spec(_id_jag_config()) + ) + + assert isinstance(result, Ok) + _, _, leg1_params = endpoint.calls[0] + assert leg1_params["subject_token"] == renewed_id_token + + +def test_the_resolver_defaults_to_the_renewing_assertion_store(): + """A resolver built without collaborators is what production gets, so the default has to renew; + the plain database reader would strand every agent an id_token lifetime after its user's login.""" + provider = UpstreamCredentialProvider() + + assert isinstance(provider._sso_assertion_store, RefreshingSSOAssertionStore) # noqa: SLF001 # the wiring is the assertion + + @pytest.mark.asyncio async def test_id_jag_accepts_a_stored_assertion_that_declares_no_expiry(): endpoint = _FakeTokenEndpoint(_two_leg_ok("final-access")) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_refresher.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_refresher.py new file mode 100644 index 00000000000..d80913f8d33 --- /dev/null +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_sso_assertion_refresher.py @@ -0,0 +1,794 @@ +"""Tests for renewing the stored SSO identity assertion behind the ID-JAG arm. + +Pins the contract an unattended agent depends on: an assertion that has run out is renewed from the +refresh token captured beside it instead of stranding the agent until its user signs in again, the +IdP sees one redemption per user no matter how many tool calls arrive at once, a rotation is written +back without overwriting a sign-in that landed mid-renewal, and the two failure kinds stay +distinguishable - a dead refresh token still challenges the user, an unreachable IdP does not. +""" + +import asyncio +import base64 +import itertools +import logging +import time +from collections.abc import Awaitable, Callable, Mapping +from datetime import datetime, timedelta, timezone + +import httpx +import jwt as pyjwt +import pytest +from pydantic import SecretStr + +from litellm.proxy._experimental.mcp_server.outbound_credentials.result import ( + Error, + Ok, + Result, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_refresher import ( + HttpxTokenEndpointTransport, + RefreshFailure, + RefreshingSSOAssertionStore, + SSOAssertionRefresher, + SSOClientConfig, + default_sso_assertion_store, + sso_client_config, +) +from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import ( + AssertionStoreUnavailable, + SSOIdentityAssertion, +) + +SIGNING_KEY = "test-idp-signing-key-32-bytes-long-xxxx" +ISSUER = "https://idp.example.com" +TOKEN_ENDPOINT = "https://idp.example.com/token" + +_CLIENT = SSOClientConfig( + token_endpoint=TOKEN_ENDPOINT, + client_id="litellm", + client_secret=SecretStr("s3cret"), + auth_method="client_secret_basic", +) +_POST_CLIENT = SSOClientConfig( + token_endpoint=TOKEN_ENDPOINT, + client_id="litellm", + client_secret=SecretStr("s3cret"), + auth_method="client_secret_post", +) + + +_MINTED = itertools.count() + + +def _id_token(subject: str = "u1", exp_offset: int = 3600) -> str: + """A distinct token per call. Two mints with the same claims in the same second would encode + identically, which would let a test that means "the renewed token replaced the old one" pass + while comparing a value to itself.""" + return pyjwt.encode( + {"iss": ISSUER, "sub": subject, "exp": int(time.time()) + exp_offset, "jti": f"t{next(_MINTED)}"}, + SIGNING_KEY, + algorithm="HS256", + ) + + +def _stored(id_token: str, *, expires_in: int, refresh_token: str | None = "rt_1") -> SSOIdentityAssertion: + """A row as the SSO callback wrote it: ``expires_in`` seconds from now, mirroring the id_token.""" + return SSOIdentityAssertion( + id_token=SecretStr(id_token), + refresh_token=SecretStr(refresh_token) if refresh_token else None, + issuer=ISSUER, + expires_at=datetime.now(timezone.utc) + timedelta(seconds=expires_in), + ) + + +class _FakeRows: + """The one assertion row per user: the inner read seam and the refresher's read/write pair.""" + + def __init__(self, rows: dict[str, SSOIdentityAssertion] | None = None) -> None: + self.rows: dict[str, SSOIdentityAssertion] = dict(rows or {}) + self.cached_rows: dict[str, SSOIdentityAssertion] = {} + self.reads: list[str] = [] + self.writes: list[tuple[str, SSOIdentityAssertion]] = [] + + async def fetch(self, user_id: str) -> SSOIdentityAssertion | None: + self.reads.append(user_id) + # A real suspension point, so concurrent callers interleave here instead of running to + # completion one at a time and never actually racing. + await asyncio.sleep(0) + return self.cached_rows.get(user_id, self.rows.get(user_id)) + + async def fetch_fresh(self, user_id: str) -> SSOIdentityAssertion | None: + self.reads.append(user_id) + await asyncio.sleep(0) + return self.rows.get(user_id) + + async def write(self, user_id: str, assertion: SSOIdentityAssertion) -> None: + self.writes.append((user_id, assertion)) + self.rows[user_id] = assertion + + +class _FakeTransport: + """Answers every refresh with the same canned result, optionally holding until ``gate`` opens.""" + + def __init__( + self, + response: Result[Mapping[str, object], RefreshFailure], + *, + gate: asyncio.Event | None = None, + on_call: Callable[[], None] | None = None, + ) -> None: + self._response = response + self._gate = gate + self._on_call = on_call + self.calls: list[tuple[str, dict[str, str]]] = [] + self.headers: list[dict[str, str]] = [] + + async def post( + self, url: str, form: Mapping[str, str], headers: Mapping[str, str] + ) -> Result[Mapping[str, object], RefreshFailure]: + self.calls.append((url, dict(form))) + self.headers.append(dict(headers)) + if self._on_call is not None: + self._on_call() + if self._gate is not None: + await self._gate.wait() + return self._response + + +def _renewal(id_token: str, refresh_token: str | None = None) -> Result[Mapping[str, object], RefreshFailure]: + body: dict[str, object] = {"access_token": "at", "id_token": id_token, "token_type": "Bearer"} + return Ok({**body, "refresh_token": refresh_token} if refresh_token else body) + + +def _store( + rows: _FakeRows, + transport: _FakeTransport, + *, + client_config: Callable[[], SSOClientConfig | None] = lambda: _CLIENT, + coordinator_factory: Callable[[], object] = lambda: None, +) -> RefreshingSSOAssertionStore: + refresher = SSOAssertionRefresher(transport, client_config=client_config, read=rows.fetch, write=rows.write) + return RefreshingSSOAssertionStore( + rows, + refresher, + fresh_read=rows.fetch_fresh, + coordinator_factory=coordinator_factory, # pyright: ignore[reportArgumentType] # test doubles stand in for the runtime factory + ) + + +async def _until(predicate: Callable[[], bool]) -> None: + for _ in range(2000): + if predicate(): + return + await asyncio.sleep(0) + raise AssertionError("condition never became true") + + +@pytest.mark.asyncio +async def test_an_expiring_assertion_is_renewed_and_the_renewal_is_what_the_reader_gets(): + """The whole point: an agent calling after its user's id_token ran out keeps working.""" + stale, fresh = _id_token(exp_offset=-1), _id_token() + rows = _FakeRows({"alice": _stored(stale, expires_in=-1)}) + transport = _FakeTransport(_renewal(fresh)) + + served = await _store(rows, transport).fetch("alice") + + assert served is not None + assert served.id_token.get_secret_value() == fresh + assert len(transport.calls) == 1 + url, form = transport.calls[0] + assert url == TOKEN_ENDPOINT + assert form["grant_type"] == "refresh_token" + assert form["refresh_token"] == "rt_1" + + +@pytest.mark.asyncio +async def test_a_basic_auth_login_gets_a_basic_auth_refresh(): + """The non-PKCE login always sends HTTP Basic, so the renewal must too; credentials in the body + would 401 against an IdP application registered for Basic.""" + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token())) + + await _store(rows, transport).fetch("alice") + + expected = base64.b64encode(b"litellm:s3cret").decode() + assert transport.headers[0]["Authorization"] == f"Basic {expected}" + _url, form = transport.calls[0] + assert "client_secret" not in form + assert "client_id" not in form + + +@pytest.mark.asyncio +async def test_a_body_credential_login_gets_a_body_credential_refresh(): + """The mirror case. A PKCE deployment with GENERIC_INCLUDE_CLIENT_ID set signs in with the + credentials in the body, so Basic here would 401 against an application registered for post; the + renewal has to follow the login rather than a constant.""" + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token())) + + await _store(rows, transport, client_config=lambda: _POST_CLIENT).fetch("alice") + + assert "Authorization" not in transport.headers[0] + _url, form = transport.calls[0] + assert form["client_id"] == "litellm" + assert form["client_secret"] == "s3cret" + + +@pytest.mark.parametrize( + ("include_client_id", "expected"), + [ + (None, "client_secret_basic"), + ("false", "client_secret_basic"), + ("TRUE", "client_secret_post"), + ("true", "client_secret_post"), + ], +) +def test_the_auth_method_follows_the_flag_the_login_reads(include_client_id, expected): + """``GENERIC_INCLUDE_CLIENT_ID`` is what the PKCE login branches on, parsed the same way it + parses it, so the renewal cannot pick a method the sign-in did not use.""" + env = { + "GENERIC_TOKEN_ENDPOINT": TOKEN_ENDPOINT, + "GENERIC_CLIENT_ID": "litellm", + "GENERIC_CLIENT_SECRET": "s3cret", + **({"GENERIC_INCLUDE_CLIENT_ID": include_client_id} if include_client_id is not None else {}), + } + + config = sso_client_config(env) + + assert config is not None + assert config.auth_method == expected + + +@pytest.mark.asyncio +async def test_an_assertion_well_inside_its_lifetime_never_reaches_the_idp(): + """The common path must cost exactly what it did before this store existed.""" + current = _id_token() + rows = _FakeRows({"alice": _stored(current, expires_in=1800)}) + transport = _FakeTransport(_renewal(_id_token())) + + served = await _store(rows, transport).fetch("alice") + + assert served is not None + assert served.id_token.get_secret_value() == current + assert transport.calls == [] + assert rows.writes == [] + + +@pytest.mark.asyncio +async def test_renewal_starts_inside_the_skew_rather_than_after_expiry(): + """A token that would die between resolution and the second exchange leg is replaced first.""" + about_to_expire, fresh = _id_token(), _id_token() + assert about_to_expire != fresh + rows = _FakeRows({"alice": _stored(about_to_expire, expires_in=30)}) + transport = _FakeTransport(_renewal(fresh)) + + served = await _store(rows, transport).fetch("alice") + + assert served is not None + assert served.id_token.get_secret_value() == fresh + + +@pytest.mark.asyncio +async def test_a_user_with_no_stored_assertion_is_still_absent(): + rows = _FakeRows() + transport = _FakeTransport(_renewal(_id_token())) + + assert await _store(rows, transport).fetch("nobody") is None + assert transport.calls == [] + + +@pytest.mark.asyncio +async def test_a_refused_refresh_leaves_the_expired_assertion_for_the_reader_to_reject(): + """A dead refresh token is the user's problem, and the reader's expiry guard is what tells them; + swapping in a renewed-looking value or hiding the row would break that challenge.""" + stale = _id_token(exp_offset=-1) + rows = _FakeRows({"alice": _stored(stale, expires_in=-1)}) + transport = _FakeTransport(Error(RefreshFailure.of_rejected("the IdP refused the refresh with status 400"))) + + served = await _store(rows, transport).fetch("alice") + + assert served is not None + assert served.id_token.get_secret_value() == stale + assert rows.writes == [] + + +@pytest.mark.asyncio +async def test_an_unreachable_idp_is_a_store_outage_not_a_sign_in_again_challenge(): + """503, not 412: the user has nothing to fix by signing in again while the IdP is down.""" + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(Error(RefreshFailure.of_unavailable("the IdP token endpoint is unreachable"))) + + with pytest.raises(AssertionStoreUnavailable): + await _store(rows, transport).fetch("alice") + + +@pytest.mark.asyncio +async def test_a_missing_refresh_token_names_the_scope_the_operator_has_to_set(caplog): + """Nothing to redeem is the default state of a deployment, so the log has to say what to change + or the feature stays silently inert.""" + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1, refresh_token=None)}) + transport = _FakeTransport(_renewal(_id_token())) + + with caplog.at_level(logging.WARNING): + served = await _store(rows, transport).fetch("alice") + + assert served is not None + assert transport.calls == [] + assert "GENERIC_SCOPE" in caplog.text + assert "offline_access" in caplog.text + + +@pytest.mark.asyncio +async def test_an_unconfigured_sso_client_never_calls_the_idp(caplog): + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token())) + + with caplog.at_level(logging.WARNING): + served = await _store(rows, transport, client_config=lambda: None).fetch("alice") + + assert served is not None + assert transport.calls == [] + assert "GENERIC_TOKEN_ENDPOINT" in caplog.text + + +@pytest.mark.asyncio +async def test_a_refresh_response_carrying_no_id_token_is_refused(caplog): + """An access token is not an identity assertion, so there is nothing to assert upstream.""" + stale = _id_token(exp_offset=-1) + rows = _FakeRows({"alice": _stored(stale, expires_in=-1)}) + transport = _FakeTransport(Ok({"access_token": "at", "token_type": "Bearer"})) + + with caplog.at_level(logging.WARNING): + served = await _store(rows, transport).fetch("alice") + + assert served is not None + assert served.id_token.get_secret_value() == stale + assert rows.writes == [] + assert "openid" in caplog.text + + +@pytest.mark.asyncio +async def test_a_rotated_refresh_token_replaces_the_stored_one(): + """An IdP that rotates invalidates the old token, so keeping it would cost a sign-in next time.""" + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token(), refresh_token="rt_2")) + + await _store(rows, transport).fetch("alice") + + stored = rows.rows["alice"] + assert stored.refresh_token is not None + assert stored.refresh_token.get_secret_value() == "rt_2" + + +@pytest.mark.asyncio +async def test_an_omitted_refresh_token_carries_the_previous_one_forward(): + """An IdP that does not rotate expects the original to keep working; dropping it would strand + the user after exactly one renewal.""" + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token())) + + await _store(rows, transport).fetch("alice") + + stored = rows.rows["alice"] + assert stored.refresh_token is not None + assert stored.refresh_token.get_secret_value() == "rt_1" + + +@pytest.mark.asyncio +async def test_the_renewed_expiry_moves_forward_so_the_next_read_does_not_refresh_again(): + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token(exp_offset=3600))) + store = _store(rows, transport) + + await store.fetch("alice") + await store.fetch("alice") + + assert len(transport.calls) == 1 + + +async def _explode(user_id: str, assertion: SSOIdentityAssertion) -> None: + raise RuntimeError("write failed") + + +@pytest.mark.asyncio +async def test_a_renewal_that_cannot_be_recorded_is_reported_as_transient(): + """The store is what every caller reads, so a renewal nobody can see is not a success. Calling it + one would hand back a token the gateway failed to record.""" + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + refresher = SSOAssertionRefresher( + _FakeTransport(_renewal(_id_token())), client_config=lambda: _CLIENT, read=rows.fetch, write=_explode + ) + + outcome = await refresher.refresh("alice", rows.rows["alice"]) + + assert isinstance(outcome, Error) + assert outcome.error.kind == "unavailable" + + +@pytest.mark.asyncio +async def test_a_failed_write_does_not_tell_the_user_to_sign_in_again(): + """A database that cannot take the write is not something signing in again fixes, so the reader + has to see an outage rather than the stale row's expiry.""" + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token())) + refresher = SSOAssertionRefresher(transport, client_config=lambda: _CLIENT, read=rows.fetch, write=_explode) + store = RefreshingSSOAssertionStore( + rows, + refresher, + fresh_read=rows.fetch_fresh, + coordinator_factory=lambda: None, # pyright: ignore[reportArgumentType] # test double stands in for the runtime factory + ) + + with pytest.raises(AssertionStoreUnavailable): + await store.fetch("alice") + + assert len(transport.calls) == 1 + + +@pytest.mark.asyncio +async def test_concurrent_reads_for_one_user_redeem_the_refresh_token_once(): + """A burst of tool calls must not replay one refresh token N times: an IdP that rotates reads + that as reuse and can revoke the whole grant chain.""" + gate = asyncio.Event() + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + fresh = _id_token() + transport = _FakeTransport(_renewal(fresh), gate=gate) + store = _store(rows, transport) + + callers = [asyncio.create_task(store.fetch("alice")) for _ in range(8)] + await _until(lambda: len(transport.calls) >= 1 and len(rows.reads) >= 8) + # Guards against a vacuous pass: every caller must have read the expired row and entered the + # renewal branch while the winner is still blocked, otherwise they never raced at all. + assert len(rows.reads) >= 8 + assert not any(task.done() for task in callers) + + gate.set() + served = await asyncio.gather(*callers) + + assert len(transport.calls) == 1 + assert {assertion.id_token.get_secret_value() for assertion in served if assertion is not None} == {fresh} + + +@pytest.mark.asyncio +async def test_concurrent_reads_for_different_users_each_get_their_own_refresh(): + """Single-flight is per user; collapsing across users would leave everyone but one stranded.""" + gate = asyncio.Event() + rows = _FakeRows( + { + "alice": _stored(_id_token("alice", exp_offset=-1), expires_in=-1), + "bob": _stored(_id_token("bob", exp_offset=-1), expires_in=-1), + } + ) + transport = _FakeTransport(_renewal(_id_token()), gate=gate) + store = _store(rows, transport) + + callers = [asyncio.create_task(store.fetch(user)) for user in ("alice", "bob")] + await _until(lambda: len(transport.calls) >= 2) + gate.set() + await asyncio.gather(*callers) + + assert len(transport.calls) == 2 + assert {form["refresh_token"] for _url, form in transport.calls} == {"rt_1"} + + +@pytest.mark.asyncio +async def test_a_renewal_writes_back_when_the_row_did_not_move(): + """The refresh-then-sign-in ordering: nothing displaced the row, so the rotation must land.""" + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + fresh = _id_token() + transport = _FakeTransport(_renewal(fresh, refresh_token="rt_2")) + + served = await _store(rows, transport).fetch("alice") + + assert [user_id for user_id, _assertion in rows.writes] == ["alice"] + assert rows.rows["alice"].id_token.get_secret_value() == fresh + assert served is not None + assert served.id_token.get_secret_value() == fresh + + +@pytest.mark.asyncio +async def test_a_sign_in_landing_mid_renewal_is_not_overwritten(): + """The sign-in-then-refresh ordering. The login wrote a newer assertion while the IdP call was in + flight; overwriting it would put back a refresh token the IdP has already rotated away, costing + that user a sign-in later.""" + from_login = _stored(_id_token("alice"), expires_in=3600, refresh_token="rt_from_login") + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + + def _login_lands() -> None: + rows.rows["alice"] = from_login + + transport = _FakeTransport(_renewal(_id_token(), refresh_token="rt_2"), on_call=_login_lands) + + served = await _store(rows, transport).fetch("alice") + + assert rows.writes == [] + stored = rows.rows["alice"] + assert stored.refresh_token is not None + assert stored.refresh_token.get_secret_value() == "rt_from_login" + assert served is not None + assert served.id_token.get_secret_value() == from_login.id_token.get_secret_value() + + +class _RecordingCoordinator: + """Stands in for the cross-replica coordinator, running the winner's refresh inline.""" + + def __init__(self) -> None: + self.runs: list[tuple[str, str]] = [] + + async def run( + self, + user_id: str, + server_id: str, + refresh: Callable[[], Awaitable[None]], + reread: Callable[[], Awaitable[None]], + ) -> None: + self.runs.append((user_id, server_id)) + return await refresh() + + +class _ReplaceThenRefreshCoordinator: + """Replaces the row before running the elected refresh.""" + + def __init__(self, replace: Callable[[], None]) -> None: + self._replace = replace + self.runs: list[tuple[str, str]] = [] + + async def run( + self, + user_id: str, + server_id: str, + refresh: Callable[[], Awaitable[None]], + reread: Callable[[], Awaitable[None]], + ) -> None: + self.runs.append((user_id, server_id)) + self._replace() + return await refresh() + + +class _HeldCoordinator: + """Emulates a cross-replica holder finishing before the loser re-reads.""" + + def __init__(self, before_reread: Callable[[], None] | None = None) -> None: + self._before_reread = before_reread + self.runs: list[tuple[str, str]] = [] + + async def run( + self, + user_id: str, + server_id: str, + refresh: Callable[[], Awaitable[None]], + reread: Callable[[], Awaitable[None]], + ) -> None: + self.runs.append((user_id, server_id)) + if self._before_reread is not None: + self._before_reread() + return await reread() + + +@pytest.mark.asyncio +async def test_an_elected_renewal_redeems_the_row_it_re_reads_not_the_one_it_entered_with(): + stale = _id_token(exp_offset=-1) + fresh = _stored(_id_token(), expires_in=3600, refresh_token="rt_2") + rows = _FakeRows({"alice": _stored(stale, expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token())) + coordinator = _ReplaceThenRefreshCoordinator(lambda: rows.rows.__setitem__("alice", fresh)) + + served = await _store(rows, transport, coordinator_factory=lambda: coordinator).fetch("alice") + + assert transport.calls == [] + assert served is not None + assert served.id_token.get_secret_value() == fresh.id_token.get_secret_value() + + +@pytest.mark.asyncio +async def test_a_cross_replica_loser_whose_winner_renewed_reads_the_renewal_without_redeeming(): + fresh = _stored(_id_token(), expires_in=3600, refresh_token="rt_2") + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token())) + coordinator = _HeldCoordinator(before_reread=lambda: rows.rows.__setitem__("alice", fresh)) + + served = await _store(rows, transport, coordinator_factory=lambda: coordinator).fetch("alice") + + assert served is fresh + assert transport.calls == [] + assert len(coordinator.runs) == 1 + + +@pytest.mark.asyncio +async def test_a_cross_replica_loser_rereads_past_a_stale_process_local_cache(): + stale = _stored(_id_token(exp_offset=-1), expires_in=-1) + fresh = _stored(_id_token(), expires_in=3600, refresh_token="rt_2") + rows = _FakeRows({"alice": fresh}) + rows.cached_rows["alice"] = stale + transport = _FakeTransport(_renewal(_id_token())) + coordinator = _HeldCoordinator() + + served = await _store(rows, transport, coordinator_factory=lambda: coordinator).fetch("alice") + + assert served is fresh + assert transport.calls == [] + assert len(coordinator.runs) == 1 + + +@pytest.mark.asyncio +async def test_a_cross_replica_loser_does_not_turn_a_write_failure_into_a_sign_in_challenge(): + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token())) + refresher = SSOAssertionRefresher(transport, client_config=lambda: _CLIENT, read=rows.fetch, write=_explode) + coordinator = _HeldCoordinator() + store = RefreshingSSOAssertionStore( + rows, + refresher, + fresh_read=rows.fetch_fresh, + coordinator_factory=lambda: coordinator, # pyright: ignore[reportArgumentType] # test double stands in for the runtime factory + ) + + with pytest.raises(AssertionStoreUnavailable): + await store.fetch("alice") + + assert transport.calls == [] + assert len(coordinator.runs) == 1 + + +@pytest.mark.asyncio +async def test_a_cross_replica_loser_never_redeems_the_token_the_holder_may_have_rotated(): + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(Error(RefreshFailure.of_rejected("dead"))) + coordinator = _HeldCoordinator() + + with pytest.raises(AssertionStoreUnavailable): + await _store(rows, transport, coordinator_factory=lambda: coordinator).fetch("alice") + + assert transport.calls == [] + assert len(coordinator.runs) == 1 + + +@pytest.mark.asyncio +async def test_the_cross_replica_coordinator_is_used_and_built_once(): + """Redis elects one refresher across the fleet; rebuilding its client per renewal would open a + connection every time.""" + coordinator = _RecordingCoordinator() + builds: list[int] = [] + + def _factory() -> object: + builds.append(1) + return coordinator + + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token(exp_offset=-1))) + store = _store(rows, transport, coordinator_factory=_factory) + + await store.fetch("alice") + await store.fetch("alice") + + assert len(builds) == 1 + assert coordinator.runs == [("alice", "sso_identity_assertion"), ("alice", "sso_identity_assertion")] + + +@pytest.mark.asyncio +async def test_the_in_process_coordinator_is_retried_until_redis_appears(): + """A proxy that gains Redis after boot must stop electing a winner per worker.""" + coordinator = _RecordingCoordinator() + available: list[bool] = [False] + + def _factory() -> object | None: + return coordinator if available[0] else None + + rows = _FakeRows({"alice": _stored(_id_token(exp_offset=-1), expires_in=-1)}) + transport = _FakeTransport(_renewal(_id_token(exp_offset=-1))) + store = _store(rows, transport, coordinator_factory=_factory) + + await store.fetch("alice") + assert coordinator.runs == [] + + available[0] = True + await store.fetch("alice") + assert coordinator.runs == [("alice", "sso_identity_assertion")] + + +@pytest.mark.parametrize( + "env", + [ + {}, + {"GENERIC_CLIENT_ID": "litellm", "GENERIC_CLIENT_SECRET": "s"}, + {"GENERIC_TOKEN_ENDPOINT": TOKEN_ENDPOINT, "GENERIC_CLIENT_SECRET": "s"}, + {"GENERIC_TOKEN_ENDPOINT": TOKEN_ENDPOINT, "GENERIC_CLIENT_ID": "litellm"}, + {"GENERIC_TOKEN_ENDPOINT": "", "GENERIC_CLIENT_ID": "litellm", "GENERIC_CLIENT_SECRET": "s"}, + ], +) +def test_a_partial_sso_client_is_no_client(env): + """Redeeming against a half-configured client would post credentials nowhere useful; the arm + treats it as "cannot renew" and falls back to the sign-in challenge.""" + assert sso_client_config(env) is None + + +def test_the_configured_sso_client_is_the_one_the_login_used(): + config = sso_client_config( + { + "GENERIC_TOKEN_ENDPOINT": TOKEN_ENDPOINT, + "GENERIC_CLIENT_ID": "litellm", + "GENERIC_CLIENT_SECRET": "s3cret", + } + ) + + assert config is not None + assert config.token_endpoint == TOKEN_ENDPOINT + assert config.client_id == "litellm" + assert config.client_secret.get_secret_value() == "s3cret" + + +def test_the_live_store_renews_over_the_database_reader(): + """The composition root has to produce a renewing store, or none of this runs in production.""" + assert isinstance(default_sso_assertion_store(), RefreshingSSOAssertionStore) + + +def _responding(response: httpx.Response | None) -> HttpxTokenEndpointTransport: + async def _post(url: str, form: Mapping[str, str], headers: Mapping[str, str]) -> httpx.Response | None: + return response + + return HttpxTokenEndpointTransport(_post) + + +def _json_response(status: int, payload: dict[str, object]) -> httpx.Response: + return httpx.Response(status, json=payload, request=httpx.Request("POST", TOKEN_ENDPOINT)) + + +@pytest.mark.parametrize("status", [400, 401, 403]) +@pytest.mark.asyncio +async def test_the_idp_declining_the_grant_is_a_refusal_the_user_must_act_on(status): + """A 4xx means this refresh token is finished; calling that an outage would sit the user behind a + 503 forever instead of telling them to sign in.""" + outcome = await _responding(_json_response(status, {"error": "invalid_grant"})).post(TOKEN_ENDPOINT, {}, {}) + + assert isinstance(outcome, Error) + assert outcome.error.kind == "rejected" + + +@pytest.mark.parametrize("status", [500, 502, 503]) +@pytest.mark.asyncio +async def test_a_failing_idp_is_an_outage_not_a_refusal(status): + """The refresh token is probably fine; telling the user to sign in again would blame them for + someone else's outage, and would burn their session for nothing.""" + outcome = await _responding(_json_response(status, {})).post(TOKEN_ENDPOINT, {}, {}) + + assert isinstance(outcome, Error) + assert outcome.error.kind == "unavailable" + + +@pytest.mark.asyncio +async def test_an_unreachable_endpoint_is_an_outage(): + async def _post(url: str, form: Mapping[str, str], headers: Mapping[str, str]) -> httpx.Response | None: + raise httpx.ConnectError("connection refused") + + outcome = await HttpxTokenEndpointTransport(_post).post(TOKEN_ENDPOINT, {}, {}) + + assert isinstance(outcome, Error) + assert outcome.error.kind == "unavailable" + + +@pytest.mark.asyncio +async def test_a_non_json_body_is_an_outage(): + response = httpx.Response(200, text="maintenance", request=httpx.Request("POST", TOKEN_ENDPOINT)) + + outcome = await _responding(response).post(TOKEN_ENDPOINT, {}, {}) + + assert isinstance(outcome, Error) + assert outcome.error.kind == "unavailable" + + +@pytest.mark.asyncio +async def test_a_missing_response_is_an_outage(): + outcome = await _responding(None).post(TOKEN_ENDPOINT, {}, {}) + + assert isinstance(outcome, Error) + assert outcome.error.kind == "unavailable" + + +@pytest.mark.asyncio +async def test_a_successful_grant_is_handed_back_as_the_parsed_body(): + outcome = await _responding(_json_response(200, {"access_token": "at", "id_token": "idt"})).post( + TOKEN_ENDPOINT, {"grant_type": "refresh_token"}, {} + ) + + assert isinstance(outcome, Ok) + assert outcome.ok["id_token"] == "idt" diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 481e3591ce9..225134b4e2b 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,6 +1,6 @@ { "LIT001": { - "limit": 22181 + "limit": 22180 }, "LIT002": { "limit": 26745 @@ -9,7 +9,7 @@ "limit": 261 }, "LIT004": { - "limit": 40 + "limit": 38 }, "LIT005": { "limit": 0 @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16464 + "limit": 16462 }, "LIT011": { "limit": 5506 From 17e13126cc082134dd2686957c05e74b3e109b05 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Sat, 5 Sep 2026 12:43:09 -0700 Subject: [PATCH 258/410] feat(mcp): warn when an oauth2_id_jag server outruns the SSO provider's assertion capture (#35394) * feat(mcp): warn when an oauth2_id_jag server outruns the SSO provider's assertion capture Only the generic OIDC login path captures the IdP id_token that an oauth2_id_jag MCP server spends as its RFC 8693 subject token. Under Google, Microsoft, SAML or no SSO at all, registration succeeds and then every ID-JAG credential resolution fails for every user, with nothing in the logs, the config or the API response to say why. Report the mismatch from the two places it is knowable: when an oauth2_id_jag server is created or updated through the management endpoint, and at SSO callback time when a login hands the arm nothing while such a server is registered. Provider selection mirrors the callback's precedence, so a generic client id sitting behind GOOGLE_CLIENT_ID does not clear the warning. * test(sso): update merged CLI diagnostic patch target Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(mcp): warn about the ID-JAG capture gap for config-declared servers and on the SSO debug page (#39350) * feat(sso): surface the ID-JAG capture gap on the SSO debug page /sso/debug/callback is where an operator lands when they are already trying to work out why ID-JAG is failing, so the reason belongs on it. The annotation appears only when the active SSO provider captures no identity assertion AND an oauth2_id_jag server is registered for that gap to break; a deployment without both renders the page it rendered before, byte for byte. Only the provider name and the remedy are rendered, never a configured value, and an unreachable MCP table costs the page its annotation rather than the page itself. The payload carries the one mutable-ok in this work. Conditionally including a member of a JSON document has to construct a mapping, and the rejected alternatives are recorded on the helper so the next reader does not rediscover them. Held out of the diagnosability PR deliberately: that PR is already reviewed and green, and this surface ships with the remaining config-load warning as one follow-up. * feat(mcp): warn at config load when an oauth2_id_jag server outruns the SSO provider's assertion capture Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(sso): trim comments on the ID-JAG debug page diagnostic Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(sso): clean up merged imports Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(sso): satisfy type discipline for diagnostic payload Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(sso): keep the optional ID-JAG payload member on one line for ruff format Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(sso): use Python 3.10-compatible assert_never Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Yassin Kortam Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(sso): keep the ID-JAG capture-gap diagnostic out of the unauthenticated debug page Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(sso): inject the retention check and log via caplog so the ID-JAG tests pass the test-quality gate Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(sso): keep the debug-page outage test on the capture-gap path Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(sso): annotate the retention check type alias Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../mcp_server/mcp_server_manager.py | 18 + .../mcp_management_endpoints.py | 22 + .../sso/id_jag_assertion_capture.py | 81 ++++ litellm/proxy/management_endpoints/ui_sso.py | 50 ++- .../mcp_server/test_mcp_server_manager.py | 90 +++++ .../test_id_jag_assertion_capture.py | 117 ++++++ .../test_mcp_management_endpoints.py | 150 +++++++ .../proxy/management_endpoints/test_ui_sso.py | 380 +++++++++++++++++- 8 files changed, 899 insertions(+), 9 deletions(-) create mode 100644 litellm/proxy/management_endpoints/sso/id_jag_assertion_capture.py create mode 100644 tests/test_litellm/proxy/management_endpoints/test_id_jag_assertion_capture.py diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index bcbcc6bc579..dc1e8db1628 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -159,6 +159,9 @@ from litellm.proxy._types import ( from litellm.proxy.auth.ip_address_utils import IPAddressUtils from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper from litellm.proxy.common_utils.user_api_key_cache import get_management_object_ttl +from litellm.proxy.management_endpoints.sso.id_jag_assertion_capture import ( + id_jag_assertion_capture_gap_at_startup, +) from litellm.proxy.utils import PrismaClient, ProxyLogging, get_server_root_path from litellm.repositories.table_repositories import MCPServerRepository from litellm.types.llms.custom_http import httpxSpecialProvider @@ -1382,6 +1385,20 @@ def _warn_internal_delegate_pkce_if_applicable(server: MCPServer, *, source: str ) +def _warn_config_id_jag_server_outruns_sso(server: MCPServer) -> None: + if server.auth_type != MCPAuth.oauth2_id_jag: + return + gap: Final = id_jag_assertion_capture_gap_at_startup() + if gap is None: + return + verbose_logger.warning( + "MCP server %r (id=%s, source=config) is declared with auth_type=oauth2_id_jag, but %s.", + get_server_prefix(server), + server.server_id, + gap, + ) + + def _deserialize_json_dict(data: str | _StringMap | None) -> dict[str, str] | None: """ Deserialize optional JSON mappings stored in the database. @@ -2393,6 +2410,7 @@ class MCPServerManager: ) self._assign_unique_short_prefix(new_server) _warn_internal_delegate_pkce_if_applicable(new_server, source="config") + _warn_config_id_jag_server_outruns_sso(new_server) self.config_mcp_servers[server_id] = new_server self._set_oauth_discovery_deferred( server_id, diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 40cc2e57932..ae266792391 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -64,6 +64,9 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import ( decrypt_value_helper, encrypt_value_helper, ) +from litellm.proxy.management_endpoints.sso.id_jag_assertion_capture import ( + id_jag_assertion_capture_gap, +) from litellm.proxy.management_helpers.audit_logs import ( get_audit_log_changed_by, is_audit_logging_enabled, @@ -272,6 +275,22 @@ if MCP_AVAILABLE: _validate_mcp_server_name_fields(payload) _validate_upstream_token_header(payload) + def warn_if_id_jag_server_outruns_sso(server_id: str | None, auth_type: MCPAuth | str | None) -> None: + """Registering an ``oauth2_id_jag`` server under an SSO provider that captures no IdP + identity assertion is a dead configuration: nothing here fails, and then every ID-JAG call + fails for every user with a message that only ever tells them to sign in again. Say it once, + at the moment the admin can still act on it.""" + if auth_type != MCPAuth.oauth2_id_jag: + return + gap = id_jag_assertion_capture_gap() + if gap is None: + return + verbose_proxy_logger.warning( + "MCP server %s is registered with auth_type=oauth2_id_jag, but %s.", + server_id, + gap, + ) + def stamp_omitted_oauth2_flow(payload: NewMCPServerRequest) -> None: """Fallback only: fill in oauth2_flow when an oauth2 create omits it. @@ -1623,6 +1642,8 @@ if MCP_AVAILABLE: detail={"error": f"Error creating mcp server: {e}"}, ) + warn_if_id_jag_server_outruns_sso(new_mcp_server.server_id, new_mcp_server.auth_type) + # Registry refresh is best-effort: the row is already committed, so a # failure here (e.g. an unrelated malformed row in the table) must not # surface as a 500 and orphan the created server, which would push the @@ -2726,6 +2747,7 @@ if MCP_AVAILABLE: status_code=status.HTTP_404_NOT_FOUND, detail={"error": f"MCP Server not found, passed server_id={payload.server_id}"}, ) + warn_if_id_jag_server_outruns_sso(mcp_server_record_updated.server_id, mcp_server_record_updated.auth_type) await global_mcp_server_manager.update_server(mcp_server_record_updated) # Ensure registry is up to date by reloading from database diff --git a/litellm/proxy/management_endpoints/sso/id_jag_assertion_capture.py b/litellm/proxy/management_endpoints/sso/id_jag_assertion_capture.py new file mode 100644 index 00000000000..404fdfc83a9 --- /dev/null +++ b/litellm/proxy/management_endpoints/sso/id_jag_assertion_capture.py @@ -0,0 +1,81 @@ +"""Whether the SSO provider the login callback dispatches to can capture an IdP identity assertion. + +An ``oauth2_id_jag`` MCP server spends the ``id_token`` captured at SSO login as its RFC 8693 +subject token. Only the generic OIDC login path reaches a token response the gateway retains one +from, so a deployment whose SSO runs through Google, Microsoft or SAML never stores an assertion +and every store-sourced ID-JAG exchange fails for every user, however many times they sign in. +Neither side can see that alone: the MCP registration knows nothing about SSO and the login knows +nothing about MCP. This module is the one shared answer both warn from. +""" + +from __future__ import annotations + +import os +from enum import Enum + +from typing_extensions import assert_never + +from litellm.proxy.management_endpoints.sso.saml_sso import SAMLAuthHandler + +_GENERIC_OIDC_REMEDY = ( + "Point SSO at the generic OIDC provider (GENERIC_CLIENT_ID), the one login path whose token " + "response the gateway retains an id_token from" +) + + +class ActiveSSOProvider(str, Enum): + google = "google" + microsoft = "microsoft" + generic = "generic" + saml = "saml" + none = "none" + + +def active_sso_provider() -> ActiveSSOProvider: + """The provider the SSO callback will dispatch to. + + Mirrors the callback's precedence rather than reporting everything configured: an environment + carrying both GOOGLE_CLIENT_ID and GENERIC_CLIENT_ID runs the Google branch, so it must report + Google. Presence is judged the way the callback judges it, so a client id set to the empty + string still selects that branch here. + """ + if os.getenv("GOOGLE_CLIENT_ID") is not None: + return ActiveSSOProvider.google + if os.getenv("MICROSOFT_CLIENT_ID") is not None: + return ActiveSSOProvider.microsoft + if os.getenv("GENERIC_CLIENT_ID") is not None: + return ActiveSSOProvider.generic + if SAMLAuthHandler.is_saml_configured(): + return ActiveSSOProvider.saml + return ActiveSSOProvider.none + + +def id_jag_assertion_capture_gap() -> str | None: + """Why ID-JAG cannot work under the active SSO provider, phrased for an operator reading a log, + or ``None`` when that provider does capture an assertion.""" + provider = active_sso_provider() + match provider: + case ActiveSSOProvider.generic: + return None + case ActiveSSOProvider.none: + return ( + "no SSO provider is configured, so no IdP identity assertion is ever captured and " + f"ID-JAG credential resolution fails for every user. {_GENERIC_OIDC_REMEDY}" + ) + case ActiveSSOProvider.google | ActiveSSOProvider.microsoft | ActiveSSOProvider.saml: + return ( + f"the active SSO provider ({provider.value}) has no identity-assertion capture path, so no " + "IdP id_token is ever stored and ID-JAG credential resolution fails for every user no matter " + f"how often they sign in. {_GENERIC_OIDC_REMEDY}" + ) + case _: + assert_never(provider) + + +def id_jag_assertion_capture_gap_at_startup() -> str | None: + """Config load runs before SSO settings stored in the database are reconciled into the process + environment, so an unresolved provider at that point is not yet a gap; the SSO callback reports it + once a login happens.""" + if active_sso_provider() is ActiveSSOProvider.none: + return None + return id_jag_assertion_capture_gap() diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 84150ef7935..3e6434a5afd 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -16,7 +16,7 @@ import json import os import re import secrets -from collections.abc import Mapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, Sequence from copy import deepcopy from html import escape from types import MappingProxyType @@ -29,6 +29,7 @@ from typing import ( NoReturn, Optional, Protocol, + TypeAlias, Union, cast, overload, @@ -70,6 +71,7 @@ from litellm.llms.custom_httpx.http_handler import ( from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import ( SSOIdentityAssertion, assertion_from_sso_login, + ema_assertion_retention_enabled, retain_sso_identity_assertion_for_ema, ) from litellm.proxy._types import ( @@ -105,6 +107,9 @@ from litellm.proxy.common_utils.html_forms.ui_login import build_ui_login_form from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache from litellm.proxy.management_endpoints.internal_user_endpoints import new_user from litellm.proxy.management_endpoints.sso import CustomMicrosoftSSO +from litellm.proxy.management_endpoints.sso.id_jag_assertion_capture import ( + id_jag_assertion_capture_gap, +) from litellm.proxy.management_endpoints.sso.saml_sso import SAMLAuthHandler from litellm.proxy.management_endpoints.sso_helper_utils import ( check_is_admin_only_access, @@ -1677,6 +1682,46 @@ async def get_generic_sso_response( return result or {}, received_response, access_token_payload, sso_assertion +RetentionCheck: TypeAlias = Callable[[], Awaitable[bool]] # mutable-ok: Callable parameter syntax + + +async def warn_if_id_jag_assertion_uncaptured( + assertion: SSOIdentityAssertion | None, *, retention_enabled: RetentionCheck | None = None +) -> None: + """Say, at the one moment it is knowable, that this login gave an ``oauth2_id_jag`` server + nothing to spend. Without it the operator only ever sees the per-request failure, which cannot + tell a user who has never signed in from a provider that will never capture. Kept strictly + diagnostic: a store outage is swallowed, since a login must not fail over a log line.""" + if assertion is not None: + return + try: + check: Final = retention_enabled if retention_enabled is not None else ema_assertion_retention_enabled + if not await check(): + return + except Exception as exc: # noqa: BLE001 # diagnostics must never break the login + verbose_proxy_logger.debug("Could not check for oauth2_id_jag MCP servers after SSO login: %s", exc) + return + gap: Final = id_jag_assertion_capture_gap() + verbose_proxy_logger.warning( + "SSO login captured no IdP identity assertion while an oauth2_id_jag MCP server is registered: %s", + gap if gap is not None else "the identity provider's token response carried no usable id_token", + ) + + +async def warn_if_id_jag_capture_gap(*, retention_enabled: RetentionCheck | None = None) -> None: + gap: Final = id_jag_assertion_capture_gap() + if gap is None: + return + try: + check: Final = retention_enabled if retention_enabled is not None else ema_assertion_retention_enabled + if not await check(): + return + except Exception as exc: # noqa: BLE001 # diagnostics must never break the page they annotate + verbose_proxy_logger.debug("Could not check for oauth2_id_jag MCP servers: %s", exc) + return + verbose_proxy_logger.warning("SSO debug callback ran with an oauth2_id_jag capture gap: %s", gap) + + async def create_team_member_add_task(team_id, user_info): """Create a task for adding a member to a team.""" try: @@ -2269,6 +2314,7 @@ async def _complete_cli_sso_callback_session( raise HTTPException(status_code=500, detail="Failed to retrieve user information from SSO") await retain_sso_identity_assertion_for_ema(user_id=user_info.user_id, assertion=sso_assertion) + await warn_if_id_jag_assertion_uncaptured(sso_assertion) teams: list[str] = [] if hasattr(user_info, "teams") and user_info.teams: @@ -3599,6 +3645,7 @@ class SSOAuthenticationHandler: if isinstance(user_id, str) and user_id: await retain_sso_identity_assertion_for_ema(user_id=user_id, assertion=sso_assertion) + await warn_if_id_jag_assertion_uncaptured(sso_assertion) disabled_non_admin_personal_key_creation: Final = get_disabled_non_admin_personal_key_creation() litellm_dashboard_ui = get_custom_url(request_base_url=str(request.base_url), route="ui/") @@ -4733,6 +4780,7 @@ async def debug_sso_callback(request: Request): safe_raw_claims: Final = {k: v for k, v in (received_response or {}).items() if k not in _OAUTH_TOKEN_FIELDS} safe_access_token_claims = {k: v for k, v in (access_token_payload or {}).items() if k not in _OAUTH_TOKEN_FIELDS} + await warn_if_id_jag_capture_gap() sso_payload: Final = { "parsed_by_proxy": filtered_result, "raw_claims": safe_raw_claims, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 34dc067e7a3..e3ed48713aa 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -461,6 +461,30 @@ class TestMCPServerManager: base.update(overrides) return {"m2mserver": base} + def _id_jag_config(self): + return { + "idjag_server": { + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + "auth_type": MCPAuth.oauth2_id_jag, + "client_id": "cid", + "client_secret": "csec", + "token_exchange_endpoint": "https://idp.example.com/token", + "id_jag_resource_token_endpoint": "https://resource.example.com/token", + "id_jag_resource": "https://resource.example.com", + } + } + + def _clear_sso_env(self, monkeypatch): + for env_var in ( + "GOOGLE_CLIENT_ID", + "MICROSOFT_CLIENT_ID", + "GENERIC_CLIENT_ID", + "SAML_IDP_METADATA_URL", + "SAML_IDP_METADATA_XML", + ): + monkeypatch.delenv(env_var, raising=False) + @pytest.mark.parametrize("value", ["1", "true", "TRUE", "yes", "on"]) def test_mcp_oauth_discovery_on_startup_true_values(self, value): with patch.dict(os.environ, {"LITELLM_MCP_OAUTH_DISCOVERY_ON_STARTUP": value}): @@ -1130,6 +1154,72 @@ class TestMCPServerManager: server = next(iter(manager.config_mcp_servers.values())) assert server.oauth2_flow is None + @pytest.mark.asyncio + async def test_load_servers_from_config_warns_for_id_jag_with_google_sso(self, monkeypatch, caplog): + self._clear_sso_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-cid") + manager = MCPServerManager() + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)), + patch.object(manager, "_hydrate_config_servers_dcr_clients", new=AsyncMock()), + caplog.at_level(logging.WARNING, logger="LiteLLM"), + ): + await manager.load_servers_from_config(self._id_jag_config()) + + warnings = [message for message in caplog.messages if "oauth2_id_jag" in message] + assert len(warnings) == 1 + assert "idjag_server" in warnings[0] + assert "GENERIC_CLIENT_ID" in warnings[0] + + @pytest.mark.asyncio + async def test_load_servers_from_config_does_not_warn_for_id_jag_without_sso(self, monkeypatch, caplog): + self._clear_sso_env(monkeypatch) + manager = MCPServerManager() + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)), + patch.object(manager, "_hydrate_config_servers_dcr_clients", new=AsyncMock()), + caplog.at_level(logging.WARNING, logger="LiteLLM"), + ): + await manager.load_servers_from_config(self._id_jag_config()) + + assert not any("oauth2_id_jag" in message for message in caplog.messages) + + @pytest.mark.asyncio + async def test_load_servers_from_config_does_not_warn_for_api_key_with_google_sso(self, monkeypatch, caplog): + self._clear_sso_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-cid") + manager = MCPServerManager() + config = { + "api_key_server": { + "url": "https://example.com/mcp", + "transport": MCPTransport.http, + "auth_type": MCPAuth.api_key, + "auth_value": "upstream-secret", + } + } + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)), + patch.object(manager, "_hydrate_config_servers_dcr_clients", new=AsyncMock()), + caplog.at_level(logging.WARNING, logger="LiteLLM"), + ): + await manager.load_servers_from_config(config) + + assert not any("oauth2_id_jag" in message for message in caplog.messages) + + @pytest.mark.asyncio + async def test_load_servers_from_config_does_not_warn_for_id_jag_with_generic_sso(self, monkeypatch, caplog): + self._clear_sso_env(monkeypatch) + monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid") + manager = MCPServerManager() + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)), + patch.object(manager, "_hydrate_config_servers_dcr_clients", new=AsyncMock()), + caplog.at_level(logging.WARNING, logger="LiteLLM"), + ): + await manager.load_servers_from_config(self._id_jag_config()) + + assert not any("oauth2_id_jag" in message for message in caplog.messages) + def _client_forwarded_config(self, auth_type, **overrides): base = { "url": "https://example.com/mcp", diff --git a/tests/test_litellm/proxy/management_endpoints/test_id_jag_assertion_capture.py b/tests/test_litellm/proxy/management_endpoints/test_id_jag_assertion_capture.py new file mode 100644 index 00000000000..ff4fbbfb695 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_id_jag_assertion_capture.py @@ -0,0 +1,117 @@ +import pytest + +from litellm.proxy.management_endpoints.sso.id_jag_assertion_capture import ( + ActiveSSOProvider, + active_sso_provider, + id_jag_assertion_capture_gap, + id_jag_assertion_capture_gap_at_startup, +) + +_SSO_ENV_VARS = ( + "GOOGLE_CLIENT_ID", + "MICROSOFT_CLIENT_ID", + "GENERIC_CLIENT_ID", + "SAML_IDP_METADATA_URL", + "SAML_IDP_METADATA_XML", +) + + +@pytest.fixture(autouse=True) +def _isolated_sso_env(monkeypatch): + """Every SSO selector is read from the process environment, so a value left behind by + another test would silently decide this one's answer.""" + for name in _SSO_ENV_VARS: + monkeypatch.delenv(name, raising=False) + + +class TestActiveSSOProviderMirrorsTheCallback: + """The gap warning is only as good as its agreement with the branch the login callback + actually takes, so provider selection is asserted branch by branch, including the + precedence that makes a co-configured generic client unreachable.""" + + def test_google_client_id_selects_google(self, monkeypatch): + monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-cid") + assert active_sso_provider() is ActiveSSOProvider.google + + def test_microsoft_client_id_selects_microsoft(self, monkeypatch): + monkeypatch.setenv("MICROSOFT_CLIENT_ID", "ms-cid") + assert active_sso_provider() is ActiveSSOProvider.microsoft + + def test_generic_client_id_selects_generic(self, monkeypatch): + monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid") + assert active_sso_provider() is ActiveSSOProvider.generic + + def test_saml_metadata_selects_saml(self, monkeypatch): + monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata") + assert active_sso_provider() is ActiveSSOProvider.saml + + def test_nothing_configured_selects_none(self): + assert active_sso_provider() is ActiveSSOProvider.none + + def test_google_outranks_a_co_configured_generic_client(self, monkeypatch): + """The callback tests GOOGLE_CLIENT_ID first, so the generic arm never runs here and + no assertion is captured; reporting generic would clear a gap that is still open.""" + monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-cid") + monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid") + assert active_sso_provider() is ActiveSSOProvider.google + + def test_microsoft_outranks_a_co_configured_generic_client(self, monkeypatch): + monkeypatch.setenv("MICROSOFT_CLIENT_ID", "ms-cid") + monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid") + assert active_sso_provider() is ActiveSSOProvider.microsoft + + def test_generic_outranks_saml(self, monkeypatch): + monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid") + monkeypatch.setenv("SAML_IDP_METADATA_URL", "https://idp.example.com/metadata") + assert active_sso_provider() is ActiveSSOProvider.generic + + +class TestIdJagAssertionCaptureGap: + def test_generic_oidc_has_no_gap(self, monkeypatch): + monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid") + assert id_jag_assertion_capture_gap() is None + + @pytest.mark.parametrize( + "env_var, provider_label", + [ + ("GOOGLE_CLIENT_ID", "google"), + ("MICROSOFT_CLIENT_ID", "microsoft"), + ("SAML_IDP_METADATA_URL", "saml"), + ], + ) + def test_non_capturing_provider_is_named_with_the_remedy(self, monkeypatch, env_var, provider_label): + monkeypatch.setenv(env_var, "configured") + gap = id_jag_assertion_capture_gap() + assert gap is not None + assert provider_label in gap + assert "GENERIC_CLIENT_ID" in gap + + def test_no_sso_configured_reports_a_gap(self): + gap = id_jag_assertion_capture_gap() + assert gap is not None + assert "no SSO provider is configured" in gap + + def test_google_beside_generic_still_reports_a_gap(self, monkeypatch): + """The precedence trap in operator terms: adding a generic client id without removing + GOOGLE_CLIENT_ID does not fix the deployment, so the gap must not clear.""" + monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-cid") + monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid") + gap = id_jag_assertion_capture_gap() + assert gap is not None + assert "google" in gap + + +class TestIdJagAssertionCaptureGapAtStartup: + def test_no_provider_at_startup_is_not_yet_a_gap(self): + assert id_jag_assertion_capture_gap_at_startup() is None + + def test_google_provider_at_startup_reports_the_capture_gap(self, monkeypatch): + monkeypatch.setenv("GOOGLE_CLIENT_ID", "google-cid") + startup_gap = id_jag_assertion_capture_gap_at_startup() + callback_gap = id_jag_assertion_capture_gap() + assert startup_gap is not None + assert startup_gap == callback_gap + + def test_generic_provider_at_startup_has_no_gap(self, monkeypatch): + monkeypatch.setenv("GENERIC_CLIENT_ID", "generic-cid") + assert id_jag_assertion_capture_gap_at_startup() is None diff --git a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py index adab3538b58..71ff7de89b0 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_mcp_management_endpoints.py @@ -2,6 +2,7 @@ import os import sys import types import json +import logging from contextlib import ExitStack from datetime import datetime, timedelta from types import SimpleNamespace @@ -3840,6 +3841,155 @@ class TestAddMCPServerAtomicity: mock_manager.reload_servers_from_database.assert_not_awaited() +class TestIdJagRegistrationWarnsAboutTheSSOGap: + """An `oauth2_id_jag` server only ever works when the login path captures an IdP identity + assertion, and only the generic OIDC arm does. Registering one under Google or Microsoft + succeeds and then fails for every user on every call, so the mismatch has to be said at + registration time, while the admin is still looking at the configuration.""" + + @staticmethod + def _clear_sso_env(monkeypatch): + for name in ( + "GOOGLE_CLIENT_ID", + "MICROSOFT_CLIENT_ID", + "GENERIC_CLIENT_ID", + "SAML_IDP_METADATA_URL", + "SAML_IDP_METADATA_XML", + ): + monkeypatch.delenv(name, raising=False) + + @staticmethod + def _id_jag_warnings(caplog) -> list[str]: + return [ + record.getMessage() + for record in caplog.records + if record.levelno == logging.WARNING and "oauth2_id_jag" in record.getMessage() + ] + + @staticmethod + def _server_record(auth_type) -> LiteLLM_MCPServerTable: + record = generate_mock_mcp_server_db_record(server_id="ema-1", alias="ema") + record.auth_type = auth_type + return record + + async def _run_create(self, monkeypatch, provider_env, auth_type, caplog): + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + add_mcp_server, + ) + + self._clear_sso_env(monkeypatch) + for name, value in provider_env.items(): + monkeypatch.setenv(name, value) + + mock_manager = MagicMock() + mock_manager.add_server = AsyncMock() + mock_manager.reload_servers_from_database = AsyncMock() + + with ( + patch( # test-quality-ok: endpoint test stubs the Prisma client lookup + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=MagicMock(), + ), + patch( # test-quality-ok: endpoint test stubs MCP server creation + "litellm.proxy.management_endpoints.mcp_management_endpoints.create_mcp_server", + AsyncMock(return_value=self._server_record(auth_type)), + ), + patch( # test-quality-ok: endpoint reads the global MCP manager + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ), + ): + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + await add_mcp_server( + payload=NewMCPServerRequest( + alias="ema", + url="https://ema.example.com/mcp", + transport=MCPTransport.http, + ), + user_api_key_dict=generate_mock_user_api_key_auth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user" + ), + ) + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "provider_env, expected_fragment", + [ + ({"GOOGLE_CLIENT_ID": "cid"}, "google"), + ({"MICROSOFT_CLIENT_ID": "cid"}, "microsoft"), + ({"SAML_IDP_METADATA_URL": "https://idp.example.com/metadata"}, "saml"), + ({}, "no SSO provider is configured"), + ], + ) + async def test_create_warns_under_a_provider_that_captures_nothing( + self, monkeypatch, caplog, provider_env, expected_fragment + ): + await self._run_create(monkeypatch, provider_env, MCPAuth.oauth2_id_jag, caplog) + warnings = self._id_jag_warnings(caplog) + assert len(warnings) == 1 + assert expected_fragment in str(warnings[0]) + assert "ema-1" in str(warnings[0]) + + @pytest.mark.asyncio + async def test_create_is_silent_under_generic_oidc(self, monkeypatch, caplog): + await self._run_create(monkeypatch, {"GENERIC_CLIENT_ID": "cid"}, MCPAuth.oauth2_id_jag, caplog) + assert self._id_jag_warnings(caplog) == [] + + @pytest.mark.asyncio + async def test_create_is_silent_for_other_auth_types(self, monkeypatch, caplog): + """Nothing but the id_jag arm sources credentials from a stored SSO assertion, so no + other server registered under Google has anything to warn about.""" + await self._run_create(monkeypatch, {"GOOGLE_CLIENT_ID": "cid"}, MCPAuth.api_key, caplog) + assert self._id_jag_warnings(caplog) == [] + + @pytest.mark.asyncio + async def test_update_to_id_jag_warns(self, monkeypatch, caplog): + """Switching an existing server onto id_jag opens the same gap a create does.""" + from litellm.proxy.management_endpoints.mcp_management_endpoints import ( + edit_mcp_server, + ) + + self._clear_sso_env(monkeypatch) + monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid") + + mock_manager = MagicMock() + mock_manager.update_server = AsyncMock() + mock_manager.reload_servers_from_database = AsyncMock() + with ( + patch( # test-quality-ok: endpoint test stubs the Prisma client lookup + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", + return_value=MagicMock(), + ), + patch( # test-quality-ok: endpoint test stubs the MCP server lookup + "litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server", + AsyncMock(return_value=self._server_record(MCPAuth.api_key)), + ), + patch( # test-quality-ok: endpoint test stubs MCP server updates + "litellm.proxy.management_endpoints.mcp_management_endpoints.update_mcp_server", + AsyncMock(return_value=self._server_record(MCPAuth.oauth2_id_jag)), + ), + patch( # test-quality-ok: endpoint test stubs credential cleanup + "litellm.proxy.management_endpoints.mcp_management_endpoints.purge_user_oauth_credentials_for_server", + AsyncMock(return_value=0), + ), + patch( # test-quality-ok: endpoint reads the global MCP manager + "litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager", + mock_manager, + ), + ): + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + await edit_mcp_server( + payload=UpdateMCPServerRequest(server_id="ema-1", auth_type=MCPAuth.oauth2_id_jag), + user_api_key_dict=generate_mock_user_api_key_auth( + user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-user" + ), + ) + + warnings = self._id_jag_warnings(caplog) + assert len(warnings) == 1 + assert "google" in str(warnings[0]) + + class TestHealthCheckServers: """Test suite for health check servers endpoint""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 5dfff53f7c3..8d8bc15f9be 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -1,17 +1,16 @@ import asyncio import json +import logging import os -from contextlib import asynccontextmanager +from contextlib import ExitStack, asynccontextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException, Request -from litellm._uuid import uuid - - import litellm +from litellm._uuid import uuid from litellm.proxy._types import LiteLLM_UserTable, NewUserResponse from litellm.proxy.auth.handle_jwt import JWTHandler from litellm.proxy.management_endpoints.sso import CustomMicrosoftSSO @@ -1615,8 +1614,8 @@ async def test_get_generic_sso_response_with_empty_headers(): async def test_get_generic_sso_response_includes_token_claims_when_enabled(monkeypatch): import jwt as pyjwt - from litellm.proxy.management_endpoints.ui_sso import get_generic_sso_response from litellm.proxy._types import LitellmUserRoles + from litellm.proxy.management_endpoints.ui_sso import get_generic_sso_response mock_request = MagicMock(spec=Request) mock_jwt_handler = MagicMock(spec=JWTHandler) @@ -2321,10 +2320,10 @@ class TestCustomUISSO: async def test_handle_custom_ui_sso_sign_in_success(self): """Test successful custom UI SSO sign-in with valid headers""" from fastapi_sso.sso.base import OpenID - from litellm_enterprise.proxy.auth.custom_sso_handler import ( EnterpriseCustomSSOHandler, ) + from litellm.integrations.custom_sso_handler import CustomSSOLoginHandler # Mock request with custom headers @@ -2400,6 +2399,7 @@ class TestCustomUISSO: from litellm_enterprise.proxy.auth.custom_sso_handler import ( EnterpriseCustomSSOHandler, ) + from litellm.integrations.custom_sso_handler import CustomSSOLoginHandler mock_request = MagicMock(spec=Request) @@ -2436,10 +2436,10 @@ class TestCustomUISSO: and its methods are called with the correct parameters """ from fastapi_sso.sso.base import OpenID - from litellm_enterprise.proxy.auth.custom_sso_handler import ( EnterpriseCustomSSOHandler, ) + from litellm.integrations.custom_sso_handler import CustomSSOLoginHandler # Create a real custom handler class instance @@ -8167,6 +8167,128 @@ async def test_debug_sso_callback_handles_missing_raw_response(): assert "user@example.com" in body +# ── The debug page is where an operator lands when ID-JAG is failing ────────── + +_GOOGLE_DEBUG_CLIENT_ID = "debug-google-client-id" +_GENERIC_DEBUG_CLIENT_ID = "debug-generic-client-id" + + +async def _render_debug_page(provider_env, id_jag_registered, force_inert=False): + """Drive /sso/debug/callback and return the raw response body.""" + from litellm.proxy.management_endpoints.ui_sso import GoogleSSOHandler, debug_sso_callback + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "http://proxy.example.com/" + mock_request.cookies = {} + mock_request.query_params = {} + + parsed = {"sub": "user_123", "email": "u@example.com"} + + async def fake_generic(**kwargs): + return parsed, {"sub": "user_123"}, {"scope": "openid"}, None + + async def fake_google(**kwargs): + return parsed + + stack = [ + patch.dict(os.environ, provider_env, clear=False), + patch( # test-quality-ok: endpoint test stubs the upstream generic IdP boundary + "litellm.proxy.management_endpoints.ui_sso.get_generic_sso_response", side_effect=fake_generic + ), + patch.object( # test-quality-ok: endpoint test stubs the upstream Google IdP boundary + GoogleSSOHandler, "get_google_callback_response", side_effect=fake_google + ), + patch( # test-quality-ok: debug endpoint reads this module global without an injection seam + "litellm.proxy.management_endpoints.ui_sso.ema_assertion_retention_enabled", + AsyncMock(return_value=id_jag_registered), + ), + patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: debug endpoint reads proxy globals + patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: debug endpoint reads proxy DB + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), # test-quality-ok: debug endpoint reads proxy globals + patch("litellm.proxy.proxy_server.jwt_handler", MagicMock(spec=JWTHandler)), # test-quality-ok: debug endpoint reads proxy globals + ] + if force_inert: + stack.append( + patch( # test-quality-ok: force-inert reference isolates the endpoint's pre-change response + "litellm.proxy.management_endpoints.ui_sso.warn_if_id_jag_capture_gap", + AsyncMock(return_value=None), + ) + ) + + with ExitStack() as es: + for ctx in stack: + es.enter_context(ctx) + for var in ("MICROSOFT_CLIENT_ID", "GOOGLE_CLIENT_ID", "GENERIC_CLIENT_ID", "SAML_IDP_METADATA_URL"): + if var not in provider_env: + os.environ.pop(var, None) + response = await debug_sso_callback(mock_request) + + return response.body.decode() + + +@pytest.mark.asyncio +async def test_debug_page_logs_the_capture_gap_but_never_renders_it(caplog): + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + body = await _render_debug_page({"GOOGLE_CLIENT_ID": _GOOGLE_DEBUG_CLIENT_ID}, id_jag_registered=True) + + warnings = _id_jag_gap_warnings(caplog) + assert len(warnings) == 1 + assert "google" in warnings[0] + assert "GENERIC_CLIENT_ID" in warnings[0] + assert "id_jag" not in body + assert "GENERIC_CLIENT_ID" not in body + + +@pytest.mark.asyncio +async def test_debug_page_is_byte_identical_when_the_provider_captures(): + """A deployment with no gap must get the page it got before this change, to the byte. The + comparison is against the endpoint with the diagnostic forced inert, not against a guess.""" + with_feature = await _render_debug_page( + {"GENERIC_CLIENT_ID": _GENERIC_DEBUG_CLIENT_ID}, id_jag_registered=True + ) + pre_change = await _render_debug_page( + {"GENERIC_CLIENT_ID": _GENERIC_DEBUG_CLIENT_ID}, + id_jag_registered=True, + force_inert=True, + ) + + assert with_feature == pre_change + assert "id_jag" not in with_feature + + +@pytest.mark.asyncio +async def test_debug_page_is_byte_identical_when_no_id_jag_server_is_registered(): + """Most deployments run Google SSO and no id_jag server at all; their debug page must not + grow an ID-JAG section about a feature they do not use.""" + with_feature = await _render_debug_page( + {"GOOGLE_CLIENT_ID": _GOOGLE_DEBUG_CLIENT_ID}, id_jag_registered=False + ) + pre_change = await _render_debug_page( + {"GOOGLE_CLIENT_ID": _GOOGLE_DEBUG_CLIENT_ID}, + id_jag_registered=False, + force_inert=True, + ) + + assert with_feature == pre_change + assert "id_jag" not in with_feature + + +@pytest.mark.asyncio +async def test_debug_page_survives_a_store_outage(monkeypatch, caplog): + """The page's job is to render claims; an unreachable MCP table must cost it the annotation, + not the page.""" + from litellm.proxy.management_endpoints.ui_sso import warn_if_id_jag_capture_gap + + monkeypatch.setenv("GOOGLE_CLIENT_ID", _GOOGLE_DEBUG_CLIENT_ID) + retention_check = AsyncMock(side_effect=Exception("db down")) + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + assert await warn_if_id_jag_capture_gap(retention_enabled=retention_check) is None + + retention_check.assert_awaited_once() + + assert _id_jag_gap_warnings(caplog) == [] + + async def _render_legacy_login_page(env_overrides, general_settings): from litellm.proxy.management_endpoints.ui_sso import google_login @@ -8261,8 +8383,8 @@ async def test_saml_callback_enforces_free_sso_user_limit_after_validation(): that /sso/key/generate enforces; the ACS re-checks it after validating the assertion, so the entitlement DB query never runs on unvalidated input.""" from litellm.proxy._types import ProxyException - from litellm.proxy.management_endpoints.ui_sso import saml_callback from litellm.proxy.management_endpoints.types import CustomOpenID + from litellm.proxy.management_endpoints.ui_sso import saml_callback call_order: list[str] = [] @@ -8681,6 +8803,248 @@ async def test_cli_completion_persists_assertion_under_db_user_id(): assert response.status_code == 200 +def _id_jag_gap_warnings(caplog) -> list[str]: + return [ + record.getMessage() + for record in caplog.records + if record.levelno == logging.WARNING and "oauth2_id_jag" in record.getMessage() + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "provider_env, expected_fragment", + [ + ({"GOOGLE_CLIENT_ID": "cid"}, "google"), + ({"MICROSOFT_CLIENT_ID": "cid", "MICROSOFT_TENANT": "t"}, "microsoft"), + ({}, "no SSO provider is configured"), + ], +) +async def test_uncaptured_assertion_warns_when_an_id_jag_server_is_registered( + monkeypatch, caplog, provider_env, expected_fragment +): + """A provider with no capture path leaves ID-JAG permanently broken, and the only place + that is knowable is the login itself; without this line the operator sees nothing at all.""" + from litellm.proxy.management_endpoints.ui_sso import ( + warn_if_id_jag_assertion_uncaptured, + ) + + for name in ("GOOGLE_CLIENT_ID", "MICROSOFT_CLIENT_ID", "GENERIC_CLIENT_ID", "SAML_IDP_METADATA_URL"): + monkeypatch.delenv(name, raising=False) + for name, value in provider_env.items(): + monkeypatch.setenv(name, value) + + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + await warn_if_id_jag_assertion_uncaptured(None, retention_enabled=AsyncMock(return_value=True)) + + warnings = _id_jag_gap_warnings(caplog) + assert len(warnings) == 1 + assert expected_fragment in str(warnings[0]) + + +@pytest.mark.asyncio +async def test_generic_provider_that_returned_no_id_token_still_warns(monkeypatch, caplog): + """Generic OIDC has a capture path, so there is no configuration gap to report; the login + still handed the id_jag arm nothing, and that must not pass silently.""" + from litellm.proxy.management_endpoints.ui_sso import ( + warn_if_id_jag_assertion_uncaptured, + ) + + for name in ("GOOGLE_CLIENT_ID", "MICROSOFT_CLIENT_ID", "SAML_IDP_METADATA_URL"): + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("GENERIC_CLIENT_ID", "cid") + + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + await warn_if_id_jag_assertion_uncaptured(None, retention_enabled=AsyncMock(return_value=True)) + + warnings = _id_jag_gap_warnings(caplog) + assert len(warnings) == 1 + assert "no usable id_token" in str(warnings[0]) + + +@pytest.mark.asyncio +async def test_no_warning_when_the_assertion_was_captured(monkeypatch, caplog): + from litellm.proxy._experimental.mcp_server.outbound_credentials.sso_assertion_store import ( + assertion_from_sso_login, + ) + from litellm.proxy.management_endpoints.ui_sso import ( + warn_if_id_jag_assertion_uncaptured, + ) + + monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid") + assertion = assertion_from_sso_login(_ema_id_token(), None) + assert assertion is not None + + retention_mock = AsyncMock(return_value=True) + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + await warn_if_id_jag_assertion_uncaptured(assertion, retention_enabled=retention_mock) + + assert _id_jag_gap_warnings(caplog) == [] + retention_mock.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_no_warning_when_no_id_jag_server_is_registered(monkeypatch, caplog): + """Most deployments never register one; a warning about ID-JAG on every login there would + be pure noise and would train operators to ignore it.""" + from litellm.proxy.management_endpoints.ui_sso import ( + warn_if_id_jag_assertion_uncaptured, + ) + + monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid") + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + await warn_if_id_jag_assertion_uncaptured(None, retention_enabled=AsyncMock(return_value=False)) + + assert _id_jag_gap_warnings(caplog) == [] + + +@pytest.mark.asyncio +async def test_store_outage_does_not_break_the_login(monkeypatch, caplog): + from litellm.proxy.management_endpoints.ui_sso import ( + warn_if_id_jag_assertion_uncaptured, + ) + + monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid") + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + assert ( + await warn_if_id_jag_assertion_uncaptured( + None, retention_enabled=AsyncMock(side_effect=Exception("db down")) + ) + is None + ) + + assert _id_jag_gap_warnings(caplog) == [] + + +@pytest.mark.asyncio +async def test_browser_funnel_reports_an_uncaptured_assertion(monkeypatch, caplog): + """Wiring: the browser login path must reach the diagnostic, not just define it.""" + monkeypatch.setenv("GOOGLE_CLIENT_ID", "cid") + mock_request = MagicMock(spec=Request) + mock_request.base_url = "http://localhost:4000/" + mock_request.cookies = {} + + with ( + patch( # test-quality-ok: endpoint test stubs the Prisma client lookup + "litellm.proxy.utils.get_prisma_client_or_throw", return_value=MagicMock() + ), + patch("litellm.proxy.proxy_server.master_key", "sk-master"), # test-quality-ok: endpoint reads proxy globals + patch("litellm.proxy.proxy_server.general_settings", {}), # test-quality-ok: endpoint reads proxy globals + patch("litellm.proxy.proxy_server.premium_user", False), # test-quality-ok: endpoint reads proxy globals + patch("litellm.proxy.proxy_server.user_custom_sso", None), # test-quality-ok: endpoint reads proxy globals + patch("litellm.proxy.proxy_server.proxy_logging_obj", MagicMock()), # test-quality-ok: endpoint reads proxy globals + patch("litellm.proxy.proxy_server.redis_usage_cache", None), # test-quality-ok: endpoint reads proxy globals + patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), # test-quality-ok: endpoint reads proxy globals + patch( # test-quality-ok: endpoint test stubs key generation at its module boundary + "litellm.proxy.proxy_server.generate_key_helper_fn", + AsyncMock(return_value={"token": "sk-ui-key", "user_id": "canonical-user-id"}), + ), + patch( # test-quality-ok: endpoint test stubs the user database lookup + "litellm.proxy.management_endpoints.ui_sso.get_user_info_from_db", + AsyncMock(return_value=None), + ), + patch( # test-quality-ok: endpoint test stubs the admin database lookup + "litellm.proxy.management_endpoints.ui_sso.check_and_update_if_proxy_admin_id", + AsyncMock(return_value="internal_user"), + ), + patch( # test-quality-ok: endpoint test stubs assertion persistence + "litellm.proxy.management_endpoints.ui_sso.retain_sso_identity_assertion_for_ema", + AsyncMock(), + ), + patch( # test-quality-ok: endpoint reads this module global without an injection seam + "litellm.proxy.management_endpoints.ui_sso.ema_assertion_retention_enabled", + AsyncMock(return_value=True), + ), + ): + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + await SSOAuthenticationHandler.get_redirect_response_from_openid( + result=CustomOpenID( + id="raw-idp-subject", + email="u@example.com", + first_name="U", + last_name="Ser", + display_name="U Ser", + provider="google", + team_ids=[], + user_role=None, + ), + request=mock_request, + received_response=None, + generic_client_id=None, + ui_access_mode=None, + access_token_payload=None, + jwt_handler=None, + sso_assertion=None, + ) + + warnings = _id_jag_gap_warnings(caplog) + assert len(warnings) == 1 + assert "google" in str(warnings[0]) + + +@pytest.mark.asyncio +async def test_cli_funnel_reports_an_uncaptured_assertion(monkeypatch, caplog): + """Wiring: the CLI login path shares the gap, so it must share the diagnostic.""" + from litellm.proxy.management_endpoints.ui_sso import ( + _complete_cli_sso_callback_session, + ) + + monkeypatch.setenv("MICROSOFT_CLIENT_ID", "cid") + mock_request = MagicMock(spec=Request) + mock_request.base_url = "http://localhost:4000/" + + user_info = MagicMock() + user_info.user_id = "cli-user-id" + user_info.user_role = "internal_user" + user_info.models = [] + user_info.teams = [] + + with ( + patch( # test-quality-ok: endpoint test stubs the user database lookup + "litellm.proxy.management_endpoints.ui_sso.get_user_info_from_db", + AsyncMock(return_value=user_info), + ), + patch( # test-quality-ok: endpoint test stubs CLI team lookup + "litellm.proxy.management_endpoints.ui_sso.fetch_cli_sso_team_details", + AsyncMock(return_value=[]), + ), + patch( # test-quality-ok: endpoint test stubs attribution metadata + "litellm.proxy.management_endpoints.ui_sso.build_cli_sso_attribution_metadata", + return_value={}, + ), + patch( # test-quality-ok: endpoint test stubs assertion persistence + "litellm.proxy.management_endpoints.ui_sso.retain_sso_identity_assertion_for_ema", + AsyncMock(), + ), + patch( # test-quality-ok: endpoint reads this module global without an injection seam + "litellm.proxy.management_endpoints.ui_sso.ema_assertion_retention_enabled", + AsyncMock(return_value=True), + ), + ): + with caplog.at_level(logging.WARNING, logger="LiteLLM Proxy"): + await _complete_cli_sso_callback_session( + request=mock_request, + key="cli-login-id", + flow={}, + result={"sub": "raw-idp-subject"}, + parsed_openid_result={ + "user_id": "raw-idp-subject", + "user_email": "u@example.com", + "user_role": None, + }, + user_defined_values=None, + prisma_client=MagicMock(), + user_api_key_cache=MagicMock(), + cli_sso_session_cache=MagicMock(), + proxy_logging_obj=MagicMock(), + sso_assertion=None, + ) + + warnings = _id_jag_gap_warnings(caplog) + assert len(warnings) == 1 + assert "microsoft" in str(warnings[0]) + + def _cli_callback_kwargs(flow): return { "request": _cli_callback_request(), From def734923f683055f04fc5e46d9ef78ecf912381 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 12:55:30 -0700 Subject: [PATCH 259/410] test(e2e): prove Vertex context caching on the first cold call and on the spend row --- .../e2e/llm_translation/test_cache_control.py | 76 +++++++++++++++++-- tests/e2e/models.py | 1 + 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/tests/e2e/llm_translation/test_cache_control.py b/tests/e2e/llm_translation/test_cache_control.py index 0d224061381..3ad98bc6072 100644 --- a/tests/e2e/llm_translation/test_cache_control.py +++ b/tests/e2e/llm_translation/test_cache_control.py @@ -8,8 +8,12 @@ Each case asserts the feature actually happened, not just a 200. Coverage matrix cache-read usage tokens > 0. service_tier is out of scope for Bedrock; AWS Bedrock does not expose an OpenAI-style request service tier, so that cell is intentionally not covered here. -- Vertex (gemini-2.5-flash): prompt caching via ``cache_control`` context - caching; the second identical call must report cached prompt tokens > 0. +- Vertex (gemini-2.5-flash): explicit context caching via ``cache_control`` + with a 5-minute ttl. litellm builds the Vertex cache before the generate + call, so a never-seen prefix must come back cached on its very first call + (Gemini's implicit caching cannot hit a cold prefix), the cached count must + cover the marked block, and the spend row must be billed below the uncached + price of the prompt. - Anthropic (claude-haiku-4-5, direct): the same ``cache_control`` prefix over the OpenAI-compatible route; the second call must report cache-read tokens > 0. - OpenAI (gpt-5.6): automatic prompt caching needs no request marker, so the @@ -26,7 +30,8 @@ built from the typed content blocks shared in ``endpoints_client.py``. from __future__ import annotations import time -from collections.abc import Callable +from collections.abc import Callable, Iterator +from typing import Final import pytest from pydantic import BaseModel @@ -45,6 +50,10 @@ BEDROCK_MODEL = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" VERTEX_MODEL = "vertex_ai/gemini-2.5-flash" ANTHROPIC_MODEL = "anthropic/claude-haiku-4-5-20251001" OPENAI_MODEL = "openai/gpt-5.6" +VERTEX_CACHE_TTL: Final = "300s" +VERTEX_COLD_CALL_ATTEMPTS: Final = 3 +VERTEX_MINIMUM_CACHED_TOKENS: Final = 1024 +CACHED_SHARE_OF_PROMPT: Final = 0.9 class CacheChatBody(BaseModel): @@ -77,14 +86,14 @@ def _cached_read_tokens(usage: Usage | None) -> int: def _cache_chat( - client: PassthroughClient, key: str, model: str, prefix: str + client: PassthroughClient, key: str, model: str, prefix: str, ttl: str | None = None ) -> Result[ChatResponse]: body = CacheChatBody( model=model, messages=[ RichMessage( role="system", - content=[TextBlock(text=prefix, cache_control=CacheControl())], + content=[TextBlock(text=prefix, cache_control=CacheControl(ttl=ttl))], ), RichMessage(role="user", content=[TextBlock(text="Reply with one word.")]), ], @@ -138,6 +147,58 @@ def _assert_cache_read_on_second_call( ) +def _cold_cache_calls(send: Callable[[str], Result[ChatResponse]]) -> Iterator[ChatResponse]: + for _ in range(VERTEX_COLD_CALL_ATTEMPTS): + yield unwrap(send(_cacheable_prefix())) + + +def _first_cold_call_reads_cache(model: str, send: Callable[[str], Result[ChatResponse]]) -> ChatResponse: + completion: Final = next( + ( + candidate + for candidate in _cold_cache_calls(send) + if _cached_read_tokens(candidate.usage) >= VERTEX_MINIMUM_CACHED_TOKENS + ), + None, + ) + assert completion is not None, ( + f"{model}: {VERTEX_COLD_CALL_ATTEMPTS} never-seen prompts marked with cache_control all reported fewer " + f"than {VERTEX_MINIMUM_CACHED_TOKENS} cached tokens on their first call; explicit context caching did " + "not engage" + ) + assert completion.choices, f"{model}: cached call returned no choices: {completion}" + usage: Final = completion.usage + cached: Final = _cached_read_tokens(usage) + assert usage and usage.prompt_tokens and cached >= CACHED_SHARE_OF_PROMPT * usage.prompt_tokens, ( + f"{model}: only {cached} of {usage.prompt_tokens if usage else None} prompt tokens were served from the " + "cache; the cache_control block was not cached whole" + ) + return completion + + +def _input_rate(client: PassthroughClient, model: str) -> float: + entry: Final = next((row for row in client.proxy.model_info() if row.model_name == model), None) + assert entry and entry.model_info.input_cost_per_token, f"/model/info resolved no input rate for {model}" + return entry.model_info.input_cost_per_token + + +def _assert_billed_below_uncached_prompt(client: PassthroughClient, model: str, completion: ChatResponse) -> None: + assert completion.id, f"{model}: cached completion carried no id to find its spend row by" + usage: Final = completion.usage + assert usage and usage.prompt_tokens, f"{model}: cached completion carried no prompt_tokens: {usage}" + rows: Final = client.proxy.poll_logs_for_request_id(completion.id, predicate=lambda rs: (rs[0].spend or 0) > 0) + assert rows, f"{model}: no costed /spend/logs row for request {completion.id}" + row: Final = rows[0] + assert row.prompt_tokens == usage.prompt_tokens, ( + f"{model}: spend row prompt_tokens {row.prompt_tokens} != response prompt_tokens {usage.prompt_tokens}" + ) + uncached_prompt_cost: Final = usage.prompt_tokens * _input_rate(client, model) + assert row.spend is not None and row.spend < uncached_prompt_cost, ( + f"{model}: spend {row.spend} is not below the uncached price of the prompt alone ({uncached_prompt_cost} for " + f"{usage.prompt_tokens} tokens); cache-read pricing was not applied" + ) + + class TestCacheControl: @pytest.mark.covers( "llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works", @@ -174,7 +235,10 @@ class TestCacheControl: ) resources.defer(lambda: client.proxy.delete_model(model_id)) key = resources.key() - _assert_cache_read_on_second_call(model, lambda prefix: _cache_chat(client, key, model, prefix)) + completion = _first_cold_call_reads_cache( + model, lambda prefix: _cache_chat(client, key, model, prefix, ttl=VERTEX_CACHE_TTL) + ) + _assert_billed_below_uncached_prompt(client, model, completion) @pytest.mark.covers( "llm.chat_completions.anthropic.prompt_cache_5m.nonstream.works", diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 5de49ead3ed..016a9de56b8 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -181,6 +181,7 @@ class ChatMessage(BaseModel): class CacheControl(BaseModel): type: str = "ephemeral" + ttl: str | None = None class TextBlock(BaseModel): From 29b93b57aaa0cef7660da3636df377422a2cd704 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 5 Sep 2026 12:58:18 -0700 Subject: [PATCH 260/410] feat(ui): show the guardrail cost math in a popover table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "How is this calculated?" hover was a plain-text tooltip. It is now a popover (opens on hover or click) with a title, the formula, a table of one row per counter or guardrail (units, × price, = cost, with unpriced units called out under the row) and a total row, so the math reads as a worked sum instead of a sentence. Refs LIT-5652 --- .../GuardrailUsageBreakdown.test.tsx | 55 ++++++++++----- .../_components/GuardrailUsageBreakdown.tsx | 26 +++---- .../_components/GuardrailsOverview.test.tsx | 25 ++++--- .../_components/GuardrailsOverview.tsx | 25 ++++--- .../GuardrailsMonitor/CalcPopover.tsx | 67 +++++++++++++++++++ .../GuardrailsMonitor/MetricCard.tsx | 23 +------ .../GuardrailsMonitor/UnpricedNote.tsx | 4 +- .../GuardrailsMonitor/usageUnits.test.ts | 55 +++++++++------ .../GuardrailsMonitor/usageUnits.ts | 30 ++++++--- 9 files changed, 204 insertions(+), 106 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/GuardrailsMonitor/CalcPopover.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx index 3a0a4c38ecb..db7855ab0d5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.test.tsx @@ -85,20 +85,33 @@ describe("GuardrailUsageBreakdown", () => { expect(within(unpricedKey).getByText("7", { selector: ".text-warning" })).toBeInTheDocument(); }); - it("explains the cost math per counter on hover", async () => { + const cellsOf = (dialog: HTMLElement): string[][] => + within(dialog) + .getAllByRole("row") + .map((row) => + within(row) + .getAllByRole("cell") + .map((cell) => cell.textContent ?? ""), + ); + + it("lays the cost math out per counter as units × price = cost", async () => { const user = userEvent.setup(); render(); - await user.hover( + await user.click( within(screen.getByRole("group", { name: "Cost" })).getByRole("button", { name: /How is this calculated/ }), ); - expect(await screen.findByText("Content Policy: 1,000 × $0.00015 = $0.1500")).toBeInTheDocument(); - expect(screen.getByText("Sensitive Information Policy: 300 × $0.0001 = $0.0300")).toBeInTheDocument(); - expect(screen.getByText("Some Future Counter: 7 units with no known price, left out")).toBeInTheDocument(); - expect(screen.getByText("Total: $0.1800")).toBeInTheDocument(); - expect(screen.getByText(/7 units with no known price are left out of the cost/)).toBeInTheDocument(); - const issueLink = screen.getByRole("link", { name: "Request pricing on GitHub" }); + const dialog = await screen.findByRole("dialog", { name: "How this cost is calculated" }); + expect(cellsOf(dialog)).toEqual([ + ["Content Policy", "1,000", "× $0.00015", "= $0.1500"], + ["Sensitive Information Policy", "300", "× $0.0001", "= $0.0300"], + ["Some Future Counter", "7", "× —", "= —"], + ["no known price, left out"], + ["Total", "$0.1800"], + ]); + expect(within(dialog).getByText(/7 units with no known price are left out of the cost/)).toBeInTheDocument(); + const issueLink = within(dialog).getByRole("link", { name: "Request pricing on GitHub" }); expect(issueLink).toHaveAttribute("target", "_blank"); const issueUrl = new URL(issueLink.getAttribute("href") ?? ""); expect(issueUrl.searchParams.get("title")).toBe("[Feature]: add Bedrock guardrail pricing to the cost map"); @@ -119,29 +132,35 @@ describe("GuardrailUsageBreakdown", () => { />, ); - await user.hover( + await user.click( within(screen.getByRole("group", { name: "Cost" })).getByRole("button", { name: /How is this calculated/ }), ); - expect(await screen.findByText("Total: $0.1500")).toBeInTheDocument(); - expect(screen.queryByRole("link", { name: "Request pricing on GitHub" })).not.toBeInTheDocument(); + const dialog = await screen.findByRole("dialog", { name: "How this cost is calculated" }); + expect(cellsOf(dialog)).toEqual([ + ["Content Policy", "1,000", "× $0.00015", "= $0.1500"], + ["Total", "$0.1500"], + ]); + expect(within(dialog).queryByRole("link", { name: "Request pricing on GitHub" })).not.toBeInTheDocument(); }); - it("explains the units sum on hover", async () => { + it("lays the units sum out per counter", async () => { const user = userEvent.setup(); render(); - await user.hover( + await user.click( within(screen.getByRole("group", { name: "Usage Units" })).getByRole("button", { name: /How is this calculated/, }), ); - expect( - await screen.findByText( - "Content Policy 1,000 + Sensitive Information Policy 300 + Some Future Counter 7 = 1,307", - ), - ).toBeInTheDocument(); + const dialog = await screen.findByRole("dialog", { name: "How usage units add up" }); + expect(cellsOf(dialog)).toEqual([ + ["Content Policy", "1,000"], + ["Sensitive Information Policy", "300"], + ["Some Future Counter", "7"], + ["Total", "1,307"], + ]); }); it("orders teams and keys by units, largest first", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx index e67425adb10..27d9ba5162f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailUsageBreakdown.tsx @@ -2,14 +2,15 @@ import type { ColumnDef } from "@tanstack/react-table"; import { CircleDollarSign } from "lucide-react"; import React from "react"; import type { GuardrailUsageDetail } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; +import { CalcPopover, MathTable } from "@/components/GuardrailsMonitor/CalcPopover"; import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; import { UnpricedNote } from "@/components/GuardrailsMonitor/UnpricedNote"; import { counterLabel, - counterMathLine, + counterMathRow, formatCost, totalUnits, - unitsSumLine, + unitsMathRows, unpricedSummary, } from "@/components/GuardrailsMonitor/usageUnits"; import { DataTable } from "@/components/shared/DataTable"; @@ -113,21 +114,20 @@ const teamColumns = groupColumns("Team", "No team"); const keyColumns = groupColumns("Key", "No key"); const CostMath = ({ counters, detail }: { counters: CounterRow[]; detail: GuardrailUsageDetail }) => ( -
    - {counters.map((row) => ( -
    {counterMathLine(row)}
    - ))} -
    Total: {formatCost(detail.cost)}
    -
    Each counter is its priced units × the per-unit price LiteLLM has for it in the cost map.
    + + +

    Per-unit prices come from the cost map LiteLLM ships with.

    -
    + ); const UnitsMath = ({ units }: { units: GuardrailUsageDetail["usage_units"] }) => ( -
    -
    {unitsSumLine(units)}
    -
    Units are the billable counters the provider reported for this guardrail, added up over every call.
    -
    + + +

    + Units are the billable counters the provider reported for this guardrail, added up over every call. +

    +
    ); const TableHeading = ({ title }: { title: string }) => ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx index 16361bdec27..959ed8b172e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx @@ -211,19 +211,28 @@ describe("GuardrailsOverview", () => { expect(card).toHaveTextContent("250 units unpriced"); }); - it("explains the guardrail cost total on hover", async () => { + it("lays the guardrail cost total out per guardrail", async () => { const user = userEvent.setup(); renderOverview(); const card = await screen.findByRole("group", { name: "Guardrail Cost" }); - await user.hover(within(card).getByRole("button", { name: /How is this calculated/ })); + await user.click(within(card).getByRole("button", { name: /How is this calculated/ })); - expect(await screen.findByText("High Failure Guardrail: $0.1500")).toBeInTheDocument(); - expect(screen.getByText("Free Bedrock Guardrail: $0.0000")).toBeInTheDocument(); - expect(screen.queryByText(/Low Failure Guardrail: /)).not.toBeInTheDocument(); - expect(screen.getByText("Total: $0.1500")).toBeInTheDocument(); - expect(screen.getByText(/250 units with no known price are left out of the cost/)).toBeInTheDocument(); - const issueLink = screen.getByRole("link", { name: "Request pricing on GitHub" }); + const dialog = await screen.findByRole("dialog", { name: "How this cost is calculated" }); + const cells = within(dialog) + .getAllByRole("row") + .map((row) => + within(row) + .getAllByRole("cell") + .map((cell) => cell.textContent ?? ""), + ); + expect(cells).toEqual([ + ["High Failure Guardrail", "$0.1500"], + ["Free Bedrock Guardrail", "$0.0000"], + ["Total", "$0.1500"], + ]); + expect(within(dialog).getByText(/250 units with no known price are left out of the cost/)).toBeInTheDocument(); + const issueLink = within(dialog).getByRole("link", { name: "Request pricing on GitHub" }); const issueUrl = new URL(issueLink.getAttribute("href") ?? ""); expect(issueUrl.searchParams.get("template")).toBe("feature_request.yml"); expect(issueUrl.searchParams.get("the-feature")).toContain("sensitiveInformationPolicyUnits"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx index 33be85f3c81..468e6967d81 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.tsx @@ -8,6 +8,7 @@ import { type GuardrailUsageOverviewRow, useGuardrailsUsageOverview, } from "@/app/(dashboard)/hooks/guardrails/useGuardrailsUsage"; +import { CalcPopover, MathTable } from "@/components/GuardrailsMonitor/CalcPopover"; import { UnpricedNote } from "@/components/GuardrailsMonitor/UnpricedNote"; import { counterLabel, @@ -80,20 +81,18 @@ function TotalCostMath({ untracked: UsageUnits; }) { return ( -
    - {rows - .filter((row) => row.cost != null) - .map((row) => ( -
    - {row.name}: {formatCost(row.cost)} -
    - ))} -
    Total: {formatCost(total)}
    -
    - {`Each guardrail's cost is its units per counter × that counter's per-unit price from the cost map, added up. Open a guardrail for its per-counter math.`} -
    + + row.cost != null) + .map((row) => ({ label: row.name, parts: [formatCost(row.cost)], note: null }))} + total={formatCost(total)} + /> +

    + {`Each guardrail's cost is its units per counter × that counter's per-unit price from the cost map. Open a guardrail for its per-counter math.`} +

    -
    + ); } diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/CalcPopover.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/CalcPopover.tsx new file mode 100644 index 00000000000..686992a6fb4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/CalcPopover.tsx @@ -0,0 +1,67 @@ +import { CircleHelp } from "lucide-react"; +import React, { type ReactNode } from "react"; +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@/components/ui/popover"; +import type { MathRow } from "./usageUnits"; + +export function CalcPopover({ title, formula, children }: { title: string; formula: string; children: ReactNode }) { + return ( + + + } + > + + How is this calculated? + + + {title} + {formula} + {children} + + + ); +} + +export function MathTable({ rows, total }: { rows: readonly MathRow[]; total: string }) { + const width = 1 + Math.max(...rows.map((row) => row.parts.length), 1); + return ( + + + {rows.map((row) => ( + + + + {row.parts.map((part, i) => ( + + ))} + + {row.note && ( + + + + )} + + ))} + + + + + + + +
    {row.label} + {part} +
    + {row.note} +
    + Total + {total}
    + ); +} diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx index 1805dc797e4..008dc279f13 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/MetricCard.tsx @@ -1,6 +1,4 @@ -import { CircleHelp } from "lucide-react"; import React, { type ReactNode } from "react"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; interface MetricCardProps { label: string; @@ -20,26 +18,7 @@ export function MetricCard({ label, value, valueColor = "text-foreground", icon,
    {value}
    {subtitle &&

    {subtitle}

    } - {hint && ( - - - - - How is this calculated? - - } - /> - - {hint} - - - - )} + {hint}
    ); } diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/UnpricedNote.tsx b/ui/litellm-dashboard/src/components/GuardrailsMonitor/UnpricedNote.tsx index 174124d18aa..b43d9d18841 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/UnpricedNote.tsx +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/UnpricedNote.tsx @@ -6,7 +6,7 @@ export function UnpricedNote({ unpriced, provider }: { unpriced: UsageUnits; pro if (total === 0) return null; const [noun, verb] = total === 1 ? ["unit", "is"] : ["units", "are"]; return ( -
    +

    {`${total.toLocaleString()} ${noun} with no known price ${verb} left out of the cost. `} Request pricing on GitHub -

    +

    ); } diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts index 9b45eefaf54..8e2baaaa3c5 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; import { counterLabel, - counterMathLine, + counterMathRow, formatCost, formatUnitPrice, pricingIssueUrl, totalUnits, unitPrice, - unitsSumLine, + unitsMathRows, unpricedSummary, } from "./usageUnits"; @@ -91,40 +91,51 @@ describe("formatUnitPrice", () => { }); }); -describe("counterMathLine", () => { +describe("counterMathRow", () => { it("shows units × price = cost for a fully priced counter", () => { - expect(counterMathLine({ counter: "contentPolicyUnits", units: 1000, unpriced: 0, cost: 0.15 })).toBe( - "Content Policy: 1,000 × $0.00015 = $0.1500", - ); + expect(counterMathRow({ counter: "contentPolicyUnits", units: 1000, unpriced: 0, cost: 0.15 })).toEqual({ + label: "Content Policy", + parts: ["1,000", "× $0.00015", "= $0.1500"], + note: null, + }); }); it("prices only the priced share and calls out the rest", () => { - expect(counterMathLine({ counter: "sensitiveInformationPolicyUnits", units: 8, unpriced: 2, cost: 0.0006 })).toBe( - "Sensitive Information Policy: 6 × $0.0001 = $0.0006 (2 unpriced left out)", + expect(counterMathRow({ counter: "sensitiveInformationPolicyUnits", units: 8, unpriced: 2, cost: 0.0006 })).toEqual( + { + label: "Sensitive Information Policy", + parts: ["6", "× $0.0001", "= $0.0006"], + note: "2 unpriced units left out", + }, ); + expect( + counterMathRow({ counter: "sensitiveInformationPolicyUnits", units: 8, unpriced: 1, cost: 0.0007 }).note, + ).toBe("1 unpriced unit left out"); }); it("says so when a counter has no known price at all", () => { - expect(counterMathLine({ counter: "someFutureCounter", units: 7, unpriced: 7, cost: null })).toBe( - "Some Future Counter: 7 units with no known price, left out", - ); - expect(counterMathLine({ counter: "someFutureCounter", units: 1, unpriced: 1, cost: null })).toBe( - "Some Future Counter: 1 unit with no known price, left out", - ); + expect(counterMathRow({ counter: "someFutureCounter", units: 7, unpriced: 7, cost: null })).toEqual({ + label: "Some Future Counter", + parts: ["7", "× —", "= —"], + note: "no known price, left out", + }); }); it("shows a free counter as × $0", () => { - expect(counterMathLine({ counter: "wordPolicyUnits", units: 2, unpriced: 0, cost: 0 })).toBe( - "Word Policy: 2 × $0 = $0.0000", - ); + expect(counterMathRow({ counter: "wordPolicyUnits", units: 2, unpriced: 0, cost: 0 }).parts).toEqual([ + "2", + "× $0", + "= $0.0000", + ]); }); }); -describe("unitsSumLine", () => { - it("adds the counters up in order", () => { - expect(unitsSumLine({ contentPolicyUnits: 2, topicPolicyUnits: 2, wordPolicyUnits: 1200 })).toBe( - "Content Policy 2 + Topic Policy 2 + Word Policy 1,200 = 1,204", - ); +describe("unitsMathRows", () => { + it("lists the counters in order with their counts", () => { + expect(unitsMathRows({ contentPolicyUnits: 2, wordPolicyUnits: 1200 })).toEqual([ + { label: "Content Policy", parts: ["2"], note: null }, + { label: "Word Policy", parts: ["1,200"], note: null }, + ]); }); }); diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts index f914a3e9698..c47442de200 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.ts @@ -40,20 +40,34 @@ export const formatUnitPrice = (price: number): string => { return price > 0 && Number(fixed) === 0 ? "< $0.000001" : `$${fixed}`; }; -export const counterMathLine = (row: CounterMath): string => { +export interface MathRow { + readonly label: string; + readonly parts: readonly string[]; + readonly note: string | null; +} + +export const counterMathRow = (row: CounterMath): MathRow => { const label = counterLabel(row.counter); const price = unitPrice(row); if (price == null) { - return `${label}: ${row.units.toLocaleString()} ${row.units === 1 ? "unit" : "units"} with no known price, left out`; + return { label, parts: [row.units.toLocaleString(), "× —", "= —"], note: "no known price, left out" }; } - const line = `${label}: ${pricedUnits(row).toLocaleString()} × ${formatUnitPrice(price)} = ${formatCost(row.cost)}`; - return row.unpriced > 0 ? `${line} (${row.unpriced.toLocaleString()} unpriced left out)` : line; + return { + label, + parts: [pricedUnits(row).toLocaleString(), `× ${formatUnitPrice(price)}`, `= ${formatCost(row.cost)}`], + note: + row.unpriced > 0 + ? `${row.unpriced.toLocaleString()} unpriced ${row.unpriced === 1 ? "unit" : "units"} left out` + : null, + }; }; -export const unitsSumLine = (units: UsageUnits): string => - `${Object.entries(units) - .map(([counter, n]) => `${counterLabel(counter)} ${n.toLocaleString()}`) - .join(" + ")} = ${totalUnits(units).toLocaleString()}`; +export const unitsMathRows = (units: UsageUnits): readonly MathRow[] => + Object.entries(units).map(([counter, n]) => ({ + label: counterLabel(counter), + parts: [n.toLocaleString()], + note: null, + })); export const pricingIssueUrl = (unpriced: UsageUnits, provider?: string): string => { const subject = provider ? `${provider} guardrail` : "guardrail"; From c091dd46087bc3a40f18ab0bc48dc08a2b0552a8 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 19:58:35 +0000 Subject: [PATCH 261/410] perf: lazy-load SDK symbols so import litellm stays under 60 MB RSS (#39121) * perf: lazy-load SDK symbols so import litellm stays under 60 MB RSS Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: resolve litellm.proxy submodules lazily so litellm.proxy._types stays importable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: correct SlackAlerting lazy mapping and keep eager encoding path importable Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix: register module-valued public names as module aliases instead of symbol imports Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: justify module-alias cache write with rebind-ok Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/__init__.py | 410 ++-- litellm/_lazy_imports.py | 96 +- litellm/_lazy_imports_registry.py | 2693 +++++++++++++++++++++++ litellm/proxy/__init__.py | 12 +- tests/test_litellm/test_lazy_imports.py | 87 + 5 files changed, 3095 insertions(+), 203 deletions(-) diff --git a/litellm/__init__.py b/litellm/__init__.py index 42c0ea881fd..b2bf3f09152 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -13,6 +13,7 @@ warnings.filterwarnings("ignore", message=".*`ReadOnly` qualifier.*") ### INIT VARIABLES ######################### import threading import os +import sys # Load .env before any other litellm imports so env vars (e.g. LITELLM_UI_SESSION_DURATION) are available import dotenv as _dotenv @@ -45,8 +46,6 @@ from typing import ( TYPE_CHECKING, Union, ) -from litellm.types.integrations.datadog import DatadogInitParams -from litellm.types.integrations.newrelic import NewRelicInitParams from litellm._logging import ( set_verbose, _turn_on_debug, @@ -95,8 +94,7 @@ from litellm.constants import ( DEFAULT_SOFT_BUDGET, DEFAULT_ALLOWED_FAILS, ) -import httpx - +# httpx is lazy-loaded via __getattr__ # register_async_client_cleanup is lazy-loaded and called on first access litellm_mode = os.getenv("LITELLM_MODE", "DEV") # "PRODUCTION", "DEV" @@ -364,8 +362,6 @@ guardrail_name_config_map: Dict[str, GuardrailItem] = {} include_cost_in_streaming_usage: bool = False reasoning_auto_summary: bool = False ### PROMPTS #### -from litellm.types.prompts.init_prompts import PromptSpec - prompt_name_config_map: Dict[str, PromptSpec] = {} ################## @@ -1271,206 +1267,203 @@ openai_video_generation_models = ["sora-2"] # get_llm_provider is lazy-loaded via __getattr__ # remove_index_from_tool_calls is lazy-loaded via __getattr__ -# Import KeyManagementSettings here (before utils import) because _key_management_settings -# is accessed during import time in secret_managers/main.py (via dd_tracing -> datadog -> _service_logger -> utils) -from litellm.types.secret_managers.main import KeyManagementSettings +# SDK symbols previously imported eagerly here are lazy-loaded via __getattr__ +# (_SDK_SYMBOLS_IMPORT_MAP in _lazy_imports_registry.py); mirrored under TYPE_CHECKING +# so static type checkers still see them +if TYPE_CHECKING: + _key_management_settings: KeyManagementSettings -_key_management_settings: KeyManagementSettings = KeyManagementSettings() + from .utils import client -# client must be imported immediately as it's used as a decorator at function definition time -from .utils import client + from .llms.custom_llm import CustomLLM + from .llms.anthropic.common_utils import AnthropicModelInfo + from .llms.ai21.chat.transformation import AI21ChatConfig, AI21ChatConfig as AI21Config + from .llms.deprecated_providers.palm import ( + PalmConfig, + ) # here to prevent breaking changes + from .llms.deprecated_providers.aleph_alpha import AlephAlphaConfig + from .llms.gemini.common_utils import GeminiModelInfo -# Note: Most other utils imports are lazy-loaded via __getattr__ to avoid loading utils.py -# (which imports tiktoken) at import time + from .llms.vertex_ai.vertex_embeddings.transformation import ( + VertexAITextEmbeddingConfig, + ) -from .llms.custom_llm import CustomLLM -from .llms.anthropic.common_utils import AnthropicModelInfo -from .llms.ai21.chat.transformation import AI21ChatConfig, AI21ChatConfig as AI21Config -from .llms.deprecated_providers.palm import ( - PalmConfig, -) # here to prevent breaking changes -from .llms.deprecated_providers.aleph_alpha import AlephAlphaConfig -from .llms.gemini.common_utils import GeminiModelInfo + vertexAITextEmbeddingConfig = VertexAITextEmbeddingConfig() + from .llms.bedrock.embed.amazon_titan_v2_transformation import ( + AmazonTitanV2Config, + ) + from .llms.topaz.common_utils import TopazModelInfo -from .llms.vertex_ai.vertex_embeddings.transformation import ( - VertexAITextEmbeddingConfig, -) + # OpenAIOSeriesConfig is lazy loaded - openaiOSeriesConfig will be created on first access + # OpenAIGPTConfig, OpenAIGPT5Config, etc. are lazy loaded - instances will be created on first access + from .llms.xai.common_utils import XAIModelInfo -vertexAITextEmbeddingConfig = VertexAITextEmbeddingConfig() + # PublicAI now uses JSON-based configuration (see litellm/llms/openai_like/providers.json) + # All remaining configs are now lazy loaded - see _lazy_imports_registry.py + # Import LlmProviders here (before main import) because it's imported during import time + # in multiple places including openai.py (via main import) -from .llms.bedrock.embed.amazon_titan_v2_transformation import ( - AmazonTitanV2Config, -) -from .llms.topaz.common_utils import TopazModelInfo + ## Lazy loading this is not straightforward, will leave it here for now. + from .main import * + from .compression import compress -# OpenAIOSeriesConfig is lazy loaded - openaiOSeriesConfig will be created on first access -# OpenAIGPTConfig, OpenAIGPT5Config, etc. are lazy loaded - instances will be created on first access -from .llms.xai.common_utils import XAIModelInfo + # Skills API + from .skills.main import ( + create_skill, + acreate_skill, + list_skills, + alist_skills, + get_skill, + aget_skill, + delete_skill, + adelete_skill, + ) + from .evals.main import ( + create_eval, + acreate_eval, + list_evals, + alist_evals, + get_eval, + aget_eval, + delete_eval, + adelete_eval, + cancel_eval, + acancel_eval, + create_run, + acreate_run, + list_runs, + alist_runs, + get_run, + aget_run, + delete_run, + adelete_run, + cancel_run, + acancel_run, + ) + from .integrations import * + from .llms.custom_httpx.async_client_cleanup import close_litellm_async_clients + from .exceptions import ( + AuthenticationError, + InvalidRequestError, + BadRequestError, + ImageFetchError, + NotFoundError, + PermissionDeniedError, + RateLimitError, + RateLimitErrorCategory, + RateLimitType, + ServiceUnavailableError, + BadGatewayError, + OpenAIError, + ContextWindowExceededError, + ContentPolicyViolationError, + BudgetExceededError, + APIError, + Timeout, + APIConnectionError, + UnsupportedParamsError, + APIResponseValidationError, + UnprocessableEntityError, + InternalServerError, + JSONSchemaValidationError, + LITELLM_EXCEPTION_TYPES, + MockException, + ) + from .budget_manager import BudgetManager + from .proxy.proxy_cli import run_server + from .router import Router + from .assistants.main import * + from .batches.main import * + from .images.main import * + from .videos.main import * + from .batch_completion.main import * + from .rerank_api.main import * + from .llms.anthropic.experimental_pass_through.messages.handler import * + from .responses.main import * -# PublicAI now uses JSON-based configuration (see litellm/llms/openai_like/providers.json) -# All remaining configs are now lazy loaded - see _lazy_imports_registry.py + # Interactions API is available as litellm.interactions module + # Usage: litellm.interactions.create(), litellm.interactions.get(), etc. + from . import interactions + from .interactions.agents.main import ( + acreate as acreate_agent, + create as create_agent, + alist as alist_agents, + list as list_agents, + aget as aget_agent, + get as get_agent, + adelete as adelete_agent, + delete as delete_agent, + alist_versions as alist_agent_versions, + list_versions as list_agent_versions, + ) + from .skills.main import ( + create_skill, + acreate_skill, + list_skills, + alist_skills, + get_skill, + aget_skill, + delete_skill, + adelete_skill, + ) + from .containers.main import * + from .ocr.main import * + from .rust_bridge import rust + from .rag.main import * + from .sandbox.main import * + from .search.main import * + from .realtime_api.main import ( + _arealtime, + acreate_realtime_client_secret, + acreate_realtime_transcription_session, + arealtime_calls, + ) + from .responses.main import _aresponses_websocket + from .fine_tuning.main import * + from .files.main import * + from .vector_store_files.main import ( + acreate as avector_store_file_create, + adelete as avector_store_file_delete, + alist as avector_store_file_list, + aretrieve as avector_store_file_retrieve, + aretrieve_content as avector_store_file_content, + aupdate as avector_store_file_update, + create as vector_store_file_create, + delete as vector_store_file_delete, + list as vector_store_file_list, + retrieve as vector_store_file_retrieve, + retrieve_content as vector_store_file_content, + update as vector_store_file_update, + ) + from .scheduler import * -# Import LlmProviders here (before main import) because it's imported during import time -# in multiple places including openai.py (via main import) -from litellm.types.utils import LlmProviders + ### ADAPTERS ### + import litellm.anthropic_interface as anthropic -## Lazy loading this is not straightforward, will leave it here for now. -from .main import * -from .compression import compress + ### Vector Store Registry ### -# Skills API -from .skills.main import ( - create_skill, - acreate_skill, - list_skills, - alist_skills, - get_skill, - aget_skill, - delete_skill, - adelete_skill, -) -from .evals.main import ( - create_eval, - acreate_eval, - list_evals, - alist_evals, - get_eval, - aget_eval, - delete_eval, - adelete_eval, - cancel_eval, - acancel_eval, - create_run, - acreate_run, - list_runs, - alist_runs, - get_run, - aget_run, - delete_run, - adelete_run, - cancel_run, - acancel_run, -) -from .integrations import * -from .llms.custom_httpx.async_client_cleanup import close_litellm_async_clients -from .exceptions import ( - AuthenticationError, - InvalidRequestError, - BadRequestError, - ImageFetchError, - NotFoundError, - PermissionDeniedError, - RateLimitError, - RateLimitErrorCategory, - RateLimitType, - ServiceUnavailableError, - BadGatewayError, - OpenAIError, - ContextWindowExceededError, - ContentPolicyViolationError, - BudgetExceededError, - APIError, - Timeout, - APIConnectionError, - UnsupportedParamsError, - APIResponseValidationError, - UnprocessableEntityError, - InternalServerError, - JSONSchemaValidationError, - LITELLM_EXCEPTION_TYPES, - MockException, -) -from .budget_manager import BudgetManager -from .proxy.proxy_cli import run_server -from .router import Router -from .assistants.main import * -from .batches.main import * -from .images.main import * -from .videos.main import * -from .batch_completion.main import * -from .rerank_api.main import * -from .llms.anthropic.experimental_pass_through.messages.handler import * -from .responses.main import * + ### RAG ### + from . import rag -# Interactions API is available as litellm.interactions module -# Usage: litellm.interactions.create(), litellm.interactions.get(), etc. -from . import interactions -from .interactions.agents.main import ( - acreate as acreate_agent, - create as create_agent, - alist as alist_agents, - list as list_agents, - aget as aget_agent, - get as get_agent, - adelete as adelete_agent, - delete as delete_agent, - alist_versions as alist_agent_versions, - list_versions as list_agent_versions, -) -from .skills.main import ( - create_skill, - acreate_skill, - list_skills, - alist_skills, - get_skill, - aget_skill, - delete_skill, - adelete_skill, -) -from .containers.main import * -from .ocr.main import * -from .rust_bridge import rust -from .rag.main import * -from .sandbox.main import * -from .search.main import * -from .realtime_api.main import ( - _arealtime, - acreate_realtime_client_secret, - acreate_realtime_transcription_session, - arealtime_calls, -) -from .responses.main import _aresponses_websocket -from .fine_tuning.main import * -from .files.main import * -from .vector_store_files.main import ( - acreate as avector_store_file_create, - adelete as avector_store_file_delete, - alist as avector_store_file_list, - aretrieve as avector_store_file_retrieve, - aretrieve_content as avector_store_file_content, - aupdate as avector_store_file_update, - create as vector_store_file_create, - delete as vector_store_file_delete, - list as vector_store_file_list, - retrieve as vector_store_file_retrieve, - retrieve_content as vector_store_file_content, - update as vector_store_file_update, -) -from .scheduler import * + ### CUSTOM LLMs ### + + ### CLI UTILITIES ### + from litellm.litellm_core_utils.cli_token_utils import get_litellm_gateway_api_key + + ### PASSTHROUGH ### + from .passthrough import allm_passthrough_route, llm_passthrough_route + from .google_genai import agenerate_content ### ADAPTERS ### -from .types.adapter import AdapterItem -import litellm.anthropic_interface as anthropic - adapters: List[AdapterItem] = [] ### Vector Store Registry ### -from .vector_stores.vector_store_registry import ( - VectorStoreRegistry, - VectorStoreIndexRegistry, -) - vector_store_registry: Optional[VectorStoreRegistry] = None vector_store_index_registry: Optional[VectorStoreIndexRegistry] = None -### RAG ### -from . import rag - ### CUSTOM LLMs ### -from .types.llms.custom_llm import CustomLLMItem - custom_provider_map: List[CustomLLMItem] = [] _custom_providers: List[str] = [] # internal helper util, used to track names of custom providers disable_hf_tokenizer_download: Optional[bool] = ( @@ -1478,13 +1471,6 @@ disable_hf_tokenizer_download: Optional[bool] = ( ) global_disable_no_log_param: bool = False -### CLI UTILITIES ### -from litellm.litellm_core_utils.cli_token_utils import get_litellm_gateway_api_key - -### PASSTHROUGH ### -from .passthrough import allm_passthrough_route, llm_passthrough_route -from .google_genai import agenerate_content - ### GLOBAL CONFIG ### global_bitbucket_config: Optional[Dict[str, Any]] = None @@ -1508,10 +1494,21 @@ def set_global_gitlab_config(config: Dict[str, Any]) -> None: # Lazy loading system for heavy modules to reduce initial import time and memory usage if TYPE_CHECKING: + import httpx + from litellm.types.utils import ModelInfo as _ModelInfoType from litellm.types.utils import PriorityReservationSettings from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler, HTTPHandler from litellm.caching.caching import Cache + from litellm.types.adapter import AdapterItem + from litellm.types.integrations.datadog import DatadogInitParams + from litellm.types.integrations.newrelic import NewRelicInitParams + from litellm.types.llms.custom_llm import CustomLLMItem + from litellm.types.prompts.init_prompts import PromptSpec + from litellm.vector_stores.vector_store_registry import ( + VectorStoreIndexRegistry, + VectorStoreRegistry, + ) # Type stubs for lazy-loaded configs to help mypy from .llms.bedrock.chat.converse_transformation import ( @@ -2187,16 +2184,6 @@ if TYPE_CHECKING: # Track if async client cleanup has been registered (for lazy loading) _async_client_cleanup_registered = False -# Eager loading for backwards compatibility with VCR and other HTTP recording tools -# When LITELLM_DISABLE_LAZY_LOADING is set, lazy-loaded attributes are loaded at import time -# For now, this only affects encoding (tiktoken) as it was the only reported issue -# See: https://github.com/BerriAI/litellm/issues/18659 -# This ensures encoding is initialized before VCR starts recording HTTP requests -if os.getenv("LITELLM_DISABLE_LAZY_LOADING", "").lower() in ("1", "true", "yes", "on"): - # Load encoding at import time (pre-#18070 behavior) - # This ensures encoding is initialized before VCR starts recording - from .main import encoding - def __getattr__(name: str) -> Any: """Lazy import handler with cached registry for improved performance.""" @@ -2276,6 +2263,8 @@ def __getattr__(name: str) -> Any: "openAIGPT5Config": "OpenAIGPT5Config", "nvidiaNimConfig": "NvidiaNimConfig", "nvidiaNimEmbeddingConfig": "NvidiaNimEmbeddingConfig", + "vertexAITextEmbeddingConfig": "VertexAITextEmbeddingConfig", + "_key_management_settings": "KeyManagementSettings", } if name in _config_instances: from ._lazy_imports import get_litellm_globals @@ -2393,7 +2382,30 @@ def __getattr__(name: str) -> Any: return locals()[name] + from ._lazy_imports import lazy_import_litellm_submodule + + submodule: Final = lazy_import_litellm_submodule(name) + if submodule is not None: + return submodule + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") +from ._lazy_imports import LiteLLMModule +from ._lazy_imports_registry import STAR_IMPORT_PUBLIC_NAMES + +sys.modules[__name__].__class__ = LiteLLMModule + +__all__ = list(STAR_IMPORT_PUBLIC_NAMES) # mutable-ok: star imports require __all__ to be a list of str + + # ALL_LITELLM_RESPONSE_TYPES is lazy-loaded via __getattr__ to avoid loading utils at import time + +# Eager loading for backwards compatibility with VCR and other HTTP recording tools +# When LITELLM_DISABLE_LAZY_LOADING is set, lazy-loaded attributes are loaded at import time +# For now, this only affects encoding (tiktoken) as it was the only reported issue +# See: https://github.com/BerriAI/litellm/issues/18659 +# This ensures encoding is initialized before VCR starts recording HTTP requests +# This block stays at the bottom so __getattr__ can resolve attributes main.py needs during its import +if os.getenv("LITELLM_DISABLE_LAZY_LOADING", "").lower() in ("1", "true", "yes", "on"): + from .main import encoding diff --git a/litellm/_lazy_imports.py b/litellm/_lazy_imports.py index 553aeb6680d..004297a559e 100644 --- a/litellm/_lazy_imports.py +++ b/litellm/_lazy_imports.py @@ -16,9 +16,10 @@ until they're actually needed. """ import importlib +import importlib.util import sys from collections.abc import Callable, Mapping -from types import ModuleType +from types import MappingProxyType, ModuleType from typing import TYPE_CHECKING, Any, Final, cast from typing_extensions import ReadOnly, TypedDict @@ -34,6 +35,8 @@ from ._lazy_imports_registry import ( _LITELLM_LOGGING_IMPORT_MAP, _LLM_CONFIGS_IMPORT_MAP, _LLM_PROVIDER_LOGIC_IMPORT_MAP, + _SDK_MODULE_ALIASES, + _SDK_SYMBOLS_IMPORT_MAP, _TOKEN_COUNTER_IMPORT_MAP, _TYPES_IMPORT_MAP, _TYPES_UTILS_IMPORT_MAP, @@ -78,7 +81,10 @@ def _get_utils_globals() -> dict[str, object]: This is where we cache imported attributes so we don't import them twice. When you do `litellm.utils.some_function`, it gets stored in this dictionary. """ - return sys.modules["litellm.utils"].__dict__ + cached: Final = sys.modules.get("litellm.utils") + if cached is not None: + return cached.__dict__ + return importlib.import_module("litellm.utils").__dict__ def _get_module_level_client_timeout(litellm_globals: Mapping[str, Any]) -> "float | httpx.Timeout | None": @@ -214,6 +220,10 @@ def _get_lazy_import_registry() -> dict[str, Callable[[str], object]]: _LAZY_IMPORT_REGISTRY[name] = _lazy_import_llm_provider_logic for name in UTILS_MODULE_NAMES: _LAZY_IMPORT_REGISTRY[name] = _lazy_import_utils_module + for name in _SDK_SYMBOLS_IMPORT_MAP: + _LAZY_IMPORT_REGISTRY.setdefault(name, _lazy_import_sdk_symbols) + for name in _SDK_MODULE_ALIASES: + _LAZY_IMPORT_REGISTRY.setdefault(name, _lazy_import_sdk_module_alias) return _LAZY_IMPORT_REGISTRY @@ -229,7 +239,7 @@ def _module_attribute(module: ModuleType, attr_name: str) -> object: return attribute["value"] -def _generic_lazy_import(name: str, import_map: dict[str, tuple[str, str]], category: str) -> object: +def _generic_lazy_import(name: str, import_map: Mapping[str, tuple[str, str]], category: str) -> object: """ Generic function that handles lazy importing for most attributes. @@ -350,6 +360,86 @@ def _lazy_import_llm_provider_logic(name: str) -> object: return _generic_lazy_import(name, _LLM_PROVIDER_LOGIC_IMPORT_MAP, "LLM provider logic") +def _lazy_import_sdk_symbols(name: str) -> object: + """Handler for SDK symbols previously imported eagerly at the bottom of litellm/__init__.py""" + return _generic_lazy_import(name, _SDK_SYMBOLS_IMPORT_MAP, "SDK symbols") + + +def _lazy_import_sdk_module_alias(name: str) -> object: + """Handler for litellm attributes that bind a module (e.g. litellm.anthropic)""" + _globals: Final = get_litellm_globals() + if name in _globals: + return _globals[name] + module: Final = importlib.import_module(_SDK_MODULE_ALIASES[name]) + _globals[name] = module # rebind-ok: caches the resolved module alias on the package + return module + + +_SHADOWABLE_SDK_FUNCTIONS: Final = MappingProxyType( + { + "batch_completion": ("litellm.batch_completion.main", "batch_completion"), + "ocr": ("litellm.ocr.main", "ocr"), + "responses": ("litellm.responses.main", "responses"), + "search": ("litellm.search.main", "search"), + } +) + + +def _shadowable_function_property(name: str) -> property: + """Property keeping litellm. bound to the SDK function even after the import + machinery binds the identically named litellm. subpackage onto the litellm module.""" + module_path, attr_name = _SHADOWABLE_SDK_FUNCTIONS[name] + + def _get(module: ModuleType) -> object: + stored: Final = module.__dict__.get(name) + if stored is not None and not (isinstance(stored, ModuleType) and stored.__name__ == f"litellm.{name}"): + return stored + value: Final = _module_attribute(importlib.import_module(module_path), attr_name) + module.__dict__[name] = value # rebind-ok: caches the resolved function on the litellm module + return value + + def _set(module: ModuleType, value: object) -> None: + module.__dict__[name] = value # rebind-ok: property setter must store assignments on the module + + return property(_get, _set) + + +class LiteLLMModule(ModuleType): + """Module type installed on the litellm package so function names shadowed by + same-named subpackages (litellm.responses, ...) keep resolving to the functions.""" + + batch_completion = _shadowable_function_property("batch_completion") + ocr = _shadowable_function_property("ocr") + responses = _shadowable_function_property("responses") + search = _shadowable_function_property("search") + + +def lazy_import_submodule(package: str, name: str) -> "ModuleType | None": + """Resolve . as a submodule (e.g. litellm.utils) when no other handler matches""" + if name.startswith("__") or not name.isidentifier(): + return None + qualified_name: Final = f"{package}.{name}" + try: + spec: Final = importlib.util.find_spec(qualified_name) + except ModuleNotFoundError: + return None + if spec is None: + return None + try: + module: Final = importlib.import_module(qualified_name) + except ModuleNotFoundError as exc: + if exc.name == qualified_name: + return None + raise + sys.modules[package].__dict__[name] = module # rebind-ok: caches the resolved submodule on the package + return module + + +def lazy_import_litellm_submodule(name: str) -> "ModuleType | None": + """Resolve litellm. as a submodule (e.g. litellm.utils) when no other handler matches""" + return lazy_import_submodule("litellm", name) + + def _lazy_import_utils_module(name: str) -> object: """ Handler for utils module lazy imports. diff --git a/litellm/_lazy_imports_registry.py b/litellm/_lazy_imports_registry.py index e9199e1ec80..b0e2fb1398c 100644 --- a/litellm/_lazy_imports_registry.py +++ b/litellm/_lazy_imports_registry.py @@ -5,6 +5,8 @@ This module contains all the name tuples and import maps used by the lazy import Separated from the handler functions for better organization. """ +from collections.abc import Mapping +from types import MappingProxyType from typing import Final # Cost calculator names that support lazy loading via _lazy_import_cost_calculator @@ -1479,6 +1481,1171 @@ _UTILS_MODULE_IMPORT_MAP: Final = { "LiteLLM_Params": ("litellm.types.router", "LiteLLM_Params"), } +_SDK_SYMBOLS_IMPORT_MAP: Final[Mapping[str, tuple[str, str]]] = MappingProxyType( + { + "AI21Config": ("litellm.llms.ai21.chat.transformation", "AI21ChatConfig"), + "ALL_RESPONSES_API_TOOL_PARAMS": ("litellm.assistants.main", "ALL_RESPONSES_API_TOOL_PARAMS"), + "APIConnectionError": ("litellm.exceptions", "APIConnectionError"), + "APIError": ("litellm.exceptions", "APIError"), + "APIResponseValidationError": ("litellm.exceptions", "APIResponseValidationError"), + "AZURE_OPENAI_AUDIO_PROVIDERS": ("litellm.main", "AZURE_OPENAI_AUDIO_PROVIDERS"), + "AdapterCompletionStreamWrapper": ("litellm.types.utils", "AdapterCompletionStreamWrapper"), + "AdapterItem": ("litellm.types.adapter", "AdapterItem"), + "AdaptiveRouterConfig": ("litellm.types.router", "AdaptiveRouterConfig"), + "AdaptiveRouterPreferences": ("litellm.types.router", "AdaptiveRouterPreferences"), + "AdaptiveRouterWeights": ("litellm.types.router", "AdaptiveRouterWeights"), + "AlephAlphaConfig": ("litellm.llms.deprecated_providers.aleph_alpha", "AlephAlphaConfig"), + "AlertingConfig": ("litellm.types.router", "AlertingConfig"), + "AllEmbeddingInputValues": ("litellm.assistants.main", "AllEmbeddingInputValues"), + "AllMessageValues": ("litellm.assistants.main", "AllMessageValues"), + "AllPromptValues": ("litellm.assistants.main", "AllPromptValues"), + "AllowedFailsPolicy": ("litellm.types.router", "AllowedFailsPolicy"), + "AmazonTitanV2Config": ("litellm.llms.bedrock.embed.amazon_titan_v2_transformation", "AmazonTitanV2Config"), + "Annotated": ("litellm.assistants.main", "Annotated"), + "AnthropicBatchesHandler": ("litellm.llms.anthropic.batches.handler", "AnthropicBatchesHandler"), + "AnthropicChatCompletion": ("litellm.llms.anthropic.chat.handler", "AnthropicChatCompletion"), + "AnthropicMessagesRequestUtils": ( + "litellm.llms.anthropic.experimental_pass_through.messages.utils", + "AnthropicMessagesRequestUtils", + ), + "AnthropicMessagesResponse": ( + "litellm.types.llms.anthropic_messages.anthropic_response", + "AnthropicMessagesResponse", + ), + "AnthropicMetadata": ("litellm.types.llms.anthropic_messages.anthropic_request", "AnthropicMetadata"), + "AnthropicModelInfo": ("litellm.llms.anthropic.common_utils", "AnthropicModelInfo"), + "Assistant": ("litellm.assistants.main", "Assistant"), + "AssistantDeleted": ("litellm.assistants.main", "AssistantDeleted"), + "AssistantEventHandler": ("litellm.assistants.main", "AssistantEventHandler"), + "AssistantStreamManager": ("litellm.assistants.main", "AssistantStreamManager"), + "AssistantToolParam": ("litellm.assistants.main", "AssistantToolParam"), + "AssistantsTypedDict": ("litellm.types.router", "AssistantsTypedDict"), + "AsyncAssistantEventHandler": ("litellm.assistants.main", "AsyncAssistantEventHandler"), + "AsyncAssistantStreamManager": ("litellm.assistants.main", "AsyncAssistantStreamManager"), + "AsyncCompletions": ("litellm.main", "AsyncCompletions"), + "AsyncCursorPage": ("litellm.assistants.main", "AsyncCursorPage"), + "AsyncIterator": ("litellm.llms.anthropic.experimental_pass_through.messages.handler", "AsyncIterator"), + "AsyncOpenAI": ("litellm.assistants.main", "AsyncOpenAI"), + "Attachment": ("litellm.types.llms.openai", "Attachment"), + "AttachmentTool": ("litellm.assistants.main", "AttachmentTool"), + "AuthenticationError": ("litellm.exceptions", "AuthenticationError"), + "AutoRouterCapabilityLimit": ("litellm.types.router", "AutoRouterCapabilityLimit"), + "AzureAIEmbedding": ("litellm.llms.azure_ai.embed.handler", "AzureAIEmbedding"), + "AzureAnthropicChatCompletion": ("litellm.llms.azure_ai.anthropic.handler", "AzureAnthropicChatCompletion"), + "AzureAssistantsAPI": ("litellm.llms.azure.assistants", "AzureAssistantsAPI"), + "AzureAudioTranscription": ("litellm.llms.azure.audio_transcriptions", "AzureAudioTranscription"), + "AzureBatchesAPI": ("litellm.llms.azure.batches.handler", "AzureBatchesAPI"), + "AzureChatCompletion": ("litellm.llms.azure.azure", "AzureChatCompletion"), + "AzureOpenAIFilesAPI": ("litellm.llms.azure.files.handler", "AzureOpenAIFilesAPI"), + "AzureOpenAIFineTuningAPI": ("litellm.llms.azure.fine_tuning.handler", "AzureOpenAIFineTuningAPI"), + "AzureOpenAIO1ChatCompletion": ("litellm.llms.azure.chat.o_series_handler", "AzureOpenAIO1ChatCompletion"), + "AzureTextCompletion": ("litellm.llms.azure.completion.handler", "AzureTextCompletion"), + "BATCH_GUARDRAIL_RESPONSE_FIELD": ("litellm.assistants.main", "BATCH_GUARDRAIL_RESPONSE_FIELD"), + "BadGatewayError": ("litellm.exceptions", "BadGatewayError"), + "BadRequestError": ("litellm.exceptions", "BadRequestError"), + "BaseConfig": ("litellm.llms.base_llm.chat.transformation", "BaseConfig"), + "BaseLLMAIOHTTPHandler": ("litellm.llms.custom_httpx.aiohttp_handler", "BaseLLMAIOHTTPHandler"), + "BaseLLMException": ("litellm.llms.base_llm.chat.transformation", "BaseLLMException"), + "BaseLLMHTTPHandler": ("litellm.llms.custom_httpx.llm_http_handler", "BaseLLMHTTPHandler"), + "BaseLiteLLMOpenAIResponseObject": ("litellm.types.llms.base", "BaseLiteLLMOpenAIResponseObject"), + "BaseModel": ("litellm.scheduler", "BaseModel"), + "BaseResponsesAPIConfig": ("litellm.llms.base_llm.responses.transformation", "BaseResponsesAPIConfig"), + "BaseResponsesAPIStreamingIterator": ( + "litellm.responses.streaming_iterator", + "BaseResponsesAPIStreamingIterator", + ), + "Batch": ("litellm.assistants.main", "Batch"), + "BatchGuardrailRecord": ("litellm.types.llms.openai", "BatchGuardrailRecord"), + "BatchGuardrailReport": ("litellm.types.llms.openai", "BatchGuardrailReport"), + "BatchJobStatus": ("litellm.assistants.main", "BatchJobStatus"), + "BatchRequestCounts": ("litellm.batches.main", "BatchRequestCounts"), + "BedrockBatchesHandler": ("litellm.llms.bedrock.batches.handler", "BedrockBatchesHandler"), + "BedrockConverseLLM": ("litellm.llms.bedrock.chat.converse_handler", "BedrockConverseLLM"), + "BedrockEmbedding": ("litellm.llms.bedrock.embed.embedding", "BedrockEmbedding"), + "BedrockFilesHandler": ("litellm.llms.bedrock.files.handler", "BedrockFilesHandler"), + "BedrockImageEdit": ("litellm.llms.bedrock.image_edit.handler", "BedrockImageEdit"), + "BedrockImageGeneration": ("litellm.llms.bedrock.image_generation.image_handler", "BedrockImageGeneration"), + "BedrockRerankHandler": ("litellm.llms.bedrock.rerank.handler", "BedrockRerankHandler"), + "BudgetExceededError": ("litellm.exceptions", "BudgetExceededError"), + "BudgetManager": ("litellm.budget_manager", "BudgetManager"), + "CARRY_UNMATCHED_MESSAGE_POINTS": ("litellm.responses.main", "CARRY_UNMATCHED_MESSAGE_POINTS"), + "CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS": ("litellm.files.main", "CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS"), + "CREATE_FILE_REQUESTS_PURPOSE": ("litellm.assistants.main", "CREATE_FILE_REQUESTS_PURPOSE"), + "CallTypes": ("litellm.types.utils", "CallTypes"), + "CancelBatchRequest": ("litellm.types.llms.openai", "CancelBatchRequest"), + "CharacterObject": ("litellm.types.videos.main", "CharacterObject"), + "Chat": ("litellm.main", "Chat"), + "ChatCompletionAnnotation": ("litellm.types.llms.openai", "ChatCompletionAnnotation"), + "ChatCompletionAnnotationURLCitation": ("litellm.types.llms.openai", "ChatCompletionAnnotationURLCitation"), + "ChatCompletionAssistantContentValue": ("litellm.assistants.main", "ChatCompletionAssistantContentValue"), + "ChatCompletionAssistantMessage": ("litellm.types.llms.openai", "ChatCompletionAssistantMessage"), + "ChatCompletionAssistantToolCall": ("litellm.types.llms.openai", "ChatCompletionAssistantToolCall"), + "ChatCompletionAudioDelta": ("litellm.types.llms.openai", "ChatCompletionAudioDelta"), + "ChatCompletionAudioObject": ("litellm.types.llms.openai", "ChatCompletionAudioObject"), + "ChatCompletionAudioParam": ("litellm.assistants.main", "ChatCompletionAudioParam"), + "ChatCompletionCachedContent": ("litellm.types.llms.openai", "ChatCompletionCachedContent"), + "ChatCompletionChunk": ("litellm.assistants.main", "ChatCompletionChunk"), + "ChatCompletionContentPartInputAudioParam": ( + "litellm.assistants.main", + "ChatCompletionContentPartInputAudioParam", + ), + "ChatCompletionDeltaChunk": ("litellm.types.llms.openai", "ChatCompletionDeltaChunk"), + "ChatCompletionDeveloperMessage": ("litellm.types.llms.openai", "ChatCompletionDeveloperMessage"), + "ChatCompletionDocumentObject": ("litellm.types.llms.openai", "ChatCompletionDocumentObject"), + "ChatCompletionFileObject": ("litellm.types.llms.openai", "ChatCompletionFileObject"), + "ChatCompletionFileObjectFile": ("litellm.types.llms.openai", "ChatCompletionFileObjectFile"), + "ChatCompletionFunctionMessage": ("litellm.types.llms.openai", "ChatCompletionFunctionMessage"), + "ChatCompletionImageObject": ("litellm.types.llms.openai", "ChatCompletionImageObject"), + "ChatCompletionImageUrlObject": ("litellm.types.llms.openai", "ChatCompletionImageUrlObject"), + "ChatCompletionMessageToolCall": ("litellm.types.utils", "ChatCompletionMessageToolCall"), + "ChatCompletionModality": ("litellm.assistants.main", "ChatCompletionModality"), + "ChatCompletionNamedToolChoiceParam": ("litellm.types.llms.openai", "ChatCompletionNamedToolChoiceParam"), + "ChatCompletionPredictionContentParam": ("litellm.assistants.main", "ChatCompletionPredictionContentParam"), + "ChatCompletionReasoningItem": ("litellm.types.llms.openai", "ChatCompletionReasoningItem"), + "ChatCompletionReasoningSummaryTextBlock": ( + "litellm.types.llms.openai", + "ChatCompletionReasoningSummaryTextBlock", + ), + "ChatCompletionRedactedThinkingBlock": ("litellm.types.llms.openai", "ChatCompletionRedactedThinkingBlock"), + "ChatCompletionRequest": ("litellm.types.llms.openai", "ChatCompletionRequest"), + "ChatCompletionResponseMessage": ("litellm.types.llms.openai", "ChatCompletionResponseMessage"), + "ChatCompletionSystemMessage": ("litellm.types.llms.openai", "ChatCompletionSystemMessage"), + "ChatCompletionTextObject": ("litellm.types.llms.openai", "ChatCompletionTextObject"), + "ChatCompletionThinkingBlock": ("litellm.types.llms.openai", "ChatCompletionThinkingBlock"), + "ChatCompletionToolChoiceFunctionParam": ("litellm.types.llms.openai", "ChatCompletionToolChoiceFunctionParam"), + "ChatCompletionToolChoiceObjectParam": ("litellm.types.llms.openai", "ChatCompletionToolChoiceObjectParam"), + "ChatCompletionToolChoiceStringValues": ("litellm.assistants.main", "ChatCompletionToolChoiceStringValues"), + "ChatCompletionToolChoiceValues": ("litellm.assistants.main", "ChatCompletionToolChoiceValues"), + "ChatCompletionToolMessage": ("litellm.types.llms.openai", "ChatCompletionToolMessage"), + "ChatCompletionToolParam": ("litellm.types.llms.openai", "ChatCompletionToolParam"), + "ChatCompletionToolParamFunctionChunk": ("litellm.types.llms.openai", "ChatCompletionToolParamFunctionChunk"), + "ChatCompletionToolReferenceObject": ("litellm.types.llms.openai", "ChatCompletionToolReferenceObject"), + "ChatCompletionUsageBlock": ("litellm.types.llms.openai", "ChatCompletionUsageBlock"), + "ChatCompletionUserMessage": ("litellm.types.llms.openai", "ChatCompletionUserMessage"), + "ChatCompletionVideoObject": ("litellm.types.llms.openai", "ChatCompletionVideoObject"), + "ChatCompletionVideoUrlObject": ("litellm.types.llms.openai", "ChatCompletionVideoUrlObject"), + "Choices": ("litellm.types.utils", "Choices"), + "ChunkProcessor": ("litellm.litellm_core_utils.streaming_chunk_builder_utils", "ChunkProcessor"), + "CitationsObject": ("litellm.types.llms.openai", "CitationsObject"), + "ClassVar": ("litellm.files.main", "ClassVar"), + "ClassifierPlugin": ("litellm.types.router", "ClassifierPlugin"), + "CodeInterpreterToolParam": ("litellm.types.llms.openai", "CodeInterpreterToolParam"), + "CodestralTextCompletion": ("litellm.llms.codestral.completion.handler", "CodestralTextCompletion"), + "CompletionRequest": ("litellm.types.completion", "CompletionRequest"), + "CompletionTimeout": ("litellm.litellm_core_utils.completion_timeout", "CompletionTimeout"), + "CompletionTokensDetails": ("litellm.main", "CompletionTokensDetails"), + "Completions": ("litellm.main", "Completions"), + "ComputerToolParam": ("litellm.types.llms.openai", "ComputerToolParam"), + "ConfigDict": ("litellm.files.main", "ConfigDict"), + "ConfigurableClientsideParamsCustomAuth": ("litellm.types.router", "ConfigurableClientsideParamsCustomAuth"), + "ConsumedRequestTagsStamp": ("litellm.types.router", "ConsumedRequestTagsStamp"), + "ContentPartAddedEvent": ("litellm.types.llms.openai", "ContentPartAddedEvent"), + "ContentPartDoneEvent": ("litellm.types.llms.openai", "ContentPartDoneEvent"), + "ContentPartDonePartOutputText": ("litellm.types.llms.openai", "ContentPartDonePartOutputText"), + "ContentPartDonePartReasoningText": ("litellm.types.llms.openai", "ContentPartDonePartReasoningText"), + "ContentPartDonePartRefusal": ("litellm.types.llms.openai", "ContentPartDonePartRefusal"), + "ContentPolicyViolationError": ("litellm.exceptions", "ContentPolicyViolationError"), + "ContextManagementEntry": ("litellm.types.llms.openai", "ContextManagementEntry"), + "ContextWindowExceededError": ("litellm.exceptions", "ContextWindowExceededError"), + "Coroutine": ("litellm.files.main", "Coroutine"), + "CreateBatchRequest": ("litellm.types.llms.openai", "CreateBatchRequest"), + "CreateFileRequest": ("litellm.types.llms.openai", "CreateFileRequest"), + "CreateVideoRequest": ("litellm.types.llms.openai", "CreateVideoRequest"), + "CredentialLiteLLMParams": ("litellm.types.router", "CredentialLiteLLMParams"), + "CustomLLM": ("litellm.llms.custom_llm", "CustomLLM"), + "CustomLLMItem": ("litellm.types.llms.custom_llm", "CustomLLMItem"), + "CustomPricingLiteLLMParams": ("litellm.types.utils", "CustomPricingLiteLLMParams"), + "CustomRoutingStrategyBase": ("litellm.types.router", "CustomRoutingStrategyBase"), + "CustomToolCallOutputItem": ("litellm.types.responses.main", "CustomToolCallOutputItem"), + "DEFAULT_IMAGE_ENDPOINT_MODEL": ("litellm.images.main", "DEFAULT_IMAGE_ENDPOINT_MODEL"), + "DEFAULT_IN_MEMORY_TTL": ("litellm.scheduler", "DEFAULT_IN_MEMORY_TTL"), + "DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT": ( + "litellm.main", + "DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT", + ), + "DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT": ("litellm.main", "DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT"), + "DEFAULT_POLLING_INTERVAL": ("litellm.scheduler", "DEFAULT_POLLING_INTERVAL"), + "DEFAULT_REQUEST_TIMEOUT": ("litellm.videos.main", "DEFAULT_REQUEST_TIMEOUT"), + "DEFAULT_VIDEO_ENDPOINT_MODEL": ("litellm.videos.main", "DEFAULT_VIDEO_ENDPOINT_MODEL"), + "DatabricksEmbeddingHandler": ("litellm.llms.databricks.embed.handler", "DatabricksEmbeddingHandler"), + "DatadogInitParams": ("litellm.types.integrations.datadog", "DatadogInitParams"), + "DecodedResponseId": ("litellm.types.responses.main", "DecodedResponseId"), + "DeleteResponseResult": ("litellm.types.responses.main", "DeleteResponseResult"), + "Deployment": ("litellm.types.router", "Deployment"), + "DeploymentTypedDict": ("litellm.types.router", "DeploymentTypedDict"), + "Discriminator": ("litellm.assistants.main", "Discriminator"), + "DocumentObject": ("litellm.types.llms.openai", "DocumentObject"), + "EmbeddingCreateParams": ("litellm.assistants.main", "EmbeddingCreateParams"), + "EmbeddingInput": ("litellm.assistants.main", "EmbeddingInput"), + "EmbeddingRequest": ("litellm.types.embedding", "EmbeddingRequest"), + "Enum": ("litellm.assistants.main", "Enum"), + "ErrorEvent": ("litellm.types.llms.openai", "ErrorEvent"), + "ErrorEventError": ("litellm.types.llms.openai", "ErrorEventError"), + "FIRST_COMPLETED": ("litellm.batch_completion.main", "FIRST_COMPLETED"), + "FORWARDED_KWARGS_KEYS": ("litellm.main", "FORWARDED_KWARGS_KEYS"), + "FallbackAccessCheck": ("litellm.types.router", "FallbackAccessCheck"), + "Field": ("litellm.files.main", "Field"), + "FileContent": ("litellm.videos.main", "FileContent"), + "FileContentProvider": ("litellm.files.main", "FileContentProvider"), + "FileContentRequest": ("litellm.types.llms.openai", "FileContentRequest"), + "FileContentStreamingResponse": ("litellm.files.streaming", "FileContentStreamingResponse"), + "FileContentStreamingResult": ("litellm.files.types", "FileContentStreamingResult"), + "FileCreateProvider": ("litellm.files.main", "FileCreateProvider"), + "FileDeleteProvider": ("litellm.files.main", "FileDeleteProvider"), + "FileDeleted": ("litellm.files.main", "FileDeleted"), + "FileExpiresAfter": ("litellm.types.llms.openai", "FileExpiresAfter"), + "FileListPage": ("litellm.types.llms.openai", "FileListPage"), + "FileListProvider": ("litellm.files.main", "FileListProvider"), + "FileObject": ("litellm.files.main", "FileObject"), + "FileRetrieveProvider": ("litellm.files.main", "FileRetrieveProvider"), + "FileSearchCallCompletedEvent": ("litellm.types.llms.openai", "FileSearchCallCompletedEvent"), + "FileSearchCallInProgressEvent": ("litellm.types.llms.openai", "FileSearchCallInProgressEvent"), + "FileSearchCallSearchingEvent": ("litellm.types.llms.openai", "FileSearchCallSearchingEvent"), + "FileSearchTool": ("litellm.types.llms.openai", "FileSearchTool"), + "FileSearchToolParam": ("litellm.types.llms.openai", "FileSearchToolParam"), + "FileTypes": ("litellm.files.main", "FileTypes"), + "FineTuningConfig": ("litellm.types.router", "FineTuningConfig"), + "FineTuningJob": ("litellm.assistants.main", "FineTuningJob"), + "FineTuningJobCreate": ("litellm.types.llms.openai", "FineTuningJobCreate"), + "FlowItem": ("litellm.scheduler", "FlowItem"), + "Function": ("litellm.types.llms.openai", "Function"), + "FunctionCallArgumentsDeltaEvent": ("litellm.types.llms.openai", "FunctionCallArgumentsDeltaEvent"), + "FunctionCallArgumentsDoneEvent": ("litellm.types.llms.openai", "FunctionCallArgumentsDoneEvent"), + "GeminiModelInfo": ("litellm.llms.gemini.common_utils", "GeminiModelInfo"), + "GenAIHubOrchestration": ("litellm.llms.sap.chat.handler", "GenAIHubOrchestration"), + "Generator": ("litellm.responses.main", "Generator"), + "Generic": ("litellm.files.main", "Generic"), + "GenericBudgetWindowDetails": ("litellm.types.router", "GenericBudgetWindowDetails"), + "GenericChatCompletionMessage": ("litellm.types.llms.openai", "GenericChatCompletionMessage"), + "GenericEvent": ("litellm.types.llms.openai", "GenericEvent"), + "GenericLiteLLMParams": ("litellm.types.router", "GenericLiteLLMParams"), + "GenericResponseOutputItem": ("litellm.types.responses.main", "GenericResponseOutputItem"), + "GenericResponseOutputItemContentAnnotation": ( + "litellm.types.responses.main", + "GenericResponseOutputItemContentAnnotation", + ), + "GoogleBatchEmbeddings": ( + "litellm.llms.vertex_ai.gemini_embeddings.batch_embed_content_handler", + "GoogleBatchEmbeddings", + ), + "GroqChatCompletion": ("litellm.llms.groq.chat.handler", "GroqChatCompletion"), + "GuardrailLiteLLMParams": ("litellm.types.router", "GuardrailLiteLLMParams"), + "GuardrailTypedDict": ("litellm.types.router", "GuardrailTypedDict"), + "HiddenParams": ("litellm.types.llms.base", "HiddenParams"), + "HttpxBinaryResponseContent": ("litellm.types.llms.openai", "HttpxBinaryResponseContent"), + "HuggingFaceEmbedding": ("litellm.llms.huggingface.embedding.handler", "HuggingFaceEmbedding"), + "Hyperparameters": ("litellm.types.llms.openai", "Hyperparameters"), + "IBMWatsonXMixin": ("litellm.llms.watsonx.common_utils", "IBMWatsonXMixin"), + "IO": ("litellm.assistants.main", "IO"), + "IOBase": ("litellm.ocr.main", "IOBase"), + "ImageEditOptionalRequestParams": ("litellm.types.images.main", "ImageEditOptionalRequestParams"), + "ImageFetchError": ("litellm.exceptions", "ImageFetchError"), + "ImageFileObject": ("litellm.types.llms.openai", "ImageFileObject"), + "ImageGenerationPartialImageEvent": ("litellm.types.llms.openai", "ImageGenerationPartialImageEvent"), + "ImageGenerationRequestQuality": ("litellm.types.llms.openai", "ImageGenerationRequestQuality"), + "ImageURLListItem": ("litellm.types.llms.openai", "ImageURLListItem"), + "ImageURLObject": ("litellm.types.llms.openai", "ImageURLObject"), + "IncompleteDetails": ("litellm.assistants.main", "IncompleteDetails"), + "InputTokensDetails": ("litellm.types.llms.openai", "InputTokensDetails"), + "InternalServerError": ("litellm.exceptions", "InternalServerError"), + "InvalidRequestError": ("litellm.exceptions", "InvalidRequestError"), + "Iterable": ("litellm.responses.main", "Iterable"), + "Iterator": ("litellm.llms.anthropic.experimental_pass_through.messages.handler", "Iterator"), + "JSONProviderRegistry": ("litellm.llms.openai_like.json_loader", "JSONProviderRegistry"), + "JSONSchemaValidationError": ("litellm.exceptions", "JSONSchemaValidationError"), + "KeyManagementSettings": ("litellm.types.secret_managers.main", "KeyManagementSettings"), + "LIST_BATCHES_SUPPORTED_PROVIDERS": ("litellm.batches.main", "LIST_BATCHES_SUPPORTED_PROVIDERS"), + "LITELLM_EXCEPTION_TYPES": ("litellm.exceptions", "LITELLM_EXCEPTION_TYPES"), + "LITELLM_IMAGE_VARIATION_PROVIDERS": ("litellm.types.utils", "LITELLM_IMAGE_VARIATION_PROVIDERS"), + "ListBatchRequest": ("litellm.types.llms.openai", "ListBatchRequest"), + "ListBatchesSupportedProvider": ("litellm.batches.main", "ListBatchesSupportedProvider"), + "LiteLLM": ("litellm.main", "LiteLLM"), + "LiteLLMBatch": ("litellm.types.utils", "LiteLLMBatch"), + "LiteLLMBatchCreateRequest": ("litellm.types.llms.openai", "LiteLLMBatchCreateRequest"), + "LiteLLMCompletionTransformationHandler": ( + "litellm.responses.litellm_completion_transformation.handler", + "LiteLLMCompletionTransformationHandler", + ), + "LiteLLMFineTuningJob": ("litellm.types.utils", "LiteLLMFineTuningJob"), + "LiteLLMFineTuningJobCreate": ("litellm.types.llms.openai", "LiteLLMFineTuningJobCreate"), + "LiteLLMLoggingObj": ("litellm.files.main", "LiteLLMLoggingObj"), + "LiteLLMMessagesToCompletionTransformationHandler": ( + "litellm.llms.anthropic.experimental_pass_through.adapters.handler", + "LiteLLMMessagesToCompletionTransformationHandler", + ), + "LiteLLMMessagesToResponsesAPIHandler": ( + "litellm.llms.anthropic.experimental_pass_through.responses_adapters.handler", + "LiteLLMMessagesToResponsesAPIHandler", + ), + "LiteLLMParamsTypedDict": ("litellm.types.router", "LiteLLMParamsTypedDict"), + "LiteLLMResponsesTransformationHandler": ( + "litellm.completion_extras.litellm_responses_transformation.transformation", + "LiteLLMResponsesTransformationHandler", + ), + "LiteLLMUnknownProvider": ("litellm.exceptions", "LiteLLMUnknownProvider"), + "LiteLLM_RouterFileObject": ("litellm.types.router", "LiteLLM_RouterFileObject"), + "LlmProviders": ("litellm.types.utils", "LlmProviders"), + "MCPCallArgumentsDeltaEvent": ("litellm.types.llms.openai", "MCPCallArgumentsDeltaEvent"), + "MCPCallArgumentsDoneEvent": ("litellm.types.llms.openai", "MCPCallArgumentsDoneEvent"), + "MCPCallCompletedEvent": ("litellm.types.llms.openai", "MCPCallCompletedEvent"), + "MCPCallFailedEvent": ("litellm.types.llms.openai", "MCPCallFailedEvent"), + "MCPCallInProgressEvent": ("litellm.types.llms.openai", "MCPCallInProgressEvent"), + "MCPListToolsCompletedEvent": ("litellm.types.llms.openai", "MCPListToolsCompletedEvent"), + "MCPListToolsFailedEvent": ("litellm.types.llms.openai", "MCPListToolsFailedEvent"), + "MCPListToolsInProgressEvent": ("litellm.types.llms.openai", "MCPListToolsInProgressEvent"), + "MCPTool": ("litellm.responses.main", "MCPTool"), + "MOCK_RESPONSE_TYPE": ("litellm.main", "MOCK_RESPONSE_TYPE"), + "Mapping": ("litellm.files.main", "Mapping"), + "MappingProxyType": ("litellm.main", "MappingProxyType"), + "Message": ("litellm.types.utils", "Message"), + "MessageContent": ("litellm.assistants.main", "MessageContent"), + "MessageContentImageFileObject": ("litellm.types.llms.openai", "MessageContentImageFileObject"), + "MessageContentImageURLObject": ("litellm.types.llms.openai", "MessageContentImageURLObject"), + "MessageContentTextObject": ("litellm.types.llms.openai", "MessageContentTextObject"), + "MessageData": ("litellm.types.llms.openai", "MessageData"), + "MirroredPricingParams": ("litellm.types.utils", "MirroredPricingParams"), + "MockException": ("litellm.exceptions", "MockException"), + "MockRouterTestingParams": ("litellm.types.router", "MockRouterTestingParams"), + "ModelConfig": ("litellm.types.router", "ModelConfig"), + "ModelGroupInfo": ("litellm.types.router", "ModelGroupInfo"), + "ModelGroupSettings": ("litellm.types.router", "ModelGroupSettings"), + "ModelInfo": ("litellm.types.router", "ModelInfo"), + "NOT_GIVEN": ("litellm.types.llms.openai", "NOT_GIVEN"), + "NewRelicInitParams": ("litellm.types.integrations.newrelic", "NewRelicInitParams"), + "NonNegativeInt": ("litellm.assistants.main", "NonNegativeInt"), + "NotFoundError": ("litellm.exceptions", "NotFoundError"), + "NotGiven": ("litellm.types.llms.openai", "NotGiven"), + "NotRequired": ("litellm.assistants.main", "NotRequired"), + "NvidiaRivaAudioTranscription": ( + "litellm.llms.nvidia_riva.audio_transcription.handler", + "NvidiaRivaAudioTranscription", + ), + "NvidiaRivaAudioTranscriptionConfig": ( + "litellm.llms.nvidia_riva.audio_transcription.transformation", + "NvidiaRivaAudioTranscriptionConfig", + ), + "OCRResponse": ("litellm.llms.base_llm.ocr.transformation", "OCRResponse"), + "OCR_REQUEST_FORMAT_PARAM": ("litellm.ocr.main", "OCR_REQUEST_FORMAT_PARAM"), + "OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS": ( + "litellm.files.main", + "OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS", + ), + "OPTIONAL_KWARGS_KEYS": ("litellm.main", "OPTIONAL_KWARGS_KEYS"), + "Omit": ("litellm.assistants.main", "Omit"), + "OpenAI": ("litellm.assistants.main", "OpenAI"), + "OpenAIAssistantsAPI": ("litellm.llms.openai.openai", "OpenAIAssistantsAPI"), + "OpenAIAudioTranscription": ("litellm.llms.openai.transcriptions.handler", "OpenAIAudioTranscription"), + "OpenAIAudioTranscriptionOptionalParams": ("litellm.assistants.main", "OpenAIAudioTranscriptionOptionalParams"), + "OpenAIBatchResponse": ("litellm.types.llms.openai", "OpenAIBatchResponse"), + "OpenAIBatchResult": ("litellm.types.llms.openai", "OpenAIBatchResult"), + "OpenAIBatchesAPI": ("litellm.llms.openai.openai", "OpenAIBatchesAPI"), + "OpenAIChatCompletion": ("litellm.llms.openai.openai", "OpenAIChatCompletion"), + "OpenAIChatCompletionAssistantMessage": ("litellm.types.llms.openai", "OpenAIChatCompletionAssistantMessage"), + "OpenAIChatCompletionChoices": ("litellm.types.llms.openai", "OpenAIChatCompletionChoices"), + "OpenAIChatCompletionChunk": ("litellm.types.llms.openai", "OpenAIChatCompletionChunk"), + "OpenAIChatCompletionDeveloperMessage": ("litellm.types.llms.openai", "OpenAIChatCompletionDeveloperMessage"), + "OpenAIChatCompletionFinishReason": ("litellm.assistants.main", "OpenAIChatCompletionFinishReason"), + "OpenAIChatCompletionLogprobs": ("litellm.types.llms.openai", "OpenAIChatCompletionLogprobs"), + "OpenAIChatCompletionLogprobsContent": ("litellm.types.llms.openai", "OpenAIChatCompletionLogprobsContent"), + "OpenAIChatCompletionLogprobsContentTopLogprobs": ( + "litellm.types.llms.openai", + "OpenAIChatCompletionLogprobsContentTopLogprobs", + ), + "OpenAIChatCompletionResponse": ("litellm.types.llms.openai", "OpenAIChatCompletionResponse"), + "OpenAIChatCompletionSystemMessage": ("litellm.types.llms.openai", "OpenAIChatCompletionSystemMessage"), + "OpenAIChatCompletionTextObject": ("litellm.types.llms.openai", "OpenAIChatCompletionTextObject"), + "OpenAIChatCompletionToolParam": ("litellm.types.llms.openai", "OpenAIChatCompletionToolParam"), + "OpenAIChatCompletionUserMessage": ("litellm.types.llms.openai", "OpenAIChatCompletionUserMessage"), + "OpenAICreateFileRequestOptionalParams": ("litellm.assistants.main", "OpenAICreateFileRequestOptionalParams"), + "OpenAICreateThreadParamsMessage": ("litellm.assistants.main", "OpenAICreateThreadParamsMessage"), + "OpenAICreateThreadParamsToolResources": ("litellm.types.llms.openai", "OpenAICreateThreadParamsToolResources"), + "OpenAIEmbedding": ("litellm.assistants.main", "OpenAIEmbedding"), + "OpenAIError": ("litellm.exceptions", "OpenAIError"), + "OpenAIErrorBody": ("litellm.types.llms.openai", "OpenAIErrorBody"), + "OpenAIFileObject": ("litellm.types.llms.openai", "OpenAIFileObject"), + "OpenAIFilesAPI": ("litellm.llms.openai.openai", "OpenAIFilesAPI"), + "OpenAIFilesPurpose": ("litellm.assistants.main", "OpenAIFilesPurpose"), + "OpenAIFineTuningAPI": ("litellm.llms.openai.fine_tuning.handler", "OpenAIFineTuningAPI"), + "OpenAIImageEditOptionalParams": ("litellm.assistants.main", "OpenAIImageEditOptionalParams"), + "OpenAIImageGenerationOptionalParams": ("litellm.assistants.main", "OpenAIImageGenerationOptionalParams"), + "OpenAIImageVariationOptionalParams": ("litellm.assistants.main", "OpenAIImageVariationOptionalParams"), + "OpenAIImageVariationsHandler": ( + "litellm.llms.openai.image_variations.handler", + "OpenAIImageVariationsHandler", + ), + "OpenAILikeChatHandler": ("litellm.llms.openai_like.chat.handler", "OpenAILikeChatHandler"), + "OpenAILikeEmbeddingHandler": ("litellm.llms.openai_like.embedding.handler", "OpenAILikeEmbeddingHandler"), + "OpenAILikeResponsesConfig": ( + "litellm.llms.openai_like.responses.transformation", + "OpenAILikeResponsesConfig", + ), + "OpenAIMcpServerTool": ("litellm.types.llms.openai", "OpenAIMcpServerTool"), + "OpenAIMessage": ("litellm.assistants.main", "OpenAIMessage"), + "OpenAIMessageContent": ("litellm.assistants.main", "OpenAIMessageContent"), + "OpenAIMessageContentListBlock": ("litellm.assistants.main", "OpenAIMessageContentListBlock"), + "OpenAIModerationResponse": ("litellm.types.llms.openai", "OpenAIModerationResponse"), + "OpenAIModerationResult": ("litellm.types.llms.openai", "OpenAIModerationResult"), + "OpenAIRealtimeContentPartDone": ("litellm.types.llms.openai", "OpenAIRealtimeContentPartDone"), + "OpenAIRealtimeConversationCreated": ("litellm.types.llms.openai", "OpenAIRealtimeConversationCreated"), + "OpenAIRealtimeConversationItemAdded": ("litellm.types.llms.openai", "OpenAIRealtimeConversationItemAdded"), + "OpenAIRealtimeConversationItemCreated": ("litellm.types.llms.openai", "OpenAIRealtimeConversationItemCreated"), + "OpenAIRealtimeConversationItemDone": ("litellm.types.llms.openai", "OpenAIRealtimeConversationItemDone"), + "OpenAIRealtimeConversationObject": ("litellm.types.llms.openai", "OpenAIRealtimeConversationObject"), + "OpenAIRealtimeDoneEvent": ("litellm.types.llms.openai", "OpenAIRealtimeDoneEvent"), + "OpenAIRealtimeEventTypes": ("litellm.types.llms.openai", "OpenAIRealtimeEventTypes"), + "OpenAIRealtimeEvents": ("litellm.assistants.main", "OpenAIRealtimeEvents"), + "OpenAIRealtimeFunctionCallArgumentsDone": ( + "litellm.types.llms.openai", + "OpenAIRealtimeFunctionCallArgumentsDone", + ), + "OpenAIRealtimeInputAudioBufferSpeechEvent": ( + "litellm.types.llms.openai", + "OpenAIRealtimeInputAudioBufferSpeechEvent", + ), + "OpenAIRealtimeInputAudioTranscriptionCompleted": ( + "litellm.types.llms.openai", + "OpenAIRealtimeInputAudioTranscriptionCompleted", + ), + "OpenAIRealtimeInputAudioTranscriptionDelta": ( + "litellm.types.llms.openai", + "OpenAIRealtimeInputAudioTranscriptionDelta", + ), + "OpenAIRealtimeOutputItemDone": ("litellm.types.llms.openai", "OpenAIRealtimeOutputItemDone"), + "OpenAIRealtimeResponseAudioDone": ("litellm.types.llms.openai", "OpenAIRealtimeResponseAudioDone"), + "OpenAIRealtimeResponseContentPart": ("litellm.types.llms.openai", "OpenAIRealtimeResponseContentPart"), + "OpenAIRealtimeResponseContentPartAdded": ( + "litellm.types.llms.openai", + "OpenAIRealtimeResponseContentPartAdded", + ), + "OpenAIRealtimeResponseDelta": ("litellm.types.llms.openai", "OpenAIRealtimeResponseDelta"), + "OpenAIRealtimeResponseDoneObject": ("litellm.types.llms.openai", "OpenAIRealtimeResponseDoneObject"), + "OpenAIRealtimeResponseTextDone": ("litellm.types.llms.openai", "OpenAIRealtimeResponseTextDone"), + "OpenAIRealtimeResponseUsage": ("litellm.types.llms.openai", "OpenAIRealtimeResponseUsage"), + "OpenAIRealtimeStreamList": ("litellm.assistants.main", "OpenAIRealtimeStreamList"), + "OpenAIRealtimeStreamResponseBaseObject": ( + "litellm.types.llms.openai", + "OpenAIRealtimeStreamResponseBaseObject", + ), + "OpenAIRealtimeStreamResponseOutputItem": ( + "litellm.types.llms.openai", + "OpenAIRealtimeStreamResponseOutputItem", + ), + "OpenAIRealtimeStreamResponseOutputItemAdded": ( + "litellm.types.llms.openai", + "OpenAIRealtimeStreamResponseOutputItemAdded", + ), + "OpenAIRealtimeStreamResponseOutputItemContent": ( + "litellm.types.llms.openai", + "OpenAIRealtimeStreamResponseOutputItemContent", + ), + "OpenAIRealtimeStreamSession": ("litellm.types.llms.openai", "OpenAIRealtimeStreamSession"), + "OpenAIRealtimeStreamSessionEvents": ("litellm.types.llms.openai", "OpenAIRealtimeStreamSessionEvents"), + "OpenAIRealtimeTurnDetection": ("litellm.types.llms.openai", "OpenAIRealtimeTurnDetection"), + "OpenAIRealtimeUsageTokenDetails": ("litellm.types.llms.openai", "OpenAIRealtimeUsageTokenDetails"), + "OpenAITextCompletion": ("litellm.llms.openai.completion.handler", "OpenAITextCompletion"), + "OpenAITextCompletionUserMessage": ("litellm.types.llms.openai", "OpenAITextCompletionUserMessage"), + "OpenAIVideoObject": ("litellm.types.llms.openai", "OpenAIVideoObject"), + "OpenAIWebSearchOptions": ("litellm.types.llms.openai", "OpenAIWebSearchOptions"), + "OpenAIWebSearchUserLocation": ("litellm.types.llms.openai", "OpenAIWebSearchUserLocation"), + "OpenAIWebSearchUserLocationApproximate": ( + "litellm.types.llms.openai", + "OpenAIWebSearchUserLocationApproximate", + ), + "OptionalPreCallChecks": ("litellm.files.main", "OptionalPreCallChecks"), + "OutputCodeInterpreterCall": ("litellm.types.responses.main", "OutputCodeInterpreterCall"), + "OutputCodeInterpreterCallLog": ("litellm.types.responses.main", "OutputCodeInterpreterCallLog"), + "OutputFunctionToolCall": ("litellm.types.responses.main", "OutputFunctionToolCall"), + "OutputImageGenerationCall": ("litellm.types.responses.main", "OutputImageGenerationCall"), + "OutputItemAddedEvent": ("litellm.types.llms.openai", "OutputItemAddedEvent"), + "OutputItemDoneEvent": ("litellm.types.llms.openai", "OutputItemDoneEvent"), + "OutputText": ("litellm.types.responses.main", "OutputText"), + "OutputTextAnnotationAddedEvent": ("litellm.types.llms.openai", "OutputTextAnnotationAddedEvent"), + "OutputTextDeltaEvent": ("litellm.types.llms.openai", "OutputTextDeltaEvent"), + "OutputTextDoneEvent": ("litellm.types.llms.openai", "OutputTextDoneEvent"), + "OutputTokensDetails": ("litellm.types.llms.openai", "OutputTokensDetails"), + "PART_UNION_TYPES": ("litellm.assistants.main", "PART_UNION_TYPES"), + "PalmConfig": ("litellm.llms.deprecated_providers.palm", "PalmConfig"), + "PathLike": ("litellm.assistants.main", "PathLike"), + "PermissionDeniedError": ("litellm.exceptions", "PermissionDeniedError"), + "Phase": ("litellm.responses.main", "Phase"), + "PreRoutingHookResponse": ("litellm.types.router", "PreRoutingHookResponse"), + "PreRoutingStrategy": ("litellm.types.router", "PreRoutingStrategy"), + "PredibaseChatCompletion": ("litellm.llms.predibase.chat.handler", "PredibaseChatCompletion"), + "PrivateAttr": ("litellm.responses.main", "PrivateAttr"), + "PromptCacheBreakpoint": ("litellm.types.llms.openai", "PromptCacheBreakpoint"), + "PromptCacheOptions": ("litellm.types.llms.openai", "PromptCacheOptions"), + "PromptObject": ("litellm.types.llms.openai", "PromptObject"), + "PromptSpec": ("litellm.types.prompts.init_prompts", "PromptSpec"), + "PromptTokensDetails": ("litellm.main", "PromptTokensDetails"), + "Protocol": ("litellm.files.main", "Protocol"), + "ProviderConfigManager": ("litellm.utils", "ProviderConfigManager"), + "ProviderSpecificHeader": ("litellm.types.utils", "ProviderSpecificHeader"), + "ProviderSpecificHeaderUtils": ( + "litellm.litellm_core_utils.get_provider_specific_headers", + "ProviderSpecificHeaderUtils", + ), + "REASONING_EFFORT": ("litellm.assistants.main", "REASONING_EFFORT"), + "RateLimitError": ("litellm.exceptions", "RateLimitError"), + "RateLimitErrorCategory": ("litellm.exceptions", "RateLimitErrorCategory"), + "RateLimitType": ("litellm.exceptions", "RateLimitType"), + "RawRequestTypedDict": ("litellm.types.utils", "RawRequestTypedDict"), + "ReadOnly": ("litellm.files.main", "ReadOnly"), + "Reasoning": ("litellm.responses.main", "Reasoning"), + "ReasoningSummaryPartDoneEvent": ("litellm.types.llms.openai", "ReasoningSummaryPartDoneEvent"), + "ReasoningSummaryTextDeltaEvent": ("litellm.types.llms.openai", "ReasoningSummaryTextDeltaEvent"), + "ReasoningSummaryTextDoneEvent": ("litellm.types.llms.openai", "ReasoningSummaryTextDoneEvent"), + "RefusalDeltaEvent": ("litellm.types.llms.openai", "RefusalDeltaEvent"), + "RefusalDoneEvent": ("litellm.types.llms.openai", "RefusalDoneEvent"), + "RequestType": ("litellm.types.router", "RequestType"), + "Required": ("litellm.files.main", "Required"), + "Response": ("litellm.assistants.main", "Response"), + "ResponseAPIUsage": ("litellm.types.llms.openai", "ResponseAPIUsage"), + "ResponseCompletedEvent": ("litellm.types.llms.openai", "ResponseCompletedEvent"), + "ResponseCreatedEvent": ("litellm.types.llms.openai", "ResponseCreatedEvent"), + "ResponseFailedEvent": ("litellm.types.llms.openai", "ResponseFailedEvent"), + "ResponseFunctionToolCall": ("litellm.responses.main", "ResponseFunctionToolCall"), + "ResponseInProgressEvent": ("litellm.types.llms.openai", "ResponseInProgressEvent"), + "ResponseIncludable": ("litellm.responses.main", "ResponseIncludable"), + "ResponseIncompleteEvent": ("litellm.types.llms.openai", "ResponseIncompleteEvent"), + "ResponseInputParam": ("litellm.responses.main", "ResponseInputParam"), + "ResponseOutputItem": ("litellm.assistants.main", "ResponseOutputItem"), + "ResponsePartAddedEvent": ("litellm.types.llms.openai", "ResponsePartAddedEvent"), + "ResponseText": ("litellm.responses.main", "ResponseText"), + "ResponsesAPIOptionalRequestParams": ("litellm.types.llms.openai", "ResponsesAPIOptionalRequestParams"), + "ResponsesAPIRequestParams": ("litellm.types.llms.openai", "ResponsesAPIRequestParams"), + "ResponsesAPIRequestUtils": ("litellm.responses.utils", "ResponsesAPIRequestUtils"), + "ResponsesAPIResponse": ("litellm.types.llms.openai", "ResponsesAPIResponse"), + "ResponsesAPIStatus": ("litellm.assistants.main", "ResponsesAPIStatus"), + "ResponsesAPIStreamEvents": ("litellm.types.llms.openai", "ResponsesAPIStreamEvents"), + "ResponsesAPIStreamOptions": ("litellm.types.llms.openai", "ResponsesAPIStreamOptions"), + "ResponsesAPIStreamingResponse": ("litellm.assistants.main", "ResponsesAPIStreamingResponse"), + "ResponsesToolUsage": ("litellm.types.llms.openai", "ResponsesToolUsage"), + "RetrieveBatchRequest": ("litellm.types.llms.openai", "RetrieveBatchRequest"), + "RetryPolicy": ("litellm.types.router", "RetryPolicy"), + "Router": ("litellm.router", "Router"), + "RouterCacheEnum": ("litellm.types.router", "RouterCacheEnum"), + "RouterConfig": ("litellm.types.router", "RouterConfig"), + "RouterErrors": ("litellm.types.router", "RouterErrors"), + "RouterGeneralSettings": ("litellm.types.router", "RouterGeneralSettings"), + "RouterModelGroupAliasItem": ("litellm.types.router", "RouterModelGroupAliasItem"), + "RouterRateLimitError": ("litellm.types.router", "RouterRateLimitError"), + "RouterRateLimitErrorBasic": ("litellm.types.router", "RouterRateLimitErrorBasic"), + "RoutingContext": ("litellm.types.router", "RoutingContext"), + "RoutingGroup": ("litellm.types.router", "RoutingGroup"), + "RoutingPlugin": ("litellm.types.router", "RoutingPlugin"), + "RoutingStrategy": ("litellm.types.router", "RoutingStrategy"), + "Run": ("litellm.assistants.main", "Run"), + "SPECIAL_MODEL_INFO_PARAMS": ("litellm.files.main", "SPECIAL_MODEL_INFO_PARAMS"), + "SagemakerChatHandler": ("litellm.llms.sagemaker.chat.handler", "SagemakerChatHandler"), + "SagemakerLLM": ("litellm.llms.sagemaker.completion.handler", "SagemakerLLM"), + "Scheduler": ("litellm.scheduler", "Scheduler"), + "SchedulerCacheKeys": ("litellm.scheduler", "SchedulerCacheKeys"), + "SearchProvider": ("litellm.files.main", "SearchProvider"), + "SearchResponse": ("litellm.llms.base_llm.search.transformation", "SearchResponse"), + "SearchToolInfoTypedDict": ("litellm.types.router", "SearchToolInfoTypedDict"), + "SearchToolLiteLLMParams": ("litellm.types.router", "SearchToolLiteLLMParams"), + "SearchToolTypedDict": ("litellm.types.router", "SearchToolTypedDict"), + "SerializerFunctionWrapHandler": ("litellm.assistants.main", "SerializerFunctionWrapHandler"), + "ServiceUnavailableError": ("litellm.exceptions", "ServiceUnavailableError"), + "ShellToolParam": ("litellm.types.llms.openai", "ShellToolParam"), + "SlackAlerting": ("litellm.integrations.SlackAlerting.slack_alerting", "SlackAlerting"), + "StandardLoggingRoutingDecision": ("litellm.types.utils", "StandardLoggingRoutingDecision"), + "StreamingChoices": ("litellm.types.utils", "StreamingChoices"), + "SyncCursorPage": ("litellm.assistants.main", "SyncCursorPage"), + "TaggedPreRoutingStrategy": ("litellm.types.router", "TaggedPreRoutingStrategy"), + "TextChoices": ("litellm.types.utils", "TextChoices"), + "TextCompletionStreamWrapper": ("litellm.utils", "TextCompletionStreamWrapper"), + "Thread": ("litellm.types.llms.openai", "Thread"), + "ThreadPoolExecutor": ("litellm.batch_completion.main", "ThreadPoolExecutor"), + "Timeout": ("litellm.exceptions", "Timeout"), + "TogetherAIRerank": ("litellm.llms.together_ai.rerank.handler", "TogetherAIRerank"), + "Tool": ("litellm.assistants.main", "Tool"), + "ToolChoice": ("litellm.responses.main", "ToolChoice"), + "ToolMessageContentPart": ("litellm.assistants.main", "ToolMessageContentPart"), + "ToolParam": ("litellm.responses.main", "ToolParam"), + "ToolResourcesCodeInterpreter": ("litellm.types.llms.openai", "ToolResourcesCodeInterpreter"), + "ToolResourcesFileSearch": ("litellm.types.llms.openai", "ToolResourcesFileSearch"), + "ToolResourcesFileSearchVectorStore": ("litellm.types.llms.openai", "ToolResourcesFileSearchVectorStore"), + "TopazModelInfo": ("litellm.llms.topaz.common_utils", "TopazModelInfo"), + "TypeAlias": ("litellm.assistants.main", "TypeAlias"), + "TypeVar": ("litellm.files.main", "TypeVar"), + "TypedDict": ("litellm.files.main", "TypedDict"), + "UnprocessableEntityError": ("litellm.exceptions", "UnprocessableEntityError"), + "UnsupportedParamsError": ("litellm.exceptions", "UnsupportedParamsError"), + "UpdateRouterConfig": ("litellm.types.router", "UpdateRouterConfig"), + "Usage": ("litellm.types.utils", "Usage"), + "VALID_LITELLM_ENVIRONMENTS": ("litellm.files.main", "VALID_LITELLM_ENVIRONMENTS"), + "ValidAssistantMessageContentTypes": ("litellm.assistants.main", "ValidAssistantMessageContentTypes"), + "ValidAssistantMessageContentTypesLiteral": ( + "litellm.assistants.main", + "ValidAssistantMessageContentTypesLiteral", + ), + "ValidChatCompletionMessageContentTypes": ("litellm.assistants.main", "ValidChatCompletionMessageContentTypes"), + "ValidChatCompletionMessageContentTypesLiteral": ( + "litellm.assistants.main", + "ValidChatCompletionMessageContentTypesLiteral", + ), + "ValidUserMessageContentTypes": ("litellm.assistants.main", "ValidUserMessageContentTypes"), + "ValidUserMessageContentTypesLiteral": ("litellm.assistants.main", "ValidUserMessageContentTypesLiteral"), + "VectorStoreIndexRegistry": ("litellm.vector_stores.vector_store_registry", "VectorStoreIndexRegistry"), + "VectorStoreRegistry": ("litellm.vector_stores.vector_store_registry", "VectorStoreRegistry"), + "VertexAIBatchPrediction": ("litellm.llms.vertex_ai.batches.handler", "VertexAIBatchPrediction"), + "VertexAIFilesHandler": ("litellm.llms.vertex_ai.files.handler", "VertexAIFilesHandler"), + "VertexAIGemmaModels": ("litellm.llms.vertex_ai.vertex_gemma_models.main", "VertexAIGemmaModels"), + "VertexAIModelGardenModels": ("litellm.llms.vertex_ai.vertex_model_garden.main", "VertexAIModelGardenModels"), + "VertexAIModelRoute": ("litellm.llms.vertex_ai.common_utils", "VertexAIModelRoute"), + "VertexAIPartnerModels": ("litellm.llms.vertex_ai.vertex_ai_partner_models.main", "VertexAIPartnerModels"), + "VertexAITextEmbeddingConfig": ( + "litellm.llms.vertex_ai.vertex_embeddings.transformation", + "VertexAITextEmbeddingConfig", + ), + "VertexEmbedding": ("litellm.llms.vertex_ai.vertex_embeddings.embedding_handler", "VertexEmbedding"), + "VertexFineTuningAPI": ("litellm.llms.vertex_ai.fine_tuning.handler", "VertexFineTuningAPI"), + "VertexImageGeneration": ( + "litellm.llms.vertex_ai.image_generation.image_generation_handler", + "VertexImageGeneration", + ), + "VertexLLM": ("litellm.llms.vertex_ai.gemini.vertex_and_google_ai_studio_gemini", "VertexLLM"), + "VertexMultimodalEmbedding": ( + "litellm.llms.vertex_ai.multimodal_embeddings.embedding_handler", + "VertexMultimodalEmbedding", + ), + "VideoCreateOptionalRequestParams": ("litellm.types.videos.main", "VideoCreateOptionalRequestParams"), + "VideoGenerationRequestUtils": ("litellm.videos.utils", "VideoGenerationRequestUtils"), + "VideoObject": ("litellm.types.videos.main", "VideoObject"), + "WatsonXChatHandler": ("litellm.llms.watsonx.chat.handler", "WatsonXChatHandler"), + "WebSearchCallCompletedEvent": ("litellm.types.llms.openai", "WebSearchCallCompletedEvent"), + "WebSearchCallInProgressEvent": ("litellm.types.llms.openai", "WebSearchCallInProgressEvent"), + "WebSearchCallSearchingEvent": ("litellm.types.llms.openai", "WebSearchCallSearchingEvent"), + "WebSearchOptions": ("litellm.types.llms.openai", "WebSearchOptions"), + "WebSearchOptionsUserLocation": ("litellm.types.llms.openai", "WebSearchOptionsUserLocation"), + "WebSearchOptionsUserLocationApproximate": ( + "litellm.types.llms.openai", + "WebSearchOptionsUserLocationApproximate", + ), + "WebSearchToolUsage": ("litellm.types.llms.openai", "WebSearchToolUsage"), + "XAIModelInfo": ("litellm.llms.xai.common_utils", "XAIModelInfo"), + "_arealtime": ("litellm.realtime_api.main", "_arealtime"), + "_aresponses_websocket": ("litellm.responses.main", "_aresponses_websocket"), + "a_add_message": ("litellm.assistants.main", "a_add_message"), + "aadapter_completion": ("litellm.main", "aadapter_completion"), + "aadapter_generate_content": ("litellm.main", "aadapter_generate_content"), + "acancel_batch": ("litellm.batches.main", "acancel_batch"), + "acancel_fine_tuning_job": ("litellm.fine_tuning.main", "acancel_fine_tuning_job"), + "acancel_responses": ("litellm.responses.main", "acancel_responses"), + "acode_interpreter_tool": ("litellm.sandbox.main", "acode_interpreter_tool"), + "acompact_responses": ("litellm.responses.main", "acompact_responses"), + "acompletion": ("litellm.main", "acompletion"), + "acompletion_with_retries": ("litellm.main", "acompletion_with_retries"), + "acount_tokens": ("litellm.main", "acount_tokens"), + "acreate_agent": ("litellm.interactions.agents.main", "acreate"), + "acreate_assistants": ("litellm.assistants.main", "acreate_assistants"), + "acreate_batch": ("litellm.batches.main", "acreate_batch"), + "acreate_container": ("litellm.containers.main", "acreate_container"), + "acreate_file": ("litellm.files.main", "acreate_file"), + "acreate_fine_tuning_job": ("litellm.fine_tuning.main", "acreate_fine_tuning_job"), + "acreate_realtime_client_secret": ("litellm.realtime_api.main", "acreate_realtime_client_secret"), + "acreate_realtime_transcription_session": ( + "litellm.realtime_api.main", + "acreate_realtime_transcription_session", + ), + "acreate_sandbox": ("litellm.sandbox.main", "acreate_sandbox"), + "acreate_skill": ("litellm.skills.main", "acreate_skill"), + "acreate_thread": ("litellm.assistants.main", "acreate_thread"), + "adapter_completion": ("litellm.main", "adapter_completion"), + "add_message": ("litellm.assistants.main", "add_message"), + "add_provider_specific_params_to_optional_params": ( + "litellm.utils", + "add_provider_specific_params_to_optional_params", + ), + "add_system_prompt_to_messages": ( + "litellm.litellm_core_utils.prompt_templates.common_utils", + "add_system_prompt_to_messages", + ), + "add_trusted_model_credentials_to_litellm_params": ( + "litellm.litellm_core_utils.get_litellm_params", + "add_trusted_model_credentials_to_litellm_params", + ), + "adelete_agent": ("litellm.interactions.agents.main", "adelete"), + "adelete_assistant": ("litellm.assistants.main", "adelete_assistant"), + "adelete_container": ("litellm.containers.main", "adelete_container"), + "adelete_responses": ("litellm.responses.main", "adelete_responses"), + "adelete_sandbox": ("litellm.sandbox.main", "adelete_sandbox"), + "adelete_skill": ("litellm.skills.main", "adelete_skill"), + "aembedding": ("litellm.main", "aembedding"), + "afile_content": ("litellm.files.main", "afile_content"), + "afile_delete": ("litellm.files.main", "afile_delete"), + "afile_list": ("litellm.files.main", "afile_list"), + "afile_retrieve": ("litellm.files.main", "afile_retrieve"), + "agenerate_content": ("litellm.google_genai.main", "agenerate_content"), + "aget_agent": ("litellm.interactions.agents.main", "aget"), + "aget_assistants": ("litellm.assistants.main", "aget_assistants"), + "aget_messages": ("litellm.assistants.main", "aget_messages"), + "aget_responses": ("litellm.responses.main", "aget_responses"), + "aget_skill": ("litellm.skills.main", "aget_skill"), + "aget_thread": ("litellm.assistants.main", "aget_thread"), + "ahealth_check": ("litellm.main", "ahealth_check"), + "aimage_edit": ("litellm.images.main", "aimage_edit"), + "aimage_generation": ("litellm.images.main", "aimage_generation"), + "aimage_variation": ("litellm.images.main", "aimage_variation"), + "aingest": ("litellm.rag.main", "aingest"), + "alist_agent_versions": ("litellm.interactions.agents.main", "alist_versions"), + "alist_agents": ("litellm.interactions.agents.main", "alist"), + "alist_batches": ("litellm.batches.main", "alist_batches"), + "alist_container_files": ("litellm.containers.main", "alist_container_files"), + "alist_containers": ("litellm.containers.main", "alist_containers"), + "alist_fine_tuning_jobs": ("litellm.fine_tuning.main", "alist_fine_tuning_jobs"), + "alist_input_items": ("litellm.responses.main", "alist_input_items"), + "alist_skills": ("litellm.skills.main", "alist_skills"), + "allm_passthrough_route": ("litellm.passthrough.main", "allm_passthrough_route"), + "amoderation": ("litellm.main", "amoderation"), + "anthropic_batches_instance": ("litellm.batches.main", "anthropic_batches_instance"), + "anthropic_chat_completions": ("litellm.main", "anthropic_chat_completions"), + "anthropic_messages": ( + "litellm.llms.anthropic.experimental_pass_through.messages.handler", + "anthropic_messages", + ), + "anthropic_messages_handler": ( + "litellm.llms.anthropic.experimental_pass_through.messages.handler", + "anthropic_messages_handler", + ), + "aocr": ("litellm.ocr.main", "aocr"), + "aquery": ("litellm.rag.main", "aquery"), + "arealtime_calls": ("litellm.realtime_api.main", "arealtime_calls"), + "arerank": ("litellm.rerank_api.main", "arerank"), + "aresponses": ("litellm.responses.main", "aresponses"), + "aresponses_api_with_mcp": ("litellm.responses.main", "aresponses_api_with_mcp"), + "aresponses_with_retries": ("litellm.main", "aresponses_with_retries"), + "aretrieve_batch": ("litellm.batches.main", "aretrieve_batch"), + "aretrieve_container": ("litellm.containers.main", "aretrieve_container"), + "aretrieve_fine_tuning_job": ("litellm.fine_tuning.main", "aretrieve_fine_tuning_job"), + "arun_code": ("litellm.sandbox.main", "arun_code"), + "arun_thread": ("litellm.assistants.main", "arun_thread"), + "arun_thread_stream": ("litellm.assistants.main", "arun_thread_stream"), + "asearch": ("litellm.search.main", "asearch"), + "aspeech": ("litellm.main", "aspeech"), + "async_completion_with_fallbacks": ( + "litellm.litellm_core_utils.fallback_utils", + "async_completion_with_fallbacks", + ), + "async_mock_completion_streaming_obj": ("litellm.utils", "async_mock_completion_streaming_obj"), + "atext_completion": ("litellm.main", "atext_completion"), + "atranscription": ("litellm.main", "atranscription"), + "aupload_container_file": ("litellm.containers.main", "aupload_container_file"), + "avector_store_file_content": ("litellm.vector_store_files.main", "aretrieve_content"), + "avector_store_file_create": ("litellm.vector_store_files.main", "acreate"), + "avector_store_file_delete": ("litellm.vector_store_files.main", "adelete"), + "avector_store_file_list": ("litellm.vector_store_files.main", "alist"), + "avector_store_file_retrieve": ("litellm.vector_store_files.main", "aretrieve"), + "avector_store_file_update": ("litellm.vector_store_files.main", "aupdate"), + "avideo_content": ("litellm.videos.main", "avideo_content"), + "avideo_create_character": ("litellm.videos.main", "avideo_create_character"), + "avideo_edit": ("litellm.videos.main", "avideo_edit"), + "avideo_extension": ("litellm.videos.main", "avideo_extension"), + "avideo_generation": ("litellm.videos.main", "avideo_generation"), + "avideo_get_character": ("litellm.videos.main", "avideo_get_character"), + "avideo_list": ("litellm.videos.main", "avideo_list"), + "avideo_remix": ("litellm.videos.main", "avideo_remix"), + "avideo_status": ("litellm.videos.main", "avideo_status"), + "azure_ai_embedding": ("litellm.main", "azure_ai_embedding"), + "azure_anthropic_chat_completions": ("litellm.main", "azure_anthropic_chat_completions"), + "azure_assistants_api": ("litellm.assistants.main", "azure_assistants_api"), + "azure_audio_transcriptions": ("litellm.main", "azure_audio_transcriptions"), + "azure_batches_instance": ("litellm.batches.main", "azure_batches_instance"), + "azure_chat_completions": ("litellm.images.main", "azure_chat_completions"), + "azure_files_instance": ("litellm.files.main", "azure_files_instance"), + "azure_fine_tuning_apis_instance": ("litellm.fine_tuning.main", "azure_fine_tuning_apis_instance"), + "azure_o1_chat_completions": ("litellm.main", "azure_o1_chat_completions"), + "azure_text_completions": ("litellm.main", "azure_text_completions"), + "base_llm_aiohttp_handler": ("litellm.images.main", "base_llm_aiohttp_handler"), + "base_llm_http_handler": ("litellm.files.main", "base_llm_http_handler"), + "batch_completion": ("litellm.batch_completion.main", "batch_completion"), + "batch_completion_models": ("litellm.batch_completion.main", "batch_completion_models"), + "batch_completion_models_all_responses": ( + "litellm.batch_completion.main", + "batch_completion_models_all_responses", + ), + "bedrock_converse_chat_completion": ("litellm.main", "bedrock_converse_chat_completion"), + "bedrock_embedding": ("litellm.main", "bedrock_embedding"), + "bedrock_files_instance": ("litellm.files.main", "bedrock_files_instance"), + "bedrock_image_edit": ("litellm.images.main", "bedrock_image_edit"), + "bedrock_image_generation": ("litellm.images.main", "bedrock_image_generation"), + "bedrock_rerank": ("litellm.rerank_api.main", "bedrock_rerank"), + "bfl_image_edit": ("litellm.llms.black_forest_labs.image_edit.handler", "bfl_image_edit"), + "bfl_image_generation": ("litellm.llms.black_forest_labs.image_generation.handler", "bfl_image_generation"), + "build_code_interpreter_log_outputs": ("litellm.types.responses.main", "build_code_interpreter_log_outputs"), + "bytez_transformation": ("litellm.main", "bytez_transformation"), + "calculate_request_duration": ("litellm.litellm_core_utils.audio_utils.utils", "calculate_request_duration"), + "cancel_batch": ("litellm.batches.main", "cancel_batch"), + "cancel_fine_tuning_job": ("litellm.fine_tuning.main", "cancel_fine_tuning_job"), + "cancel_responses": ("litellm.responses.main", "cancel_responses"), + "cast": ("litellm.files.main", "cast"), + "client": ("litellm.utils", "client"), + "close_litellm_async_clients": ( + "litellm.llms.custom_httpx.async_client_cleanup", + "close_litellm_async_clients", + ), + "codestral_text_completions": ("litellm.main", "codestral_text_completions"), + "compact_responses": ("litellm.responses.main", "compact_responses"), + "completion": ("litellm.main", "completion"), + "completion_with_fallbacks": ("litellm.litellm_core_utils.fallback_utils", "completion_with_fallbacks"), + "completion_with_retries": ("litellm.main", "completion_with_retries"), + "compress": ("litellm.compression.compress", "compress"), + "config_completion": ("litellm.main", "config_completion"), + "contextmanager": ("litellm.responses.main", "contextmanager"), + "convert_file_document_to_url_document": ("litellm.ocr.main", "convert_file_document_to_url_document"), + "convert_model_response_to_streaming": ( + "litellm.llms.base_llm.base_model_iterator", + "convert_model_response_to_streaming", + ), + "create_agent": ("litellm.interactions.agents.main", "create"), + "create_assistants": ("litellm.assistants.main", "create_assistants"), + "create_batch": ("litellm.batches.main", "create_batch"), + "create_container": ("litellm.containers.main", "create_container"), + "create_file": ("litellm.files.main", "create_file"), + "create_fine_tuning_job": ("litellm.fine_tuning.main", "create_fine_tuning_job"), + "create_skill": ("litellm.skills.main", "create_skill"), + "create_thread": ("litellm.assistants.main", "create_thread"), + "custom_chat_llm_router": ("litellm.llms.custom_llm", "custom_chat_llm_router"), + "custom_prompt": ("litellm.litellm_core_utils.prompt_templates.factory", "custom_prompt"), + "databricks_embedding": ("litellm.main", "databricks_embedding"), + "dataclass": ("litellm.files.main", "dataclass"), + "decode_video_id_with_provider": ("litellm.types.videos.utils", "decode_video_id_with_provider"), + "declared_authenticating_provider": ( + "litellm.litellm_core_utils.get_llm_provider_logic", + "declared_authenticating_provider", + ), + "deepcopy": ("litellm.main", "deepcopy"), + "delete_agent": ("litellm.interactions.agents.main", "delete"), + "delete_assistant": ("litellm.assistants.main", "delete_assistant"), + "delete_container": ("litellm.containers.main", "delete_container"), + "delete_responses": ("litellm.responses.main", "delete_responses"), + "delete_skill": ("litellm.skills.main", "delete_skill"), + "disable_cache": ("litellm.caching.caching", "disable_cache"), + "embedding": ("litellm.main", "embedding"), + "enable_cache": ("litellm.caching.caching", "enable_cache"), + "field_serializer": ("litellm.assistants.main", "field_serializer"), + "field_validator": ("litellm.files.main", "field_validator"), + "file_content": ("litellm.files.main", "file_content"), + "file_content_streaming": ("litellm.files.main", "file_content_streaming"), + "file_delete": ("litellm.files.main", "file_delete"), + "file_list": ("litellm.files.main", "file_list"), + "file_retrieve": ("litellm.files.main", "file_retrieve"), + "filter_out_litellm_params": ("litellm.utils", "filter_out_litellm_params"), + "flatten_form_field_values": ("litellm.litellm_core_utils.llm_request_utils", "flatten_form_field_values"), + "flatten_unencrypted_web_search_results_in_anthropic_messages": ( + "litellm.llms.anthropic.common_utils", + "flatten_unencrypted_web_search_results_in_anthropic_messages", + ), + "function_call_prompt": ("litellm.litellm_core_utils.prompt_templates.factory", "function_call_prompt"), + "gdc_transformation": ("litellm.main", "gdc_transformation"), + "get_agent": ("litellm.interactions.agents.main", "get"), + "get_api_key_from_env": ("litellm.llms.gemini.common_utils", "get_api_key_from_env"), + "get_assistants": ("litellm.assistants.main", "get_assistants"), + "get_audio_file_for_health_check": ( + "litellm.litellm_core_utils.audio_utils.utils", + "get_audio_file_for_health_check", + ), + "get_azure_credentials": ("litellm.llms.azure.common_utils", "get_azure_credentials"), + "get_completion_messages": ( + "litellm.litellm_core_utils.prompt_templates.common_utils", + "get_completion_messages", + ), + "get_configured_request_timeout": ( + "litellm.litellm_core_utils.request_timeout_resolver", + "get_configured_request_timeout", + ), + "get_content_from_model_response": ( + "litellm.litellm_core_utils.prompt_templates.common_utils", + "get_content_from_model_response", + ), + "get_litellm_gateway_api_key": ("litellm.litellm_core_utils.cli_token_utils", "get_litellm_gateway_api_key"), + "get_messages": ("litellm.assistants.main", "get_messages"), + "get_messages_interceptors": ( + "litellm.llms.anthropic.experimental_pass_through.messages.interceptors", + "get_messages_interceptors", + ), + "get_mime_type": ("litellm.ocr.main", "get_mime_type"), + "get_non_default_completion_params": ("litellm.utils", "get_non_default_completion_params"), + "get_non_default_transcription_params": ("litellm.utils", "get_non_default_transcription_params"), + "get_openai_credentials": ("litellm.llms.openai.common_utils", "get_openai_credentials"), + "get_optional_params_add_message": ("litellm.assistants.utils", "get_optional_params_add_message"), + "get_optional_params_embeddings": ("litellm.utils", "get_optional_params_embeddings"), + "get_optional_params_image_gen": ("litellm.utils", "get_optional_params_image_gen"), + "get_optional_params_transcription": ("litellm.utils", "get_optional_params_transcription"), + "get_optional_rerank_params": ("litellm.rerank_api.rerank_utils", "get_optional_rerank_params"), + "get_requester_metadata": ("litellm.utils", "get_requester_metadata"), + "get_responses": ("litellm.responses.main", "get_responses"), + "get_secret": ("litellm.secret_managers.main", "get_secret"), + "get_secret_bool": ("litellm.secret_managers.main", "get_secret_bool"), + "get_secret_str": ("litellm.secret_managers.main", "get_secret_str"), + "get_skill": ("litellm.skills.main", "get_skill"), + "get_standard_openai_params": ("litellm.utils", "get_standard_openai_params"), + "get_thread": ("litellm.assistants.main", "get_thread"), + "get_type_hints": ("litellm.files.main", "get_type_hints"), + "get_vertex_ai_model_route": ("litellm.llms.vertex_ai.common_utils", "get_vertex_ai_model_route"), + "google_batch_embeddings": ("litellm.main", "google_batch_embeddings"), + "groq_chat_completions": ("litellm.main", "groq_chat_completions"), + "heroku_transformation": ("litellm.main", "heroku_transformation"), + "huggingface_embed": ("litellm.main", "huggingface_embed"), + "image_edit": ("litellm.images.main", "image_edit"), + "image_generation": ("litellm.images.main", "image_generation"), + "image_variation": ("litellm.images.main", "image_variation"), + "infer_openai_data_residency": ("litellm.llms.openai.data_residency", "infer_openai_data_residency"), + "ingest": ("litellm.rag.main", "ingest"), + "is_azure_document_intelligence_model": ( + "litellm.llms.azure_ai.ocr.common_utils", + "is_azure_document_intelligence_model", + ), + "is_reasoning_auto_summary_enabled": ( + "litellm.llms.anthropic.experimental_pass_through.utils", + "is_reasoning_auto_summary_enabled", + ), + "lemonade_transformation": ("litellm.main", "lemonade_transformation"), + "list_agent_versions": ("litellm.interactions.agents.main", "list_versions"), + "list_agents": ("litellm.interactions.agents.main", "list"), + "list_batches": ("litellm.batches.main", "list_batches"), + "list_container_files": ("litellm.containers.main", "list_container_files"), + "list_containers": ("litellm.containers.main", "list_containers"), + "list_fine_tuning_jobs": ("litellm.fine_tuning.main", "list_fine_tuning_jobs"), + "list_input_items": ("litellm.responses.main", "list_input_items"), + "list_skills": ("litellm.skills.main", "list_skills"), + "litellm_completion_transformation_handler": ( + "litellm.responses.main", + "litellm_completion_transformation_handler", + ), + "llm_http_handler": ("litellm.videos.main", "llm_http_handler"), + "llm_passthrough_route": ("litellm.passthrough.main", "llm_passthrough_route"), + "map_system_message_pt": ("litellm.litellm_core_utils.prompt_templates.factory", "map_system_message_pt"), + "maybe_run_chat_completion_agentic_loop": ( + "litellm.litellm_core_utils.chat_completion_agentic_loop", + "maybe_run_chat_completion_agentic_loop", + ), + "mock_completion": ("litellm.main", "mock_completion"), + "mock_completion_streaming_obj": ("litellm.utils", "mock_completion_streaming_obj"), + "mock_embedding": ("litellm.litellm_core_utils.mock_functions", "mock_embedding"), + "mock_image_generation": ("litellm.litellm_core_utils.mock_functions", "mock_image_generation"), + "mock_response": ("litellm.llms.anthropic.experimental_pass_through.messages.utils", "mock_response"), + "mock_responses_api_response": ("litellm.responses.main", "mock_responses_api_response"), + "model_serializer": ("litellm.assistants.main", "model_serializer"), + "model_validator": ("litellm.files.main", "model_validator"), + "moderation": ("litellm.main", "moderation"), + "nlp_cloud_chat_completion": ("litellm.main", "nlp_cloud_chat_completion"), + "nvidia_riva_audio_transcriptions": ("litellm.main", "nvidia_riva_audio_transcriptions"), + "oci_transformation": ("litellm.main", "oci_transformation"), + "ocr": ("litellm.ocr.main", "ocr"), + "ollama_pt": ("litellm.litellm_core_utils.prompt_templates.factory", "ollama_pt"), + "openai_assistants_api": ("litellm.assistants.main", "openai_assistants_api"), + "openai_audio_transcriptions": ("litellm.main", "openai_audio_transcriptions"), + "openai_batches_instance": ("litellm.batches.main", "openai_batches_instance"), + "openai_chat_completions": ("litellm.images.main", "openai_chat_completions"), + "openai_files_instance": ("litellm.files.main", "openai_files_instance"), + "openai_fine_tuning_apis_instance": ("litellm.fine_tuning.main", "openai_fine_tuning_apis_instance"), + "openai_image_variations": ("litellm.images.main", "openai_image_variations"), + "openai_like_chat_completion": ("litellm.main", "openai_like_chat_completion"), + "openai_like_embedding": ("litellm.main", "openai_like_embedding"), + "openai_text_completions": ("litellm.main", "openai_text_completions"), + "override": ("litellm.assistants.main", "override"), + "ovhcloud_transformation": ("litellm.main", "ovhcloud_transformation"), + "parse_ocr_request_format": ("litellm.llms.base_llm.ocr.transformation", "parse_ocr_request_format"), + "partial": ("litellm.files.main", "partial"), + "peek_reasoning_summary_aliases": ("litellm.utils", "peek_reasoning_summary_aliases"), + "pre_process_non_default_params": ("litellm.utils", "pre_process_non_default_params"), + "predibase_chat_completions": ("litellm.main", "predibase_chat_completions"), + "print_verbose": ("litellm.main", "print_verbose"), + "prompt_factory": ("litellm.litellm_core_utils.prompt_templates.factory", "prompt_factory"), + "query": ("litellm.rag.main", "query"), + "read_config_args": ("litellm.utils", "read_config_args"), + "replicate_chat_completion": ("litellm.main", "replicate_chat_completion"), + "rerank": ("litellm.rerank_api.main", "rerank"), + "responses": ("litellm.responses.main", "responses"), + "responses_api_bridge_check": ("litellm.main", "responses_api_bridge_check"), + "responses_with_retries": ("litellm.main", "responses_with_retries"), + "retrieve_batch": ("litellm.batches.main", "retrieve_batch"), + "retrieve_container": ("litellm.containers.main", "retrieve_container"), + "retrieve_fine_tuning_job": ("litellm.fine_tuning.main", "retrieve_fine_tuning_job"), + "run_async_function": ("litellm.litellm_core_utils.asyncify", "run_async_function"), + "run_server": ("litellm.proxy.proxy_cli", "run_server"), + "run_thread": ("litellm.assistants.main", "run_thread"), + "run_thread_stream": ("litellm.assistants.main", "run_thread_stream"), + "runtime_checkable": ("litellm.files.main", "runtime_checkable"), + "rust": ("litellm.rust_bridge", "rust"), + "safe_deep_copy": ("litellm.litellm_core_utils.core_helpers", "safe_deep_copy"), + "sagemaker_chat_completion": ("litellm.main", "sagemaker_chat_completion"), + "sagemaker_llm": ("litellm.main", "sagemaker_llm"), + "sanitize_tool_use_ids_in_anthropic_messages": ( + "litellm.llms.anthropic.common_utils", + "sanitize_tool_use_ids_in_anthropic_messages", + ), + "sap_gen_ai_hub_chat_completions": ("litellm.main", "sap_gen_ai_hub_chat_completions"), + "sap_gen_ai_hub_emb": ("litellm.main", "sap_gen_ai_hub_emb"), + "search": ("litellm.search.main", "search"), + "should_run_mock_completion": ("litellm.utils", "should_run_mock_completion"), + "speech": ("litellm.main", "speech"), + "stream_chunk_builder": ("litellm.main", "stream_chunk_builder"), + "stream_chunk_builder_text_completion": ("litellm.main", "stream_chunk_builder_text_completion"), + "stringify_json_tool_call_content": ( + "litellm.litellm_core_utils.prompt_templates.factory", + "stringify_json_tool_call_content", + ), + "strip_empty_content_blocks_from_anthropic_messages": ( + "litellm.llms.anthropic.common_utils", + "strip_empty_content_blocks_from_anthropic_messages", + ), + "strip_reasoning_summary_aliases_from_optional_params": ( + "litellm.utils", + "strip_reasoning_summary_aliases_from_optional_params", + ), + "supports_httpx_timeout": ("litellm.utils", "supports_httpx_timeout"), + "text_completion": ("litellm.main", "text_completion"), + "together_rerank": ("litellm.rerank_api.main", "together_rerank"), + "tracer": ("litellm.litellm_core_utils.dd_tracing", "tracer"), + "transcription": ("litellm.main", "transcription"), + "updateDeployment": ("litellm.types.router", "updateDeployment"), + "updateLiteLLMParams": ("litellm.types.router", "updateLiteLLMParams"), + "update_cache": ("litellm.caching.caching", "update_cache"), + "update_messages_with_model_file_ids": ( + "litellm.litellm_core_utils.prompt_templates.common_utils", + "update_messages_with_model_file_ids", + ), + "update_responses_input_with_model_file_ids": ( + "litellm.litellm_core_utils.prompt_templates.common_utils", + "update_responses_input_with_model_file_ids", + ), + "update_responses_tools_with_model_file_ids": ( + "litellm.litellm_core_utils.prompt_templates.common_utils", + "update_responses_tools_with_model_file_ids", + ), + "upload_container_file": ("litellm.containers.main", "upload_container_file"), + "urlsplit": ("litellm.main", "urlsplit"), + "validate_and_fix_openai_messages": ("litellm.utils", "validate_and_fix_openai_messages"), + "validate_and_fix_openai_tools": ("litellm.utils", "validate_and_fix_openai_tools"), + "validate_and_fix_thinking_param": ("litellm.utils", "validate_and_fix_thinking_param"), + "validate_anthropic_api_metadata": ( + "litellm.llms.anthropic.experimental_pass_through.messages.handler", + "validate_anthropic_api_metadata", + ), + "validate_chat_completion_tool_choice": ("litellm.utils", "validate_chat_completion_tool_choice"), + "validate_openai_optional_params": ("litellm.utils", "validate_openai_optional_params"), + "vector_store_file_content": ("litellm.vector_store_files.main", "retrieve_content"), + "vector_store_file_create": ("litellm.vector_store_files.main", "create"), + "vector_store_file_delete": ("litellm.vector_store_files.main", "delete"), + "vector_store_file_list": ("litellm.vector_store_files.main", "list"), + "vector_store_file_retrieve": ("litellm.vector_store_files.main", "retrieve"), + "vector_store_file_update": ("litellm.vector_store_files.main", "update"), + "vertex_ai_batches_instance": ("litellm.batches.main", "vertex_ai_batches_instance"), + "vertex_ai_files_instance": ("litellm.files.main", "vertex_ai_files_instance"), + "vertex_chat_completion": ("litellm.main", "vertex_chat_completion"), + "vertex_embedding": ("litellm.main", "vertex_embedding"), + "vertex_fine_tuning_apis_instance": ("litellm.fine_tuning.main", "vertex_fine_tuning_apis_instance"), + "vertex_gemma_chat_completion": ("litellm.main", "vertex_gemma_chat_completion"), + "vertex_image_generation": ("litellm.main", "vertex_image_generation"), + "vertex_model_garden_chat_completion": ("litellm.main", "vertex_model_garden_chat_completion"), + "vertex_multimodal_embedding": ("litellm.main", "vertex_multimodal_embedding"), + "vertex_partner_models_chat_completion": ("litellm.main", "vertex_partner_models_chat_completion"), + "video_content": ("litellm.videos.main", "video_content"), + "video_create_character": ("litellm.videos.main", "video_create_character"), + "video_edit": ("litellm.videos.main", "video_edit"), + "video_extension": ("litellm.videos.main", "video_extension"), + "video_generation": ("litellm.videos.main", "video_generation"), + "video_get_character": ("litellm.videos.main", "video_get_character"), + "video_list": ("litellm.videos.main", "video_list"), + "video_remix": ("litellm.videos.main", "video_remix"), + "video_status": ("litellm.videos.main", "video_status"), + "wait": ("litellm.batch_completion.main", "wait"), + "watsonx_chat_completion": ("litellm.main", "watsonx_chat_completion"), + } +) + +_SDK_MODULE_ALIASES: Final[Mapping[str, str]] = MappingProxyType( + { + "additional_logging_utils": "litellm.integrations.additional_logging_utils", + "agentops": "litellm.integrations.agentops", + "aleph_alpha": "litellm.llms.deprecated_providers.aleph_alpha", + "anthropic_cache_control_hook": "litellm.integrations.anthropic_cache_control_hook", + "argilla": "litellm.integrations.argilla", + "arize": "litellm.integrations.arize", + "asyncio": "asyncio", + "athina": "litellm.integrations.athina", + "azure_sentinel": "litellm.integrations.azure_sentinel", + "azure_storage": "litellm.integrations.azure_storage", + "base64": "base64", + "cohere_embed": "litellm.llms.cohere.embed.handler", + "contextvars": "contextvars", + "custom_batch_logger": "litellm.integrations.custom_batch_logger", + "custom_guardrail": "litellm.integrations.custom_guardrail", + "custom_logger": "litellm.integrations.custom_logger", + "custom_prompt_management": "litellm.integrations.custom_prompt_management", + "datadog": "litellm.integrations.datadog", + "datetime": "datetime", + "deepeval": "litellm.integrations.deepeval", + "dotenv": "dotenv", + "dotprompt": "litellm.integrations.dotprompt", + "dynamodb": "litellm.integrations.dynamodb", + "email_templates": "litellm.integrations.email_templates", + "enum": "enum", + "futures": "concurrent.futures", + "galileo": "litellm.integrations.galileo", + "gcs_bucket": "litellm.integrations.gcs_bucket", + "gcs_pubsub": "litellm.integrations.gcs_pubsub", + "generic_api": "litellm.integrations.generic_api", + "greenscale": "litellm.integrations.greenscale", + "heapq": "heapq", + "helicone": "litellm.integrations.helicone", + "helicone_mock_client": "litellm.integrations.helicone_mock_client", + "humanloop": "litellm.integrations.humanloop", + "importlib": "importlib", + "inspect": "inspect", + "json": "json", + "lago": "litellm.integrations.lago", + "langfuse": "litellm.integrations.langfuse", + "langsmith": "litellm.integrations.langsmith", + "langsmith_mock_client": "litellm.integrations.langsmith_mock_client", + "litellm": "litellm", + "litellm_agent": "litellm.integrations.litellm_agent", + "literal_ai": "litellm.integrations.literal_ai", + "logfire_logger": "litellm.integrations.logfire_logger", + "lunary": "litellm.integrations.lunary", + "mimetypes": "mimetypes", + "mlflow": "litellm.integrations.mlflow", + "mock_client_factory": "litellm.integrations.mock_client_factory", + "newrelic": "litellm.integrations.newrelic", + "ollama": "litellm.llms.ollama.completion.handler", + "oobabooga": "litellm.llms.oobabooga.chat.oobabooga", + "openai": "openai", + "openmeter": "litellm.integrations.openmeter", + "opentelemetry": "litellm.integrations.opentelemetry", + "opentelemetry_utils": "litellm.integrations.opentelemetry_utils", + "opik": "litellm.integrations.opik", + "otel": "litellm.integrations.otel", + "palm": "litellm.llms.deprecated_providers.palm", + "petals_handler": "litellm.llms.petals.completion.handler", + "posthog": "litellm.integrations.posthog", + "posthog_mock_client": "litellm.integrations.posthog_mock_client", + "prompt_layer": "litellm.integrations.prompt_layer", + "prompt_management_base": "litellm.integrations.prompt_management_base", + "random": "random", + "rust_ocr_bridge": "litellm.rust_bridge.ocr", + "s3": "litellm.integrations.s3", + "s3_v2": "litellm.integrations.s3_v2", + "sqs": "litellm.integrations.sqs", + "supabase": "litellm.integrations.supabase", + "sys": "sys", + "tiktoken": "tiktoken", + "time": "time", + "traceback": "traceback", + "traceloop": "litellm.integrations.traceloop", + "uuid": "fastuuid", + "uuid_module": "uuid", + "vertex_ai_non_gemini": "litellm.llms.vertex_ai.vertex_ai_non_gemini", + "vllm_handler": "litellm.llms.vllm.completion.handler", + "anthropic": "litellm.anthropic_interface", + "httpx": "httpx", + "interactions": "litellm.interactions", + "rag": "litellm.rag", + } +) + # Export all name tuples and import maps for use in _lazy_imports.py __all__ = [ "BEDROCK_TYPES_NAMES", @@ -1490,6 +2657,7 @@ __all__ = [ "LLM_CLIENT_CACHE_NAMES", "LLM_CONFIG_NAMES", "LLM_PROVIDER_LOGIC_NAMES", + "STAR_IMPORT_PUBLIC_NAMES", "TOKEN_COUNTER_NAMES", "TYPES_NAMES", "TYPES_UTILS_NAMES", @@ -1502,9 +2670,1534 @@ __all__ = [ "_LITELLM_LOGGING_IMPORT_MAP", "_LLM_CONFIGS_IMPORT_MAP", "_LLM_PROVIDER_LOGIC_IMPORT_MAP", + "_SDK_MODULE_ALIASES", + "_SDK_SYMBOLS_IMPORT_MAP", "_TOKEN_COUNTER_IMPORT_MAP", "_TYPES_IMPORT_MAP", "_TYPES_UTILS_IMPORT_MAP", "_UTILS_IMPORT_MAP", "_UTILS_MODULE_IMPORT_MAP", ] + + +STAR_IMPORT_PUBLIC_NAMES: Final = ( + "AI21ChatConfig", + "AI21Config", + "ALL_RESPONSES_API_TOOL_PARAMS", + "APIConnectionError", + "APIError", + "APIResponseValidationError", + "AZURE_DEFAULT_API_VERSION", + "AZURE_OPENAI_AUDIO_PROVIDERS", + "AdapterCompletionStreamWrapper", + "AdapterItem", + "AdaptiveRouterConfig", + "AdaptiveRouterPreferences", + "AdaptiveRouterWeights", + "AlephAlphaConfig", + "AlertingConfig", + "AllEmbeddingInputValues", + "AllMessageValues", + "AllPromptValues", + "AllowedFailsPolicy", + "AmazonTitanV2Config", + "Annotated", + "AnthropicBatchesHandler", + "AnthropicChatCompletion", + "AnthropicMessagesRequestUtils", + "AnthropicMessagesResponse", + "AnthropicMetadata", + "AnthropicModelInfo", + "AnthropicThinkingParam", + "Any", + "Assistant", + "AssistantDeleted", + "AssistantEventHandler", + "AssistantStreamManager", + "AssistantToolParam", + "AssistantsTypedDict", + "AsyncAssistantEventHandler", + "AsyncAssistantStreamManager", + "AsyncCompletions", + "AsyncCursorPage", + "AsyncHTTPHandler", + "AsyncIterator", + "AsyncOpenAI", + "Attachment", + "AttachmentTool", + "AuthenticationError", + "AutoRouterCapabilityLimit", + "AzureAIEmbedding", + "AzureAnthropicChatCompletion", + "AzureAssistantsAPI", + "AzureAudioTranscription", + "AzureBatchesAPI", + "AzureChatCompletion", + "AzureOpenAIFilesAPI", + "AzureOpenAIFineTuningAPI", + "AzureOpenAIO1ChatCompletion", + "AzureTextCompletion", + "BATCH_GUARDRAIL_RESPONSE_FIELD", + "BEDROCK_CONVERSE_MODELS", + "BEDROCK_EMBEDDING_PROVIDERS_LITERAL", + "BEDROCK_INVOKE_PROVIDERS_LITERAL", + "BadGatewayError", + "BadRequestError", + "BaseAnthropicMessagesConfig", + "BaseConfig", + "BaseImageEditConfig", + "BaseImageGenerationConfig", + "BaseLLMAIOHTTPHandler", + "BaseLLMException", + "BaseLLMHTTPHandler", + "BaseLiteLLMOpenAIResponseObject", + "BaseModel", + "BaseOCRConfig", + "BaseRerankConfig", + "BaseResponsesAPIConfig", + "BaseResponsesAPIStreamingIterator", + "BaseSearchConfig", + "BaseVideoConfig", + "Batch", + "BatchGuardrailRecord", + "BatchGuardrailReport", + "BatchJobStatus", + "BatchRequestCounts", + "BedrockBatchesHandler", + "BedrockConverseLLM", + "BedrockEmbedding", + "BedrockFilesHandler", + "BedrockImageEdit", + "BedrockImageGeneration", + "BedrockModelInfo", + "BedrockRerankHandler", + "BudgetExceededError", + "BudgetManager", + "BytezChatConfig", + "CALLBACK_TYPES", + "CARRY_UNMATCHED_MESSAGE_POINTS", + "COHERE_DEFAULT_EMBEDDING_INPUT_TYPE", + "CONFIGURABLE_CLIENTSIDE_AUTH_PARAMS", + "CREATE_FILE_REQUESTS_PURPOSE", + "CallTypes", + "Callable", + "CancelBatchRequest", + "CharacterObject", + "Chat", + "ChatCompletionAnnotation", + "ChatCompletionAnnotationURLCitation", + "ChatCompletionAssistantContentValue", + "ChatCompletionAssistantMessage", + "ChatCompletionAssistantToolCall", + "ChatCompletionAudioDelta", + "ChatCompletionAudioObject", + "ChatCompletionAudioParam", + "ChatCompletionCachedContent", + "ChatCompletionChunk", + "ChatCompletionContentPartInputAudioParam", + "ChatCompletionDeltaChunk", + "ChatCompletionDeltaToolCallChunk", + "ChatCompletionDeveloperMessage", + "ChatCompletionDocumentObject", + "ChatCompletionFileObject", + "ChatCompletionFileObjectFile", + "ChatCompletionFunctionMessage", + "ChatCompletionImageObject", + "ChatCompletionImageUrlObject", + "ChatCompletionMessageToolCall", + "ChatCompletionModality", + "ChatCompletionNamedToolChoiceParam", + "ChatCompletionPredictionContentParam", + "ChatCompletionReasoningItem", + "ChatCompletionReasoningSummaryTextBlock", + "ChatCompletionRedactedThinkingBlock", + "ChatCompletionRequest", + "ChatCompletionResponseMessage", + "ChatCompletionSystemMessage", + "ChatCompletionTextObject", + "ChatCompletionThinkingBlock", + "ChatCompletionToolCallChunk", + "ChatCompletionToolCallFunctionChunk", + "ChatCompletionToolChoiceFunctionParam", + "ChatCompletionToolChoiceObjectParam", + "ChatCompletionToolChoiceStringValues", + "ChatCompletionToolChoiceValues", + "ChatCompletionToolMessage", + "ChatCompletionToolParam", + "ChatCompletionToolParamFunctionChunk", + "ChatCompletionToolReferenceObject", + "ChatCompletionUsageBlock", + "ChatCompletionUserMessage", + "ChatCompletionVideoObject", + "ChatCompletionVideoUrlObject", + "Choices", + "ChunkProcessor", + "CitationsObject", + "ClarifaiConfig", + "ClassVar", + "ClassifierPlugin", + "CodeInterpreterToolParam", + "CodestralTextCompletion", + "CohereModelInfo", + "CompletionRequest", + "CompletionTimeout", + "CompletionTokensDetails", + "Completions", + "ComputerToolParam", + "ConfigDict", + "ConfigurableClientsideParamsCustomAuth", + "ConsumedRequestTagsStamp", + "ContentPartAddedEvent", + "ContentPartDoneEvent", + "ContentPartDonePartOutputText", + "ContentPartDonePartReasoningText", + "ContentPartDonePartRefusal", + "ContentPolicyViolationError", + "ContextManagementEntry", + "ContextWindowExceededError", + "Coroutine", + "CreateBatchRequest", + "CreateFileRequest", + "CreateVideoRequest", + "CredentialLiteLLMParams", + "CustomLLM", + "CustomLLMItem", + "CustomLogger", + "CustomPricingLiteLLMParams", + "CustomRoutingStrategyBase", + "CustomStreamWrapper", + "CustomToolCallOutputItem", + "DEFAULT_ALLOWED_FAILS", + "DEFAULT_BATCH_SIZE", + "DEFAULT_FLUSH_INTERVAL_SECONDS", + "DEFAULT_IMAGE_ENDPOINT_MODEL", + "DEFAULT_IN_MEMORY_TTL", + "DEFAULT_MAX_RETRIES", + "DEFAULT_MAX_TOKENS", + "DEFAULT_MOCK_RESPONSE_COMPLETION_TOKEN_COUNT", + "DEFAULT_MOCK_RESPONSE_PROMPT_TOKEN_COUNT", + "DEFAULT_POLLING_INTERVAL", + "DEFAULT_REPLICATE_POLLING_DELAY_SECONDS", + "DEFAULT_REPLICATE_POLLING_RETRIES", + "DEFAULT_REQUEST_TIMEOUT", + "DEFAULT_SOFT_BUDGET", + "DEFAULT_VIDEO_ENDPOINT_MODEL", + "DatabricksEmbeddingHandler", + "DatadogInitParams", + "DecodedResponseId", + "DeleteResponseResult", + "Deployment", + "DeploymentTypedDict", + "Dict", + "Discriminator", + "DocumentObject", + "DualCache", + "EmbeddingCreateParams", + "EmbeddingInput", + "EmbeddingRequest", + "EmbeddingResponse", + "Enum", + "ErrorEvent", + "ErrorEventError", + "FIRST_COMPLETED", + "FORWARDED_KWARGS_KEYS", + "FallbackAccessCheck", + "Field", + "FileContent", + "FileContentProvider", + "FileContentRequest", + "FileContentStreamingResponse", + "FileContentStreamingResult", + "FileCreateProvider", + "FileDeleteProvider", + "FileDeleted", + "FileExpiresAfter", + "FileListPage", + "FileListProvider", + "FileObject", + "FileRetrieveProvider", + "FileSearchCallCompletedEvent", + "FileSearchCallInProgressEvent", + "FileSearchCallSearchingEvent", + "FileSearchTool", + "FileSearchToolParam", + "FileTypes", + "Final", + "FineTuningConfig", + "FineTuningJob", + "FineTuningJobCreate", + "FlowItem", + "Function", + "FunctionCallArgumentsDeltaEvent", + "FunctionCallArgumentsDoneEvent", + "GDCGeminiConfig", + "GeminiModelInfo", + "GenAIHubOrchestration", + "Generator", + "Generic", + "GenericBudgetWindowDetails", + "GenericChatCompletionMessage", + "GenericEvent", + "GenericLiteLLMParams", + "GenericResponseOutputItem", + "GenericResponseOutputItemContentAnnotation", + "GoogleBatchEmbeddings", + "GroqChatCompletion", + "GuardrailLiteLLMParams", + "GuardrailTypedDict", + "HTTPHandler", + "HUMANLOOP_PROMPT_CACHE_TTL_SECONDS", + "HerokuChatConfig", + "HiddenParams", + "HttpxBinaryResponseContent", + "HuggingFaceEmbedding", + "Hyperparameters", + "IBMWatsonXMixin", + "IO", + "IOBase", + "ImageEditOptionalRequestParams", + "ImageFetchError", + "ImageFileObject", + "ImageGenerationPartialImageEvent", + "ImageGenerationRequestQuality", + "ImageResponse", + "ImageURLListItem", + "ImageURLObject", + "IncompleteDetails", + "InputTokensDetails", + "InternalServerError", + "InvalidRequestError", + "Iterable", + "Iterator", + "JSONProviderRegistry", + "JSONSchemaValidationError", + "KeyManagementSettings", + "LIST_BATCHES_SUPPORTED_PROVIDERS", + "LITELLM_CHAT_PROVIDERS", + "LITELLM_EXCEPTION_TYPES", + "LITELLM_IMAGE_VARIATION_PROVIDERS", + "LemonadeChatConfig", + "List", + "ListBatchRequest", + "ListBatchesSupportedProvider", + "LiteLLM", + "LiteLLMBatch", + "LiteLLMBatchCreateRequest", + "LiteLLMCompletionTransformationHandler", + "LiteLLMFineTuningJob", + "LiteLLMFineTuningJobCreate", + "LiteLLMLoggingObj", + "LiteLLMMessagesToCompletionTransformationHandler", + "LiteLLMMessagesToResponsesAPIHandler", + "LiteLLMParamsTypedDict", + "LiteLLMResponsesTransformationHandler", + "LiteLLMUnknownProvider", + "LiteLLM_Params", + "LiteLLM_RouterFileObject", + "Literal", + "LlmProviders", + "Logging", + "MCPCallArgumentsDeltaEvent", + "MCPCallArgumentsDoneEvent", + "MCPCallCompletedEvent", + "MCPCallFailedEvent", + "MCPCallInProgressEvent", + "MCPListToolsCompletedEvent", + "MCPListToolsFailedEvent", + "MCPListToolsInProgressEvent", + "MCPTool", + "MOCK_RESPONSE_TYPE", + "Mapping", + "MappingProxyType", + "Message", + "MessageContent", + "MessageContentImageFileObject", + "MessageContentImageURLObject", + "MessageContentTextObject", + "MessageData", + "MirroredPricingParams", + "MockException", + "MockRouterTestingParams", + "ModelConfig", + "ModelGroupInfo", + "ModelGroupSettings", + "ModelInfo", + "ModelResponse", + "ModelResponseStream", + "MyLocal", + "NOT_GIVEN", + "NewRelicInitParams", + "NonNegativeInt", + "NotFoundError", + "NotGiven", + "NotRequired", + "NvidiaRivaAudioTranscription", + "NvidiaRivaAudioTranscriptionConfig", + "OCIChatConfig", + "OCRResponse", + "OCR_REQUEST_FORMAT_PARAM", + "OPENAI_CHAT_COMPLETION_PARAMS", + "OPENAI_COMPATIBLE_BATCH_AND_FILES_PROVIDERS", + "OPENAI_FINISH_REASONS", + "OPTIONAL_KWARGS_KEYS", + "OVHCloudChatConfig", + "Omit", + "OpenAI", + "OpenAIAssistantsAPI", + "OpenAIAudioTranscription", + "OpenAIAudioTranscriptionOptionalParams", + "OpenAIBatchResponse", + "OpenAIBatchResult", + "OpenAIBatchesAPI", + "OpenAIChatCompletion", + "OpenAIChatCompletionAssistantMessage", + "OpenAIChatCompletionChoices", + "OpenAIChatCompletionChunk", + "OpenAIChatCompletionDeveloperMessage", + "OpenAIChatCompletionFinishReason", + "OpenAIChatCompletionLogprobs", + "OpenAIChatCompletionLogprobsContent", + "OpenAIChatCompletionLogprobsContentTopLogprobs", + "OpenAIChatCompletionResponse", + "OpenAIChatCompletionSystemMessage", + "OpenAIChatCompletionTextObject", + "OpenAIChatCompletionToolParam", + "OpenAIChatCompletionUserMessage", + "OpenAICreateFileRequestOptionalParams", + "OpenAICreateThreadParamsMessage", + "OpenAICreateThreadParamsToolResources", + "OpenAIEmbedding", + "OpenAIError", + "OpenAIErrorBody", + "OpenAIFileObject", + "OpenAIFilesAPI", + "OpenAIFilesPurpose", + "OpenAIFineTuningAPI", + "OpenAIGPT5Config", + "OpenAIImageEditOptionalParams", + "OpenAIImageGenerationOptionalParams", + "OpenAIImageVariationOptionalParams", + "OpenAIImageVariationsHandler", + "OpenAILikeChatHandler", + "OpenAILikeEmbeddingHandler", + "OpenAILikeResponsesConfig", + "OpenAIMcpServerTool", + "OpenAIMessage", + "OpenAIMessageContent", + "OpenAIMessageContentListBlock", + "OpenAIModerationResponse", + "OpenAIModerationResult", + "OpenAIRealtimeContentPartDone", + "OpenAIRealtimeConversationCreated", + "OpenAIRealtimeConversationItemAdded", + "OpenAIRealtimeConversationItemCreated", + "OpenAIRealtimeConversationItemDone", + "OpenAIRealtimeConversationObject", + "OpenAIRealtimeDoneEvent", + "OpenAIRealtimeEventTypes", + "OpenAIRealtimeEvents", + "OpenAIRealtimeFunctionCallArgumentsDone", + "OpenAIRealtimeInputAudioBufferSpeechEvent", + "OpenAIRealtimeInputAudioTranscriptionCompleted", + "OpenAIRealtimeInputAudioTranscriptionDelta", + "OpenAIRealtimeOutputItemDone", + "OpenAIRealtimeResponseAudioDone", + "OpenAIRealtimeResponseContentPart", + "OpenAIRealtimeResponseContentPartAdded", + "OpenAIRealtimeResponseDelta", + "OpenAIRealtimeResponseDoneObject", + "OpenAIRealtimeResponseTextDone", + "OpenAIRealtimeResponseUsage", + "OpenAIRealtimeStreamList", + "OpenAIRealtimeStreamResponseBaseObject", + "OpenAIRealtimeStreamResponseOutputItem", + "OpenAIRealtimeStreamResponseOutputItemAdded", + "OpenAIRealtimeStreamResponseOutputItemContent", + "OpenAIRealtimeStreamSession", + "OpenAIRealtimeStreamSessionEvents", + "OpenAIRealtimeTurnDetection", + "OpenAIRealtimeUsageTokenDetails", + "OpenAITextCompletion", + "OpenAITextCompletionUserMessage", + "OpenAIVideoObject", + "OpenAIWebSearchOptions", + "OpenAIWebSearchUserLocation", + "OpenAIWebSearchUserLocationApproximate", + "Optional", + "OptionalPreCallChecks", + "OutputCodeInterpreterCall", + "OutputCodeInterpreterCallLog", + "OutputFunctionToolCall", + "OutputImageGenerationCall", + "OutputItemAddedEvent", + "OutputItemDoneEvent", + "OutputText", + "OutputTextAnnotationAddedEvent", + "OutputTextDeltaEvent", + "OutputTextDoneEvent", + "OutputTokensDetails", + "PART_UNION_TYPES", + "PalmConfig", + "PathLike", + "PermissionDeniedError", + "Phase", + "PreRoutingHookResponse", + "PreRoutingStrategy", + "PredibaseChatCompletion", + "PrivateAttr", + "PromptCacheBreakpoint", + "PromptCacheOptions", + "PromptObject", + "PromptSpec", + "PromptTokensDetails", + "Protocol", + "ProviderConfigManager", + "ProviderSpecificHeader", + "ProviderSpecificHeaderUtils", + "REASONING_EFFORT", + "REPEATED_STREAMING_CHUNK_LIMIT", + "ROUTER_MAX_FALLBACKS", + "RateLimitError", + "RateLimitErrorCategory", + "RateLimitType", + "RawRequestTypedDict", + "ReadOnly", + "Reasoning", + "ReasoningSummaryPartDoneEvent", + "ReasoningSummaryTextDeltaEvent", + "ReasoningSummaryTextDoneEvent", + "RedisCache", + "RefusalDeltaEvent", + "RefusalDoneEvent", + "RequestType", + "Required", + "RerankResponse", + "Response", + "ResponseAPIUsage", + "ResponseCompletedEvent", + "ResponseCreatedEvent", + "ResponseFailedEvent", + "ResponseFunctionToolCall", + "ResponseInProgressEvent", + "ResponseIncludable", + "ResponseIncompleteEvent", + "ResponseInputParam", + "ResponseOutputItem", + "ResponsePartAddedEvent", + "ResponseText", + "ResponsesAPIOptionalRequestParams", + "ResponsesAPIRequestParams", + "ResponsesAPIRequestUtils", + "ResponsesAPIResponse", + "ResponsesAPIStatus", + "ResponsesAPIStreamEvents", + "ResponsesAPIStreamOptions", + "ResponsesAPIStreamingResponse", + "ResponsesToolUsage", + "RetrieveBatchRequest", + "RetryPolicy", + "Router", + "RouterCacheEnum", + "RouterConfig", + "RouterErrors", + "RouterGeneralSettings", + "RouterModelGroupAliasItem", + "RouterRateLimitError", + "RouterRateLimitErrorBasic", + "RoutingContext", + "RoutingGroup", + "RoutingPlugin", + "RoutingStrategy", + "Run", + "SPECIAL_MODEL_INFO_PARAMS", + "SagemakerChatHandler", + "SagemakerLLM", + "Scheduler", + "SchedulerCacheKeys", + "SearchProvider", + "SearchProviders", + "SearchResponse", + "SearchToolInfoTypedDict", + "SearchToolLiteLLMParams", + "SearchToolTypedDict", + "Sequence", + "SerializerFunctionWrapHandler", + "ServiceUnavailableError", + "Set", + "ShellToolParam", + "SlackAlerting", + "StandardLoggingRoutingDecision", + "StreamingChoices", + "SyncCursorPage", + "TYPE_CHECKING", + "TaggedPreRoutingStrategy", + "TextChoices", + "TextCompletionResponse", + "TextCompletionStreamWrapper", + "Thread", + "ThreadPoolExecutor", + "Timeout", + "TogetherAIRerank", + "Tool", + "ToolChoice", + "ToolMessageContentPart", + "ToolParam", + "ToolResourcesCodeInterpreter", + "ToolResourcesFileSearch", + "ToolResourcesFileSearchVectorStore", + "TopazModelInfo", + "TranscriptionResponse", + "Tuple", + "Type", + "TypeAlias", + "TypeVar", + "TypedDict", + "Union", + "UnprocessableEntityError", + "UnsupportedParamsError", + "UpdateRouterConfig", + "Usage", + "VALID_LITELLM_ENVIRONMENTS", + "ValidAssistantMessageContentTypes", + "ValidAssistantMessageContentTypesLiteral", + "ValidChatCompletionMessageContentTypes", + "ValidChatCompletionMessageContentTypesLiteral", + "ValidUserMessageContentTypes", + "ValidUserMessageContentTypesLiteral", + "VectorStoreIndexRegistry", + "VectorStoreRegistry", + "VertexAIBatchPrediction", + "VertexAIFilesHandler", + "VertexAIGemmaModels", + "VertexAIModelGardenModels", + "VertexAIModelRoute", + "VertexAIPartnerModels", + "VertexAITextEmbeddingConfig", + "VertexEmbedding", + "VertexFineTuningAPI", + "VertexImageGeneration", + "VertexLLM", + "VertexMultimodalEmbedding", + "VideoCreateOptionalRequestParams", + "VideoGenerationRequestUtils", + "VideoObject", + "WANDB_MODELS", + "WATSONX_DEFAULT_API_VERSION", + "WatsonXChatHandler", + "WebSearchCallCompletedEvent", + "WebSearchCallInProgressEvent", + "WebSearchCallSearchingEvent", + "WebSearchOptions", + "WebSearchOptionsUserLocation", + "WebSearchOptionsUserLocationApproximate", + "WebSearchToolUsage", + "XAIModelInfo", + "a_add_message", + "aadapter_completion", + "aadapter_generate_content", + "acancel_batch", + "acancel_eval", + "acancel_fine_tuning_job", + "acancel_responses", + "acancel_run", + "aclient_session", + "acode_interpreter_tool", + "acompact_responses", + "acompletion", + "acompletion_with_retries", + "acount_tokens", + "acreate_agent", + "acreate_assistants", + "acreate_batch", + "acreate_container", + "acreate_eval", + "acreate_file", + "acreate_fine_tuning_job", + "acreate_realtime_client_secret", + "acreate_realtime_transcription_session", + "acreate_run", + "acreate_sandbox", + "acreate_skill", + "acreate_thread", + "adapter_completion", + "adapters", + "add_function_to_prompt", + "add_known_models", + "add_message", + "add_provider_specific_params_to_optional_params", + "add_system_prompt_to_messages", + "add_trusted_model_credentials_to_litellm_params", + "add_user_information_to_llm_headers", + "additional_logging_utils", + "adelete_agent", + "adelete_assistant", + "adelete_container", + "adelete_eval", + "adelete_responses", + "adelete_run", + "adelete_sandbox", + "adelete_skill", + "aembedding", + "afile_content", + "afile_delete", + "afile_list", + "afile_retrieve", + "agenerate_content", + "agent_search_embedding_model", + "agentops", + "aget_agent", + "aget_assistants", + "aget_eval", + "aget_messages", + "aget_responses", + "aget_run", + "aget_skill", + "aget_thread", + "ahealth_check", + "ai21_chat_models", + "ai21_key", + "ai21_models", + "aimage_edit", + "aimage_generation", + "aimage_variation", + "aiml_models", + "aingest", + "aiohttp_trust_env", + "aleph_alpha", + "aleph_alpha_key", + "aleph_alpha_models", + "alist_agent_versions", + "alist_agents", + "alist_batches", + "alist_container_files", + "alist_containers", + "alist_evals", + "alist_fine_tuning_jobs", + "alist_input_items", + "alist_runs", + "alist_skills", + "all_embedding_models", + "all_litellm_params", + "allm_passthrough_route", + "allow_dynamic_callback_disabling", + "allowed_fails", + "amazon_nova_api_key", + "amazon_nova_models", + "amoderation", + "annotations", + "anthropic", + "anthropic_batches_instance", + "anthropic_beta_headers_manager", + "anthropic_beta_headers_url", + "anthropic_cache_control_hook", + "anthropic_chat_completions", + "anthropic_interface", + "anthropic_key", + "anthropic_messages", + "anthropic_messages_handler", + "anthropic_models", + "anthropic_prompt_caching_ttl", + "anthropic_sse_ping_interval_seconds", + "anyscale_models", + "aocr", + "api_base", + "api_key", + "api_version", + "aquery", + "arealtime_calls", + "arerank", + "aresponses", + "aresponses_api_with_mcp", + "aresponses_with_retries", + "aretrieve_batch", + "aretrieve_container", + "aretrieve_fine_tuning_job", + "argilla", + "argilla_batch_size", + "argilla_transformation_object", + "arize", + "arun_code", + "arun_thread", + "arun_thread_stream", + "asearch", + "aspeech", + "assemblyai_models", + "assistants", + "async_completion_with_fallbacks", + "async_mock_completion_streaming_obj", + "asyncio", + "atext_completion", + "athina", + "atranscription", + "audit_log_callbacks", + "aupload_container_file", + "autorouter_presets_url", + "avector_store_file_content", + "avector_store_file_create", + "avector_store_file_delete", + "avector_store_file_list", + "avector_store_file_retrieve", + "avector_store_file_update", + "avideo_content", + "avideo_create_character", + "avideo_edit", + "avideo_extension", + "avideo_generation", + "avideo_get_character", + "avideo_list", + "avideo_remix", + "avideo_status", + "aws_polly_models", + "aws_sqs_callback_params", + "azure_ai_embedding", + "azure_ai_models", + "azure_anthropic_chat_completions", + "azure_anthropic_models", + "azure_assistants_api", + "azure_audio_transcriptions", + "azure_batches_instance", + "azure_chat_completions", + "azure_embedding_models", + "azure_files_instance", + "azure_fine_tuning_apis_instance", + "azure_key", + "azure_llms", + "azure_models", + "azure_o1_chat_completions", + "azure_sentinel", + "azure_storage", + "azure_text_completions", + "azure_text_models", + "banned_keywords_list", + "base64", + "base_llm_aiohttp_handler", + "base_llm_http_handler", + "baseten_key", + "baseten_models", + "batch_completion", + "batch_completion_models", + "batch_completion_models_all_responses", + "batches", + "bedrock_converse_chat_completion", + "bedrock_converse_models", + "bedrock_embedding", + "bedrock_embedding_models", + "bedrock_files_instance", + "bedrock_image_edit", + "bedrock_image_generation", + "bedrock_mantle_models", + "bedrock_models", + "bedrock_request_metadata_fields", + "bedrock_rerank", + "bfl_image_edit", + "bfl_image_generation", + "black_forest_labs_models", + "block_requests_for_models_without_pricing", + "blocked_user_list", + "blog_posts_url", + "budget_duration", + "budget_exceeded_throttle_percentage", + "budget_manager", + "budget_rollover", + "build_code_interpreter_log_outputs", + "bytez_key", + "bytez_transformation", + "cache", + "caching", + "caching_with_models", + "calculate_request_duration", + "callback_settings", + "callbacks", + "cancel_batch", + "cancel_eval", + "cancel_fine_tuning_job", + "cancel_responses", + "cancel_run", + "cast", + "cerebras_models", + "chatgpt_models", + "check_provider_endpoint", + "clarifai_key", + "clarifai_models", + "client", + "client_session", + "close_litellm_async_clients", + "cloudflare_api_key", + "cloudflare_models", + "codestral_models", + "codestral_text_completions", + "cohere_chat_models", + "cohere_embed", + "cohere_embedding_models", + "cohere_key", + "cohere_models", + "cold_storage_custom_logger", + "cometapi_key", + "cometapi_models", + "common_cloud_provider_auth_params", + "compact_responses", + "completion", + "completion_extras", + "completion_with_fallbacks", + "completion_with_retries", + "compress", + "compression", + "config_completion", + "config_path", + "constants", + "containers", + "content_policy_fallbacks", + "context_window_fallbacks", + "contextmanager", + "contextvars", + "convert_file_document_to_url_document", + "convert_model_response_to_streaming", + "convert_to_model_response_object", + "cost_calculator", + "cost_discount_config", + "cost_margin_config", + "create_agent", + "create_assistants", + "create_batch", + "create_container", + "create_eval", + "create_file", + "create_fine_tuning_job", + "create_pretrained_tokenizer", + "create_run", + "create_skill", + "create_thread", + "create_tokenizer", + "credential_list", + "custom_batch_logger", + "custom_chat_llm_router", + "custom_guardrail", + "custom_logger", + "custom_prometheus_metadata_labels", + "custom_prometheus_tags", + "custom_prompt", + "custom_prompt_dict", + "custom_prompt_management", + "custom_provider_map", + "darkbloom_models", + "dashscope_models", + "databricks_embedding", + "databricks_key", + "databricks_models", + "dataclass", + "datadog", + "datadog_llm_observability_params", + "datadog_params", + "datadog_use_v1", + "datarobot_key", + "datarobot_models", + "datetime", + "decode_video_id_with_provider", + "declared_authenticating_provider", + "deepcopy", + "deepeval", + "deepgram_models", + "deepinfra_models", + "deepseek_models", + "default_fallbacks", + "default_in_memory_ttl", + "default_internal_user_params", + "default_key_generate_params", + "default_key_max_budget_alert_emails", + "default_max_internal_user_budget", + "default_redis_batch_cache_expiry", + "default_redis_ttl", + "default_soft_budget", + "default_team_params", + "default_team_settings", + "delete_agent", + "delete_assistant", + "delete_container", + "delete_eval", + "delete_responses", + "delete_run", + "delete_skill", + "disable_add_prefix_to_prompt", + "disable_add_transform_inline_image_block", + "disable_add_user_agent_to_request_tags", + "disable_aiohttp_transport", + "disable_aiohttp_trust_env", + "disable_anthropic_gemini_context_caching_transform", + "disable_cache", + "disable_copilot_system_to_assistant", + "disable_end_user_cost_tracking", + "disable_end_user_cost_tracking_prometheus_only", + "disable_hf_tokenizer_download", + "disable_stop_sequence_limit", + "disable_streaming_logging", + "disable_token_counter", + "disable_vertex_batch_output_transformation", + "docker_model_runner_models", + "dotenv", + "dotprompt", + "drop_params", + "dynamodb", + "dynamodb_table_name", + "elevenlabs_models", + "email", + "email_templates", + "embedding", + "empower_models", + "enable_anthropic_prompt_caching", + "enable_azure_ad_token_refresh", + "enable_cache", + "enable_caching_on_provider_specific_optional_params", + "enable_end_user_cost_tracking_prometheus_only", + "enable_gemini_default_thinking_level_low", + "enable_json_schema_validation", + "enable_key_alias_format_validation", + "enable_loadbalancing_on_batch_endpoints", + "enable_model_config_credential_overrides", + "enable_preview_features", + "enum", + "error_logs", + "evals", + "exception_type", + "exceptions", + "expose_router_debug_in_errors", + "extra_spend_tag_headers", + "failure_callback", + "fal_ai_models", + "fallbacks", + "featherless_ai_models", + "field_serializer", + "field_validator", + "file_content", + "file_content_streaming", + "file_delete", + "file_list", + "file_retrieve", + "files", + "filter_invalid_headers", + "filter_out_litellm_params", + "fine_tuning", + "fireworks_ai_embedding_models", + "fireworks_ai_models", + "flatten_form_field_values", + "flatten_unencrypted_web_search_results_in_anthropic_messages", + "force_ipv4", + "forward_traceparent_to_llm_provider", + "friendliai_models", + "function_call_prompt", + "futures", + "galadriel_models", + "galileo", + "gcs_bucket", + "gcs_pub_sub_use_v1", + "gcs_pubsub", + "gdc_api_base", + "gdc_key", + "gdc_transformation", + "gemini_live_defer_setup", + "gemini_models", + "generic_api", + "generic_api_use_v1", + "generic_logger_headers", + "get_agent", + "get_api_key_from_env", + "get_args", + "get_assistants", + "get_audio_file_for_health_check", + "get_azure_credentials", + "get_completion_messages", + "get_configured_request_timeout", + "get_content_from_model_response", + "get_eval", + "get_litellm_gateway_api_key", + "get_litellm_params", + "get_llm_provider", + "get_messages", + "get_messages_interceptors", + "get_mime_type", + "get_model_cost_map", + "get_model_info", + "get_non_default_completion_params", + "get_non_default_transcription_params", + "get_openai_credentials", + "get_optional_params", + "get_optional_params_add_message", + "get_optional_params_embeddings", + "get_optional_params_image_gen", + "get_optional_params_transcription", + "get_optional_rerank_params", + "get_requester_metadata", + "get_responses", + "get_run", + "get_secret", + "get_secret_bool", + "get_secret_str", + "get_skill", + "get_standard_openai_params", + "get_thread", + "get_type_hints", + "get_vertex_ai_model_route", + "gigachat_key", + "gigachat_models", + "github_copilot_models", + "global_bitbucket_config", + "global_disable_no_log_param", + "global_gitlab_config", + "google_batch_embeddings", + "google_genai", + "google_moderation_confidence_threshold", + "gradient_ai_api_key", + "gradient_ai_models", + "greenscale", + "groq_chat_completions", + "groq_key", + "groq_models", + "guardrail_name_config_map", + "headers", + "heapq", + "helicone", + "helicone_mock_client", + "heroku_key", + "heroku_models", + "heroku_transformation", + "httpx", + "huggingface_embed", + "huggingface_key", + "huggingface_models", + "humanloop", + "hyperbolic_models", + "identify", + "image_edit", + "image_generation", + "image_variation", + "images", + "importlib", + "in_memory_llm_clients_cache", + "inception_key", + "inception_models", + "include_cost_in_streaming_usage", + "infer_openai_data_residency", + "infinity_key", + "infinity_models", + "ingest", + "initialized_langfuse_clients", + "input_callback", + "inspect", + "integrations", + "interactions", + "internal_user_budget_duration", + "is_azure_document_intelligence_model", + "is_bedrock_pricing_only_model", + "is_openai_finetune_model", + "is_reasoning_auto_summary_enabled", + "jina_ai_models", + "json", + "json_logs", + "key_generation_settings", + "known_tokenizer_config", + "lago", + "lambda_ai_models", + "langfuse", + "langfuse_default_tags", + "langfuse_enable_update_trace_keys", + "langsmith", + "langsmith_batch_size", + "langsmith_mock_client", + "lemonade_key", + "lemonade_models", + "lemonade_transformation", + "list_agent_versions", + "list_agents", + "list_batches", + "list_container_files", + "list_containers", + "list_evals", + "list_fine_tuning_jobs", + "list_input_items", + "list_runs", + "list_skills", + "litellm", + "litellm_agent", + "litellm_completion_transformation_handler", + "litellm_core_utils", + "litellm_mode", + "literal_ai", + "llama_api_key", + "llama_models", + "llamagate_models", + "llamaguard_model_name", + "llamaguard_unsafe_content_categories", + "llm_guard_mode", + "llm_http_handler", + "llm_passthrough_route", + "llms", + "log_client_error_tracebacks", + "log_level", + "log_raw_request_response", + "logfire_logger", + "logged_real_time_event_types", + "logging", + "longer_context_model_fallback_dict", + "lunary", + "main", + "map_system_message_pt", + "maritalk_key", + "maritalk_models", + "max_budget", + "max_end_user_budget", + "max_end_user_budget_id", + "max_fallbacks", + "max_internal_user_budget", + "max_tokens", + "max_ui_session_budget", + "max_user_budget", + "maybe_run_chat_completion_agentic_loop", + "mcp_tool_search", + "mimetypes", + "minimax_models", + "mistral_chat_models", + "mlflow", + "mock_client_factory", + "mock_completion", + "mock_completion_streaming_obj", + "mock_embedding", + "mock_image_generation", + "mock_response", + "mock_responses_api_response", + "model_alias_map", + "model_cost", + "model_cost_map_url", + "model_fallbacks", + "model_group_settings", + "model_list", + "model_list_set", + "model_serializer", + "model_validator", + "models", + "models_by_provider", + "modelscope_models", + "moderation", + "modify_params", + "moonshot_models", + "morph_models", + "nebius_embedding_models", + "nebius_key", + "nebius_models", + "network_mock", + "newrelic", + "newrelic_params", + "nlp_cloud_chat_completion", + "nlp_cloud_key", + "nlp_cloud_models", + "novita_api_key", + "novita_models", + "nscale_models", + "num_retries", + "num_retries_per_request", + "nvidia_nim_models", + "nvidia_riva_audio_transcriptions", + "nvidia_riva_models", + "oci_models", + "oci_transformation", + "ocr", + "ollama", + "ollama_key", + "ollama_models", + "ollama_pt", + "oobabooga", + "open_ai_chat_completion_models", + "open_ai_embedding_models", + "open_ai_text_completion_models", + "openai", + "openai_assistants_api", + "openai_audio_transcriptions", + "openai_batches_instance", + "openai_chat_completions", + "openai_compatible_endpoints", + "openai_compatible_providers", + "openai_files_instance", + "openai_fine_tuning_apis_instance", + "openai_image_generation_models", + "openai_image_variations", + "openai_key", + "openai_like_chat_completion", + "openai_like_embedding", + "openai_like_key", + "openai_moderations_model_name", + "openai_text_completion_compatible_providers", + "openai_text_completions", + "openai_video_generation_models", + "openmeter", + "openrouter_key", + "openrouter_models", + "opentelemetry", + "opentelemetry_utils", + "opik", + "organization", + "os", + "otel", + "output_parse_pii", + "overload", + "override", + "overwrite_user_with_key_hash", + "ovhcloud_embedding_models", + "ovhcloud_key", + "ovhcloud_models", + "ovhcloud_transformation", + "palm", + "palm_models", + "parse_ocr_request_format", + "partial", + "passthrough", + "peek_reasoning_summary_aliases", + "perplexity_models", + "petals_handler", + "petals_models", + "post_call_rules", + "posthog", + "posthog_mock_client", + "pre_call_rules", + "pre_process_non_default_params", + "predibase_chat_completions", + "predibase_key", + "predibase_tenant_id", + "presidio_ad_hoc_recognizers", + "print_verbose", + "priority_reservation", + "project", + "prometheus_deployment_and_latency_caller_identity", + "prometheus_emit_rate_limit_labels", + "prometheus_emit_stream_label", + "prometheus_end_user_metrics_cleanup_interval_seconds", + "prometheus_end_user_metrics_max_series_per_metric", + "prometheus_end_user_metrics_ttl_seconds", + "prometheus_exclude_labels", + "prometheus_exclude_metrics", + "prometheus_initialize_budget_metrics", + "prometheus_latency_buckets", + "prometheus_metrics_config", + "prometheus_user_budget_label_include_email_alias", + "prompt_factory", + "prompt_layer", + "prompt_management_base", + "prompt_name_config_map", + "provider_url_destination_allowed_hosts", + "proxy", + "proxy_auth", + "public_agent_groups", + "public_mcp_hub_strict_whitelist", + "public_mcp_servers", + "public_model_groups", + "public_model_groups_links", + "publicai_models", + "query", + "qwen_ai_platform_models", + "qwencloud_models", + "rag", + "random", + "re", + "read_config_args", + "realtime_api", + "reasoning_auto_summary", + "recraft_models", + "redact_messages_in_exceptions", + "redact_user_api_key_info", + "reducto_models", + "replicate_chat_completion", + "replicate_key", + "replicate_models", + "repositories", + "request_correlation_in_logs", + "request_timeout", + "request_timeout_explicitly_set", + "require_auth_for_metrics_endpoint", + "require_managed_files", + "rerank", + "rerank_api", + "responses", + "responses_api_bridge_check", + "responses_with_retries", + "retrieve_batch", + "retrieve_container", + "retrieve_fine_tuning_job", + "retry", + "return_response_headers", + "route_all_chat_openai_to_responses", + "router", + "router_strategy", + "router_utils", + "run_async_function", + "run_server", + "run_thread", + "run_thread_stream", + "runtime_checkable", + "runwayml_models", + "rust", + "rust_bridge", + "rust_ocr_bridge", + "s3", + "s3_audit_callback_params", + "s3_callback_params", + "s3_v2", + "safe_deep_copy", + "safe_memory_mode", + "sagemaker_chat_completion", + "sagemaker_llm", + "sambanova_embedding_models", + "sambanova_models", + "sandbox", + "sanitize_tool_use_ids_in_anthropic_messages", + "sap_gen_ai_hub_chat_completions", + "sap_gen_ai_hub_emb", + "sap_service_key", + "scheduler", + "search", + "secret_manager_client", + "secret_managers", + "service_callback", + "set_global_bitbucket_config", + "set_global_gitlab_config", + "set_verbose", + "should_run_mock_completion", + "skills", + "skip_system_message_in_guardrail", + "skip_tool_message_in_guardrail", + "snowflake_key", + "snowflake_models", + "soniox_models", + "speech", + "sqs", + "sse_keepalive_ping_interval_seconds", + "ssl_certificate", + "ssl_ecdh_curve", + "ssl_security_level", + "ssl_verify", + "stability_models", + "standard_logging_payload_excluded_fields", + "store_audit_logs", + "stream_chunk_builder", + "stream_chunk_builder_text_completion", + "stringify_json_tool_call_content", + "strip_anthropic_total_tokens", + "strip_empty_content_blocks_from_anthropic_messages", + "strip_reasoning_summary_aliases_from_optional_params", + "success_callback", + "supabase", + "supports_httpx_timeout", + "suppress_debug_info", + "sys", + "tag_budget_config", + "telemetry", + "tencent_models", + "text_completion", + "text_completion_codestral_models", + "text_completion_inception_models", + "threading", + "tiktoken", + "time", + "together_ai_models", + "together_rerank", + "togetherai_api_key", + "token", + "token_counter", + "traceback", + "traceloop", + "tracer", + "transcription", + "turn_off_message_logging", + "types", + "updateDeployment", + "updateLiteLLMParams", + "update_cache", + "update_messages_with_model_file_ids", + "update_responses_input_with_model_file_ids", + "update_responses_tools_with_model_file_ids", + "upload_container_file", + "upperbound_key_generate_params", + "urlsplit", + "use_aiohttp_transport", + "use_chat_completions_url_for_anthropic_messages", + "use_client", + "use_legacy_interactions_schema", + "use_litellm_proxy", + "user_url_allowed_hosts", + "user_url_validation", + "utils", + "uuid", + "uuid_module", + "v0_models", + "validate_and_fix_openai_messages", + "validate_and_fix_openai_tools", + "validate_and_fix_thinking_param", + "validate_anthropic_api_metadata", + "validate_chat_completion_tool_choice", + "validate_end_user_id_in_db", + "validate_openai_optional_params", + "vector_store_file_content", + "vector_store_file_create", + "vector_store_file_delete", + "vector_store_file_list", + "vector_store_file_retrieve", + "vector_store_file_update", + "vector_store_files", + "vector_store_index_registry", + "vector_store_registry", + "vector_stores", + "verbose_logger", + "vercel_ai_gateway_key", + "vercel_ai_gateway_models", + "vertexAITextEmbeddingConfig", + "vertex_ai_ai21_models", + "vertex_ai_batches_instance", + "vertex_ai_files_instance", + "vertex_ai_image_models", + "vertex_ai_non_gemini", + "vertex_ai_safety_settings", + "vertex_ai_video_models", + "vertex_anthropic_models", + "vertex_chat_completion", + "vertex_chat_models", + "vertex_code_chat_models", + "vertex_code_text_models", + "vertex_deepseek_models", + "vertex_embedding", + "vertex_embedding_models", + "vertex_fine_tuning_apis_instance", + "vertex_gemma_chat_completion", + "vertex_image_generation", + "vertex_language_models", + "vertex_llama3_models", + "vertex_location", + "vertex_minimax_models", + "vertex_mistral_models", + "vertex_model_garden_chat_completion", + "vertex_moonshot_models", + "vertex_multimodal_embedding", + "vertex_openai_models", + "vertex_partner_models_chat_completion", + "vertex_project", + "vertex_text_models", + "vertex_vision_models", + "vertex_zai_models", + "video_content", + "video_create_character", + "video_edit", + "video_extension", + "video_generation", + "video_get_character", + "video_list", + "video_remix", + "video_status", + "videos", + "vllm_handler", + "volcengine_models", + "voyage_models", + "wait", + "wandb_key", + "wandb_models", + "warnings", + "watsonx_chat_completion", + "watsonx_models", + "xai_key", + "xai_models", + "zai_models", +) diff --git a/litellm/proxy/__init__.py b/litellm/proxy/__init__.py index b6e690fd591..dc819fbc85c 100644 --- a/litellm/proxy/__init__.py +++ b/litellm/proxy/__init__.py @@ -1 +1,11 @@ -from . import * +from types import ModuleType +from typing import Final + + +def __getattr__(name: str) -> ModuleType: + from litellm._lazy_imports import lazy_import_submodule + + submodule: Final = lazy_import_submodule(__name__, name) + if submodule is not None: + return submodule + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/tests/test_litellm/test_lazy_imports.py b/tests/test_litellm/test_lazy_imports.py index 2b16a812611..09d5e23e2e6 100644 --- a/tests/test_litellm/test_lazy_imports.py +++ b/tests/test_litellm/test_lazy_imports.py @@ -1,5 +1,8 @@ """Simple tests for lazy import functionality.""" +import importlib +import json +import subprocess import sys import pytest @@ -7,6 +10,10 @@ import pytest import litellm from litellm._lazy_imports import ( + _SDK_MODULE_ALIASES, + _SDK_SYMBOLS_IMPORT_MAP, + lazy_import_litellm_submodule, + _lazy_import_sdk_symbols, COST_CALCULATOR_NAMES, LITELLM_LOGGING_NAMES, UTILS_NAMES, @@ -346,3 +353,83 @@ def test_utils_module_lazy_imports(): assert name in utils_globals _verify_only_requested_name_imported_in_utils(name, UTILS_MODULE_NAMES) + + +def test_sdk_symbols_lazy_imports(): + """Every symbol previously imported eagerly in litellm/__init__.py resolves to the source module attribute.""" + for name, (module_path, attr_name) in _SDK_SYMBOLS_IMPORT_MAP.items(): + resolved = getattr(litellm, name) + expected = getattr(importlib.import_module(module_path), attr_name) + assert resolved is expected, f"litellm.{name} is not {module_path}.{attr_name}" + + +def test_sdk_module_aliases(): + """Module-valued attributes (litellm.anthropic, litellm.httpx, ...) resolve to the aliased modules.""" + for name, module_path in _SDK_MODULE_ALIASES.items(): + assert getattr(litellm, name) is importlib.import_module(module_path) + + +def test_litellm_submodule_fallback(): + """litellm. attribute access resolves real submodules and returns None for unknown names.""" + assert lazy_import_litellm_submodule("budget_manager") is importlib.import_module("litellm.budget_manager") + assert litellm.utils is importlib.import_module("litellm.utils") + assert lazy_import_litellm_submodule("not_a_real_submodule") is None + with pytest.raises(AttributeError): + _ = litellm.not_a_real_attribute + + +def test_missing_attribute_stays_attribute_error_when_find_spec_lies(monkeypatch): + """getattr(litellm, name, default) must not leak ModuleNotFoundError when find_spec is patched to always succeed.""" + monkeypatch.setattr(importlib.util, "find_spec", lambda name: object()) + assert getattr(litellm, "not_a_real_submodule", None) is None + with pytest.raises(AttributeError): + _ = litellm.not_a_real_attribute + + +def test_proxy_private_submodule_resolves_in_fresh_process(): + """litellm.proxy._types resolves without an eager proxy import (used by documentation checks).""" + code = "import litellm\nprint(litellm.proxy._types.__name__)\n" + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "litellm.proxy._types" + + +def test_lazy_instances_are_singletons(): + """Lazily created instances are cached, so repeated access returns the same object.""" + assert litellm._key_management_settings is litellm._key_management_settings + assert litellm.vertexAITextEmbeddingConfig is litellm.vertexAITextEmbeddingConfig + from litellm.types.secret_managers.main import KeyManagementSettings + + assert isinstance(litellm._key_management_settings, KeyManagementSettings) + + +def test_star_import_exports_public_api(): + """`from litellm import *` keeps exporting the full public surface despite lazy loading.""" + code = ( + "from litellm import *\n" + "import litellm\n" + "missing = [n for n in litellm.__all__ if n not in dir()]\n" + "assert not missing, missing[:20]\n" + "assert callable(completion) and callable(Router)\n" + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + + +@pytest.mark.skipif(sys.platform != "linux", reason="reads /proc for RSS") +def test_import_litellm_stays_lightweight(): + """`import litellm` must not pull in the SDK/proxy heavyweights or blow up RSS (LIT-6607).""" + code = ( + "import json, re, sys\n" + "import litellm\n" + "heavy = [m for m in ('litellm.main', 'litellm.utils', 'litellm.router', 'litellm.proxy.proxy_cli',\n" + " 'tiktoken', 'fastapi', 'grpc', 'boto3') if m in sys.modules]\n" + "with open('/proc/self/status') as f:\n" + " rss_kb = int(re.search(r'VmRSS:\\s+(\\d+) kB', f.read()).group(1))\n" + "print(json.dumps({'total': len(sys.modules), 'heavy': heavy, 'rss_mb': rss_kb / 1024}))\n" + ) + result = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True) + stats = json.loads(result.stdout) + assert stats["heavy"] == [], f"heavy modules imported eagerly: {stats['heavy']}" + assert stats["total"] < 800, f"import litellm loaded {stats['total']} modules" + assert stats["rss_mb"] < 75, f"import litellm used {stats['rss_mb']:.1f} MB RSS" From b98f8ee2c5be9bbaa1b54297f7cfdcb57ec4e615 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 13:00:24 -0700 Subject: [PATCH 262/410] test(e2e): retry a fresh prefix when Vertex rejects the cache create on its minimum-token check --- tests/e2e/llm_translation/test_cache_control.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/e2e/llm_translation/test_cache_control.py b/tests/e2e/llm_translation/test_cache_control.py index 3ad98bc6072..4e11ad6cec5 100644 --- a/tests/e2e/llm_translation/test_cache_control.py +++ b/tests/e2e/llm_translation/test_cache_control.py @@ -37,7 +37,7 @@ import pytest from pydantic import BaseModel from e2e_config import unique_marker -from e2e_http import Result, unwrap +from e2e_http import Result, UnknownApiError, unwrap from endpoints_client import CacheControl, RichMessage, TextBlock from lifecycle import ResourceManager from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody, Usage @@ -54,6 +54,7 @@ VERTEX_CACHE_TTL: Final = "300s" VERTEX_COLD_CALL_ATTEMPTS: Final = 3 VERTEX_MINIMUM_CACHED_TOKENS: Final = 1024 CACHED_SHARE_OF_PROMPT: Final = 0.9 +VERTEX_CACHE_REJECTION_MARKER: Final = "minimum token count to start explicit caching" class CacheChatBody(BaseModel): @@ -149,7 +150,12 @@ def _assert_cache_read_on_second_call( def _cold_cache_calls(send: Callable[[str], Result[ChatResponse]]) -> Iterator[ChatResponse]: for _ in range(VERTEX_COLD_CALL_ATTEMPTS): - yield unwrap(send(_cacheable_prefix())) + result = send(_cacheable_prefix()) + match result: + case UnknownApiError(status_code=400, body=body) if VERTEX_CACHE_REJECTION_MARKER in body: + continue + case _: + yield unwrap(result) def _first_cold_call_reads_cache(model: str, send: Callable[[str], Result[ChatResponse]]) -> ChatResponse: @@ -162,9 +168,9 @@ def _first_cold_call_reads_cache(model: str, send: Callable[[str], Result[ChatRe None, ) assert completion is not None, ( - f"{model}: {VERTEX_COLD_CALL_ATTEMPTS} never-seen prompts marked with cache_control all reported fewer " - f"than {VERTEX_MINIMUM_CACHED_TOKENS} cached tokens on their first call; explicit context caching did " - "not engage" + f"{model}: {VERTEX_COLD_CALL_ATTEMPTS} never-seen prompts marked with cache_control were each either " + f"rejected by Vertex's minimum-token check or served with fewer than {VERTEX_MINIMUM_CACHED_TOKENS} " + "cached tokens on their first call; explicit context caching did not engage" ) assert completion.choices, f"{model}: cached call returned no choices: {completion}" usage: Final = completion.usage From 948e5755eba9cb80e1239ecebfb717fcad9b2c36 Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Sat, 5 Sep 2026 13:03:28 -0700 Subject: [PATCH 263/410] test(e2e): cover presidio post_call, tool_permission, and weave logging cells (#39279) * test(e2e): cover presidio post_call, tool_permission, and weave logging cells Five registry cells in Logging & Guardrails had no covering test. Each one now has a live scenario read back from the real destination: - guardrail.presidio.post_call.masks: an output-scoped Presidio guardrail anonymizes the PII the model repeats back. The prompt also asks for the address's local part, which Presidio does not mask, so one response proves the model saw the raw address (no pre-call masking) while the address itself comes back as - guardrail.tool_permission.pre_call.blocks / .allows: an allow-list of one tool. A request declaring an unlisted tool is rejected 400 naming it; a request declaring the permitted tool is served and carries x-litellm-applied-guardrails, so the allow half cannot pass by the guardrail never running - logging.niche_integrations.success.logs_spend / .failure.logs_spend: a key-scoped weave_otel callback delivers to the real Weave project, read back through Weave's query API. Success asserts exactly one call whose llm.response.cost equals the x-litellm-response-cost header; failure asserts one ERROR-status call naming the provider exception and carrying no cost Logging & Guardrails coverage goes 24/59 to 29/59. No registry rows are added. * test(e2e): make the tool-permission allow case deterministic and scope the Weave read-back Review follow-ups on the coverage PR. - the allow scenario forced the outcome to depend on whether the model felt like calling an optional tool, and checked for the tool name as a substring of the whole body, which a prose mention would satisfy. It now sends tool_choice="required" and asserts the parsed response carries exactly one tool call, for the permitted tool - the Weave read-back queried the newest 200 calls of a shared project and filtered client-side, so busy traffic could push the target out of the window and read as a delivery failure. The query now scopes server-side to the litellm_request op and to calls started after the request, and pages through the window with offset - the reader builds its results as tuples instead of accumulating into lists Also unblocks the lint gate: `basedpyright tests/e2e` runs only on PRs that touch tests/e2e, and it has been failing on staging for three FakeItem arguments in test_junit_properties.py. The stand-in now goes through one typed adapter that says why, so the gate is green without touching junit_properties.py itself. * test(e2e): scope the presidio post_call guardrail to email and phone Running the suite three times in a row caught a real flake: Presidio's broader recognizers sometimes claim the email's local part as an NRP entity, so the answer came back as `\n\n` and the assertion that the raw local part survives failed. That token is what tells output masking apart from input masking, so it has to survive. The post_call guardrail now registers pii_entities_config for EMAIL_ADDRESS and PHONE_NUMBER only, which is also the narrower thing the scenario means. Verified against the exact marker that failed, plus two others. * test(e2e): mark weave logging cells stage red * test(e2e): use per-test stage red skips for the weave logging cells --- tests/e2e/CONTRIBUTING.md | 4 +- tests/e2e/guardrails/guardrails_client.py | 71 ++++- .../guardrails/test_presidio_masking_e2e.py | 106 ++++++- .../test_tool_permission_guardrail_e2e.py | 167 +++++++++++ tests/e2e/logging/logging_client.py | 39 +++ tests/e2e/logging/test_weave_log_e2e.py | 192 ++++++++++++ tests/e2e/logging/weave_reader.py | 282 ++++++++++++++++++ tests/e2e/models.py | 2 + 8 files changed, 844 insertions(+), 19 deletions(-) create mode 100644 tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py create mode 100644 tests/e2e/logging/test_weave_log_e2e.py create mode 100644 tests/e2e/logging/weave_reader.py diff --git a/tests/e2e/CONTRIBUTING.md b/tests/e2e/CONTRIBUTING.md index 871d6b3904c..b270feb820e 100644 --- a/tests/e2e/CONTRIBUTING.md +++ b/tests/e2e/CONTRIBUTING.md @@ -50,7 +50,9 @@ The suites run against a live proxy, so bring one up first by running the litell They also need a proxy whose bundled UI contains the change under test, so run the proxy from your branch (an editable install serves the UI your checkout builds) -Some suites need extra services the bare proxy does not start. The `logging/` OTEL trace-completeness tests read spans back from a jaeger query API at `http://localhost:16686` (override with `E2E_OTEL_QUERY_URL`); run a `jaegertracing/all-in-one` and point `PHOENIX_COLLECTOR_HTTP_ENDPOINT` at its OTLP ingest. The `mcp/` suite needs the deterministic upstream MCP server in `mcp_tests/mcp_e2e_upstream_server.py` reachable by the proxy +Some suites need extra services the bare proxy does not start. The `logging/` OTEL trace-completeness tests read spans back from a jaeger query API at `http://localhost:16686` (override with `E2E_OTEL_QUERY_URL`); run a `jaegertracing/all-in-one` and point `PHOENIX_COLLECTOR_HTTP_ENDPOINT` at its OTLP ingest. The `mcp/` suite needs the deterministic upstream MCP server in `mcp_tests/mcp_e2e_upstream_server.py` reachable by the proxy. The presidio guardrail tests need a running Presidio analyzer and anonymizer the proxy can reach, addressed by `PRESIDIO_ANALYZER_API_BASE` / `PRESIDIO_ANONYMIZER_API_BASE` + +A couple of logging destinations are configured on the proxy rather than by the test. The Weave tests scope their callback to the key they create, but litellm builds the `weave_otel` logger from `WANDB_API_KEY` and `WANDB_PROJECT_ID` before it applies the per-key vars, so the proxy needs both in its own environment or the key-scoped callback never initializes and nothing ships ### Record and replay diff --git a/tests/e2e/guardrails/guardrails_client.py b/tests/e2e/guardrails/guardrails_client.py index f03e70df84a..1f55a0f9a56 100644 --- a/tests/e2e/guardrails/guardrails_client.py +++ b/tests/e2e/guardrails/guardrails_client.py @@ -18,6 +18,7 @@ from models import ( ChatBody, ChatMessage, ChatResponse, + ChatTool, KeyGenerateBody, LiteLLMParamsBody, TeamDeleteBody, @@ -31,6 +32,8 @@ from proxy_client import ProxyClient from pydantic import BaseModel GuardrailMode = Literal["pre_call", "post_call", "during_call", "logging_only"] +PiiEntity = Literal["EMAIL_ADDRESS", "PHONE_NUMBER", "PERSON", "CREDIT_CARD", "US_SSN"] +PiiAction = Literal["MASK", "BLOCK"] BlockedWordAction = Literal["BLOCK", "MASK"] @@ -81,6 +84,27 @@ class PresidioParamsBody(GuardrailParamsBase): presidio_filter_scope: Literal["input", "output", "both"] | None = None presidio_language: str | None = None output_parse_pii: bool | None = None + pii_entities_config: dict[PiiEntity, PiiAction] | None = None + + +class ToolPermissionRuleBody(BaseModel): + """One tool_permission rule: a decision for the tool named by `tool_name`.""" + + id: str + tool_name: str + decision: Literal["allow", "deny"] + + +class ToolPermissionParamsBody(GuardrailParamsBase): + """Tool-permission guardrail params. `default_action="deny"` makes the rules + an allow-list, and `on_disallowed_action="block"` turns a disallowed tool into + a 400 instead of rewriting the request; "rewrite" is a different product + promise and belongs to its own scenario.""" + + guardrail: Literal["tool_permission"] = "tool_permission" + rules: list[ToolPermissionRuleBody] + default_action: Literal["allow", "deny"] = "deny" + on_disallowed_action: Literal["block", "rewrite"] = "block" GuardrailParamsBody = ( @@ -89,6 +113,7 @@ GuardrailParamsBody = ( | OpenAIModerationParamsBody | BlockCodeExecutionParamsBody | PresidioParamsBody + | ToolPermissionParamsBody ) @@ -200,9 +225,7 @@ class GuardrailsClient: self.proxy.transport.post( "/guardrails", headers=self.proxy.transport.master, - json=GuardrailCreateBody( - guardrail=GuardrailSpecBody(guardrail_name=name, litellm_params=params) - ), + json=GuardrailCreateBody(guardrail=GuardrailSpecBody(guardrail_name=name, litellm_params=params)), response_type=GuardrailCreateResponse, ) ).guardrail_id @@ -241,9 +264,7 @@ class GuardrailsClient: ) def create_key_in_team(self, team_id: str) -> str: - return self.proxy.generate_key( - KeyGenerateBody(team_id=team_id, user_id="e2e-guardrails-user") - ) + return self.proxy.generate_key(KeyGenerateBody(team_id=team_id, user_id="e2e-guardrails-user")) def chat( self, @@ -253,6 +274,7 @@ class GuardrailsClient: *, guardrails: list[str] | None = None, max_tokens: int = 16, + tools: list[ChatTool] | None = None, ) -> Result[ChatResponse]: """Drive a chat call, optionally opting into named guardrails for this request only (the per-request `guardrails` selector). With `guardrails` @@ -266,6 +288,35 @@ class GuardrailsClient: messages=[ChatMessage(role="user", content=text)], max_tokens=max_tokens, guardrails=guardrails, + tools=tools, + ), + ) + + def chat_raw( + self, + key: str, + model: str, + text: str, + *, + guardrails: list[str] | None = None, + max_tokens: int = 16, + tools: list[ChatTool] | None = None, + tool_choice: str | None = None, + ) -> StreamingResponse: + """Drive /chat/completions returning the raw HTTP outcome, for the + assertions a typed body cannot carry: the `x-litellm-applied-guardrails` + response header, which is how an ALLOW scenario proves the guardrail ran + rather than being absent.""" + return self.proxy.transport.send( + "/chat/completions", + headers=self.proxy.transport.bearer(key), + json=ChatBody( + model=model, + messages=[ChatMessage(role="user", content=text)], + max_tokens=max_tokens, + guardrails=guardrails, + tools=tools, + tool_choice=tool_choice, ), ) @@ -323,9 +374,7 @@ class GuardrailsClient: return self.proxy.transport.send( "/v1/responses", headers=self.proxy.transport.bearer(key), - json=_ResponsesGuardrailBody( - model=model, input=text, guardrails=guardrails - ), + json=_ResponsesGuardrailBody(model=model, input=text, guardrails=guardrails), ) def apply_guardrail(self, key: str, *, name: str, text: str) -> Result[ApplyGuardrailResponse]: @@ -349,9 +398,7 @@ class GuardrailsClient: if isinstance(last, Success): return time.sleep(POLL_INTERVAL) - raise AssertionError( - f"team {team_id!r} was created but /team/info never returned it: {last}" - ) + raise AssertionError(f"team {team_id!r} was created but /team/info never returned it: {last}") def build_client(proxy: ProxyClient) -> GuardrailsClient: diff --git a/tests/e2e/guardrails/test_presidio_masking_e2e.py b/tests/e2e/guardrails/test_presidio_masking_e2e.py index 6d927292975..c6d87473c21 100644 --- a/tests/e2e/guardrails/test_presidio_masking_e2e.py +++ b/tests/e2e/guardrails/test_presidio_masking_e2e.py @@ -6,11 +6,19 @@ messages BEFORE the model runs, so the model only ever sees placeholders like must come back with the placeholders echoed and the raw PII absent, on /chat/completions and on /v1/messages (Anthropic format). +post_call: the mirror hook. The request reaches the model unmasked and the +MODEL OUTPUT is what gets anonymized, so the caller never receives raw PII the +model repeated back. The two hooks are told apart behaviorally rather than by +configuration: the post_call prompt asks for a value derived from the raw email +(its local part, which is not itself an entity Presidio masks) alongside the +address itself, so the answer proves the model saw the raw address while the +address in the same response comes back as . + The analyzer/anonymizer endpoints come from PRESIDIO_ANALYZER_API_BASE / PRESIDIO_ANONYMIZER_API_BASE; missing env is a hard failure, never a skip. -Each guardrail registers with presidio_filter_scope="input" so only the -configured hook's callback exists (the default "both" adds a second post_call -output masker), and is deleted on teardown. +Each guardrail registers with an explicit presidio_filter_scope so only the +configured hook's callback exists (the default "both" registers input masking +AND a post_call output masker), and is deleted on teardown. """ from __future__ import annotations @@ -18,13 +26,14 @@ from __future__ import annotations import os import time from collections.abc import Callable +from typing import Literal import pytest from pydantic import BaseModel from e2e_config import unique_marker from e2e_http import Result, Success -from guardrails_client import GuardrailsClient, PresidioParamsBody +from guardrails_client import GuardrailMode, GuardrailsClient, PiiAction, PiiEntity, PresidioParamsBody from lifecycle import ResourceManager from models import AnthropicMessagesResponse, ChatResponse @@ -65,16 +74,20 @@ def _register_presidio( resources: ResourceManager, *, name: str, + mode: GuardrailMode = "pre_call", + filter_scope: Literal["input", "output", "both"] = "input", + entities: dict[PiiEntity, PiiAction] | None = None, ) -> None: analyzer, anonymizer = _presidio_bases() guardrail_id = client.register( name, PresidioParamsBody( - mode="pre_call", + mode=mode, default_on=False, presidio_analyzer_api_base=analyzer, presidio_anonymizer_api_base=anonymizer, - presidio_filter_scope="input", + presidio_filter_scope=filter_scope, + pii_entities_config=entities, ), ) resources.defer(lambda: client.delete_guardrail(guardrail_id)) @@ -182,3 +195,84 @@ class TestPresidioPreCallMasking: _messages_text, email=email, ) + + +#: Room for the model's reasoning tokens plus the three-line answer; a lower cap +#: truncates the response before the address it is supposed to mask. +_POST_CALL_MAX_TOKENS = 512 + +#: The post_call scenario masks these two entities and nothing else. Left +#: unscoped, Presidio's broader recognizers claim the local part too (a random +#: marker reads as an NRP), which would erase the very token that tells output +#: masking apart from input masking. +_POST_CALL_ENTITIES: dict[PiiEntity, PiiAction] = {"EMAIL_ADDRESS": "MASK", "PHONE_NUMBER": "MASK"} + + +def _post_call_prompt(marker: str, local_part: str) -> str: + """Ask for the local part and the full address in one answer. Presidio masks + an EMAIL_ADDRESS entity and a bare local part is not one, so the two land + differently in the same response and pin the hook point behaviorally.""" + return ( + f"{marker} My email address is {local_part}@example.com and my phone number is {FAKE_PHONE}. " + "Reply with exactly three lines and nothing else. " + "Line 1: the part of the email address before the @ sign. " + "Line 2: the full email address. " + "Line 3: the phone number." + ) + + +class TestPresidioPostCallMasking: + @pytest.mark.covers( + "guardrail.presidio.post_call.masks", + exercised_on=["chat_completions"], + ) + def test_post_call_masks_pii_in_model_output( + self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str + ) -> None: + """A guardrail scoped to the output must anonymize the PII the model + repeats back, so a caller (or a downstream log of the response) never + receives it, while the request itself reaches the model untouched. + + Both facts are asserted from one response: the local part comes back raw, + which is only possible if the model saw the real address, and the address + itself comes back as in the same answer. + """ + name = f"e2e-presidio-post-chat-{unique_marker()}" + _register_presidio( + client, + resources, + name=name, + mode="post_call", + filter_scope="output", + entities=_POST_CALL_ENTITIES, + ) + + local_part = f"e2euser{unique_marker()}" + email = f"{local_part}@example.com" + prompt = _post_call_prompt(unique_marker(), local_part) + + deadline = time.monotonic() + GUARDRAIL_PROPAGATION_DEADLINE_SECONDS + last = "" + while True: + result = client.chat(scoped_key, MODEL, prompt, guardrails=[name], max_tokens=_POST_CALL_MAX_TOKENS) + match result: + case Success(data=data): + last = _first_content(data) + if MASKED_EMAIL_TOKEN in last and email not in last: + assert local_part in last, ( + "the model must have seen the RAW address (it is asked for the local " + "part, which Presidio does not mask); the local part is missing, so " + f"this response cannot tell post_call masking from pre_call: {last[:300]!r}" + ) + assert MASKED_PHONE_TOKEN in last and FAKE_PHONE not in last, ( + f"the phone number in the model's answer must be masked too, got: {last[:300]!r}" + ) + return + case _: + last = f"" + if time.monotonic() >= deadline: + pytest.fail( + f"presidio post_call guardrail never masked the model's output within " + f"{GUARDRAIL_PROPAGATION_DEADLINE_SECONDS}s; last observation: {last[:300]!r}" + ) + time.sleep(GUARDRAIL_PROPAGATION_POLL_INTERVAL_SECONDS) diff --git a/tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py b/tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py new file mode 100644 index 00000000000..9ef3650625c --- /dev/null +++ b/tests/e2e/guardrails/test_tool_permission_guardrail_e2e.py @@ -0,0 +1,167 @@ +"""Live e2e: the tool_permission guardrail gates which tools a request may declare. + +The guardrail is registered `mode="pre_call"` with `default_action="deny"`, so its +rules are an allow-list applied to the tools the CALLER declares, before the model +runs. Two halves of one product promise: + +- blocks: a request declaring a tool outside the allow-list is rejected with a 400 + naming the denied tool, and never reaches the model +- allows: a request declaring only the permitted tool is served normally, comes + back with a real tool call for that tool, and carries an + `x-litellm-applied-guardrails` header naming the guardrail, which is what + separates "the guardrail ran and allowed it" from "the guardrail was never + attached". `tool_choice="required"` keeps the model from answering directly and + making the outcome depend on its mood + +No vendor API is involved: `tool_permission` is a built-in guardrail, so the +verdict comes from the proxy itself. +""" + +from __future__ import annotations + +from typing import Final + +import pytest + +from e2e_config import unique_marker +from e2e_http import StreamingResponse, UnknownApiError +from guardrails_client import ( + GuardrailsClient, + ToolPermissionParamsBody, + ToolPermissionRuleBody, + poll_until_blocked, +) +from lifecycle import ResourceManager +from models import ChatResponse, ChatTool, ChatToolFunction + +pytestmark = pytest.mark.e2e + +MODEL = "gemini-2.5-flash" + +#: The one tool the guardrail permits, and one it does not. Both are declared by +#: the caller in the request body; the guardrail reads them there. +ALLOWED_TOOL: Final = ChatTool( + function=ChatToolFunction( + name="get_weather", + description="Get the current weather for a city", + parameters={ + "type": "object", + "properties": {"city": {"type": "string"}}, + "required": ["city"], + }, + ) +) +DENIED_TOOL: Final = ChatTool( + function=ChatToolFunction( + name="delete_customer_database", + description="Permanently delete the customer database", + parameters={"type": "object", "properties": {}}, + ) +) + +TOOL_PROMPT: Final = "What is the weather in Paris right now?" + + +def _register_tool_permission(client: GuardrailsClient, resources: ResourceManager, *, name: str) -> None: + """Allow-list exactly one tool: everything else falls to `default_action=deny` + and, with `on_disallowed_action=block`, is rejected outright.""" + guardrail_id = client.register( + name, + ToolPermissionParamsBody( + mode="pre_call", + default_on=False, + default_action="deny", + on_disallowed_action="block", + rules=[ + ToolPermissionRuleBody( + id="allow-get-weather", + tool_name=ALLOWED_TOOL.function.name, + decision="allow", + ) + ], + ), + ) + resources.defer(lambda: client.delete_guardrail(guardrail_id)) + + +def _applied_guardrails(outcome: StreamingResponse) -> str: + return outcome.headers.get("x-litellm-applied-guardrails", "") + + +def _tool_call_names(response: ChatResponse) -> tuple[str, ...]: + return tuple( + call.function.name + for choice in response.choices + if choice.message + for call in choice.message.tool_calls or () + if call.function.name + ) + + +class TestToolPermissionPreCall: + @pytest.mark.covers("guardrail.tool_permission.pre_call.blocks", exercised_on=["chat_completions"]) + def test_pre_call_blocks_tool_outside_the_allow_list( + self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str + ) -> None: + """A request declaring a tool the guardrail does not permit must be + rejected with a 400 that names the denied tool. An unauthorized tool that + merely reaches the model is the whole failure mode this guardrail exists + to prevent, so a 200 here is a hard failure.""" + name = f"e2e-toolperm-block-{unique_marker()}" + _register_tool_permission(client, resources, name=name) + + result = poll_until_blocked( + lambda: client.chat( + scoped_key, + MODEL, + TOOL_PROMPT, + guardrails=[name], + max_tokens=128, + tools=[DENIED_TOOL], + ) + ) + + match result: + case UnknownApiError(status_code=status, body=body): + assert status == 400, f"expected the guardrail block status 400, got {status}: {body[:400]}" + assert DENIED_TOOL.function.name in body, ( + f"the block must name the denied tool so the caller can fix the request; got: {body[:400]}" + ) + assert "guardrail" in body.lower(), ( + f"the block body should identify itself as a guardrail verdict; got: {body[:400]}" + ) + case _: + pytest.fail(f"tool_permission let a tool outside the allow-list through; got {result}") + + @pytest.mark.covers("guardrail.tool_permission.pre_call.allows", exercised_on=["chat_completions"]) + def test_pre_call_allows_permitted_tool( + self, client: GuardrailsClient, resources: ResourceManager, scoped_key: str + ) -> None: + """The mirror half: a request declaring only the permitted tool is served + and the model calls it. Without the header check a guardrail that never + attached would pass this test for the wrong reason, so the 200 alone is + not the contract.""" + name = f"e2e-toolperm-allow-{unique_marker()}" + _register_tool_permission(client, resources, name=name) + + outcome = client.chat_raw( + scoped_key, + MODEL, + TOOL_PROMPT, + guardrails=[name], + max_tokens=128, + tools=[ALLOWED_TOOL], + tool_choice="required", + ) + + assert outcome.ok, f"the permitted tool must be served, got {outcome.status_code}: {outcome.body[:400]}" + applied = _applied_guardrails(outcome) + assert name in applied, ( + "the allowed call must carry x-litellm-applied-guardrails naming the guardrail; " + f"without it the 200 only proves the guardrail never ran. Got {applied!r}" + ) + + called = _tool_call_names(ChatResponse.model_validate_json(outcome.body)) + assert called == (ALLOWED_TOOL.function.name,), ( + f"the served call must carry one tool call for the permitted tool, got {called!r}: {outcome.body[:400]}" + ) diff --git a/tests/e2e/logging/logging_client.py b/tests/e2e/logging/logging_client.py index f0f7ad7eaa4..66dfa233ec4 100644 --- a/tests/e2e/logging/logging_client.py +++ b/tests/e2e/logging/logging_client.py @@ -189,6 +189,45 @@ class LangfuseCreds: ) +@dataclass(frozen=True, slots=True) +class WeaveCreds: + """Weights & Biases Weave credentials for a key-scoped ``weave_otel`` callback. + + The proxy still needs WANDB_API_KEY / WANDB_PROJECT_ID in its own environment: + the weave_otel logger is constructed from those before the per-key vars are + applied, so a key-scoped callback on a proxy without them never initializes. + The per-key vars are what direct THIS key's spans at this project. + """ + + api_key: str + project_id: str + + def key_logging_metadata(self) -> KeyMetadata: + return KeyMetadata( + logging=[ + KeyLoggingCallback( + callback_name="weave_otel", + callback_type="success_and_failure", + callback_vars=KeyLoggingCallbackVars( + wandb_api_key=self.api_key, + weave_project_id=self.project_id, + ), + ) + ] + ) + + +def load_weave_creds() -> WeaveCreds: + api_key = os.getenv("WANDB_API_KEY") + project_id = (os.getenv("WEAVE_PROJECT_ID") or os.getenv("WANDB_PROJECT_ID") or "").strip() + if not (api_key and project_id): + pytest.fail( + "Weave e2e requires WANDB_API_KEY and WEAVE_PROJECT_ID (or WANDB_PROJECT_ID, " + "format /); missing credentials is a hard failure, not a skip" + ) + return WeaveCreds(api_key=api_key, project_id=project_id) + + def load_langfuse_creds() -> LangfuseCreds: public_key = os.getenv("LANGFUSE_PUBLIC_KEY") secret_key = os.getenv("LANGFUSE_SECRET_KEY") diff --git a/tests/e2e/logging/test_weave_log_e2e.py b/tests/e2e/logging/test_weave_log_e2e.py new file mode 100644 index 00000000000..dab5993c87a --- /dev/null +++ b/tests/e2e/logging/test_weave_log_e2e.py @@ -0,0 +1,192 @@ +"""Live e2e: key-scoped Weave (Weights & Biases) delivery, success and failure. + +Covers the two `logging.niche_integrations.*.logs_spend` cells with a real member +of that cohort. A key carrying a `weave_otel` callback in its logging metadata +must deliver its calls to the real Weave project, and each call must arrive +exactly once, carrying the same cost the response header reported: + +- success: one `litellm_request` call, OTEL status OK, `llm.response.cost` equal + to `x-litellm-response-cost`, and non-zero tokens +- failure: a provider-rejected call arrives too, as one call with OTEL status + ERROR naming the provider exception, and with no cost - a failed call that + silently never reaches the destination is an invisible outage, and a billed + one is worse + +Both halves assert the recorded state (the key's callback registration answers +success and the destination holds the call) and the enforced behavior (the +delivered payload's status and cost). Delivery is read back through Weave's own +query API; nothing is mocked. +""" + +from __future__ import annotations + +import time + +import pytest + +from e2e_config import CHEAP_ANTHROPIC_MODEL, unique_marker +from e2e_http import StreamingResponse +from lifecycle import ResourceManager +from logging_client import ( + INVALID_UPSTREAM_API_KEY, + LoggingClient, + WeaveCreds, + costs_agree, + first_ok, + load_weave_creds, +) +from models import LiteLLMParamsBody +from weave_reader import WeaveCall, WeaveReader, build_weave_reader + +pytestmark = pytest.mark.e2e + + +@pytest.fixture(scope="session") +def weave_creds() -> WeaveCreds: + return load_weave_creds() + + +@pytest.fixture(scope="session") +def weave_reader() -> WeaveReader: + return build_weave_reader() + + +#: How far before the request the Weave read-back window opens, to absorb clock +#: skew between this host and Weave. Without it a host running slightly fast +#: would filter out its own call. +_WINDOW_SKEW_SECONDS = 120.0 + + +def _window_start() -> float: + return time.time() - _WINDOW_SKEW_SECONDS + + +def _exactly_one(calls: tuple[WeaveCall, ...], *, marker: str, what: str) -> WeaveCall: + assert calls, f"no Weave call for the {what} (marker {marker}) reached the project within the deadline" + assert len(calls) == 1, ( + f"expected exactly ONE Weave call for the {what} (marker {marker}), got {len(calls)}: " + f"{[call.id for call in calls]} - more than one call for one request is the " + "duplicate-delivery bug" + ) + return calls[0] + + +WEAVE_STAGE_RED_REASON = ( + "stage red: product gap, key-scoped weave_otel spans are not delivered when the OTEL v2 callback is active" +) + + +class TestWeaveLogDelivery: + @pytest.mark.skip(reason=WEAVE_STAGE_RED_REASON) + @pytest.mark.covers("logging.niche_integrations.success.logs_spend", exercised_on=["chat_completions"]) + def test_chat_completions_delivers_one_call_with_spend( + self, + client: LoggingClient, + weave_creds: WeaveCreds, + weave_reader: WeaveReader, + resources: ResourceManager, + ) -> None: + alias = f"weave-key-{unique_marker()}" + key = client.key_with_alias( + alias, + models=[CHEAP_ANTHROPIC_MODEL], + metadata=weave_creds.key_logging_metadata(), + ) + resources.defer(lambda: client.delete_key(key)) + + marker = unique_marker() + since = _window_start() + outcome = first_ok( + client, + lambda: client.chat_raw(key, CHEAP_ANTHROPIC_MODEL, f"reply with one word {marker}", max_tokens=64), + ) + assert outcome.response_cost is not None and outcome.response_cost > 0, ( + f"the response must report x-litellm-response-cost, got {outcome.response_cost!r}" + ) + + call = _exactly_one( + weave_reader.poll_calls_matching(marker, since=since), marker=marker, what="successful call" + ) + + assert call.status_code == "OK", f"a successful call must land at OK span status, got {call.status_code!r}" + cost = call.response_cost + assert cost is not None and costs_agree(outcome.response_cost, cost), ( + f"the Weave call's llm.response.cost {cost!r} must agree with the header cost " + f"{outcome.response_cost} - a delivered span with the wrong cost is a silent " + "billing-attribution bug" + ) + assert call.total_tokens is not None and call.total_tokens > 0, ( + f"the delivered call must carry token usage, got {call.total_tokens!r}" + ) + + @pytest.mark.skip(reason=WEAVE_STAGE_RED_REASON) + @pytest.mark.covers("logging.niche_integrations.failure.logs_spend", exercised_on=["chat_completions"]) + def test_failed_chat_completions_delivers_one_error_call( + self, + client: LoggingClient, + weave_creds: WeaveCreds, + weave_reader: WeaveReader, + resources: ResourceManager, + ) -> None: + """A deployment with an invalid upstream key passes proxy auth and fails + at the provider, so exactly one provider failure exists for it. Proxy-side + 401s during key propagation never reach the provider and ship no payload, + which is what the retry loop below relies on.""" + model_name = f"weave-err-{unique_marker()}" + model_id = client.create_model( + model_name, + LiteLLMParamsBody(model="anthropic/claude-haiku-4-5", api_key=INVALID_UPSTREAM_API_KEY), + ) + resources.defer(lambda: client.delete_model(model_id)) + key = client.key_with_alias( + f"weave-err-key-{unique_marker()}", + models=[model_name], + metadata=weave_creds.key_logging_metadata(), + ) + resources.defer(lambda: client.delete_key(key)) + + marker = unique_marker() + since = _window_start() + outcome = _provoke_provider_failure(client, key, model_name, marker) + + call = _exactly_one(weave_reader.poll_calls_matching(marker, since=since), marker=marker, what="failed call") + + assert call.status_code == "ERROR", ( + f"a failed call must land at ERROR span status, got {call.status_code!r} - " + "Weave's own summary.weave.status reads success either way, which is exactly " + "why the span status is what this asserts on" + ) + error = call.error + assert error is not None and error.message is not None and "AnthropicException" in error.message, ( + f"the delivered call must carry the provider error, got {error!r}" + ) + assert not call.response_cost, f"a failed call must not be billed, got llm.response.cost={call.response_cost!r}" + assert outcome.status_code == 401, ( + f"an upstream auth failure must map to 401, got {outcome.status_code}: {outcome.body[:200]}" + ) + + +def _provoke_provider_failure(client: LoggingClient, key: str, model_name: str, marker: str) -> StreamingResponse: + """Send until the provider (not the proxy) is the one rejecting the call. + + A network failure between the test and the proxy is NOT retried: the request + may have been served, and a retry would double-log the failure payload and + falsely trip the exactly-one assertion. + """ + deadline = time.monotonic() + client.proxy.poll_timeout + while True: + outcome = client.chat_raw(key, model_name, f"trigger an upstream auth failure {marker}", max_tokens=16) + assert not outcome.ok, "the call must fail; the deployment's upstream key is invalid" + assert outcome.status_code != -1, ( + "network failure between the test and the proxy while provoking the provider failure; " + "retrying now could double-log the failure payload and falsely trip the exactly-one " + f"assertion - fix the rig connectivity first: {outcome.body[:200]}" + ) + if "AnthropicException" in outcome.body or time.monotonic() >= deadline: + break + time.sleep(client.proxy.poll_interval) + assert "AnthropicException" in outcome.body, ( + "never saw the upstream provider failure before the deadline; the key may still be " + f"propagating - last outcome {outcome.status_code}: {outcome.body[:200]}" + ) + return outcome diff --git a/tests/e2e/logging/weave_reader.py b/tests/e2e/logging/weave_reader.py new file mode 100644 index 00000000000..2f8f759d299 --- /dev/null +++ b/tests/e2e/logging/weave_reader.py @@ -0,0 +1,282 @@ +"""Read-back for the Weave (Weights & Biases) logging tests against the real +Weave project. + +The proxy ships OTEL spans to https://trace.wandb.ai/otel/v1/traces with the +``weave_otel`` callback, and the tests read the ingested calls back through +Weave's own query API (``POST /calls/stream_query``), which answers JSON Lines: +one JSON object per call, so the body is parsed line by line rather than as one +document. + +The project is shared with other traffic, so the read never relies on the target +being among the newest N calls: the query is scoped server-side to the +``litellm_request`` op and to calls that started after the test's own request, +and pages with ``offset`` until the window is exhausted. + +Weave's own ``summary.weave.status`` is a rollup that reads "success" even for a +span the exporter marked failed, so status comes from the OTEL span itself +(``attributes.otel_span.status.code``), and the shipped cost from +``attributes.otel_span.attributes.llm.response.cost`` - the StandardLogging +``response_cost``, which is what makes this a spend assertion rather than a +delivery ping. + +Missing configuration is a hard failure, never a skip. +""" + +from __future__ import annotations + +import base64 +import json +import os +import time +from dataclasses import dataclass +from itertools import count, takewhile +from typing import Final + +import pytest +from pydantic import BaseModel, ConfigDict, Field + +from e2e_config import POLL_INTERVAL, POLL_TIMEOUT +from e2e_http import URL, AuthHeaders, send + +_WEAVE_TRACE_API: Final = "https://trace.wandb.ai" + +#: The op every litellm LLM call lands under. The proxy also exports a root +#: server span ("Received Proxy Server Request") and management spans; only the +#: LLM call carries the usage and cost this suite asserts on. +LITELLM_REQUEST_OP: Final = "litellm_request" + +#: How long to keep re-reading after the first matching call before trusting the +#: exactly-one assertion. The OTEL batch exporter flushes on its own schedule, so +#: a duplicate export can surface well after the first one, and a duplicate IS +#: the bug being guarded against. +WEAVE_SETTLE_SECONDS: Final = 45.0 + +#: Rows per page. The query is already scoped to this run's time window, so this +#: only bounds one round trip, not what the read can see. +_PAGE_SIZE: Final = 500 + + +class _WeaveSortBy(BaseModel): + field: str + direction: str + + +class _WeaveOpFilter(BaseModel): + op_names: list[str] + + +class _WeaveGetField(BaseModel): + get_field: str = Field(serialization_alias="$getField") + + +class _WeaveLiteral(BaseModel): + literal: float = Field(serialization_alias="$literal") + + +class _WeaveGreaterThan(BaseModel): + gt: tuple[_WeaveGetField, _WeaveLiteral] = Field(serialization_alias="$gt") + + +class _WeaveQuery(BaseModel): + expr: _WeaveGreaterThan = Field(serialization_alias="$expr") + + +class _WeaveQueryBody(BaseModel): + project_id: str + filter: _WeaveOpFilter + query: _WeaveQuery + limit: int = _PAGE_SIZE + offset: int = 0 + sort_by: list[_WeaveSortBy] = [_WeaveSortBy(field="started_at", direction="asc")] + + +class _OtelStatus(BaseModel): + model_config = ConfigDict(extra="ignore") + + code: str | None = None + message: str | None = None + + +class _OtelError(BaseModel): + model_config = ConfigDict(extra="ignore") + + code: str | None = None + type: str | None = None + message: str | None = None + + +class _LlmResponse(BaseModel): + model_config = ConfigDict(extra="ignore") + + cost: float | None = None + + +class _LlmAttributes(BaseModel): + model_config = ConfigDict(extra="ignore") + + response: _LlmResponse | None = None + + +class _OtelSpanAttributes(BaseModel): + model_config = ConfigDict(extra="ignore") + + llm: _LlmAttributes | None = None + error: _OtelError | None = None + + +class _OtelSpan(BaseModel): + model_config = ConfigDict(extra="ignore") + + name: str | None = None + status: _OtelStatus | None = None + attributes: _OtelSpanAttributes | None = None + + +class _CallAttributes(BaseModel): + model_config = ConfigDict(extra="ignore") + + otel_span: _OtelSpan | None = None + + +class _Usage(BaseModel): + model_config = ConfigDict(extra="ignore") + + total_tokens: int | None = None + + +class _WeaveSummary(BaseModel): + model_config = ConfigDict(extra="ignore") + + usage: dict[str, _Usage] = {} + + +class WeaveCall(BaseModel): + """One ingested Weave call, reduced to what the scenarios assert on.""" + + model_config = ConfigDict(extra="ignore") + + id: str + op_name: str + started_at: str | None = None + inputs: dict[str, object] = {} + attributes: _CallAttributes | None = None + summary: _WeaveSummary | None = Field(default=None) + + @property + def op(self) -> str: + """The bare op name out of ``weave://///op/:``.""" + return self.op_name.split("/op/")[-1].split(":")[0] + + @property + def status_code(self) -> str | None: + """The OTEL span status, not Weave's own rollup (which reads "success" + even for a span the exporter marked ERROR).""" + span = self.attributes.otel_span if self.attributes else None + return span.status.code if span and span.status else None + + @property + def error(self) -> _OtelError | None: + span = self.attributes.otel_span if self.attributes else None + return span.attributes.error if span and span.attributes else None + + @property + def response_cost(self) -> float | None: + span = self.attributes.otel_span if self.attributes else None + llm = span.attributes.llm if span and span.attributes else None + return llm.response.cost if llm and llm.response else None + + @property + def total_tokens(self) -> int | None: + """Weave keys usage by model, so the total is summed across whatever + models the call reported.""" + if not self.summary or not self.summary.usage: + return None + totals = [usage.total_tokens for usage in self.summary.usage.values() if usage.total_tokens is not None] + return sum(totals) if totals else None + + def mentions(self, needle: str) -> bool: + return needle in json.dumps(self.inputs, default=str) + + +@dataclass(frozen=True, slots=True) +class WeaveReader: + project_id: str + api_key: str + + @property + def _headers(self) -> AuthHeaders: + """Weave authenticates with HTTP Basic as the fixed user ``api``.""" + token = base64.b64encode(f"api:{self.api_key}".encode()).decode() + return AuthHeaders(authorization=f"Basic {token}") + + def _query_body(self, *, since: float, offset: int, op: str) -> _WeaveQueryBody: + return _WeaveQueryBody( + project_id=self.project_id, + filter=_WeaveOpFilter(op_names=[f"weave:///{self.project_id}/op/{op}:*"]), + query=_WeaveQuery( + expr=_WeaveGreaterThan(gt=(_WeaveGetField(get_field="started_at"), _WeaveLiteral(literal=since))) + ), + offset=offset, + ) + + def _page(self, *, since: float, offset: int, op: str) -> tuple[WeaveCall, ...]: + outcome = send( + URL(f"{_WEAVE_TRACE_API}/calls/stream_query"), + headers=self._headers, + json=self._query_body(since=since, offset=offset, op=op), + ) + if not outcome.ok: + pytest.fail( + f"Weave calls query for project {self.project_id!r} failed " + f"({outcome.status_code}): {outcome.body[:300]}" + ) + return tuple(WeaveCall.model_validate_json(line) for line in outcome.body.splitlines() if line.strip()) + + def calls_matching(self, marker: str, *, since: float, op: str = LITELLM_REQUEST_OP) -> tuple[WeaveCall, ...]: + """Every call under ``op`` started after ``since`` whose inputs carry + ``marker``, paging until the window is exhausted. + + More than one is the duplicate-delivery bug, so this never collapses to a + single call. + """ + pages = tuple( + takewhile( + bool, + (self._page(since=since, offset=offset, op=op) for offset in count(0, _PAGE_SIZE)), + ) + ) + return tuple(call for page in pages for call in page if call.mentions(marker)) + + def poll_calls_matching(self, marker: str, *, since: float, op: str = LITELLM_REQUEST_OP) -> tuple[WeaveCall, ...]: + """Poll until the call is readable, then keep re-reading for + WEAVE_SETTLE_SECONDS so a duplicate exported by a later batch flush + cannot hide from the exactly-one assertion. A duplicate ends the settle + early, because more waiting cannot clear it.""" + deadline = time.monotonic() + POLL_TIMEOUT + while time.monotonic() < deadline: + calls = self.calls_matching(marker, since=since, op=op) + if calls: + return self._settled(marker, since=since, op=op, first=calls) + time.sleep(POLL_INTERVAL) + return () + + def _settled(self, marker: str, *, since: float, op: str, first: tuple[WeaveCall, ...]) -> tuple[WeaveCall, ...]: + """A transiently empty re-read never downgrades what was already seen.""" + settle_deadline = time.monotonic() + WEAVE_SETTLE_SECONDS + latest = first # rebind-ok: one settle window, re-read per poll interval + while time.monotonic() < settle_deadline and len(latest) <= 1: + time.sleep(POLL_INTERVAL) + latest = self.calls_matching(marker, since=since, op=op) or latest + return latest + + +def build_weave_reader() -> WeaveReader: + project_id = (os.environ.get("WEAVE_PROJECT_ID") or os.environ.get("WANDB_PROJECT_ID") or "").strip() + api_key = os.environ.get("WANDB_API_KEY", "").strip() + if not project_id or not api_key: + pytest.fail( + "Weave e2e requires WANDB_API_KEY and WEAVE_PROJECT_ID (or WANDB_PROJECT_ID, " + "format /): the test reads the proxy's weave_otel delivery " + "back from the real Weave project; missing credentials is a hard failure, not a skip" + ) + return WeaveReader(project_id=project_id, api_key=api_key) diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 5de49ead3ed..1379ecb4530 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -35,6 +35,8 @@ class KeyLoggingCallbackVars(BaseModel): langfuse_public_key: str | None = None langfuse_secret_key: str | None = None langfuse_host: str | None = None + wandb_api_key: str | None = None + weave_project_id: str | None = None class KeyLoggingCallback(BaseModel): From 8544faec91c398519b305dbf7850de87ec999486 Mon Sep 17 00:00:00 2001 From: "cursor[bot]" <206951365+cursor[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:04:31 -0700 Subject: [PATCH 264/410] fix(ci): grant pull_requests write for release wheel reporter (#39922) * fix(ci): grant pull_requests write for release wheel reporter The reporter posts a PR comment via github.rest.issues.createComment. GitHub requires both issues=write and pull_requests=write to comment on a PR issue, as returned in x-accepted-github-permissions. The workflow had pull-requests: read, so the POST failed with 403 'Resource not accessible by integration'. Bumping to pull-requests: write fixes the create path; the read-only pulls.get call still works. Same-repo scope is preserved by the existing head_repository.full_name check. Co-authored-by: Krrish Dholakia * fix(ci): scope release wheel reporter permissions to pull requests --------- Co-authored-by: Cursor Agent Co-authored-by: Krrish Dholakia Co-authored-by: Yujong Lee --- .github/workflows/report-rust-release-wheel.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/report-rust-release-wheel.yml b/.github/workflows/report-rust-release-wheel.yml index 1d93b56f77f..74e6be69604 100644 --- a/.github/workflows/report-rust-release-wheel.yml +++ b/.github/workflows/report-rust-release-wheel.yml @@ -24,8 +24,7 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 permissions: - issues: write # PR comments use the issues API - pull-requests: read # Current-head validation rejects stale workflow runs + pull-requests: write steps: - name: Link release wheel report on PR From 1c0172b477cfbc52c1a74ef55397a6531a999ced Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 13:04:33 -0700 Subject: [PATCH 265/410] style(e2e): annotate the cold-call locals as Final --- tests/e2e/llm_translation/test_cache_control.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/llm_translation/test_cache_control.py b/tests/e2e/llm_translation/test_cache_control.py index 4e11ad6cec5..7530485ad79 100644 --- a/tests/e2e/llm_translation/test_cache_control.py +++ b/tests/e2e/llm_translation/test_cache_control.py @@ -150,7 +150,7 @@ def _assert_cache_read_on_second_call( def _cold_cache_calls(send: Callable[[str], Result[ChatResponse]]) -> Iterator[ChatResponse]: for _ in range(VERTEX_COLD_CALL_ATTEMPTS): - result = send(_cacheable_prefix()) + result: Final = send(_cacheable_prefix()) match result: case UnknownApiError(status_code=400, body=body) if VERTEX_CACHE_REJECTION_MARKER in body: continue @@ -241,7 +241,7 @@ class TestCacheControl: ) resources.defer(lambda: client.proxy.delete_model(model_id)) key = resources.key() - completion = _first_cold_call_reads_cache( + completion: Final = _first_cold_call_reads_cache( model, lambda prefix: _cache_chat(client, key, model, prefix, ttl=VERTEX_CACHE_TTL) ) _assert_billed_below_uncached_prompt(client, model, completion) From b56e4f80a4e97b60c47b4f177ff22b170ec9fa30 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 13:05:03 -0700 Subject: [PATCH 266/410] test(e2e): make each cold cache call a single-assignment helper so its result stays Final --- .../e2e/llm_translation/test_cache_control.py | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/tests/e2e/llm_translation/test_cache_control.py b/tests/e2e/llm_translation/test_cache_control.py index 7530485ad79..a18e03c982b 100644 --- a/tests/e2e/llm_translation/test_cache_control.py +++ b/tests/e2e/llm_translation/test_cache_control.py @@ -30,7 +30,7 @@ built from the typed content blocks shared in ``endpoints_client.py``. from __future__ import annotations import time -from collections.abc import Callable, Iterator +from collections.abc import Callable from typing import Final import pytest @@ -148,22 +148,21 @@ def _assert_cache_read_on_second_call( ) -def _cold_cache_calls(send: Callable[[str], Result[ChatResponse]]) -> Iterator[ChatResponse]: - for _ in range(VERTEX_COLD_CALL_ATTEMPTS): - result: Final = send(_cacheable_prefix()) - match result: - case UnknownApiError(status_code=400, body=body) if VERTEX_CACHE_REJECTION_MARKER in body: - continue - case _: - yield unwrap(result) +def _cold_cache_call(send: Callable[[str], Result[ChatResponse]]) -> ChatResponse | None: + result: Final = send(_cacheable_prefix()) + match result: + case UnknownApiError(status_code=400, body=body) if VERTEX_CACHE_REJECTION_MARKER in body: + return None + case _: + return unwrap(result) def _first_cold_call_reads_cache(model: str, send: Callable[[str], Result[ChatResponse]]) -> ChatResponse: completion: Final = next( ( candidate - for candidate in _cold_cache_calls(send) - if _cached_read_tokens(candidate.usage) >= VERTEX_MINIMUM_CACHED_TOKENS + for candidate in (_cold_cache_call(send) for _ in range(VERTEX_COLD_CALL_ATTEMPTS)) + if candidate is not None and _cached_read_tokens(candidate.usage) >= VERTEX_MINIMUM_CACHED_TOKENS ), None, ) From 5df0e12e0f2628ed847c8110759a589f3fa1c138 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:08:03 -0700 Subject: [PATCH 267/410] feat(guardrails): add non-blocking flag() verdict to custom code guardrails (#39728) Custom code guardrails could only allow(), block(reason) or modify(). This adds flag(reason, metadata={}) which lets the request or response through unchanged and records a guardrail_flagged entry carrying the guardrail name, configured mode, evaluated input_type (request or response), reason and structured metadata. The new status is threaded through the request-level guardrail_status aggregation, the Guardrails Monitor rollup (flagged_count), Request Logs (action=flagged, most severe phase wins when a guardrail runs pre and post call) and the Request Logs detail view in the dashboard, which now renders FLAGGED with warning styling instead of falling into FAILED. Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/litellm_core_utils/litellm_logging.py | 2 + .../custom_code/custom_code_guardrail.py | 27 ++++++ .../guardrail_hooks/custom_code/primitives.py | 28 +++++- litellm/proxy/guardrails/usage_endpoints.py | 18 ++-- litellm/proxy/guardrails/usage_tracking.py | 6 +- litellm/types/utils.py | 5 +- .../test_litellm_logging.py | 18 +++- .../guardrails/test_custom_code_security.py | 65 +++++++++++++ .../proxy/guardrails/test_usage_endpoints.py | 97 +++++++++++++++++++ .../proxy/guardrails/test_usage_tracking.py | 21 ++++ .../custom_code/CustomCodeModal.tsx | 1 + .../GuardrailViewer/GuardrailViewer.test.tsx | 16 +++ .../GuardrailViewer/GuardrailViewer.tsx | 95 ++++++++++++------ .../LogDetailContent.test.tsx | 16 ++- .../LogDetailsDrawer/LogDetailContent.tsx | 31 +++--- 15 files changed, 386 insertions(+), 60 deletions(-) diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index 83e0b4d84f1..c31c4323157 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -5881,6 +5881,7 @@ def _get_status_fields( # Mapping for legacy guardrail status values to new GuardrailStatus values GUARDRAIL_STATUS_MAP: Final[dict[str, GuardrailStatus]] = { "success": "success", + "guardrail_flagged": "guardrail_flagged", "blocked": "guardrail_intervened", # legacy "guardrail_intervened": "guardrail_intervened", # direct "failure": "guardrail_failed_to_respond", # legacy @@ -5902,6 +5903,7 @@ def _get_status_fields( GUARDRAIL_STATUS_SEVERITY: Final[tuple[GuardrailStatus, ...]] = ( "not_run", "success", + "guardrail_flagged", "guardrail_failed_to_respond", "guardrail_intervened", ) diff --git a/litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py b/litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py index 830dec8d80d..d5ef1e949b8 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py +++ b/litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py @@ -36,6 +36,7 @@ Example: block when response rejects the user (input_type response only): import asyncio import threading +import time from collections.abc import Callable, Mapping from typing import TYPE_CHECKING, Any, Final, Literal, Optional, cast @@ -93,6 +94,7 @@ class CustomCodeGuardrail(CustomGuardrail): that returns one of: - allow() - let the request/response through - block(reason) - reject with a message + - flag(reason) - let it through but log a non-blocking violation - modify(texts=...) - transform the content Example: @@ -227,6 +229,7 @@ class CustomCodeGuardrail(CustomGuardrail): raise CustomCodeExecutionError(f"Custom code guardrail not compiled: {self._compile_error}") raise CustomCodeExecutionError("Custom code guardrail not compiled") + start_time: Final = time.time() try: # Prepare inputs dict for the function @@ -245,6 +248,7 @@ class CustomCodeGuardrail(CustomGuardrail): inputs=inputs, request_data=request_data, input_type=input_type, + start_time=start_time, ) except HTTPException: @@ -290,6 +294,7 @@ class CustomCodeGuardrail(CustomGuardrail): inputs: GenericGuardrailAPIInputs, request_data: dict[str, object], input_type: Literal["request", "response"], + start_time: float, ) -> GenericGuardrailAPIInputs: """ Process the result from the custom code function. @@ -299,6 +304,7 @@ class CustomCodeGuardrail(CustomGuardrail): inputs: The original inputs request_data: The request data input_type: "request" or "response" + start_time: Unix timestamp of when the guardrail started running, used for the flagged log entry Returns: GenericGuardrailAPIInputs - possibly modified @@ -348,6 +354,27 @@ class CustomCodeGuardrail(CustomGuardrail): }, ) + elif action == "flag": + flag_reason: Final = result.get("reason", "Flagged by custom code guardrail") + verbose_proxy_logger.info( + "Custom code guardrail '%s': Flagging %s - %s", self.guardrail_name, input_type, flag_reason + ) + end_time: Final = time.time() + self.add_standard_logging_guardrail_information_to_request_data( + guardrail_json_response={ # mutable-ok: logging helper requires a dict + "action": "flag", + "reason": flag_reason, + "input_type": input_type, + "metadata": result.get("metadata") or {}, # mutable-ok: logging helper requires a dict + }, + request_data=request_data, + guardrail_status="guardrail_flagged", + start_time=start_time, + end_time=end_time, + duration=end_time - start_time, + ) + return inputs + elif action == "modify": verbose_proxy_logger.debug("Custom code guardrail '%s': Modifying %s", self.guardrail_name, input_type) diff --git a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py index 24801aa2df1..d5dbfaeb84b 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py +++ b/litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py @@ -8,7 +8,7 @@ and provide safe, sandboxed functionality for common guardrail operations. import json import re from collections.abc import Mapping, Sequence -from typing import Final +from typing import Final, Literal from urllib.parse import urlparse import httpx @@ -51,6 +51,31 @@ def block(reason: str, detection_info: Mapping[str, object] | None = None) -> di return result +class FlagResult(TypedDict): + action: ReadOnly[Literal["flag"]] + reason: ReadOnly[str] + metadata: ReadOnly[Mapping[str, object]] + + +def flag(reason: str, metadata: Mapping[str, object] | None = None) -> FlagResult: + """ + Let the request/response proceed unchanged but record a non-blocking violation. + + Args: + reason: Human-readable reason for flagging + metadata: Optional structured metadata stored alongside the reason + + Returns: + Dict indicating the request should be flagged but allowed + """ + result: Final[FlagResult] = { + "action": "flag", + "reason": reason, + "metadata": metadata if metadata is not None else {}, + } + return result + + def modify( texts: Sequence[str] | None = None, images: Sequence[object] | None = None, @@ -787,6 +812,7 @@ def get_custom_code_primitives() -> dict[str, object]: # Result types "allow": allow, "block": block, + "flag": flag, "modify": modify, # Regex "regex_match": regex_match, diff --git a/litellm/proxy/guardrails/usage_endpoints.py b/litellm/proxy/guardrails/usage_endpoints.py index 62145b9ede9..014ba3d1472 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.proxy.guardrails.usage_tracking import guardrail_status_to_action from litellm.repositories.prisma_protocols import TableActions from litellm.repositories.table_repositories import ( DailyGuardrailMetricsRepository, @@ -41,6 +42,7 @@ if TYPE_CHECKING: router: Final = APIRouter() _EMPTY_UNITS: Final[Mapping[str, int]] = MappingProxyType({}) +_ACTION_SEVERITY: Final[Mapping[str, int]] = MappingProxyType({"passed": 0, "flagged": 1, "blocked": 2}) _T = TypeVar("_T") @@ -759,21 +761,17 @@ def _usage_log_entry_from_row( except Exception: meta = {} guardrail_info_list: Final[Sequence[_GuardrailRunInfo]] = (meta or {}).get("guardrail_information") or [] - entry_for_guardrail: _GuardrailRunInfo | None = None - for gi in guardrail_info_list: - if (gi.get("guardrail_id") or gi.get("guardrail_name")) == r.guardrail_id: - entry_for_guardrail = gi - break + entry_for_guardrail: Final[_GuardrailRunInfo | None] = max( + (gi for gi in guardrail_info_list if (gi.get("guardrail_id") or gi.get("guardrail_name")) == r.guardrail_id), + key=lambda gi: _ACTION_SEVERITY[guardrail_status_to_action(gi.get("guardrail_status"))], + default=None, + ) action_val = "passed" score_val = None latency_val = None reason_val = None if entry_for_guardrail: - st: Final = (entry_for_guardrail.get("guardrail_status") or "").lower() - if "intervened" in st or "block" in st: - action_val = "blocked" - elif "fail" in st or "error" in st: - action_val = "flagged" + action_val = guardrail_status_to_action(entry_for_guardrail.get("guardrail_status")) duration: Final = entry_for_guardrail.get("duration") if duration is not None: latency_val = round(float(duration) * 1000, 0) diff --git a/litellm/proxy/guardrails/usage_tracking.py b/litellm/proxy/guardrails/usage_tracking.py index a20ad3935e5..df967058cf0 100644 --- a/litellm/proxy/guardrails/usage_tracking.py +++ b/litellm/proxy/guardrails/usage_tracking.py @@ -190,14 +190,14 @@ async def _upsert_rows_with_retry( return await _upsert_rows_with_retry(retryable, upsert_row, label, sleep, retries_left - 1) -def _guardrail_status_to_action(status: str | None) -> str: +def guardrail_status_to_action(status: str | None) -> str: """Map StandardLogging guardrail_status to blocked/passed/flagged.""" if not status: return "passed" s: Final = (status or "").lower() if "intervened" in s or "block" in s: return "blocked" - if "fail" in s or "error" in s: + if "flagged" in s or "fail" in s or "error" in s: return "flagged" return "passed" @@ -367,7 +367,7 @@ async def process_spend_logs_guardrail_usage( continue key = _MetricsKey(guardrail_id, date_key) daily_guardrail[key]["requests_evaluated"] += 1 - action = _guardrail_status_to_action(entry.get("guardrail_status")) + action = guardrail_status_to_action(entry.get("guardrail_status")) if action == "passed": daily_guardrail[key]["passed_count"] += 1 elif action == "blocked": diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 2118fe77aad..61c2fc8c5a5 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -3078,7 +3078,9 @@ class GuardrailMode(TypedDict, total=False): default: str | list[str] | None -GuardrailStatus = Literal["success", "guardrail_intervened", "guardrail_failed_to_respond", "not_run"] +GuardrailStatus = Literal[ + "success", "guardrail_flagged", "guardrail_intervened", "guardrail_failed_to_respond", "not_run" +] # Fields on a guardrail record whose values can quote the caller's prompt: the payload sent to the # guardrail, the provider response that echoes it back, and the two first-party hooks that inline @@ -3320,6 +3322,7 @@ class StandardLoggingPayloadStatusFields(TypedDict, total=False): """ Status of guardrail execution: - 'success': Guardrail ran and allowed content through + - 'guardrail_flagged': Guardrail allowed content through but recorded a non-blocking violation - 'guardrail_intervened': Guardrail blocked or modified content - 'guardrail_failed_to_respond': Guardrail had technical failure - 'not_run': No guardrail was run diff --git a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py index 1991170707d..16a99713a06 100644 --- a/tests/test_litellm/litellm_core_utils/test_litellm_logging.py +++ b/tests/test_litellm/litellm_core_utils/test_litellm_logging.py @@ -16,7 +16,10 @@ from litellm._logging import session_id_var, trace_id_var from litellm.constants import SENTRY_DENYLIST, SENTRY_PII_DENYLIST from litellm.integrations.custom_logger import CustomLogger from litellm.litellm_core_utils.litellm_logging import Logging as LitellmLogging -from litellm.litellm_core_utils.litellm_logging import set_callbacks +from litellm.litellm_core_utils.litellm_logging import ( + _get_status_fields, + set_callbacks, +) from litellm.types.utils import ModelResponse, TextCompletionResponse @@ -6441,3 +6444,16 @@ def test_passthrough_embeddings_result_swapped_for_callbacks(): assert isinstance(swapped_result, EmbeddingResponse) assert swapped_result.data[0]["embedding"] == [0.1, 0.2, 0.3] + + +def test_get_status_fields_ranks_guardrail_flagged_between_success_and_intervened(): + """LIT-6894: a non-blocking flagged verdict must outrank success in the + request-level guardrail_status but never mask an intervention.""" + flagged = {"guardrail_status": "guardrail_flagged"} + + assert _get_status_fields( + "success", [{"guardrail_status": "success"}, flagged], None + )["guardrail_status"] == "guardrail_flagged" + assert _get_status_fields( + "success", [flagged, {"guardrail_status": "guardrail_intervened"}], None + )["guardrail_status"] == "guardrail_intervened" diff --git a/tests/test_litellm/proxy/guardrails/test_custom_code_security.py b/tests/test_litellm/proxy/guardrails/test_custom_code_security.py index f93ecfc3010..7971cf62c9a 100644 --- a/tests/test_litellm/proxy/guardrails/test_custom_code_security.py +++ b/tests/test_litellm/proxy/guardrails/test_custom_code_security.py @@ -197,6 +197,71 @@ async def test_custom_code_post_call_block_raises_http_400(): } +FLAG_CODE = ( + "def apply_guardrail(inputs, request_data, input_type):\n" + ' return flag("audit hit", metadata={"category": "topic"})\n' +) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("input_type", ["request", "response"]) +async def test_custom_code_flag_passes_content_through_and_records_flagged_entry(input_type): + """LIT-6894: flag() must not raise, must return the content unchanged and must log + exactly one guardrail_flagged entry (the decorator must not add a second "success").""" + guardrail = CustomCodeGuardrail(custom_code=FLAG_CODE, guardrail_name="t", event_hook=["pre_call", "post_call"]) + request_data = {"model": "test-model", "litellm_metadata": {}} + + result = await guardrail.apply_guardrail( + inputs={"texts": ["hello"]}, + request_data=request_data, + input_type=input_type, + ) + + assert result == {"texts": ["hello"]} + entries = request_data["litellm_metadata"]["standard_logging_guardrail_information"] + assert len(entries) == 1 + entry = entries[0] + assert entry["guardrail_status"] == "guardrail_flagged" + assert entry["guardrail_name"] == "t" + assert entry["guardrail_mode"] == ["pre_call", "post_call"] + assert entry["guardrail_response"] == { + "action": "flag", + "reason": "audit hit", + "input_type": input_type, + "metadata": {"category": "topic"}, + } + assert entry["duration"] is not None and entry["duration"] >= 0 + + +@pytest.mark.asyncio +async def test_custom_code_flag_default_reason_and_empty_metadata(): + code = "def apply_guardrail(inputs, request_data, input_type):\n return flag('just a note')\n" + guardrail = _compile(code) + request_data = {"model": "m", "litellm_metadata": {}} + + await guardrail.apply_guardrail(inputs={"texts": ["x"]}, request_data=request_data, input_type="request") + + entry = request_data["litellm_metadata"]["standard_logging_guardrail_information"][0] + assert entry["guardrail_response"] == { + "action": "flag", + "reason": "just a note", + "input_type": "request", + "metadata": {}, + } + + +@pytest.mark.asyncio +async def test_custom_code_allow_still_records_success_not_flagged(): + code = "def apply_guardrail(inputs, request_data, input_type):\n return allow()\n" + guardrail = _compile(code) + request_data = {"model": "m", "litellm_metadata": {}} + + await guardrail.apply_guardrail(inputs={"texts": ["x"]}, request_data=request_data, input_type="request") + + entries = request_data["litellm_metadata"]["standard_logging_guardrail_information"] + assert [e["guardrail_status"] for e in entries] == ["success"] + + def test_typical_sync_guardrail_still_works(): code = ( "def apply_guardrail(inputs, request_data, input_type):\n" diff --git a/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py b/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py index ebb2be6edc2..4e5a7ad4b2b 100644 --- a/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_usage_endpoints.py @@ -477,6 +477,103 @@ async def test_logs_resolves_config_guardrail_logical_name(): assert where["guardrail_id"] == {"in": ["yaml-uuid", "yaml-pii"]} +def _index_row(request_id: str, guardrail_id: str = "cc-flag") -> Any: + r = MagicMock(spec=["request_id", "guardrail_id", "policy_id", "start_time"]) + r.request_id = request_id + r.guardrail_id = guardrail_id + return r + + +def _spend_log(request_id: str, *guardrail_statuses: str, guardrail_id: str = "cc-flag") -> Any: + sl = MagicMock(spec=["request_id", "metadata", "startTime", "model", "messages", "response"]) + sl.request_id = request_id + sl.startTime = datetime(2026, 4, 25, 12, 0) + sl.model = "gpt-4o-mini" + sl.messages = [{"role": "user", "content": "hi"}] + sl.response = "ok" + sl.metadata = { + "guardrail_information": [ + { + "guardrail_name": guardrail_id, + "guardrail_status": status, + "guardrail_response": ( + {"action": "flag", "reason": "audit hit"} if status == "guardrail_flagged" else "allow" + ), + "duration": 0.002, + } + for status in guardrail_statuses + ] + } + return sl + + +@pytest.mark.asyncio +async def test_logs_reports_flagged_action_for_guardrail_flagged_status(): + """LIT-6894: Request Logs surface a custom code flag() verdict as flagged with its reason.""" + prisma = _prisma(index_find_many=[_index_row("r-flag"), _index_row("r-pass"), _index_row("r-block")]) + prisma.db.litellm_spendlogs.find_many = AsyncMock( + return_value=[ + _spend_log("r-flag", "guardrail_flagged"), + _spend_log("r-pass", "success"), + _spend_log("r-block", "guardrail_intervened"), + ] + ) + p1, p2 = _patches(prisma, _config_handler()) + with p1, p2: + resp = await guardrails_usage_logs( + guardrail_id="cc-flag", + policy_id=None, + page=1, + page_size=50, + action=None, + start_date=START, + end_date=END, + user_api_key_dict=ADMIN, + ) + flagged_only = await guardrails_usage_logs( + guardrail_id="cc-flag", + policy_id=None, + page=1, + page_size=50, + action="flagged", + start_date=START, + end_date=END, + user_api_key_dict=ADMIN, + ) + assert [(log.id, log.action) for log in resp.logs] == [ + ("r-flag", "flagged"), + ("r-pass", "passed"), + ("r-block", "blocked"), + ] + assert resp.logs[0].reason == "{'action': 'flag', 'reason': 'audit hit'}" + assert [log.id for log in flagged_only.logs] == ["r-flag"] + + +@pytest.mark.asyncio +async def test_logs_reports_post_call_flag_when_pre_call_allowed(): + """LIT-6894: a guardrail on mode [pre_call, post_call] that allows the request but flags the response + shows as flagged, not hidden behind the pre_call allow entry.""" + prisma = _prisma(index_find_many=[_index_row("r-post-flag")]) + prisma.db.litellm_spendlogs.find_many = AsyncMock( + return_value=[_spend_log("r-post-flag", "success", "guardrail_flagged")] + ) + p1, p2 = _patches(prisma, _config_handler()) + with p1, p2: + resp = await guardrails_usage_logs( + guardrail_id="cc-flag", + policy_id=None, + page=1, + page_size=50, + action=None, + start_date=START, + end_date=END, + user_api_key_dict=ADMIN, + ) + assert [(log.id, log.action, log.reason) for log in resp.logs] == [ + ("r-post-flag", "flagged", "{'action': 'flag', 'reason': 'audit hit'}") + ] + + # ---- date window cap (LIT-5762) --------------------------------------------- diff --git a/tests/test_litellm/proxy/guardrails/test_usage_tracking.py b/tests/test_litellm/proxy/guardrails/test_usage_tracking.py index ae360b281cb..110de7dbe70 100644 --- a/tests/test_litellm/proxy/guardrails/test_usage_tracking.py +++ b/tests/test_litellm/proxy/guardrails/test_usage_tracking.py @@ -105,6 +105,27 @@ async def test_usage_units_rolled_up_by_guardrail_team_key_and_date(): } +@pytest.mark.asyncio +async def test_flagged_status_counts_as_flagged_not_passed_or_blocked(): + """LIT-6894: a custom code flag() verdict lands in flagged_count on the Monitor rollup.""" + prisma = _prisma() + logs = [ + _payload("r1", guardrail_status="success"), + _payload("r2", guardrail_status="guardrail_flagged"), + _payload("r3", guardrail_status="guardrail_intervened"), + ] + + await process_spend_logs_guardrail_usage(prisma, logs) + + create = prisma.db.litellm_dailyguardrailmetrics.upsert.call_args.kwargs["data"]["create"] + assert (create["requests_evaluated"], create["passed_count"], create["flagged_count"], create["blocked_count"]) == ( + 3, + 1, + 1, + 1, + ) + + def _fake_sleep() -> tuple[AsyncMock, list[float]]: delays: list[float] = [] sleep = AsyncMock(side_effect=lambda delay: delays.append(delay)) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx index a69824f32d3..05a48598859 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/custom_code/CustomCodeModal.tsx @@ -112,6 +112,7 @@ const PRIMITIVES = { "Return Values": [ { name: "allow()", desc: "Let request/response through" }, { name: "block(reason)", desc: "Reject with message" }, + { name: "flag(reason, metadata={})", desc: "Let through, record a non-blocking violation" }, { name: "modify(texts=[], images=[], tool_calls=[])", desc: "Transform content" }, ], "HTTP Requests (async)": [ diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx index b5e04c72440..aabac50a661 100644 --- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.test.tsx @@ -33,6 +33,22 @@ describe("GuardrailViewer", () => { expect(screen.getByText("1235ms")).toBeInTheDocument(); }); + it("renders guardrail_flagged as FLAGGED (warning), not FAILED", () => { + const data = makeGuardrailInformation({ + guardrail_name: "cc-flag", + guardrail_status: "guardrail_flagged", + guardrail_provider: "custom_code", + }); + renderWithProviders(); + + expect(screen.getByText(/0 Passed/)).toBeInTheDocument(); + expect(screen.getByText(/1 Flagged/)).toBeInTheDocument(); + const badges = screen.getAllByText("FLAGGED"); + expect(badges.length).toBeGreaterThan(0); + expect(badges[0]).toHaveClass("text-warning"); + expect(screen.queryByText("FAILED")).not.toBeInTheDocument(); + }); + it("calculates and displays masked entity totals", async () => { const user = userEvent.setup(); const data = makeGuardrailInformation({ diff --git a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx index 863f4117510..271b8f6ce05 100644 --- a/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx @@ -133,8 +133,27 @@ const getTotalMasked = (entry: GuardrailInformation): number => { ); }; -const isEntrySuccess = (entry: GuardrailInformation): boolean => { - return (entry.guardrail_status ?? "").toLowerCase() === "success"; +type EntryOutcome = "passed" | "flagged" | "failed"; + +const getEntryOutcome = (entry: GuardrailInformation): EntryOutcome => { + const status = (entry.guardrail_status ?? "").toLowerCase(); + if (status === "success") return "passed"; + if (status === "guardrail_flagged") return "flagged"; + return "failed"; +}; + +const isEntrySuccess = (entry: GuardrailInformation): boolean => getEntryOutcome(entry) === "passed"; + +const OUTCOME_LABEL: Record = { + passed: "PASSED", + flagged: "FLAGGED", + failed: "FAILED", +}; + +const OUTCOME_BADGE_CLASS: Record = { + passed: "bg-success/15 text-success border border-success/20", + flagged: "bg-warning/15 text-warning border border-warning/20", + failed: "bg-destructive/15 text-destructive border border-destructive/20", }; const getRiskColor = (score: number): string => { @@ -202,6 +221,19 @@ const FailCircleIcon = ({ className }: { className?: string }) => ( ); +const FlagCircleIcon = ({ className }: { className?: string }) => ( + + + + +); + +const OutcomeIcon = ({ outcome }: { outcome: EntryOutcome }) => { + if (outcome === "passed") return ; + if (outcome === "flagged") return ; + return ; +}; + const PlayCircleIcon = () => ( @@ -318,8 +350,7 @@ interface TimelineEntry { type: "request" | "guardrail" | "llm" | "response"; label: string; offsetMs: number; - status?: string; - isSuccess?: boolean; + outcome?: EntryOutcome; } const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => { @@ -348,8 +379,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => { type: "guardrail", label: `Pre-call guardrail: ${getDisplayName(e)}`, offsetMs, - status: isEntrySuccess(e) ? "PASSED" : "FAILED", - isSuccess: isEntrySuccess(e), + outcome: getEntryOutcome(e), }); } @@ -372,8 +402,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => { type: "guardrail", label: `During-call guardrail: ${getDisplayName(e)}`, offsetMs, - status: isEntrySuccess(e) ? "PASSED" : "FAILED", - isSuccess: isEntrySuccess(e), + outcome: getEntryOutcome(e), }); } @@ -384,8 +413,7 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => { type: "guardrail", label: `Post-call guardrail: ${getDisplayName(e)}`, offsetMs, - status: isEntrySuccess(e) ? "PASSED" : "FAILED", - isSuccess: isEntrySuccess(e), + outcome: getEntryOutcome(e), }); } @@ -410,10 +438,8 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => { ) : item.type === "llm" ? ( - ) : item.isSuccess ? ( - ) : ( - + )}
    {idx < timeline.length - 1 &&
    } @@ -425,13 +451,11 @@ const RequestLifecycle = ({ entries }: { entries: GuardrailInformation[] }) => { {item.label} - {item.status && ( + {item.outcome && ( - {item.status} + {OUTCOME_LABEL[item.outcome]} )} T+{item.offsetMs}ms @@ -455,7 +479,7 @@ const formatGuardrailCost = (cost: number): string => { const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => { const [expanded, setExpanded] = useState(false); - const success = isEntrySuccess(entry); + const outcome = getEntryOutcome(entry); const totalMasked = getTotalMasked(entry); const displayName = getDisplayName(entry); const durationStr = formatDurationMs(entry.duration); @@ -490,7 +514,9 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => { onClick={() => setExpanded(!expanded)} > {/* Status icon */} -
    {success ? : }
    +
    + +
    {/* Name + badges */}
    @@ -501,13 +527,9 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => { - {success ? "PASSED" : "FAILED"} + {OUTCOME_LABEL[outcome]} {matchCountStr && ( @@ -528,7 +550,7 @@ const EvaluationCard = ({ entry }: { entry: GuardrailInformation }) => { )} - {riskScore != null && success && ( + {riskScore != null && outcome === "passed" && ( getEntryOutcome(e) === "flagged").length; const allPassed = passedCount === guardrailEntries.length; + const headerOutcome: EntryOutcome = allPassed + ? "passed" + : passedCount + flaggedCount === guardrailEntries.length + ? "flagged" + : "failed"; const totalOverheadMs = useMemo(() => { return Math.round(guardrailEntries.reduce((sum, e) => sum + (e.duration ?? 0), 0) * 1000); @@ -709,11 +737,7 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps) | {allPassed ? ( @@ -728,6 +752,13 @@ const GuardrailViewer = ({ data, accessToken, logEntry }: GuardrailViewerProps) ) : null} {passedCount} Passed + {flaggedCount > 0 && ( + + {flaggedCount} Flagged + + )}
    diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx index a679dc49427..2e9bce5048f 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.test.tsx @@ -1,7 +1,7 @@ import { render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; -import { LogDetailContent } from "./LogDetailContent"; +import { GuardrailJumpLink, LogDetailContent } from "./LogDetailContent"; import type { LogEntry } from "../columns"; vi.mock("../GuardrailViewer/GuardrailViewer", () => ({ @@ -489,3 +489,17 @@ describe("LogDetailContent", () => { expect(within(descriptions).getByText("-")).toBeInTheDocument(); }); }); + +describe("GuardrailJumpLink", () => { + it.each([ + [["success", "success"], "text-success", "\u2713"], + [["success", "guardrail_flagged"], "text-warning", "\u26A0"], + [["guardrail_flagged", "guardrail_intervened"], "text-destructive", "\u2717"], + ])("styles %j as %s", (statuses, expectedClass, glyph) => { + render( ({ guardrail_status: s }))} />); + + const pill = screen.getByText(/2 guardrails evaluated/); + expect(pill).toHaveClass(expectedClass); + expect(pill).toHaveTextContent(glyph); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx index 4c5c7b7b43f..052f1ec8802 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx @@ -635,11 +635,24 @@ function RequestResponseSection({ ); } +const GUARDRAIL_JUMP_LINK_STYLE = { + passed: { className: "border border-success/20 bg-success/10 text-success", glyph: "\u2713" }, + flagged: { className: "border border-warning/20 bg-warning/10 text-warning", glyph: "\u26A0" }, + failed: { className: "border border-destructive/20 bg-destructive/10 text-destructive", glyph: "\u2717" }, +} as const; + +const isPassedStatus = (status: unknown) => status === "pass" || status === "passed" || status === "success"; +const isFlaggedStatus = (status: unknown) => status === "flagged" || status === "guardrail_flagged"; + +const guardrailJumpLinkOutcome = (statuses: unknown[]): keyof typeof GUARDRAIL_JUMP_LINK_STYLE => { + if (statuses.every(isPassedStatus)) return "passed"; + if (statuses.every((s) => isPassedStatus(s) || isFlaggedStatus(s))) return "flagged"; + return "failed"; +}; + export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[] }) { - const allPassed = guardrailEntries.every((e) => { - const status = e?.guardrail_status || e?.status; - return status === "pass" || status === "passed" || status === "success"; - }); + const outcome = guardrailJumpLinkOutcome(guardrailEntries.map((e) => e?.guardrail_status || e?.status)); + const { className, glyph } = GUARDRAIL_JUMP_LINK_STYLE[outcome]; const handleClick = () => { const el = document.getElementById("guardrail-section"); @@ -650,11 +663,7 @@ export function GuardrailJumpLink({ guardrailEntries }: { guardrailEntries: any[
    - {allPassed ? "\u2713" : "\u2717"} {guardrailEntries.length} guardrail{guardrailEntries.length !== 1 ? "s" : ""}{" "} - evaluated + {glyph} {guardrailEntries.length} guardrail + {guardrailEntries.length !== 1 ? "s" : ""} evaluated {"\u2193"}
    From d6bc8fe289271457b733190f8abd9c261599b009 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 13:23:38 -0700 Subject: [PATCH 268/410] test(e2e): ask the streamed /v1/messages pin for a reply long enough to span several deltas Anthropic now returns the 64-token 'count to 20' reply in one to three content_block_delta events, measured directly against api.anthropic.com and through proxies at 7672399 and 49a1145 alike, so the incrementality assertion (at least two deltas) failed in litellm-e2e builds 125, 130 and the 278 rerun with no proxy change behind it. A 'count to 100' reply at max_tokens 400 arrived in five to fifty deltas across every measured run --- tests/e2e/llm_translation/test_messages_e2e.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/e2e/llm_translation/test_messages_e2e.py b/tests/e2e/llm_translation/test_messages_e2e.py index e0bedd72eac..a5f36a8cbdd 100644 --- a/tests/e2e/llm_translation/test_messages_e2e.py +++ b/tests/e2e/llm_translation/test_messages_e2e.py @@ -169,9 +169,9 @@ class TestAnthropicMessages: key, AnthropicMessagesBody( model=model, - max_tokens=64, + max_tokens=400, stream=True, - messages=[ChatMessage(role="user", content="Count from 1 to 20, one number per line.")], + messages=[ChatMessage(role="user", content="Count from 1 to 100, one number per line.")], ), ) require_successful_call(result) From 80839bb33c318851af40b125c54c262bbe5dc90f Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 13:26:09 -0700 Subject: [PATCH 269/410] feat(proxy): serve Prometheus /metrics from a separate process via --prometheus_metrics_port (#39889) * feat(proxy): serve Prometheus /metrics from a separate process via --prometheus_metrics_port Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(proxy): ruff format prometheus_metrics_server Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): fail fast when the separate metrics server cannot start and force the multiproc dir whenever it is enabled - wait for the child's /health before starting uvicorn; raise a ClickException if it exits first (port in use) - create PROMETHEUS_MULTIPROC_DIR whenever --prometheus_metrics_port is set, so DB-configured prometheus callbacks work - honour lowercase prometheus_multiproc_dir; validate the port before spawning - cover main() entry point, readiness, bind failure and wildcard-host probing in tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): pin metrics-server readiness to the child pid so another service on the port cannot pass the health check Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(proxy): probe metrics-server readiness through the shared HTTPHandler instead of bare httpx.get Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(proxy): serve only /metrics on the prometheus metrics port Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): validate metrics server CLI args with pydantic instead of typing.cast Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): satisfy metrics server lint gate Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- basedpyright-code-budget.json | 4 +- litellm/proxy/prometheus_metrics_server.py | 167 +++++++++++ litellm/proxy/proxy_cli.py | 90 ++++-- .../proxy/test_prometheus_cleanup.py | 40 +++ .../proxy/test_prometheus_metrics_server.py | 259 ++++++++++++++++++ tests/test_litellm/proxy/test_proxy_cli.py | 118 ++++++++ type-discipline-budget.json | 4 +- 7 files changed, 649 insertions(+), 33 deletions(-) create mode 100644 litellm/proxy/prometheus_metrics_server.py create mode 100644 tests/test_litellm/proxy/test_prometheus_metrics_server.py diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index b876cf2d69a..0b0a61192e6 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -105,13 +105,13 @@ "limit": 109 }, "reportUnknownMemberType": { - "limit": 38283 + "limit": 38271 }, "reportUnknownParameterType": { "limit": 19584 }, "reportUnknownVariableType": { - "limit": 29829 + "limit": 29814 }, "reportUnnecessaryCast": { "limit": 110 diff --git a/litellm/proxy/prometheus_metrics_server.py b/litellm/proxy/prometheus_metrics_server.py new file mode 100644 index 00000000000..4a9651d62e1 --- /dev/null +++ b/litellm/proxy/prometheus_metrics_server.py @@ -0,0 +1,167 @@ +"""Serve Prometheus `/metrics` from its own process so a scrape never runs on an inference worker. + +Workers write their samples to `PROMETHEUS_MULTIPROC_DIR`; this process reads them back with a +``MultiProcessCollector`` and serves the aggregated output on a separate port. The proxy CLI starts +it with ``--prometheus_metrics_port``. It can also run as a sidecar sharing the same directory: +``python -m litellm.proxy.prometheus_metrics_server --host 0.0.0.0 --port 4001``. +""" + +from __future__ import annotations + +import argparse +import atexit +import os +import subprocess +import sys +import threading +import time +from collections.abc import Sequence +from contextlib import closing +from types import MappingProxyType +from typing import Final + +import httpx +from fastapi import FastAPI +from prometheus_client import CollectorRegistry, multiprocess +from pydantic import BaseModel, ConfigDict +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from litellm.integrations.prometheus_metrics_endpoint import make_metrics_asgi_app +from litellm.llms.custom_httpx.http_handler import HTTPHandler + +METRICS_PATH: Final = "/metrics" +PID_HEADER: Final = "x-litellm-metrics-pid" +_PARENT_POLL_INTERVAL_SECONDS: Final = 1.0 +_STARTUP_TIMEOUT_SECONDS: Final = 30.0 +_STARTUP_POLL_INTERVAL_SECONDS: Final = 0.1 +_STARTUP_PROBE_TIMEOUT_SECONDS: Final = 1.0 +_WILDCARD_TO_LOOPBACK: Final = MappingProxyType({"0.0.0.0": "127.0.0.1", "::": "::1"}) + + +class _CliArgs(BaseModel): + model_config = ConfigDict(frozen=True) + + host: str + port: int + multiproc_dir: str | None + + +class MetricsServerStartupError(RuntimeError): + """The metrics process died or never answered on its port before the proxy started serving.""" + + +def _add_pid_header(app: ASGIApp) -> ASGIApp: + async def app_with_pid(scope: Scope, receive: Receive, send: Send) -> None: + async def send_with_pid(message: Message) -> None: + if message["type"] == "http.response.start": + await send( + { + **message, + "headers": [ + *message["headers"], + (PID_HEADER.encode(), str(os.getpid()).encode()), + ], + } + ) + return + await send(message) + + await app(scope, receive, send_with_pid) + + return app_with_pid + + +def build_metrics_app(multiproc_dir: str) -> FastAPI: + registry: Final = CollectorRegistry() + multiprocess.MultiProcessCollector(registry, path=multiproc_dir) + app: Final = FastAPI(title="LiteLLM Prometheus metrics", docs_url=None, redoc_url=None, openapi_url=None) + app.mount(METRICS_PATH, _add_pid_header(make_metrics_asgi_app(registry))) + + return app + + +def _exit_when_parent_dies(parent_pid: int) -> None: + def watch() -> None: + while os.getppid() == parent_pid: + time.sleep(_PARENT_POLL_INTERVAL_SECONDS) + os._exit(0) + + threading.Thread(target=watch, name="litellm-metrics-parent-watchdog", daemon=True).start() + + +def run_metrics_server(host: str, port: int, multiproc_dir: str) -> None: + import uvicorn + + _exit_when_parent_dies(os.getppid()) + uvicorn.run(build_metrics_app(multiproc_dir), host=host, port=port, log_level="warning", access_log=False) + + +def metrics_url(host: str, port: int) -> str: + probe_host: Final = _WILDCARD_TO_LOOPBACK.get(host, host) + netloc: Final = f"[{probe_host}]" if ":" in probe_host else probe_host + return f"http://{netloc}:{port}{METRICS_PATH}" + + +def _answered_by(http: HTTPHandler, url: str, pid: int) -> bool: + """True only when the metrics response comes from our child, not from whatever else holds the port.""" + try: + response: Final = http.get(url) # pyright: ignore[reportUnknownMemberType] # HTTPHandler.get exposes untyped optional mappings + return response.status_code == 200 and response.headers.get(PID_HEADER) == str(pid) + except httpx.TransportError: + return False + + +def _wait_until_serving(process: subprocess.Popen[bytes], host: str, port: int) -> None: + url: Final = metrics_url(host, port) + deadline: Final = time.monotonic() + _STARTUP_TIMEOUT_SECONDS + with closing(HTTPHandler(timeout=_STARTUP_PROBE_TIMEOUT_SECONDS)) as http: + while time.monotonic() < deadline: + if (returncode := process.poll()) is not None: + raise MetricsServerStartupError( + f"Prometheus metrics server exited with code {returncode} before serving {host}:{port}; " + "is the port already in use?" + ) + if _answered_by(http, url, process.pid): + return + time.sleep(_STARTUP_POLL_INTERVAL_SECONDS) + process.terminate() + raise MetricsServerStartupError( + f"Prometheus metrics server did not answer {url} within {_STARTUP_TIMEOUT_SECONDS:.0f}s" + ) + + +def start_metrics_server_process(host: str, port: int, multiproc_dir: str) -> subprocess.Popen[bytes]: + """Spawn the metrics server next to the proxy and block until it answers on its port.""" + process: Final = subprocess.Popen( + ( + sys.executable, + "-m", + "litellm.proxy.prometheus_metrics_server", + "--host", + host, + "--port", + str(port), + "--multiproc_dir", + multiproc_dir, + ) + ) + atexit.register(process.terminate) + _wait_until_serving(process, host, port) + return process + + +def main(argv: Sequence[str] | None = None) -> None: + parser: Final = argparse.ArgumentParser( + description="Serve LiteLLM Prometheus metrics from PROMETHEUS_MULTIPROC_DIR" + ) + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--port", type=int, required=True) + parser.add_argument("--multiproc_dir", default=os.environ.get("PROMETHEUS_MULTIPROC_DIR")) + args: Final = _CliArgs.model_validate(vars(parser.parse_args(argv))) + if not args.multiproc_dir: + parser.error("--multiproc_dir or PROMETHEUS_MULTIPROC_DIR is required") + run_metrics_server(host=args.host, port=args.port, multiproc_dir=args.multiproc_dir) + + +if __name__ == "__main__": + main() diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index e780beb4410..e245367b1b4 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -7,7 +7,7 @@ import re import subprocess import sys import urllib.parse as urlparse -from collections.abc import Iterable +from collections.abc import Iterable, Mapping, Sequence from pathlib import Path from typing import TYPE_CHECKING, Any, Final @@ -610,48 +610,49 @@ class ProxyInitializationHelpers: return None # Let uvicorn choose the default loop on Windows return "uvloop" + @staticmethod + def _prometheus_callback_configured(litellm_settings: Mapping[str, object] | None) -> bool: + if litellm_settings is None: + return False + configured: Final = tuple( + litellm_settings.get(key) for key in ("callbacks", "success_callback", "failure_callback") + ) + return any( + setting == "prometheus" + if isinstance(setting, str) + else isinstance(setting, Sequence) and "prometheus" in setting + for setting in configured + ) + @staticmethod def _maybe_setup_prometheus_multiproc_dir( num_workers: int, litellm_settings: dict | None, - ) -> None: + prometheus_metrics_port: int | None = None, + ) -> str | None: """ - Auto-create PROMETHEUS_MULTIPROC_DIR when running with multiple workers - and prometheus is configured as a callback. + Auto-create PROMETHEUS_MULTIPROC_DIR when another process needs to read the samples: extra workers + with prometheus configured as a callback in config.yaml, or the separate metrics server (always, since + callbacks may also be enabled from the DB after startup). """ import tempfile - if num_workers <= 1 or litellm_settings is None: - return - - # Check if prometheus is in any callback list - # Each setting can be a list or a single string; normalize to list - callbacks = litellm_settings.get("callbacks") or [] - success_callbacks = litellm_settings.get("success_callback") or [] - failure_callbacks = litellm_settings.get("failure_callback") or [] - if isinstance(callbacks, str): - callbacks = [callbacks] - if isinstance(success_callbacks, str): - success_callbacks = [success_callbacks] - if isinstance(failure_callbacks, str): - failure_callbacks = [failure_callbacks] - all_callbacks: Final = callbacks + success_callbacks + failure_callbacks - if "prometheus" not in all_callbacks: - return + if prometheus_metrics_port is None and ( + num_workers <= 1 or not ProxyInitializationHelpers._prometheus_callback_configured(litellm_settings) + ): + return None from litellm.proxy.prometheus_cleanup import wipe_directory - multiproc_dir = os.environ.get("PROMETHEUS_MULTIPROC_DIR") or os.environ.get("prometheus_multiproc_dir") - - auto_created: Final = not multiproc_dir - if not multiproc_dir: - multiproc_dir = os.path.join(tempfile.gettempdir(), "litellm_prometheus_multiproc") - os.environ["PROMETHEUS_MULTIPROC_DIR"] = multiproc_dir + configured_dir: Final = os.environ.get("PROMETHEUS_MULTIPROC_DIR") or os.environ.get("prometheus_multiproc_dir") + multiproc_dir: Final = configured_dir or os.path.join(tempfile.gettempdir(), "litellm_prometheus_multiproc") + os.environ["PROMETHEUS_MULTIPROC_DIR"] = multiproc_dir os.makedirs(multiproc_dir, exist_ok=True) wipe_directory(multiproc_dir) - action: Final = "Auto-created" if auto_created else "Using existing" + action: Final = "Using existing" if configured_dir else "Auto-created" print(f"LiteLLM: {action} PROMETHEUS_MULTIPROC_DIR={multiproc_dir}") + return multiproc_dir @click.command() @@ -930,6 +931,19 @@ class ProxyInitializationHelpers: default=False, help="Enable uvicorn hot reload (dev only). Also reloads when the --config YAML file changes. Incompatible with --num_workers>1, --run_gunicorn, and --run_hypercorn.", ) +@click.option( + "--prometheus_metrics_port", + default=None, + type=click.IntRange(min=1, max=65535), + help=( + "Serve Prometheus /metrics from a separate process on this port (bound to --host) so scraping and " + "multi-worker aggregation never run on an inference worker's event loop. Samples appear once the " + "`prometheus` callback is enabled (config.yaml or DB). /metrics stays mounted on the main port as well; " + "the separate port has no virtual-key auth, so keep it off public ingress. Startup fails if the metrics " + "server cannot bind." + ), + envvar="PROMETHEUS_METRICS_PORT", +) def run_server( cli_args, host, @@ -980,6 +994,7 @@ def run_server( enforce_prisma_migration_check: bool, use_v2_migration_resolver: bool, reload: bool, + prometheus_metrics_port: int | None, ): if cli_args: if cli_args == ("xai-oauth", "login"): @@ -1364,6 +1379,8 @@ def run_server( ) if port == 4000 and ProxyInitializationHelpers._is_port_in_use(port): port = random.randint(1024, 49152) + if prometheus_metrics_port == port: + raise click.UsageError("--prometheus_metrics_port must differ from --port") import litellm @@ -1374,9 +1391,10 @@ def run_server( from litellm.proxy.proxy_server import app # Auto-create PROMETHEUS_MULTIPROC_DIR for multi-worker setups - ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir( + prometheus_multiproc_dir: Final = ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir( num_workers=num_workers, litellm_settings=litellm_settings if config else None, + prometheus_metrics_port=prometheus_metrics_port, ) # Skip server startup if requested (after all setup is done) @@ -1384,6 +1402,20 @@ def run_server( print("LiteLLM: Setup complete. Skipping server startup as requested.") return + if prometheus_metrics_port is not None and prometheus_multiproc_dir is not None: + from litellm.proxy.prometheus_metrics_server import MetricsServerStartupError, start_metrics_server_process + + try: + metrics_process: Final = start_metrics_server_process( + host=host, port=prometheus_metrics_port, multiproc_dir=prometheus_multiproc_dir + ) + except MetricsServerStartupError as error: + raise click.ClickException(str(error)) from error + print( + f"\033[1;32mLiteLLM: Serving Prometheus metrics on {host}:{prometheus_metrics_port}/metrics " + f"(pid {metrics_process.pid})\033[0m" + ) + running_uvicorn: Final = run_gunicorn is False and run_hypercorn is False uvicorn_args: Final = ProxyInitializationHelpers._get_default_unvicorn_init_args( host=host, diff --git a/tests/test_litellm/proxy/test_prometheus_cleanup.py b/tests/test_litellm/proxy/test_prometheus_cleanup.py index ca5476d6af9..93b9b694c2c 100644 --- a/tests/test_litellm/proxy/test_prometheus_cleanup.py +++ b/tests/test_litellm/proxy/test_prometheus_cleanup.py @@ -131,3 +131,43 @@ class TestMaybeSetupPrometheusMultiprocDir: # Cleanup os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None) + + @pytest.mark.parametrize( + "litellm_settings", + [ + {"callbacks": ["prometheus"]}, + {"callbacks": ["langfuse"]}, + None, + ], + ) + def test_separate_metrics_port_forces_dir_for_single_worker(self, litellm_settings): + """The separate metrics process reads the samples, so one worker still needs the shared dir, even when + prometheus is not in config.yaml (callbacks can be turned on from the DB after startup).""" + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None) + os.environ.pop("prometheus_multiproc_dir", None) + + result_dir = ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir( + num_workers=1, + litellm_settings=litellm_settings, + prometheus_metrics_port=4001, + ) + + assert result_dir is not None + assert os.environ.get("PROMETHEUS_MULTIPROC_DIR") == result_dir + assert os.path.isdir(result_dir) + + os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None) + + def test_lowercase_env_var_is_reused_and_exported_uppercase(self, tmp_path): + """prometheus_client honours both spellings; the metrics server only reads the uppercase one.""" + with patch.dict(os.environ, {"prometheus_multiproc_dir": str(tmp_path)}, clear=False): + os.environ.pop("PROMETHEUS_MULTIPROC_DIR", None) + + result_dir = ProxyInitializationHelpers._maybe_setup_prometheus_multiproc_dir( + num_workers=4, + litellm_settings={"callbacks": "prometheus"}, + ) + + assert result_dir == str(tmp_path) + assert os.environ["PROMETHEUS_MULTIPROC_DIR"] == str(tmp_path) diff --git a/tests/test_litellm/proxy/test_prometheus_metrics_server.py b/tests/test_litellm/proxy/test_prometheus_metrics_server.py new file mode 100644 index 00000000000..fc1fa381fa4 --- /dev/null +++ b/tests/test_litellm/proxy/test_prometheus_metrics_server.py @@ -0,0 +1,259 @@ +"""The separate metrics server must aggregate PROMETHEUS_MULTIPROC_DIR, expose only /metrics, and follow its +parent's lifetime. + +Everything here runs on loopback against a child of this test process; no LLM keys or external network. +""" + +from __future__ import annotations + +import os +import socket +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Final +from unittest.mock import patch + +import httpx +import pytest +from fastapi.testclient import TestClient +from prometheus_client import values + +from litellm.proxy.prometheus_metrics_server import ( + PID_HEADER, + MetricsServerStartupError, + build_metrics_app, + main, + metrics_url, + start_metrics_server_process, +) + +_STARTUP_TIMEOUT_SECONDS: Final = 60.0 +_SHUTDOWN_TIMEOUT_SECONDS: Final = 15.0 + + +def _write_worker_sample(pid: int, value: float) -> None: + """Write one counter sample into PROMETHEUS_MULTIPROC_DIR the way a proxy worker would.""" + counter: Final = values.MultiProcessValue(process_identifier=lambda: pid)( + "counter", + "litellm_requests_metric_total", + "litellm_requests_metric_total", + ("model",), + ("gpt-5",), + "Total number of LLM calls", + ) + counter.inc(value) + + +def _free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +def _wait_for_metrics(port: int, pid: int) -> httpx.Response: + deadline: Final = time.monotonic() + _STARTUP_TIMEOUT_SECONDS + while time.monotonic() < deadline: + try: + response: Final = httpx.get(f"http://127.0.0.1:{port}/metrics", follow_redirects=True, timeout=1.0) + if response.status_code == 200 and response.headers.get(PID_HEADER) == str(pid): + return response + except httpx.TransportError: + pass + time.sleep(0.2) + raise AssertionError(f"metrics server on port {port} never served metrics") + + +def _wait_until_down(port: int) -> None: + deadline: Final = time.monotonic() + _SHUTDOWN_TIMEOUT_SECONDS + while time.monotonic() < deadline: + try: + httpx.get(f"http://127.0.0.1:{port}/metrics", timeout=1.0) + except httpx.TransportError: + return + time.sleep(0.2) + raise AssertionError(f"metrics server on port {port} kept running after its parent died") + + +def test_metrics_app_aggregates_multiproc_dir_and_reports_pid(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", str(tmp_path)) + _write_worker_sample(pid=1001, value=2) + _write_worker_sample(pid=1002, value=3) + other_dir: Final = tmp_path / "other" + other_dir.mkdir() + + client: Final = TestClient(build_metrics_app(str(tmp_path))) + metrics: Final = client.get("/metrics") + assert metrics.status_code == 200 + assert metrics.headers[PID_HEADER] == str(os.getpid()) + assert 'litellm_requests_metric_total{model="gpt-5"} 5.0' in metrics.text + + assert client.get("/health").status_code == 404 + + empty: Final = TestClient(build_metrics_app(str(other_dir))).get("/metrics") + assert empty.status_code == 200 + assert "litellm_requests_metric_total" not in empty.text + + +@pytest.mark.parametrize( + ("host", "expected"), + ( + ("0.0.0.0", "http://127.0.0.1:4001/metrics"), + ("::", "http://[::1]:4001/metrics"), + ("10.1.2.3", "http://10.1.2.3:4001/metrics"), + ("metrics.internal", "http://metrics.internal:4001/metrics"), + ), +) +def test_metrics_url_probes_loopback_for_wildcard_binds(host: str, expected: str): + assert metrics_url(host, 4001) == expected + + +def test_main_serves_the_app_for_the_given_dir_with_uvicorn(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", str(tmp_path)) + _write_worker_sample(pid=2001, value=6) + with patch("uvicorn.run") as run: + main(["--host", "10.1.2.3", "--port", "4001", "--multiproc_dir", str(tmp_path)]) + + run.assert_called_once() + assert run.call_args.kwargs["host"] == "10.1.2.3" + assert run.call_args.kwargs["port"] == 4001 + client: Final = TestClient(run.call_args.args[0]) + metrics: Final = client.get("/metrics") + assert metrics.status_code == 200 + assert metrics.headers[PID_HEADER] == str(os.getpid()) + assert 'litellm_requests_metric_total{model="gpt-5"} 6.0' in client.get("/metrics").text + + +def test_main_falls_back_to_env_multiproc_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", str(tmp_path)) + with patch("uvicorn.run") as run: + main(["--port", "4001"]) + + (app,), served_on = run.call_args + assert served_on["host"] == "0.0.0.0" + metrics: Final = TestClient(app).get("/metrics") + assert metrics.status_code == 200 + assert metrics.headers[PID_HEADER] == str(os.getpid()) + + +def test_main_rejects_missing_multiproc_dir(monkeypatch: pytest.MonkeyPatch): + monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False) + with patch("uvicorn.run") as run, pytest.raises(SystemExit) as exit_info: + main(["--port", "4001"]) + + assert exit_info.value.code == 2 + run.assert_not_called() + + +def test_start_metrics_server_process_returns_only_once_child_serves(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", str(tmp_path)) + _write_worker_sample(pid=3001, value=4) + port: Final = _free_port() + with patch("atexit.register") as register: + process: Final = start_metrics_server_process(host="127.0.0.1", port=port, multiproc_dir=str(tmp_path)) + try: + register.assert_called_once_with(process.terminate) + assert process.poll() is None + startup_metrics: Final = httpx.get(f"http://127.0.0.1:{port}/metrics", follow_redirects=True, timeout=5.0) + assert startup_metrics.status_code == 200 + assert startup_metrics.headers[PID_HEADER] == str(process.pid) + metrics: Final = httpx.get(f"http://127.0.0.1:{port}/metrics", follow_redirects=True, timeout=10.0) + assert 'litellm_requests_metric_total{model="gpt-5"} 4.0' in metrics.text + finally: + process.kill() + process.wait(timeout=10) + + +def test_start_metrics_server_process_fails_when_port_is_taken(tmp_path: Path): + with socket.socket() as occupied: + occupied.bind(("127.0.0.1", 0)) + occupied.listen() + port: Final = occupied.getsockname()[1] + with ( + patch("atexit.register"), + pytest.raises( + MetricsServerStartupError, match=rf"exited with code [1-9]\d* before serving 127.0.0.1:{port}" + ), + ): + start_metrics_server_process(host="127.0.0.1", port=port, multiproc_dir=str(tmp_path)) + + +class _ImpostorMetrics(BaseHTTPRequestHandler): + """An unrelated service already on the port that answers /metrics with 200 and plausible metrics.""" + + def do_GET(self) -> None: + body: Final = b"# HELP impostor_metric A plausible metric\n# TYPE impostor_metric counter\nimpostor_metric 1\n" + self.send_response(200) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(body) + + def log_message(self, format: str, *args: object) -> None: + return + + +def test_start_metrics_server_process_rejects_metrics_from_another_service_on_the_port(tmp_path: Path): + with ThreadingHTTPServer(("127.0.0.1", 0), _ImpostorMetrics) as impostor: + threading.Thread(target=impostor.serve_forever, daemon=True).start() + port: Final = impostor.server_address[1] + impostor_response: Final = httpx.get(f"http://127.0.0.1:{port}/metrics") + assert impostor_response.status_code == 200 + assert "# HELP impostor_metric" in impostor_response.text + assert PID_HEADER not in impostor_response.headers + with ( + patch("atexit.register"), + pytest.raises(MetricsServerStartupError, match=rf"exited with code [1-9]\d* before serving 127.0.0.1:{port}"), + ): + start_metrics_server_process(host="127.0.0.1", port=port, multiproc_dir=str(tmp_path)) + impostor.shutdown() + + +def test_metrics_server_process_serves_and_exits_with_parent(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", str(tmp_path)) + _write_worker_sample(pid=2001, value=7) + port: Final = _free_port() + server_argv: Final = ( + sys.executable, + "-m", + "litellm.proxy.prometheus_metrics_server", + "--host", + "127.0.0.1", + "--port", + str(port), + "--multiproc_dir", + str(tmp_path), + ) + parent: Final = subprocess.Popen( + ( + sys.executable, + "-c", + "import subprocess, sys, time; p = subprocess.Popen(sys.argv[1:]); print(p.pid, flush=True); time.sleep(600)", + *server_argv, + ), + stdout=subprocess.PIPE, + text=True, + ) + assert parent.stdout is not None + server_pid: Final = int(parent.stdout.readline()) + try: + metrics: Final = _wait_for_metrics(port, server_pid) + assert metrics.status_code == 200 + assert metrics.headers[PID_HEADER] == str(server_pid) + + scrape: Final = httpx.get(f"http://127.0.0.1:{port}/metrics", follow_redirects=True, timeout=10.0) + assert scrape.status_code == 200 + assert scrape.headers[PID_HEADER] == str(server_pid) + assert 'litellm_requests_metric_total{model="gpt-5"} 7.0' in scrape.text + + parent.kill() + parent.wait(timeout=10) + _wait_until_down(port) + finally: + parent.kill() + try: + os.kill(server_pid, 9) + except ProcessLookupError: + pass diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 9256706d340..0c20d5e0ff0 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -662,6 +662,124 @@ class TestProxyInitializationHelpers: assert "Invalid value for '--limit_concurrency'" in result.output mock_uvicorn_run.assert_not_called() + @patch("uvicorn.run") + @patch("httpx.HTTPTransport.handle_request") + @patch("atexit.register") + @patch("subprocess.Popen") + @patch("litellm.proxy.db.prisma_client.PrismaManager.setup_database") # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above + @patch( # test-quality-ok: run_server always wires the DB; same isolation as the sibling CLI tests above + "litellm.proxy.db.prisma_client.should_update_prisma_schema", return_value=False + ) + def test_prometheus_metrics_port_starts_separate_metrics_process( + self, + mock_should_update, + mock_setup_db, + mock_popen, + mock_atexit_register, + mock_handle_request, + mock_uvicorn_run, + tmp_path, + ): + """--prometheus_metrics_port must spawn `python -m litellm.proxy.prometheus_metrics_server` on --host + with the shared multiproc dir, wait for its /metrics response, and only then start uvicorn. It must stay off by + default, refuse to share --port, and abort the proxy when the child dies before serving.""" + import httpx + from click.testing import CliRunner + + from litellm.proxy.proxy_cli import run_server + + runner = CliRunner() + mock_popen.return_value = MagicMock(pid=4242, **{"poll.return_value": None}) + probed_urls: list[str] = [] + + def child_metrics(request: httpx.Request) -> httpx.Response: + probed_urls.append(str(request.url)) + return httpx.Response(200, headers={"x-litellm-metrics-pid": "4242"}, content=b"") + + mock_handle_request.side_effect = child_metrics + mock_proxy_module = MagicMock( + app=MagicMock(), + ProxyConfig=MagicMock(), + KeyManagementSettings=MagicMock(), + save_worker_config=MagicMock(), + ) + clean_env = { + k: v + for k, v in os.environ.items() + if k not in ("DATABASE_URL", "DIRECT_URL", "PROMETHEUS_METRICS_PORT") + } + clean_env["PROMETHEUS_MULTIPROC_DIR"] = str(tmp_path) + with ( + patch.dict(os.environ, clean_env, clear=True), + patch.dict( + "sys.modules", + { + "proxy_server": mock_proxy_module, + "litellm.proxy.proxy_server": mock_proxy_module, + }, + ), + patch( # test-quality-ok: same isolation as the sibling CLI tests above + "litellm.proxy.proxy_cli.ProxyInitializationHelpers._get_default_unvicorn_init_args" + ) as mock_get_args, + ): + mock_get_args.side_effect = lambda *a, **k: { + "app": "litellm.proxy.proxy_server:app", + "host": "localhost", + "port": 8000, + } + + result = runner.invoke( + run_server, + ["--local", "--host", "127.0.0.1", "--port", "4000", "--prometheus_metrics_port", "4001"], + ) + assert ( + result.exit_code == 0 + ), f"exit_code={result.exit_code}, output={result.output}" + mock_popen.assert_called_once() + spawned = list(mock_popen.call_args.args[0]) + assert spawned[1:3] == ["-m", "litellm.proxy.prometheus_metrics_server"] + assert spawned[3:] == ["--host", "127.0.0.1", "--port", "4001", "--multiproc_dir", str(tmp_path)] + assert probed_urls == ["http://127.0.0.1:4001/metrics"] + assert "Serving Prometheus metrics on 127.0.0.1:4001/metrics (pid 4242)" in result.output + mock_uvicorn_run.assert_called_once() + + mock_popen.reset_mock() + mock_uvicorn_run.reset_mock() + mock_popen.return_value = MagicMock(pid=4243, **{"poll.return_value": 1}) + result = runner.invoke( + run_server, + ["--local", "--port", "4000", "--prometheus_metrics_port", "4001"], + ) + assert result.exit_code == 1, f"exit_code={result.exit_code}, output={result.output}" + assert "Prometheus metrics server exited with code 1 before serving 0.0.0.0:4001" in result.output + mock_uvicorn_run.assert_not_called() + + mock_popen.reset_mock() + mock_uvicorn_run.reset_mock() + result = runner.invoke(run_server, ["--local"]) + assert ( + result.exit_code == 0 + ), f"exit_code={result.exit_code}, output={result.output}" + mock_popen.assert_not_called() + mock_uvicorn_run.assert_called_once() + + mock_uvicorn_run.reset_mock() + result = runner.invoke( + run_server, + ["--local", "--port", "4000", "--prometheus_metrics_port", "4000"], + ) + assert result.exit_code == 2 + assert "--prometheus_metrics_port must differ from --port" in result.output + mock_popen.assert_not_called() + mock_uvicorn_run.assert_not_called() + + result = runner.invoke( + run_server, ["--local", "--prometheus_metrics_port", "0"] + ) + assert result.exit_code == 2 + assert "Invalid value for '--prometheus_metrics_port'" in result.output + mock_popen.assert_not_called() + @pytest.mark.parametrize( "timeout_config,expected_timeout", [ diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 225134b4e2b..e35e470c979 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -3,7 +3,7 @@ "limit": 22180 }, "LIT002": { - "limit": 26745 + "limit": 26729 }, "LIT003": { "limit": 261 @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16462 + "limit": 16430 }, "LIT011": { "limit": 5506 From 9832d6e4a6e3cc832e4425f759243f97926e6d46 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Sat, 5 Sep 2026 13:51:25 -0700 Subject: [PATCH 270/410] fix(mcp): scan and mask MCP tool call arguments in unified guardrails (#35142) * fix(mcp): scan and mask MCP tool call arguments in unified guardrails A guardrail configured with mode pre_mcp_call was handed only a synthetic tool definition (name plus an empty parameters schema), so it never saw the argument values it was configured to inspect, and any rewrite it returned was discarded. Detection could not fire and masking could not take effect, while the applied-guardrails metadata still reported the guardrail as having run. Pass every string leaf of the tool call arguments as texts, and fold the guardrail's rewritten leaves back into modified_arguments, which is the channel the MCP call path reads to decide what to send upstream. The leaf walk reuses the json_string_leaves / with_json_string_leaves helpers the tool result path already uses, so both directions share one bounded traversal. Two guardrails running concurrently under run_in_parallel scan the same payload snapshot, so each returns a full replacement derived from the original leaf. Rewrites of the same leaf to different values are rejected rather than silently losing one redaction; a leaf that already holds this guardrail's own replacement is convergent and still masks, which is what the bundled content filter does when it rewrites the arguments itself as well as through texts. * fix(mcp): annotate guardrail argument rewrites Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(tests): isolate MCP guardrail callback state Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: ratchet LIT010 budget after merge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(tests): remove duplicate Bedrock hook parameter Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(mcp): fail closed when guardrail rewrites cannot be mapped to MCP arguments Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(tests): patch the guardrail translation mappings cache where staging now keeps it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../guardrail_translation/handler.py | 137 ++++-- .../test_mcp_guardrail_handler.py | 429 +++++++++++++++++- type-discipline-budget.json | 2 +- 3 files changed, 530 insertions(+), 38 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py b/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py index 4918229c2b8..c0235077ecd 100644 --- a/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py +++ b/litellm/proxy/_experimental/mcp_server/guardrail_translation/handler.py @@ -1,16 +1,19 @@ """ MCP Guardrail Handler for Unified Guardrails. -Converts an MCP call_tool (name + arguments) into a single OpenAI-compatible -tool_call and passes it to apply_guardrail. Works with the synthetic payload -from ProxyLogging._convert_mcp_to_llm_format. +Converts an MCP call_tool (name + arguments) into the OpenAI-compatible shape +apply_guardrail expects: the tool as a single-entry ``tools`` definition, and +every string leaf of the call arguments as ``texts`` so text guardrails can +detect and mask sensitive values in the payload. Works with the synthetic +request from ProxyLogging._convert_mcp_to_llm_format. Note: For MCP tool definitions (schema) -> OpenAI tools=[], see litellm.experimental_mcp_client.tools.transform_mcp_tool_to_openai_tool when you have a full MCP Tool from list_tools. Here we only have the call -payload (name + arguments) so we just build the tool_call. +payload (name + arguments) so we just build the tool definition. """ +from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Final from fastapi import HTTPException @@ -20,6 +23,8 @@ from litellm._logging import verbose_proxy_logger from litellm.experimental_mcp_client.tools import transform_mcp_tool_to_openai_tool from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation from litellm.proxy._experimental.mcp_server.utils import ( + MAX_STRUCTURED_CONTENT_SCAN_DEPTH, + JSONLeafPath, json_string_leaves, json_unrewritable_labels, mcp_content_item_text, @@ -42,6 +47,72 @@ if TYPE_CHECKING: from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj +def _blocked(reason: str) -> HTTPException: + return HTTPException(status_code=400, detail={"error": f"Content blocked: {reason}"}) + + +def _too_deeply_nested() -> HTTPException: + return _blocked( + f"MCP tool call arguments exceed the maximum nesting depth of {MAX_STRUCTURED_CONTENT_SCAN_DEPTH} " + "and cannot be scanned by the configured guardrail" + ) + + +def _argument_replacements( + argument_leaves: tuple[tuple[JSONLeafPath, str], ...], + masked_texts: Sequence[str] | None, +) -> Mapping[JSONLeafPath, str]: + """Positionally pair the guardrail's returned texts with the leaves they came from. + + Only leaves the guardrail actually rewrote are returned, so a guardrail that + detects nothing leaves the outbound tool call byte-identical. A guardrail that + returns the wrong number of texts fails closed, because a positional write-back + would scramble the arguments rather than mask them. + """ + if masked_texts is not None and len(masked_texts) != len(argument_leaves): + raise _blocked( + f"guardrail returned {len(masked_texts)} texts for {len(argument_leaves)} MCP tool call argument strings, " + "so the redaction cannot be mapped back to the arguments" + ) + return {path: masked for (path, original), masked in zip(argument_leaves, masked_texts or ()) if masked != original} + + +def _conflicting_rewrite_paths( + scanned_leaves: tuple[tuple[JSONLeafPath, str], ...], + current_leaves: tuple[tuple[JSONLeafPath, str], ...], + replacements: Mapping[JSONLeafPath, str], +) -> tuple[JSONLeafPath, ...]: + """Paths another guardrail already rewrote differently from what this one wants. + + Guardrails opted into ``run_in_parallel`` all scan the same payload snapshot, so + each one returns a full replacement string derived from the *original* leaf. Two + of them rewriting one leaf to different values cannot be merged: writing either + result discards the other guardrail's redaction. A leaf still holding the text + this guardrail was handed, or already holding this guardrail's own replacement, + is safe to write; the latter is how a guardrail that masks the arguments itself + as well as through ``texts`` gets there first. Anything else fails closed, + including a payload reshaped so the leaves no longer line up, because the + write-back is positional and would land a redaction on the wrong value. + """ + if tuple(path for path, _ in scanned_leaves) != tuple(path for path, _ in current_leaves): + return tuple(replacements) + return tuple( + path + for (path, scanned), (_, current) in zip(scanned_leaves, current_leaves) + if path in replacements and current not in (scanned, replacements[path]) + ) + + +def _conflicting_rewrite(paths: tuple[JSONLeafPath, ...]) -> HTTPException: + return _blocked( + "two guardrails running concurrently rewrote the same MCP tool call " + f"argument{'s' if len(paths) > 1 else ''} " + f"({', '.join('.'.join(str(part) for part in path) for path in paths)}); " + "their redactions cannot be merged. Remove run_in_parallel from one of them so they " + "run in sequence." + ) + + class MCPGuardrailTranslationHandler(BaseTranslation): """Guardrail translation handler for MCP tool calls (passes a single tool_call to guardrail).""" @@ -52,10 +123,8 @@ class MCPGuardrailTranslationHandler(BaseTranslation): litellm_logging_obj: "LiteLLMLoggingObj | None" = None, ) -> dict[str, Any]: mcp_tool_name: Final = data.get("mcp_tool_name") or data.get("name") - mcp_arguments = data.get("mcp_arguments") or data.get("arguments") + mcp_arguments: Final[object] = data.get("mcp_arguments") or data.get("arguments") mcp_tool_description: Final = data.get("mcp_tool_description") or data.get("description") - if mcp_arguments is None or not isinstance(mcp_arguments, dict): - mcp_arguments = {} if not mcp_tool_name: verbose_proxy_logger.debug("MCP Guardrail: mcp_tool_name missing") @@ -84,16 +153,37 @@ class MCPGuardrailTranslationHandler(BaseTranslation): strict=fn.get("strict", False) or False, # Default to False if None ), } + argument_leaves: Final = json_string_leaves(mcp_arguments) + if argument_leaves is None: + raise _too_deeply_nested() inputs: Final[GenericGuardrailAPIInputs] = GenericGuardrailAPIInputs( tools=[tool_def], + texts=[text for _, text in argument_leaves], ) - await guardrail_to_apply.apply_guardrail( + guarded: Final = await guardrail_to_apply.apply_guardrail( inputs=inputs, request_data=data, input_type="request", logging_obj=litellm_logging_obj, ) + replacements: Final = _argument_replacements( + argument_leaves=argument_leaves, + masked_texts=guarded.get("texts") if guarded else None, + ) + if not replacements: + return data + + current_arguments: Final[object] = data.get("mcp_arguments") or data.get("arguments") + current_leaves: Final = json_string_leaves(current_arguments) + if current_leaves is None: + raise _too_deeply_nested() + conflicting: Final = _conflicting_rewrite_paths(argument_leaves, current_leaves, replacements) + if conflicting: + raise _conflicting_rewrite(conflicting) + masked_arguments: Final = with_json_string_leaves(current_arguments, replacements) + data["mcp_arguments"] = masked_arguments # rebind-ok: preserve the mask for the outbound MCP call + data["modified_arguments"] = masked_arguments # rebind-ok: expose the applied mask to the caller return data async def process_output_response( @@ -131,14 +221,8 @@ class MCPGuardrailTranslationHandler(BaseTranslation): structured_leaves: Final = json_string_leaves(structured) if structured is not None else () structured_labels: Final = json_unrewritable_labels(structured) if structured is not None else () if structured_leaves is None or structured_labels is None: - raise HTTPException( - status_code=400, - detail={ - "error": ( - "Content blocked: MCP tool result structuredContent is nested too deeply to be scanned " - "by the configured guardrail" - ) - }, + raise _blocked( + "MCP tool result structuredContent is nested too deeply to be scanned by the configured guardrail" ) if not text_blocks and not structured_leaves and not structured_labels: @@ -158,12 +242,10 @@ class MCPGuardrailTranslationHandler(BaseTranslation): if masked_texts is None: return response if len(masked_texts) != len(originals): - verbose_proxy_logger.warning( - "MCP Guardrail: guardrail returned %d texts for %d tool result texts; leaving the result unmasked", - len(masked_texts), - len(originals), + raise _blocked( + f"guardrail returned {len(masked_texts)} texts for {len(originals)} MCP tool result texts, " + "so the redaction cannot be mapped back to the result" ) - return response split: Final = len(text_blocks) if content is not None: @@ -173,15 +255,10 @@ class MCPGuardrailTranslationHandler(BaseTranslation): label_start: Final = split + len(structured_leaves) if any(masked != original for original, masked in zip(structured_labels, masked_texts[label_start:])): - raise HTTPException( - status_code=400, - detail={ - "error": ( - "Content blocked: MCP tool result matched a masking rule on a non-rewritable field " - "(a structuredContent key or numeric value), which cannot be redacted without changing " - "the payload contract" - ) - }, + raise _blocked( + "MCP tool result matched a masking rule on a non-rewritable field " + "(a structuredContent key or numeric value), which cannot be redacted without changing " + "the payload contract" ) structured_replacements: Final = { diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py b/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py index 2e286a237c4..28959054195 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/guardrail_translation/test_mcp_guardrail_handler.py @@ -1,13 +1,22 @@ """Tests for the MCP guardrail translation handler.""" +import asyncio + import pytest +from fastapi import HTTPException from mcp.types import CallToolResult, ImageContent, TextContent +import litellm +import litellm.llms as litellm_llms +from litellm.caching.caching import DualCache from litellm.exceptions import BlockedPiiEntityError from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._experimental.mcp_server.guardrail_translation.handler import ( MCPGuardrailTranslationHandler, ) +from litellm.proxy._experimental.mcp_server.utils import MAX_STRUCTURED_CONTENT_SCAN_DEPTH +from litellm.proxy._types import UserAPIKeyAuth +from litellm.proxy.utils import ProxyLogging from litellm.types.utils import GenericGuardrailAPIInputs @@ -24,12 +33,11 @@ class MockGuardrail(CustomGuardrail): self.call_count += 1 self.last_inputs = inputs self.last_request_data = request_data - return None # Guardrail doesn't modify for MCP tools @pytest.mark.asyncio async def test_process_input_messages_updates_content(): - """Handler should pass tool definition to guardrail when mcp_tool_name is present.""" + """Handler should pass the tool definition and the argument strings to the guardrail.""" handler = MCPGuardrailTranslationHandler() guardrail = MockGuardrail() @@ -45,7 +53,7 @@ async def test_process_input_messages_updates_content(): assert result == data # Guardrail was called assert guardrail.call_count == 1 - # Guardrail received tools (not texts) with tool definition + # Guardrail received tools with the tool definition assert guardrail.last_inputs is not None tools = guardrail.last_inputs.get("tools", []) assert len(tools) == 1 @@ -85,6 +93,412 @@ async def test_process_input_messages_handles_minimal_data(): assert tools[0]["function"]["name"] == "simple_tool" +class ArgumentMaskingGuardrail(CustomGuardrail): + """Unified guardrail that rewrites every text it is handed, like presidio does.""" + + def __init__( + self, + secret: str = "jane.doe@example.com", + replacement: str = "", + texts_override: list[str] | None = None, + **kwargs, + ): + kwargs.setdefault("guardrail_name", "argument-masking-mcp-guardrail") + super().__init__(**kwargs) + self.secret = secret + self.replacement = replacement + self.texts_override = texts_override + self.seen_texts: list[str] | None = None + + def _mask(self, text: str) -> str: + return text.replace(self.secret, self.replacement) + + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + self.seen_texts = list(inputs.get("texts") or []) + if self.texts_override is not None: + inputs["texts"] = self.texts_override + else: + inputs["texts"] = [self._mask(text) for text in self.seen_texts] + return inputs + + +@pytest.fixture +def restore_callbacks(monkeypatch): + """Restore the process-wide state driving pre_call_hook through unified_guardrail. + + litellm.llms memoizes the guardrail translation mappings in a module global, and + ProxyLogging caches callback capabilities keyed on id()s of litellm.callbacks, + so leaving either populated leaks into unrelated tests in the same worker. + """ + monkeypatch.setattr(litellm, "callbacks", litellm.callbacks) + monkeypatch.setattr( + litellm_llms, + "endpoint_guardrail_translation_mappings", + litellm_llms.endpoint_guardrail_translation_mappings, + ) + yield + ProxyLogging._callback_capabilities_cache.clear() + + +@pytest.mark.asyncio +async def test_argument_strings_are_handed_to_the_guardrail(): + """A guardrail must see the argument values, not just the tool definition. + + Without this the guardrail is handed a name and an empty schema, so no + sensitive-data detection can ever fire on an MCP tool call. + """ + handler = MCPGuardrailTranslationHandler() + guardrail = MockGuardrail() + + data = { + "mcp_tool_name": "search", + "mcp_arguments": {"query": "contact jane.doe@example.com about the invoice"}, + } + + await handler.process_input_messages(data, guardrail) + + assert guardrail.last_inputs is not None + assert guardrail.last_inputs.get("texts") == ["contact jane.doe@example.com about the invoice"] + + +@pytest.mark.asyncio +async def test_masked_arguments_are_written_back_for_the_call_path(): + """A mask only takes effect once it lands in modified_arguments.""" + handler = MCPGuardrailTranslationHandler() + guardrail = ArgumentMaskingGuardrail() + + data = { + "mcp_tool_name": "search", + "mcp_arguments": {"query": "contact jane.doe@example.com about the invoice"}, + } + + result = await handler.process_input_messages(data, guardrail) + + masked = {"query": "contact about the invoice"} + assert result["modified_arguments"] == masked + assert result["mcp_arguments"] == masked + + +@pytest.mark.asyncio +async def test_nested_arguments_keep_their_shape_when_masked(): + """Masking rewrites string leaves in place and preserves non-string values.""" + handler = MCPGuardrailTranslationHandler() + guardrail = ArgumentMaskingGuardrail() + + arguments = { + "recipients": ["jane.doe@example.com", "ops@example.net"], + "envelope": {"reply_to": "jane.doe@example.com", "retries": 3, "urgent": True, "cc": None}, + "count": 2, + } + data = {"mcp_tool_name": "send_email", "mcp_arguments": arguments} + + result = await handler.process_input_messages(data, guardrail) + + assert guardrail.seen_texts == [ + "jane.doe@example.com", + "ops@example.net", + "jane.doe@example.com", + ] + assert result["modified_arguments"] == { + "recipients": ["", "ops@example.net"], + "envelope": {"reply_to": "", "retries": 3, "urgent": True, "cc": None}, + "count": 2, + } + + +@pytest.mark.asyncio +async def test_clean_arguments_are_not_overridden(): + """A guardrail that changes nothing must not set modified_arguments.""" + handler = MCPGuardrailTranslationHandler() + guardrail = ArgumentMaskingGuardrail() + + data = {"mcp_tool_name": "search", "mcp_arguments": {"query": "quarterly revenue"}} + + result = await handler.process_input_messages(data, guardrail) + + assert "modified_arguments" not in result + assert result["mcp_arguments"] == {"query": "quarterly revenue"} + + +@pytest.mark.asyncio +async def test_guardrail_returning_wrong_text_count_blocks_the_call(): + """Write-back is positional, so a length mismatch must block the call.""" + handler = MCPGuardrailTranslationHandler() + guardrail = ArgumentMaskingGuardrail(texts_override=["only", "two", "texts"]) + + arguments = {"query": "contact jane.doe@example.com about the invoice"} + data = {"mcp_tool_name": "search", "mcp_arguments": arguments} + + with pytest.raises(HTTPException) as exc_info: + await handler.process_input_messages(data, guardrail) + + assert exc_info.value.status_code == 400 + assert "modified_arguments" not in data + + +@pytest.mark.asyncio +async def test_deeply_nested_arguments_are_blocked_rather_than_skipped(): + """Arguments too deep to walk must block instead of passing unscanned.""" + handler = MCPGuardrailTranslationHandler() + guardrail = ArgumentMaskingGuardrail() + + nested: dict = {"leaf": "jane.doe@example.com"} + for _ in range(MAX_STRUCTURED_CONTENT_SCAN_DEPTH + 1): + nested = {"next": nested} + + data = {"mcp_tool_name": "search", "mcp_arguments": nested} + + with pytest.raises(HTTPException) as exc_info: + await handler.process_input_messages(data, guardrail) + + assert exc_info.value.status_code == 400 + + +class SelfWritingMaskingGuardrail(ArgumentMaskingGuardrail): + """Masks through ``texts`` and writes the masked arguments itself. + + The shape the bundled content filter guardrail already has: it rewrites + ``request_data["mcp_arguments"]`` from inside ``apply_guardrail`` as well as + returning masked texts. + """ + + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + returned = await super().apply_guardrail(inputs, request_data, input_type, **kwargs) + arguments = request_data.get("mcp_arguments") or {} + masked = {key: self._mask(value) if isinstance(value, str) else value for key, value in arguments.items()} + request_data["mcp_arguments"] = masked + request_data["modified_arguments"] = masked + return returned + + +@pytest.mark.asyncio +async def test_guardrail_that_masks_the_arguments_itself_is_not_treated_as_a_conflict(): + """Converging on the same replacement is not an unmergeable rewrite. + + A guardrail that both returns masked texts and rewrites the arguments in + request_data must still mask, not be rejected as if a second guardrail had + clobbered the leaf. + """ + handler = MCPGuardrailTranslationHandler() + guardrail = SelfWritingMaskingGuardrail() + + data = { + "mcp_tool_name": "search", + "mcp_arguments": {"query": "contact jane.doe@example.com about the invoice"}, + } + + result = await handler.process_input_messages(data, guardrail) + + assert result["modified_arguments"] == {"query": "contact about the invoice"} + + +class ReshapingGuardrail(ArgumentMaskingGuardrail): + """Masks through ``texts`` while moving the secret to a different path.""" + + def __init__(self, reshaped: dict, **kwargs): + super().__init__(**kwargs) + self.reshaped = reshaped + + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + returned = await super().apply_guardrail(inputs, request_data, input_type, **kwargs) + request_data["mcp_arguments"] = self.reshaped + return returned + + +@pytest.mark.asyncio +async def test_arguments_reshaped_under_the_guardrail_fail_closed(): + """A payload that no longer lines up leaf for leaf must block, not be written blind. + + Write-back pairs masked texts to leaves positionally, so a tree another guardrail + reshaped would take the redaction on the wrong value. + """ + handler = MCPGuardrailTranslationHandler() + guardrail = ReshapingGuardrail({"query": "contact jane.doe@example.com", "note": "added"}) + + data = { + "mcp_tool_name": "search", + "mcp_arguments": {"query": "contact jane.doe@example.com about the invoice"}, + } + + with pytest.raises(HTTPException) as exc_info: + await handler.process_input_messages(data, guardrail) + + assert exc_info.value.status_code == 400 + + +@pytest.mark.asyncio +async def test_arguments_shortened_under_the_guardrail_fail_closed(): + handler = MCPGuardrailTranslationHandler() + guardrail = ReshapingGuardrail({"padding": "jane.doe@example.com"}) + + data = { + "mcp_tool_name": "search", + "mcp_arguments": {"padding": "x", "secret": "jane.doe@example.com"}, + } + + with pytest.raises(HTTPException) as exc_info: + await handler.process_input_messages(data, guardrail) + + assert exc_info.value.status_code == 400 + assert "jane.doe@example.com" not in str(data.get("modified_arguments")) + + +@pytest.mark.asyncio +async def test_a_renamed_argument_key_blocks_rather_than_dropping_the_mask(): + """The leak this closes: same text, new path, so the write-back would find nothing. + + Matching purely on position would see an unchanged value and write the mask to a + path that no longer exists, shipping the secret while reporting a clean scan. + """ + handler = MCPGuardrailTranslationHandler() + guardrail = ReshapingGuardrail({"renamed": "jane.doe@example.com", "other": "kept"}) + + data = { + "mcp_tool_name": "search", + "mcp_arguments": {"query": "jane.doe@example.com", "other": "kept"}, + } + + with pytest.raises(HTTPException) as exc_info: + await handler.process_input_messages(data, guardrail) + + assert exc_info.value.status_code == 400 + assert "jane.doe@example.com" not in str(data.get("modified_arguments")) + + +@pytest.mark.parametrize("run_in_parallel", [False, True]) +@pytest.mark.asyncio +async def test_masked_arguments_reach_the_outbound_mcp_call(restore_callbacks, monkeypatch, run_in_parallel): + """End to end over the real MCP pre-call path, not just the handler. + + Drives the same sequence mcp_server_manager.call_tool uses: + synthetic payload -> pre_call_hook -> arguments sent upstream. + + Covers run_in_parallel both ways: that path shares one payload snapshot and + discards whatever a guardrail returns, so the mask has to land on the caller's + dict rather than on a copy of it. + """ + guardrail = ArgumentMaskingGuardrail( + event_hook="pre_mcp_call", + default_on=True, + run_in_parallel=run_in_parallel, + ) + monkeypatch.setattr(litellm, "callbacks", [guardrail]) + + proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) + arguments = {"query": "contact jane.doe@example.com about the invoice"} + pre_hook_kwargs = { + "name": "search", + "arguments": arguments, + "server_name": "test-server", + "user_api_key_auth": UserAPIKeyAuth(api_key="sk-test", user_id="test-user"), + } + + request_obj = proxy_logging_obj._create_mcp_request_object_from_kwargs(pre_hook_kwargs) + synthetic_data = proxy_logging_obj._convert_mcp_to_llm_format(request_obj, pre_hook_kwargs) + + modified_data = await proxy_logging_obj.pre_call_hook( + user_api_key_dict=pre_hook_kwargs["user_api_key_auth"], + data=synthetic_data, + call_type="call_mcp_tool", + ) + modified_kwargs = proxy_logging_obj._convert_mcp_hook_response_to_kwargs(modified_data, pre_hook_kwargs) + + assert modified_kwargs["arguments"] == {"query": "contact about the invoice"} + + +class SlowSubstitutionGuardrail(CustomGuardrail): + """Rewrites one substring, after a delay, so two instances genuinely interleave.""" + + def __init__(self, needle: str, replacement: str, delay: float, **kwargs): + super().__init__(**kwargs) + self.needle = needle + self.replacement = replacement + self.delay = delay + + async def apply_guardrail(self, inputs, request_data, input_type, **kwargs): + await asyncio.sleep(self.delay) + inputs["texts"] = [text.replace(self.needle, self.replacement) for text in (inputs.get("texts") or [])] + return inputs + + +def _two_interleaving_maskers(run_in_parallel: bool): + return [ + SlowSubstitutionGuardrail( + "jane.doe@example.com", + "", + 0.02, + guardrail_name="mask-email", + event_hook="pre_mcp_call", + default_on=True, + run_in_parallel=run_in_parallel, + ), + SlowSubstitutionGuardrail( + "415-555-0132", + "", + 0.04, + guardrail_name="mask-phone", + event_hook="pre_mcp_call", + default_on=True, + run_in_parallel=run_in_parallel, + ), + ] + + +async def _arguments_sent_upstream(arguments: dict): + proxy_logging_obj = ProxyLogging(user_api_key_cache=DualCache()) + pre_hook_kwargs = { + "name": "search", + "arguments": arguments, + "server_name": "test-server", + "user_api_key_auth": UserAPIKeyAuth(api_key="sk-test", user_id="test-user"), + } + request_obj = proxy_logging_obj._create_mcp_request_object_from_kwargs(pre_hook_kwargs) + modified_data = await proxy_logging_obj.pre_call_hook( + user_api_key_dict=pre_hook_kwargs["user_api_key_auth"], + data=proxy_logging_obj._convert_mcp_to_llm_format(request_obj, pre_hook_kwargs), + call_type="call_mcp_tool", + ) + return proxy_logging_obj._convert_mcp_hook_response_to_kwargs(modified_data, pre_hook_kwargs)["arguments"] + + +@pytest.mark.asyncio +async def test_two_sequential_guardrails_both_masks_survive(restore_callbacks, monkeypatch): + """The recommended config: each guardrail sees the previous one's output.""" + monkeypatch.setattr(litellm, "callbacks", _two_interleaving_maskers(run_in_parallel=False)) + + sent = await _arguments_sent_upstream({"note": "mail jane.doe@example.com or call 415-555-0132"}) + + assert sent == {"note": "mail or call "} + + +@pytest.mark.asyncio +async def test_two_parallel_guardrails_on_separate_arguments_both_masks_survive(restore_callbacks, monkeypatch): + """Concurrent rewrites of different leaves compose; neither is lost.""" + monkeypatch.setattr(litellm, "callbacks", _two_interleaving_maskers(run_in_parallel=True)) + + sent = await _arguments_sent_upstream({"email": "jane.doe@example.com", "phone": "415-555-0132"}) + + assert sent == {"email": "", "phone": ""} + + +@pytest.mark.asyncio +async def test_two_parallel_guardrails_on_one_argument_block_instead_of_losing_a_mask(restore_callbacks, monkeypatch): + """Unmergeable concurrent rewrites must fail closed, not ship one redaction. + + Both guardrails derive a full replacement string from the same snapshot, so + writing either result would silently discard the other's redaction and leak + the value it was configured to mask. + """ + monkeypatch.setattr(litellm, "callbacks", _two_interleaving_maskers(run_in_parallel=True)) + original = "mail jane.doe@example.com or call 415-555-0132" + + with pytest.raises(HTTPException) as exc_info: + await _arguments_sent_upstream({"note": original}) + + assert exc_info.value.status_code == 400 + assert "note" in str(exc_info.value.detail) + + class MaskingGuardrail(CustomGuardrail): """Guardrail that rewrites every scanned text, recording what it saw.""" @@ -190,8 +604,8 @@ async def test_process_output_response_handles_result_without_content(): @pytest.mark.asyncio -async def test_process_output_response_leaves_result_unmasked_on_text_count_mismatch(): - """A guardrail returning the wrong number of texts must not shuffle content.""" +async def test_process_output_response_blocks_on_text_count_mismatch(): + """A guardrail returning the wrong number of texts must block the result.""" handler = MCPGuardrailTranslationHandler() guardrail = MaskingGuardrail(masked_texts=[""]) result = CallToolResult( @@ -202,9 +616,10 @@ async def test_process_output_response_leaves_result_unmasked_on_text_count_mism isError=False, ) - returned = await handler.process_output_response(response=result, guardrail_to_apply=guardrail) + with pytest.raises(HTTPException) as exc_info: + await handler.process_output_response(response=result, guardrail_to_apply=guardrail) - assert [item.text for item in returned.content] == ["jane@example.com", "415-555-0132"] + assert exc_info.value.status_code == 400 class SubstitutingGuardrail(CustomGuardrail): diff --git a/type-discipline-budget.json b/type-discipline-budget.json index e35e470c979..e7186dfe186 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16430 + "limit": 16426 }, "LIT011": { "limit": 5506 From a46a076b2abd46b88f65d6d21d7afd9c052bb826 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 14:22:26 -0700 Subject: [PATCH 271/410] fix(proxy): reject ambiguous name or alias keys in mcp_tool_permissions on write (#39947) Co-authored-by: yassin Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../organization_endpoints.py | 6 + .../object_permission_utils.py | 71 ++++++++++- .../test_internal_user_endpoints.py | 2 + .../test_key_management_endpoints.py | 1 + .../test_organization_endpoints.py | 31 +++++ .../test_team_endpoints.py | 1 + .../test_object_permission_utils.py | 119 ++++++++++++++++++ 7 files changed, 229 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 0af9f816318..1e711b036d2 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -46,6 +46,7 @@ from litellm.proxy.management_endpoints.common_utils import ( from litellm.proxy.management_helpers.object_permission_utils import ( handle_update_object_permission_common, prepare_object_permission_upsert, + reject_ambiguous_mcp_tool_permission_keys, ) from litellm.proxy.management_helpers.utils import ( get_new_internal_user_defaults, @@ -606,6 +607,11 @@ async def _set_object_permission( return None if data.object_permission is not None: + await reject_ambiguous_mcp_tool_permission_keys( + new_mcp_tool_permissions=data.object_permission.mcp_tool_permissions, + existing_mcp_tool_permissions=None, + prisma_client=prisma_client, + ) created_object_permission: Final = await _table(ObjectPermissionRepository(prisma_client)).create( data=data.object_permission.model_dump(exclude_none=True), ) diff --git a/litellm/proxy/management_helpers/object_permission_utils.py b/litellm/proxy/management_helpers/object_permission_utils.py index a2fbf80422c..daab38d3662 100644 --- a/litellm/proxy/management_helpers/object_permission_utils.py +++ b/litellm/proxy/management_helpers/object_permission_utils.py @@ -5,10 +5,13 @@ organizations, teams, and keys. import json from collections.abc import Mapping, Sequence +from collections.abc import Set as AbstractSet from dataclasses import dataclass +from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final, Optional from fastapi import HTTPException, status +from pydantic import TypeAdapter from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid @@ -103,6 +106,11 @@ async def prepare_object_permission_upsert( if existing_object_permission is not None else {} ) + await reject_ambiguous_mcp_tool_permission_keys( + new_mcp_tool_permissions=new_object_permission.get("mcp_tool_permissions"), + existing_mcp_tool_permissions=existing_fields.get("mcp_tool_permissions"), + prisma_client=prisma_client, + ) merged: Final[dict[str, object]] = { **existing_fields, **new_object_permission, @@ -194,6 +202,12 @@ async def _set_object_permission( k: v for k, v in permission_data.items() if v is not None and k != "object_permission_id" } + await reject_ambiguous_mcp_tool_permission_keys( + new_mcp_tool_permissions=clean_data.get("mcp_tool_permissions"), + existing_mcp_tool_permissions=None, + prisma_client=prisma_client, + ) + # Serialize mcp_tool_permissions to JSON string for GraphQL compatibility if "mcp_tool_permissions" in clean_data: clean_data["mcp_tool_permissions"] = safe_dumps(clean_data["mcp_tool_permissions"]) @@ -226,7 +240,7 @@ def _mcp_server_identifier_matches(server: Any, identifier: str) -> bool: async def _get_db_mcp_servers_by_identifiers( - identifiers: set[str], + identifiers: AbstractSet[str], prisma_client: PrismaClient | None, ) -> "Sequence[prisma_models.LiteLLM_MCPServerTable]": if prisma_client is None or not identifiers: @@ -245,7 +259,7 @@ async def _get_db_mcp_servers_by_identifiers( async def _resolve_mcp_server_identifiers_to_ids( - identifiers: set[str], + identifiers: AbstractSet[str], prisma_client: PrismaClient | None, ) -> dict[str, set[str]]: """ @@ -286,6 +300,59 @@ async def _resolve_mcp_server_identifiers_to_ids( return resolved +_MCP_TOOL_PERMISSIONS_ADAPTER: Final = TypeAdapter(dict[str, list[str] | None]) + + +def _mcp_tool_permission_entries(raw: object) -> Mapping[str, frozenset[str]]: + parsed: Final[Mapping[str, Sequence[str] | None]] = ( + _MCP_TOOL_PERMISSIONS_ADAPTER.validate_json(raw) + if isinstance(raw, str) + else _MCP_TOOL_PERMISSIONS_ADAPTER.validate_python(raw) + if isinstance(raw, Mapping) + else MappingProxyType({}) + ) + return MappingProxyType({identifier: frozenset(tools or ()) for identifier, tools in parsed.items()}) + + +async def reject_ambiguous_mcp_tool_permission_keys( + new_mcp_tool_permissions: object, + existing_mcp_tool_permissions: object, + prisma_client: PrismaClient | None, +) -> None: + """ + A name or alias shared by several MCP servers cannot key ``mcp_tool_permissions``: + the read path unions the entry into every match, so no edit can narrow one of + those servers without also changing the other. An exact server_id is never + ambiguous, even when another server uses that string as its alias. Entries the + row already stores with the same tool list are left alone, so unrelated edits + to such an entity still succeed. + + Raises HTTPException(400) naming the colliding servers. + """ + requested: Final = _mcp_tool_permission_entries(new_mcp_tool_permissions) + stored: Final = _mcp_tool_permission_entries(existing_mcp_tool_permissions) + resolved: Final = await _resolve_mcp_server_identifiers_to_ids( + identifiers=frozenset(identifier for identifier, tools in requested.items() if stored.get(identifier) != tools), + prisma_client=prisma_client, + ) + collisions: Final = "; ".join( + f"'{identifier}' matches MCP servers {sorted(server_ids)}" + for identifier, server_ids in sorted(resolved.items()) + if identifier not in server_ids and len(server_ids) > 1 + ) + if not collisions: + return + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ # mutable-ok: HTTPException.detail has no immutable form; same shape as the sibling errors here + "error": ( + f"Ambiguous mcp_tool_permissions key: {collisions}. " + "Key tool permissions by server_id when servers share a name or alias." + ) + }, + ) + + def _drop_stale_object_permission_mcp_servers( object_permission: ObjectPermissionDict, identifier_to_server_ids: dict[str, set[str]], 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 0d3ea5863a2..d1d669cae38 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 @@ -3917,6 +3917,7 @@ def _object_permission_mocks(mocker, existing_object_permission_id=None): mock_prisma_client.db.litellm_objectpermissiontable.upsert = mocker.AsyncMock( return_value=SimpleNamespace(object_permission_id="perm-new") ) + mock_prisma_client.db.litellm_mcpservertable.find_many = mocker.AsyncMock(return_value=[]) mock_prisma_client.update_data = mocker.AsyncMock( return_value={"user_id": "target-user"} ) @@ -4146,6 +4147,7 @@ async def test_new_user_persists_the_requested_mcp_entitlement(mocker): mock_prisma_client.db.litellm_objectpermissiontable.create = mocker.AsyncMock( return_value=SimpleNamespace(object_permission_id="perm-created") ) + mock_prisma_client.db.litellm_mcpservertable.find_many = mocker.AsyncMock(return_value=[]) mock_prisma_client.db.litellm_usertable.find_first = mocker.AsyncMock( return_value=None ) 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 47571497f74..8766b1a1868 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 @@ -963,6 +963,7 @@ async def test_key_generation_with_mcp_tool_permissions(monkeypatch): mock_prisma_client.db = MagicMock() mock_prisma_client.db.litellm_objectpermissiontable = MagicMock() mock_prisma_client.db.litellm_objectpermissiontable.create = mock_create + mock_prisma_client.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[]) async def _insert_data_side_effect(*args, **kwargs): table_name = kwargs.get("table_name") 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 da68492e3d7..4d13e054e46 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_organization_endpoints.py @@ -1183,6 +1183,37 @@ async def test_find_member_if_email_missing_row_raises_documented_400(): } +@pytest.mark.asyncio +async def test_new_organization_rejects_shared_alias_tool_permission_key(): + """/organization/new creates its permission row through its own helper, so the + ambiguous mcp_tool_permissions key check (LIT-4982) has to run there too.""" + from litellm.proxy._types import LiteLLM_ObjectPermissionBase, NewOrganizationRequest + from litellm.proxy.management_endpoints.organization_endpoints import ( + _set_object_permission, + ) + + prisma_client = MagicMock() + prisma_client.db.litellm_mcpservertable.find_many = AsyncMock( + return_value=[ + MagicMock(server_id="wiki-a-id", alias="wiki", server_name="wiki_a"), + MagicMock(server_id="wiki-b-id", alias="wiki", server_name="wiki_b"), + ] + ) + prisma_client.db.litellm_objectpermissiontable.create = AsyncMock() + data = NewOrganizationRequest( + organization_alias="org", + object_permission=LiteLLM_ObjectPermissionBase(mcp_tool_permissions={"wiki": ["ask_question"]}), + ) + + with pytest.raises(HTTPException) as exc_info: + await _set_object_permission(data=data, prisma_client=prisma_client) + + assert exc_info.value.status_code == 400 + assert "wiki-a-id" in str(exc_info.value.detail) + assert "wiki-b-id" in str(exc_info.value.detail) + prisma_client.db.litellm_objectpermissiontable.create.assert_not_called() + + def test_v2_update_organization_is_in_openapi_schema(): """PATCH /v2/organization/{organization_id} is documented in the generated OpenAPI spec.""" from fastapi import FastAPI 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 46678c8ff6a..051e6bed4fd 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -651,6 +651,7 @@ async def test_new_team_with_mcp_tool_permissions(mock_db_client, mock_admin_aut mock_db_client.db.litellm_objectpermissiontable = MagicMock() mock_db_client.db.litellm_objectpermissiontable.create = mock_obj_perm_create + mock_db_client.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[]) # Mock model table mock_db_client.db.litellm_modeltable = MagicMock() diff --git a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py index f2b6b799271..d7ebb1f60bf 100644 --- a/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py +++ b/tests/test_litellm/proxy/management_helpers/test_object_permission_utils.py @@ -17,6 +17,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( _resolve_team_allowed_mcp_servers, _set_object_permission, enforce_all_proxy_mcp_servers_grant_is_admin_only, + prepare_object_permission_upsert, validate_key_mcp_servers_against_team, validate_key_search_tools_against_team, validate_key_vector_stores_against_team, @@ -41,6 +42,7 @@ async def test_set_object_permission(): mock_prisma_client.db.litellm_objectpermissiontable.create = AsyncMock( return_value=mock_created_permission ) + mock_prisma_client.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[]) # Test data with object_permission data_json = { @@ -1349,6 +1351,123 @@ async def test_validate_key_update_sentinels_do_not_grandfather(monkeypatch): assert exc_info.value.status_code == 403 +# ---- Tests for rejecting ambiguous mcp_tool_permissions keys on write (LIT-4982) ---- + + +_SHARED_ALIAS_DB_SERVERS = ( + _make_mock_mcp_server("wiki-a-id", alias="wiki", server_name="wiki_a"), + _make_mock_mcp_server("wiki-b-id", alias="wiki", server_name="wiki_b"), + _make_mock_mcp_server("gh-a-id", alias="gh_a", server_name="github"), + _make_mock_mcp_server("gh-b-id", alias="gh_b", server_name="github"), + _make_mock_mcp_server("solo-id", alias="solo", server_name="Solo Server"), + _make_mock_mcp_server("shadow-id", alias="solo-id", server_name="shadow"), +) + + +def _make_ambiguity_prisma(existing_tool_permissions=None): + """Mock prisma client whose MCP server table holds _SHARED_ALIAS_DB_SERVERS and whose + object permission row (if any) stores the given mcp_tool_permissions JSON string.""" + mock_prisma = MagicMock() + mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=list(_SHARED_ALIAS_DB_SERVERS)) + mock_prisma.db.litellm_objectpermissiontable.create = AsyncMock( + return_value=MagicMock(object_permission_id="perm-id") + ) + existing_row = None + if existing_tool_permissions is not None: + existing_row = MagicMock() + existing_row.model_dump.return_value = { + "object_permission_id": "perm-id", + "mcp_tool_permissions": json.dumps(existing_tool_permissions), + } + mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock(return_value=existing_row) + return mock_prisma + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "identifier, colliding_ids", + [("wiki", ("wiki-a-id", "wiki-b-id")), ("github", ("gh-a-id", "gh-b-id"))], +) +async def test_set_object_permission_rejects_shared_alias_or_name_tool_permission_key(identifier, colliding_ids): + """An alias or server_name two servers share cannot key mcp_tool_permissions on + create: the write is rejected with 400 naming both servers and nothing is persisted.""" + mock_prisma = _make_ambiguity_prisma() + data_json = {"object_permission": {"mcp_tool_permissions": {identifier: ["read_wiki_structure"]}}} + + with pytest.raises(HTTPException) as exc_info: + await _set_object_permission(data_json=data_json, prisma_client=mock_prisma) + + assert exc_info.value.status_code == 400 + assert all(server_id in str(exc_info.value.detail) for server_id in colliding_ids) + mock_prisma.db.litellm_objectpermissiontable.create.assert_not_called() + + +@pytest.mark.asyncio +async def test_prepare_object_permission_upsert_rejects_shared_alias_tool_permission_key(): + """The update seam shared by key/team/org/user/customer/agent rejects a new + shared-alias key when the existing row does not already hold it.""" + mock_prisma = _make_ambiguity_prisma(existing_tool_permissions={"solo-id": ["tool1"]}) + + with pytest.raises(HTTPException) as exc_info: + await prepare_object_permission_upsert( + new_object_permission={"mcp_tool_permissions": {"wiki": ["ask_question"]}}, + existing_object_permission_id="perm-id", + prisma_client=mock_prisma, + ) + + assert exc_info.value.status_code == 400 + assert "'wiki'" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_unambiguous_tool_permission_keys_persist_verbatim(): + """Exact ids (even when another server uses that id string as its alias), + unique aliases, and an id plus alias pointing at one server all still write.""" + mock_prisma = _make_ambiguity_prisma() + tool_permissions = { + "wiki-a-id": ["ask_question"], + "wiki-b-id": ["read_wiki_structure"], + "solo-id": ["tool1"], + "solo": ["tool2"], + "Solo Server": ["tool3"], + } + + upsert = await prepare_object_permission_upsert( + new_object_permission={"mcp_tool_permissions": dict(tool_permissions)}, + existing_object_permission_id=None, + prisma_client=mock_prisma, + ) + + assert json.loads(upsert.record["mcp_tool_permissions"]) == tool_permissions + + +@pytest.mark.asyncio +async def test_stored_ambiguous_tool_permission_key_is_grandfathered_until_changed(): + """A shared-alias entry already on the row may be re-sent unchanged so unrelated + edits succeed, but changing its tool list is rejected.""" + mock_prisma = _make_ambiguity_prisma(existing_tool_permissions={"wiki": ["read_wiki_structure"]}) + + upsert = await prepare_object_permission_upsert( + new_object_permission={ + "mcp_tool_permissions": {"wiki": ["read_wiki_structure"], "solo-id": ["tool1"]}, + }, + existing_object_permission_id="perm-id", + prisma_client=mock_prisma, + ) + assert json.loads(upsert.record["mcp_tool_permissions"]) == { + "wiki": ["read_wiki_structure"], + "solo-id": ["tool1"], + } + + with pytest.raises(HTTPException) as exc_info: + await prepare_object_permission_upsert( + new_object_permission={"mcp_tool_permissions": {"wiki": ["ask_question"]}}, + existing_object_permission_id="perm-id", + prisma_client=mock_prisma, + ) + assert exc_info.value.status_code == 400 + + def test_object_permission_dict_mirrors_pydantic_model(): """ObjectPermissionDict must stay field-for-field aligned with LiteLLM_ObjectPermissionBase. If a new field is added to the Pydantic From b99d8ac38ee021b4a58b1fcfd4f4fbc1cf0c5b62 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 5 Sep 2026 14:27:08 -0700 Subject: [PATCH 272/410] refactor(ui): keep guardrail usage code under the inline-object-arg lint budget The staging merge pushed local/no-large-inline-object-arg to 567 against a 554 ceiling, and 15 of those hits came from this branch. useGuardrailsUsageDetail now takes the guardrail id positionally with the date window as its second argument, the usageUnits tests build CounterMath rows through a positional helper, and the overview fixture spreads a base row inside the array instead of calling a factory Claude-Session: https://claude.ai/code/session_01EX13mWex6RaBo9PYnkAtFW --- .../_components/GuardrailDetail.test.tsx | 8 ++-- .../_components/GuardrailDetail.tsx | 2 +- .../GuardrailsMonitorView.test.tsx | 3 +- .../_components/GuardrailsOverview.test.tsx | 20 +++++---- .../guardrails/useGuardrailsUsage.test.ts | 5 +-- .../hooks/guardrails/useGuardrailsUsage.ts | 10 ++--- .../GuardrailsMonitor/usageUnits.test.ts | 43 +++++++++---------- 7 files changed, 46 insertions(+), 45 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx index 3d00e29245d..9f2a4c42228 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.test.tsx @@ -89,9 +89,8 @@ describe("GuardrailDetail", () => { it("should request the detail and the logs for the guardrail and date range", async () => { renderDetail(); - expect(mockUseGuardrailsUsageDetail).toHaveBeenCalledWith({ + expect(mockUseGuardrailsUsageDetail).toHaveBeenCalledWith("pii-detector", { accessToken: "test-token", - guardrailId: "pii-detector", startDate: "2026-07-01", endDate: "2026-07-24", }); @@ -166,7 +165,10 @@ describe("GuardrailDetail", () => { it("should not request anything without an access token", () => { mockUseGuardrailsUsageDetail.mockReturnValue(loaded(undefined)); renderDetail({ accessToken: null }); - expect(mockUseGuardrailsUsageDetail).toHaveBeenCalledWith(expect.objectContaining({ accessToken: null })); + expect(mockUseGuardrailsUsageDetail).toHaveBeenCalledWith( + "pii-detector", + expect.objectContaining({ accessToken: null }), + ); expect(mockGetGuardrailsUsageLogs).not.toHaveBeenCalled(); }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx index 1e82f1fee85..81c39258f67 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailDetail.tsx @@ -38,7 +38,7 @@ export function GuardrailDetail({ guardrailId, onBack, accessToken = null, start data: detailData, isLoading: detailLoading, error: detailError, - } = useGuardrailsUsageDetail({ accessToken, guardrailId, startDate, endDate }); + } = useGuardrailsUsageDetail(guardrailId, { accessToken, startDate, endDate }); const { data: logsData, isLoading: logsLoading } = useQuery({ queryKey: ["guardrails-usage-logs", guardrailId, logsPage, logsPageSize], queryFn: () => diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx index e86b6fa53b6..df106fc38c3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsMonitorView.test.tsx @@ -107,7 +107,8 @@ describe("GuardrailsMonitorView", () => { expect(await screen.findByRole("heading", { name: "PII Guard" })).toBeInTheDocument(); expect(mockUseGuardrailsUsageDetail).toHaveBeenCalledWith( - expect.objectContaining({ accessToken: "test-token", guardrailId: "gr-pii", startDate: expect.any(String) }), + "gr-pii", + expect.objectContaining({ accessToken: "test-token", startDate: expect.any(String) }), ); expect(screen.queryByRole("heading", { name: /Guardrails Monitor/i })).not.toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx index 959ed8b172e..ead5fc9f845 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/_components/GuardrailsOverview.test.tsx @@ -20,7 +20,7 @@ vi.mock("./EvaluationSettingsModal", () => ({ EvaluationSettingsModal: ({ open }: { open: boolean }) => (open ?
    Evaluation settings modal
    : null), })); -const row = (overrides: Partial): GuardrailUsageOverviewRow => ({ +const baseRow: GuardrailUsageOverviewRow = { id: "guardrail", name: "Guardrail", type: "content_filter", @@ -34,20 +34,21 @@ const row = (overrides: Partial): GuardrailUsageOverv usageUnits: {}, cost: null, untrackedUsageUnits: {}, - ...overrides, -}); +}; const overview: GuardrailUsageOverview = { rows: [ - row({ + { + ...baseRow, id: "guardrail-low", name: "Low Failure Guardrail", requestsEvaluated: 1200, failRate: 2.5, avgLatency: 45, trend: "down", - }), - row({ + }, + { + ...baseRow, id: "guardrail-high", name: "High Failure Guardrail", provider: "Bedrock", @@ -58,8 +59,9 @@ const overview: GuardrailUsageOverview = { usageUnits: { contentPolicyUnits: 1000, sensitiveInformationPolicyUnits: 250 }, cost: 0.15, untrackedUsageUnits: { sensitiveInformationPolicyUnits: 250 }, - }), - row({ + }, + { + ...baseRow, id: "guardrail-free", name: "Free Bedrock Guardrail", provider: "Bedrock", @@ -67,7 +69,7 @@ const overview: GuardrailUsageOverview = { failRate: 0, usageUnits: { contentPolicyUnits: 40 }, cost: 0, - }), + }, ], chart: [], totalRequests: 1510, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.test.ts index f0b2709484d..70fc874fb50 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.test.ts @@ -50,9 +50,8 @@ describe("useGuardrailsUsageDetail", () => { it("queries GET /guardrails/usage/detail/{guardrail_id} with the id as a path param", () => { renderHook(() => - useGuardrailsUsageDetail({ + useGuardrailsUsageDetail("bedrock-pii-mask", { accessToken: "sk", - guardrailId: "bedrock-pii-mask", startDate: "2026-09-01", endDate: "2026-09-04", }), @@ -73,7 +72,7 @@ describe("useGuardrailsUsageDetail", () => { it("stays disabled without a guardrail id", () => { renderHook(() => - useGuardrailsUsageDetail({ accessToken: "sk", guardrailId: "", startDate: "2026-09-01", endDate: "2026-09-04" }), + useGuardrailsUsageDetail("", { accessToken: "sk", startDate: "2026-09-01", endDate: "2026-09-04" }), ); expect(lastCall()[3].enabled).toBe(false); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.ts index dc7f58fbc8f..5569bdc8beb 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/guardrails/useGuardrailsUsage.ts @@ -24,12 +24,10 @@ export const useGuardrailsUsageOverview = ({ accessToken, startDate, endDate }: { enabled: Boolean(accessToken) }, ); -export const useGuardrailsUsageDetail = ({ - accessToken, - guardrailId, - startDate, - endDate, -}: GuardrailsUsageWindow & { guardrailId: string }) => +export const useGuardrailsUsageDetail = ( + guardrailId: string, + { accessToken, startDate, endDate }: GuardrailsUsageWindow, +) => $api.useQuery( "get", "/guardrails/usage/detail/{guardrail_id}", diff --git a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts index 8e2baaaa3c5..dd5b564b49a 100644 --- a/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts +++ b/ui/litellm-dashboard/src/components/GuardrailsMonitor/usageUnits.test.ts @@ -9,8 +9,16 @@ import { unitPrice, unitsMathRows, unpricedSummary, + type CounterMath, } from "./usageUnits"; +const counterOf = (counter: string, units: number, unpriced: number, cost: number | null): CounterMath => ({ + counter, + units, + unpriced, + cost, +}); + describe("formatCost", () => { it("renders a dash when nothing was priced", () => { expect(formatCost(null)).toBe("—"); @@ -66,15 +74,12 @@ describe("unpricedSummary", () => { describe("unitPrice", () => { it("backs the per-unit price out of the priced share only", () => { - expect(unitPrice({ counter: "contentPolicyUnits", units: 1200, unpriced: 200, cost: 0.15 })).toBeCloseTo( - 0.00015, - 10, - ); + expect(unitPrice(counterOf("contentPolicyUnits", 1200, 200, 0.15))).toBeCloseTo(0.00015, 10); }); it("is null when nothing was priced", () => { - expect(unitPrice({ counter: "someFutureCounter", units: 7, unpriced: 7, cost: null })).toBeNull(); - expect(unitPrice({ counter: "someFutureCounter", units: 7, unpriced: 7, cost: 0 })).toBeNull(); + expect(unitPrice(counterOf("someFutureCounter", 7, 7, null))).toBeNull(); + expect(unitPrice(counterOf("someFutureCounter", 7, 7, 0))).toBeNull(); }); }); @@ -93,7 +98,7 @@ describe("formatUnitPrice", () => { describe("counterMathRow", () => { it("shows units × price = cost for a fully priced counter", () => { - expect(counterMathRow({ counter: "contentPolicyUnits", units: 1000, unpriced: 0, cost: 0.15 })).toEqual({ + expect(counterMathRow(counterOf("contentPolicyUnits", 1000, 0, 0.15))).toEqual({ label: "Content Policy", parts: ["1,000", "× $0.00015", "= $0.1500"], note: null, @@ -101,20 +106,18 @@ describe("counterMathRow", () => { }); it("prices only the priced share and calls out the rest", () => { - expect(counterMathRow({ counter: "sensitiveInformationPolicyUnits", units: 8, unpriced: 2, cost: 0.0006 })).toEqual( - { - label: "Sensitive Information Policy", - parts: ["6", "× $0.0001", "= $0.0006"], - note: "2 unpriced units left out", - }, + expect(counterMathRow(counterOf("sensitiveInformationPolicyUnits", 8, 2, 0.0006))).toEqual({ + label: "Sensitive Information Policy", + parts: ["6", "× $0.0001", "= $0.0006"], + note: "2 unpriced units left out", + }); + expect(counterMathRow(counterOf("sensitiveInformationPolicyUnits", 8, 1, 0.0007)).note).toBe( + "1 unpriced unit left out", ); - expect( - counterMathRow({ counter: "sensitiveInformationPolicyUnits", units: 8, unpriced: 1, cost: 0.0007 }).note, - ).toBe("1 unpriced unit left out"); }); it("says so when a counter has no known price at all", () => { - expect(counterMathRow({ counter: "someFutureCounter", units: 7, unpriced: 7, cost: null })).toEqual({ + expect(counterMathRow(counterOf("someFutureCounter", 7, 7, null))).toEqual({ label: "Some Future Counter", parts: ["7", "× —", "= —"], note: "no known price, left out", @@ -122,11 +125,7 @@ describe("counterMathRow", () => { }); it("shows a free counter as × $0", () => { - expect(counterMathRow({ counter: "wordPolicyUnits", units: 2, unpriced: 0, cost: 0 }).parts).toEqual([ - "2", - "× $0", - "= $0.0000", - ]); + expect(counterMathRow(counterOf("wordPolicyUnits", 2, 0, 0)).parts).toEqual(["2", "× $0", "= $0.0000"]); }); }); From d56affa81413ab0c04c3e3a94aae3286f3b2f8c7 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 5 Sep 2026 14:44:21 -0700 Subject: [PATCH 273/410] test(e2e): judge /v1/messages streaming on the clock, not on the provider's delta count The Anthropic and Together AI /v1/messages streaming tests required at least two content_block_delta events. How many deltas a reply is split into is the provider's choice, and Haiku answers a short count in one or two, so the assertion failed on provider variance with no change in the proxy: four of the day's full runs on the PR e2e gate went red on it on 2026-09-05. The harness now stamps when each SSE event reached the client (StreamingResponse.stream_event_arrivals, index-aligned with stream_events, with the clock injectable so the reader has a unit test). Both tests ask for a reply long enough to take seconds to generate and require the first content delta to land at least STREAM_MIN_LEAD_SECONDS before message_stop. A relayed stream shows a lead of about two seconds. A proxy that buffered the response delivers every event in one burst and fails every time, which a whole-response buffering relay in front of a live proxy confirmed. The event-grammar assertions are unchanged. Replay hands the proxy its recorded chunks back to back, so timing says nothing there. The assertion is gated on provider_paces_stream() and replay proves the grammar only, which tests/e2e/CLAUDE.md now says. --- tests/e2e/CLAUDE.md | 2 +- tests/e2e/e2e_config.py | 8 ++ tests/e2e/e2e_http.py | 99 ++++++++++++------- .../e2e/llm_translation/test_messages_e2e.py | 38 ++++--- .../llm_translation/test_together_ai_e2e.py | 29 ++++-- tests/e2e/test_e2e_http.py | 57 ++++++++++- 6 files changed, 173 insertions(+), 60 deletions(-) diff --git a/tests/e2e/CLAUDE.md b/tests/e2e/CLAUDE.md index 14b2b4e3299..89c04208d65 100644 --- a/tests/e2e/CLAUDE.md +++ b/tests/e2e/CLAUDE.md @@ -77,7 +77,7 @@ Mark live tests with `@pytest.mark.e2e` (on the class or the module). Pure cover The seam is `provider_edge.py`: `start_provider_edge` boots an in-process HTTP server (one shared instance per pytest process, `e2e_config.provider_edge_base` is the accessor) that mounts each supported provider under a path prefix (`EDGE_MOUNTS`: `/openai` -> `https://api.openai.com`, `/anthropic` -> `https://api.anthropic.com`). A test participates by registering its deployment with `api_base=provider_edge_base("openai")` plus the provider's path suffix; `quota_management/spend_tracking/test_provider_edge_spend_e2e.py` is the reference. In live mode the accessor returns None and the deployment defaults to the real provider, so an edge-wired test runs in all three modes unchanged. Non-wired tests hit their providers live in every mode. The edge binds `E2E_PROVIDER_EDGE_BIND_HOST` (default 127.0.0.1) and advertises `E2E_PROVIDER_EDGE_ADVERTISE_HOST` in the api_base it hands out, for proxies running in containers -A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per provider call in call order (`0000-post-openai-v1-chat-completions.json`). Request headers are never stored (provider credentials never touch disk), non-JSON request bodies store a canonicalized sha256 digest instead of the bytes, `multipart/form-data` bodies store their ordinary fields plus a JSON list of the uploaded parts' `[field, filename, content-type]` triples and a digest of their content, so the per-request random boundary and the envelope never reach the key, and responses store status, filtered headers, and the verbatim body base64-encoded, which is part of why bundles are gitignored. Responses come in two shapes told apart by a `kind` tag: an ordinary one holding a single base64 body, and, for a response the provider streamed (`content-type: text/event-stream`), one holding its transfer chunks in order plus why the stream ended early if it did, so replay reproduces the split points the provider chose instead of one coalesced body. `fixture_bundle.py` owns the format, and `BUNDLE_FORMAT_VERSION` is checked on load, so a bundle recorded under older rules is refused by name rather than partially read. Record serves the proxy the same filtered stored response replay will serve later, chunk for chunk on a stream, so the two modes are byte-identical from the proxy's side of the socket +A bundle (default `tests/e2e/.fixtures`, override with `E2E_FIXTURE_DIR`) is a directory: `manifest.json` carries the record timestamp, harness git version, and format version, and each test gets a subdirectory holding one JSON file per provider call in call order (`0000-post-openai-v1-chat-completions.json`). Request headers are never stored (provider credentials never touch disk), non-JSON request bodies store a canonicalized sha256 digest instead of the bytes, `multipart/form-data` bodies store their ordinary fields plus a JSON list of the uploaded parts' `[field, filename, content-type]` triples and a digest of their content, so the per-request random boundary and the envelope never reach the key, and responses store status, filtered headers, and the verbatim body base64-encoded, which is part of why bundles are gitignored. Responses come in two shapes told apart by a `kind` tag: an ordinary one holding a single base64 body, and, for a response the provider streamed (`content-type: text/event-stream`), one holding its transfer chunks in order plus why the stream ended early if it did, so replay reproduces the split points the provider chose instead of one coalesced body. `fixture_bundle.py` owns the format, and `BUNDLE_FORMAT_VERSION` is checked on load, so a bundle recorded under older rules is refused by name rather than partially read. Record serves the proxy the same filtered stored response replay will serve later, chunk for chunk on a stream, so the two modes are byte-identical from the proxy's side of the socket. Replay does not reproduce the provider's inter-chunk timing (chunks go out as fast as the socket takes them), so a test that judges streaming on the clock, such as the `stream_event_arrivals` lead between the first content delta and `message_stop`, gates that assertion on `provider_paces_stream()` and proves only the event grammar in replay Multipart identity is the fiddly corner, and the rules exist because each one had a collision behind it. A part counts as an upload when it carries a filename or declares its own content type, and everything else is an ordinary field. Field names get a `name[n]` suffix on repeats, with a literal `[` doubled first, so a form that repeats `purpose` never keys the same as one that literally sends `purpose[1]`. A field whose name reads as a credential is stored as ``, which stays key-preserving because the key is recomputed from the stored request rather than saved alongside it, so the live request carrying the real value still matches its redacted fixture. A field value that is not UTF-8 is stored as a base64 sha256 digest, base64 and not hex because the canonicalizer rewrites any 64-character hex run to `` and would fold every binary value onto one key. The uploaded parts contribute a JSON list rather than a `field:filename` string, so a separator inside a filename cannot impersonate a field boundary, and their byte length is stored for a reader's benefit but deliberately left out of the key, since the canonicalizer absorbs timestamp and id drift inside a file that changes its length diff --git a/tests/e2e/e2e_config.py b/tests/e2e/e2e_config.py index 21c5a338dc3..09d17b9a0db 100644 --- a/tests/e2e/e2e_config.py +++ b/tests/e2e/e2e_config.py @@ -10,6 +10,7 @@ import os import time import uuid from pathlib import Path +from typing import Final from dotenv import load_dotenv @@ -192,6 +193,13 @@ def provider_edge_base(mount: str) -> str | None: ) +STREAM_MIN_LEAD_SECONDS: Final = 1.0 + + +def provider_paces_stream() -> bool: + return parse_fixture_mode(FIXTURE_MODE_RAW) != "replay" + + def unique_marker() -> str: """A short unique token per call/run, so concurrent runs and the shared response cache never collide on prompts, tags, or customer ids. In record diff --git a/tests/e2e/e2e_http.py b/tests/e2e/e2e_http.py index bc76eb3ea7a..3a4e69cfb43 100644 --- a/tests/e2e/e2e_http.py +++ b/tests/e2e/e2e_http.py @@ -16,9 +16,9 @@ requests itself imports. from __future__ import annotations import time -from collections.abc import Callable +from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import Generator, Generic, Iterator, Literal, NewType, Protocol, TypeVar, cast +from typing import Final, Generator, Generic, Iterator, Literal, NewType, Protocol, TypeVar, cast import pytest import requests @@ -132,7 +132,12 @@ class StreamingResponse(BaseModel): non-streaming `application/json`), the response headers (lowercased names, e.g. the x-ratelimit-* pacing headers and retry-after on a 429), and the body. SpendLogs.request_id is the completion body id, not call_id. Used by passthrough - and streaming, where one validated JSON model does not fit.""" + and streaming, where one validated JSON model does not fit. + + ``stream_event_arrivals`` is index-aligned with ``stream_events`` and holds the + seconds after the request was sent at which each event reached the client, so a + test can tell a relayed stream (events spread over the provider's generation time) + from a buffered one (every event in one burst at the end).""" status_code: int call_id: str | None = None # x-litellm-call-id header @@ -142,6 +147,7 @@ class StreamingResponse(BaseModel): body: str chunks: int = 0 # streamed events (0 for non-streaming) stream_events: list[str] = [] + stream_event_arrivals: list[float] = [] # First in-stream error event, if any. A streamed call commits its HTTP 200 # before the upstream completes, so upstream failures (e.g. insufficient # quota) arrive as SSE error events inside an otherwise-successful response; @@ -184,7 +190,20 @@ class BinaryStream(BaseModel): return "chunked" in (self.transfer_encoding or "") -def _hdr(resp: requests.Response, name: str) -> str | None: +class SseResponse(Protocol): + @property + def status_code(self) -> int: ... + + @property + def headers(self) -> Mapping[str, str]: ... + + @property + def text(self) -> str: ... + + def iter_lines(self) -> Iterator[bytes]: ... + + +def _hdr(resp: SseResponse, name: str) -> str | None: value = resp.headers.get(name) return value if isinstance(value, str) else None @@ -457,7 +476,7 @@ def probe( return ProbeResult(status_code=resp.status_code, body=resp.text) -def _parse_response_cost(resp: requests.Response) -> float | None: +def _parse_response_cost(resp: SseResponse) -> float | None: raw = _hdr(resp, "x-litellm-response-cost") if raw is None or raw == "": return None @@ -467,11 +486,26 @@ def _parse_response_cost(resp: requests.Response) -> float | None: return None -def _streaming_outcome(resp: requests.Response, stream: bool) -> StreamingResponse: - call_id = _hdr(resp, "x-litellm-call-id") - response_cost = _parse_response_cost(resp) - content_type = _hdr(resp, "content-type") - headers = {name.lower(): value for name, value in resp.headers.items()} +_SSE_DATA_PREFIX: Final = b"data: " +_SSE_DONE: Final = "[DONE]" + + +def _is_stream_error_line(line: bytes) -> bool: + return ( + line.startswith(b"event: error") + or b'"type":"error"' in line + or b'"type": "error"' in line + or line.startswith(b'data: {"error"') + ) + + +def streaming_outcome( + resp: SseResponse, stream: bool, *, sent_at: float, clock: Callable[[], float] = time.monotonic +) -> StreamingResponse: + call_id: Final = _hdr(resp, "x-litellm-call-id") + response_cost: Final = _parse_response_cost(resp) + content_type: Final = _hdr(resp, "content-type") + headers: Final = {name.lower(): value for name, value in resp.headers.items()} if not stream or not (200 <= resp.status_code < 300): return StreamingResponse( status_code=resp.status_code, @@ -481,29 +515,13 @@ def _streaming_outcome(resp: requests.Response, stream: bool) -> StreamingRespon headers=headers, body=resp.text, ) - lines = cast("Iterator[bytes]", resp.iter_lines()) - chunks = 0 - stream_error: str | None = None - stream_events: list[str] = [] - stream_done = False - for line in lines: - if not line: - continue - chunks += 1 - decoded_line = line.decode(errors="replace") - if decoded_line.startswith("data: "): - payload = decoded_line.removeprefix("data: ") - if payload == "[DONE]": - stream_done = True - else: - stream_events.append(payload) - if stream_error is None and ( - line.startswith(b"event: error") - or b'"type":"error"' in line - or b'"type": "error"' in line - or line.startswith(b'data: {"error"') - ): - stream_error = line.decode(errors="replace")[:300] + stamped: Final = tuple((line, clock() - sent_at) for line in resp.iter_lines() if line) + payloads: Final = tuple( + (line.removeprefix(_SSE_DATA_PREFIX).decode(errors="replace"), arrived) + for line, arrived in stamped + if line.startswith(_SSE_DATA_PREFIX) + ) + events: Final = tuple((payload, arrived) for payload, arrived in payloads if payload != _SSE_DONE) return StreamingResponse( status_code=resp.status_code, call_id=call_id, @@ -511,10 +529,14 @@ def _streaming_outcome(resp: requests.Response, stream: bool) -> StreamingRespon content_type=content_type, headers=headers, body="", - chunks=chunks, - stream_events=stream_events, - stream_done=stream_done, - stream_error=stream_error, + chunks=len(stamped), + stream_events=[payload for payload, _ in events], + stream_event_arrivals=[arrived for _, arrived in events], + stream_done=any(payload == _SSE_DONE for payload, _ in payloads), + stream_error=next( + (line.decode(errors="replace")[:300] for line, _ in stamped if _is_stream_error_line(line)), + None, + ), ) @@ -531,6 +553,7 @@ def send( x-litellm-call-id header. For native/passthrough bodies and for calls judged by status rather than a typed JSON model (e.g. a budget block is a non-2xx). With ``stream=True`` the SSE body is consumed and its events counted instead.""" + sent_at: Final = time.monotonic() try: resp = request_with_retry( lambda: requests.post( @@ -544,7 +567,7 @@ def send( ) except requests.RequestException as exc: return StreamingResponse(status_code=-1, body=str(exc)) - return _streaming_outcome(resp, stream) + return streaming_outcome(resp, stream, sent_at=sent_at) def stream( diff --git a/tests/e2e/llm_translation/test_messages_e2e.py b/tests/e2e/llm_translation/test_messages_e2e.py index a5f36a8cbdd..1227b1c7119 100644 --- a/tests/e2e/llm_translation/test_messages_e2e.py +++ b/tests/e2e/llm_translation/test_messages_e2e.py @@ -9,7 +9,12 @@ litellm-regression-tests/tests/test_inference_endpoints.py. from __future__ import annotations import pytest -from e2e_config import provider_edge_base, unique_marker +from e2e_config import ( + STREAM_MIN_LEAD_SECONDS, + provider_edge_base, + provider_paces_stream, + unique_marker, +) from e2e_http import assert_client_error, require_successful_call, unwrap from endpoints_client import EndpointsClient, MessagesResult from lifecycle import ResourceManager @@ -159,19 +164,24 @@ class TestAnthropicMessages: """Edge-wired like its non-streaming siblings, so record and replay both carry the streamed response. - Asserts the shape of the event sequence, not just that deltas and a stop - appeared somewhere in it: the answer arrives across several deltas, and the - usage event sits between the last of them and ``message_stop``. A replay that - coalesced the response into one buffered body could not satisfy either.""" + Asserts what the proxy controls. The event grammar arrives intact: the usage + event sits between the last content delta and ``message_stop``. And the relay + is incremental, judged on the clock rather than by counting deltas: how many + deltas a reply is split into is the provider's choice (Haiku often sends a + 20-line count as one), so a count threshold flaked on provider variance. A + reply that takes seconds to generate must reach the client with its first + delta well before ``message_stop``; a proxy that buffered would deliver every + event in one burst. Replay hands the proxy its recorded chunks back to back, + so only live and record runs can judge the timing.""" model, key = self._register(endpoints_client, resources) result = endpoints_client.proxy.messages_stream( key, AnthropicMessagesBody( model=model, - max_tokens=400, + max_tokens=800, stream=True, - messages=[ChatMessage(role="user", content="Count from 1 to 100, one number per line.")], + messages=[ChatMessage(role="user", content="Count from 1 to 200, one number per line.")], ), ) require_successful_call(result) @@ -186,10 +196,7 @@ class TestAnthropicMessages: delta_positions = [ index for index, event in enumerate(events) if event.type == "content_block_delta" ] - assert len(delta_positions) >= 2, ( - f"stream carried {len(delta_positions)} content deltas, so it was not " - f"incremental: {types}" - ) + assert delta_positions, f"stream carried no content deltas: {types}" text = "".join( event.delta.text for event in events @@ -209,6 +216,15 @@ class TestAnthropicMessages: f"usage did not land between the last content delta and message_stop: {types}" ) + first_delta_at = result.stream_event_arrivals[delta_positions[0]] + stop_at = result.stream_event_arrivals[stop_position] + if provider_paces_stream(): + assert stop_at - first_delta_at >= STREAM_MIN_LEAD_SECONDS, ( + f"first content delta reached the client {first_delta_at:.2f}s after the request " + f"and message_stop {stop_at:.2f}s after it; a relayed stream shows the first delta " + f"at least {STREAM_MIN_LEAD_SECONDS}s before the end, so the response was buffered" + ) + @pytest.mark.covers("llm.messages.anthropic.tool_use.nonstream.works") def test_messages_tool_use( self, endpoints_client: EndpointsClient, resources: ResourceManager diff --git a/tests/e2e/llm_translation/test_together_ai_e2e.py b/tests/e2e/llm_translation/test_together_ai_e2e.py index 2c8a7a3aa20..26daf9040e0 100644 --- a/tests/e2e/llm_translation/test_together_ai_e2e.py +++ b/tests/e2e/llm_translation/test_together_ai_e2e.py @@ -23,7 +23,7 @@ from datetime import date from typing import Final import pytest -from e2e_config import unique_marker +from e2e_config import STREAM_MIN_LEAD_SECONDS, provider_paces_stream, unique_marker from e2e_http import StreamingResponse, require_successful_call, unwrap from lifecycle import ResourceManager from models import ( @@ -81,7 +81,7 @@ PERSON_RESPONSE_FORMAT: dict[str, object] = { } WEATHER_PROMPT = "What is the weather in Paris? Use the tool." WEATHER_REPORT = "Paris: 22 degrees Celsius, clear skies, wind from the northwest at 9 km/h" -COUNTING_PROMPT = "Count from 1 to 20, one number per line." +COUNTING_PROMPT = "Count from 1 to 200, one number per line." WEATHER_TOOL = ChatTool( function=ChatToolFunction( @@ -753,7 +753,7 @@ class TestTogetherMessages: key, AnthropicMessagesBody( model=model, - max_tokens=512, + max_tokens=2048, stream=True, messages=[ChatMessage(role="user", content=COUNTING_PROMPT)], ), @@ -763,11 +763,24 @@ class TestTogetherMessages: assert not result.stream_error, f"stream errored: {result.stream_error}" events = [_MessagesStreamEvent.model_validate_json(event) for event in result.stream_events] types = [event.type for event in events] - text_deltas = [ + delta_positions = [ + index for index, event in enumerate(events) if event.type == "content_block_delta" + ] + assert delta_positions, f"stream carried no content deltas: {types}" + text = "".join( event.delta.text for event in events - if event.type == "content_block_delta" and event.delta is not None and event.delta.text - ] - assert len(text_deltas) >= 2, f"stream was not incremental: {types}" - assert "20" in "".join(text_deltas), f"streamed text lost the answer: {text_deltas}" + if event.type == "content_block_delta" and event.delta is not None + ) + assert "200" in text, f"streamed text lost the answer: {text[:300]!r}" assert "message_stop" in types, f"stream never reached message_stop: {types}" + + stop_position = types.index("message_stop") + first_delta_at = result.stream_event_arrivals[delta_positions[0]] + stop_at = result.stream_event_arrivals[stop_position] + if provider_paces_stream(): + assert stop_at - first_delta_at >= STREAM_MIN_LEAD_SECONDS, ( + f"first content delta reached the client {first_delta_at:.2f}s after the request " + f"and message_stop {stop_at:.2f}s after it; a relayed stream shows the first delta " + f"at least {STREAM_MIN_LEAD_SECONDS}s before the end, so the response was buffered" + ) diff --git a/tests/e2e/test_e2e_http.py b/tests/e2e/test_e2e_http.py index 007801a797a..dc92e31fc5c 100644 --- a/tests/e2e/test_e2e_http.py +++ b/tests/e2e/test_e2e_http.py @@ -12,12 +12,13 @@ monkeypatches anything. from __future__ import annotations -from collections.abc import Callable, Sequence +from collections.abc import Callable, Iterator, Mapping, Sequence from dataclasses import dataclass, field +from types import MappingProxyType import pytest -from e2e_http import RETRY_ATTEMPTS, TRANSIENT_STATUSES, request_with_retry +from e2e_http import RETRY_ATTEMPTS, TRANSIENT_STATUSES, request_with_retry, streaming_outcome @dataclass @@ -80,3 +81,55 @@ class TestTransientRetryPolicy: assert result is responses[RETRY_ATTEMPTS - 1] assert sleep.delays == [0.5, 1.0] assert [r.close_calls for r in responses] == [1, 1, 0, 0] + + +@dataclass(frozen=True, slots=True) +class FakeSseResponse: + lines: Sequence[bytes] + status_code: int = 200 + headers: Mapping[str, str] = MappingProxyType({"content-type": "text/event-stream"}) + text: str = "" + + def iter_lines(self) -> Iterator[bytes]: + return iter(self.lines) + + +def _ticking_clock(start: float, step: float) -> Callable[[], float]: + ticks = iter(range(10_000)) + return lambda: start + step * next(ticks) + + +class TestStreamEventArrivals: + def test_each_event_is_stamped_at_the_moment_its_line_arrives(self) -> None: + resp = FakeSseResponse( + lines=( + b"event: message_start", + b'data: {"type":"message_start"}', + b"", + b"event: ping", + b'data: {"type":"ping"}', + b"event: content_block_delta", + b'data: {"type":"content_block_delta"}', + b"data: [DONE]", + ) + ) + + result = streaming_outcome(resp, True, sent_at=100.0, clock=_ticking_clock(start=100.0, step=0.5)) + + assert result.stream_events == [ + '{"type":"message_start"}', + '{"type":"ping"}', + '{"type":"content_block_delta"}', + ] + assert result.stream_event_arrivals == [0.5, 1.5, 2.5] + assert result.stream_done + assert result.chunks == 7 + + def test_a_non_streaming_outcome_carries_no_arrivals(self) -> None: + resp = FakeSseResponse(lines=(), status_code=400, text="bad request") + + result = streaming_outcome(resp, True, sent_at=0.0, clock=_ticking_clock(start=0.0, step=1.0)) + + assert result.stream_events == [] + assert result.stream_event_arrivals == [] + assert result.body == "bad request" From 0cb759772caccee55fae6ca37dfdd05cf47c1da8 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Sat, 5 Sep 2026 14:49:30 -0700 Subject: [PATCH 274/410] fix(ui): show indirectly granted and name-keyed MCP servers in the tool matrix (#35154) * fix(ui): show indirectly granted and name-keyed MCP servers in the tool matrix The MCP tool permission editor was fed the direct server list only, so a server a principal reaches through an access group or a toolset never appeared in the matrix. That single blind spot produced two opposite bugs depending on how a save handler filtered mcp_tool_permissions: filtering by the selected servers deletes an indirect server's allowlist, and because a missing entry means "no restriction from this level", the principal silently gains every tool on it; not filtering leaves a stale entry that keeps a removed access group's server reachable, since a server named under mcp_tool_permissions is entitled on purpose. The editor now resolves the selected access groups and toolsets to their servers and renders them alongside the direct ones, badged with where the grant comes from, so an admin can see and clear an inherited server's tools like any other. Resolution reuses the data the selector already loads: access groups resolve from each server's mcp_access_groups, toolsets from the toolset's own tool list. When that data cannot be loaded the editor says so instead of rendering an empty list, because an absent inherited server reads as "there are none". Servers named only by an mcp_tool_permissions key are listed too, which is what makes a leftover entry visible; the opt-out sentinel still renders nothing, since it short-circuits the backend resolver to zero servers. Opening the editor no longer applies the delete-blocked-by-default allowlist to an inherited server. Writing an entry for one would narrow a grant the admin never touched just by opening the form; direct servers keep that default. Both components also matched on server_id alone, while the backend accepts a server id, name or alias interchangeably. A grant or allowlist written by API or config with a name rendered as a selected server with no tools under it, which reads as "this server has no tools". Matching now covers all three identifiers, and an edit writes back to the key the entry already uses rather than forking a second id-keyed entry. The same mismatch could also put one server under several keys at once, its id and its name for instance. The backend unions every key's list, so reading one key understated what was in force and writing one key left the others granting. The resolver now reports, per server, the key an edit keeps, the equivalent keys it supersedes, and the union those keys allow; the card renders the union and every write goes through one function that writes the kept key and drops the superseded ones. A key that also names a DIFFERENT server, which happens when two servers share a name, is never dropped, because dropping it would strip the neighbouring server's restriction; the card names such a key and says its tools stay allowed until the servers no longer share the name, so an admin is told rather than left to infer it from an edit that bounces back. A third divergence from the backend sat in the same matching. The backend resolves an identifier with exact-id precedence: a string that is a registry server id names that server and stops, and only a string that is no server's id falls back to name and alias, which can name several. Matching all three fields at once meant a server merely named after another server's id joined the matrix as if it had been selected, and because it landed there as a directly selected server it also received the delete-blocked default write on open. Since an mcp_tool_permissions key is itself a grant source, saving then handed out a server nobody granted, with no admin gesture involved. Identifier resolution now mirrors the backend's precedence, and a key is read as this server's only when it resolves back to it, so an entry that belongs to the id's owner is neither read into this server's allowlist nor overwritten by an edit made against it. A toolset grant was also invisible to the tool matrix. The backend unions a toolset's tools with whatever mcp_tool_permissions allows, so a toolset-only grant restricts the server to that toolset's tools; the editor read the map alone, found no entry and rendered every tool on the server as allowed. Deselecting one from that state wrote all the others as a permission entry, and the union turned a revocation into a grant of every tool the toolset never included. The resolved entry now carries the toolset's tools, so the matrix opens on what is actually in force, the delete-blocked default is withheld from a server a toolset restricts, and a write keeps out the tools only the toolset accounts for so a grant that ends with the toolset does not become a standing one. Those tools cannot be revoked from this screen at all, since the backend unions them in; they render allowed and locked and the card says which of them a toolset holds open and where to go to revoke them. That guard originally covered only the keys an edit supersedes, on the assumption that the key it keeps names one server. It does not when a shared key is a server's only entry: it then becomes the key an edit writes, and writing it moves the other server's allowlist too, which is the widening the guard exists to prevent. The key an edit writes is now the first one naming this server and no other, falling back to the server's own id, so a shared key is never written through and an edit against one card cannot reach the server behind the other. Both cards say the shared key holds tools open, since neither can revoke them. No owner's save handler changes here. With the full effective set now available to the editor, the key and team handlers can filter against it instead of guessing, which makes the internal-user surface's unfiltered save redundant Resolves LIT-4963 Resolves LIT-4958 * chore: drop tsbuildinfo churn from merge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): satisfy dashboard lint budgets Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep MCP tool allowlists for indirect grants Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep standing MCP grants on team save Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(ui): format TeamInfo and hoist inline object args Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep MCP tool allowlists for team servers granted indirectly (#35153) * fix(ui): filter team MCP tool allowlists against the effective server set Saving a team filtered mcp_tool_permissions down to the directly selected servers. A server reached through an access group or a toolset is never in that list, so any save dropped its entry, including a save that only changed the team alias. Because the resolver unions tool-permission keys into the entitled server set and treats a missing entry as "no restriction from this level", the team kept the server and lost the tool allowlist on it Filtering on the direct list alone cannot get this right in either direction. Keeping every entry a level did not directly select leaves a removed access group's server reachable through its own stale entry, which breaks revocation. Dropping on deselection alone widens a server that an access group still supplies The save handler now resolves the effective server set with resolveEffectiveMcpServers and keeps an entry only when something other than the entry itself still grants that server: a direct selection, a selected access group, or a selected toolset. Unified access group ids are added when that selection is untouched, since the loaded server list is then still accurate When the server or toolset list cannot be resolved, every entry is kept and the admin is told the allowlists were saved unchanged. Pruning on incomplete knowledge is the direction that silently widens, so it only happens when the editor can show the server became unreachable. A failed lookup and a changed access group selection are separate cases in a tagged union, so the notice names what actually happened instead of describing the intentional one as a failure, and both hooks gate the filter symmetrically so a save fired before toolsets settle cannot resolve against an empty toolset list Resolves LIT-4961 * fix(ui): resolve team MCP grants from access group metadata and refuse unsafe saves Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): resolve team access group grants from team info when the access group list is role-gated Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): match every selected access group by id instead of by count Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): reload team access group grants at save time Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): keep frontend lint budget within limit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(ui): cover a standing allowlist no group grant covers at load or save Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(ui): keep MCP grant inputs in named variables for the lint budget Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): guard MCP default write on toolset load, keep create toolsets, fix flat view Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../_components/add_agent_form.test.tsx | 45 ++ .../agents/_components/add_agent_form.tsx | 3 + .../src/components/Teams.test.tsx | 26 + ui/litellm-dashboard/src/components/Teams.tsx | 8 +- .../MCPToolPermissions.test.tsx | 659 +++++++++++++++++- .../MCPToolPermissions.tsx | 204 ++++-- .../effectiveMcpServers.test.ts | 524 ++++++++++++++ .../effectiveMcpServers.ts | 216 ++++++ .../mcp_tools/McpCrudPermissionPanel.tsx | 19 +- .../organisms/create_key_button.tsx | 9 +- .../permissions/MCPServerPermissions.test.tsx | 75 +- .../permissions/MCPServerPermissions.tsx | 25 +- .../src/components/team/TeamInfo.test.tsx | 519 +++++++++++++- .../src/components/team/TeamInfo.tsx | 179 ++++- .../components/templates/key_edit_view.tsx | 11 +- 15 files changed, 2440 insertions(+), 82 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.test.ts create mode 100644 ui/litellm-dashboard/src/components/mcp_server_management/effectiveMcpServers.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx index 4e637344e2c..ccac244f019 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.test.tsx @@ -24,6 +24,30 @@ vi.mock("./agent_form_fields", () => ({ default: () =>
    , })); +vi.mock("@/components/mcp_server_management/MCPServerSelector", () => ({ + default: ({ + onChange, + }: { + onChange: (selection: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void; + }) => ( + + ), +})); + +vi.mock("@/components/mcp_server_management/MCPToolPermissions", () => ({ + default: () => null, +})); + +vi.mock("@/components/common_components/team_dropdown", () => ({ + default: () => null, +})); + const a2aInfo: AgentCreateInfo = { agent_type: "a2a", agent_type_display_name: "A2A Agent", @@ -97,4 +121,25 @@ describe("AddAgentForm logos", () => { expect(warnSpy).toHaveBeenCalledTimes(2); warnSpy.mockRestore(); }); + + it("includes selected MCP toolsets in the create payload", async () => { + const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); + vi.mocked(networking.createAgentCall).mockResolvedValue({ + agent_id: "agent-1", + agent_name: "Test Agent", + } as never); + vi.mocked(networking.keyListCall).mockResolvedValue({ keys: [] }); + + renderForm(); + await user.click(screen.getByRole("button", { name: "Next →" })); + await user.click(screen.getByTestId("select-mcp-toolset")); + await user.click(screen.getByRole("button", { name: "Next →" })); + await user.click(screen.getByRole("button", { name: "Next →" })); + await user.click(screen.getByText(/Skip for now/)); + await user.click(screen.getByRole("button", { name: "Create Agent →" })); + + await vi.waitFor(() => expect(networking.createAgentCall).toHaveBeenCalled()); + const [, payload] = vi.mocked(networking.createAgentCall).mock.calls[0]; + expect(payload.object_permission).toEqual({ mcp_toolsets: ["ts-1"] }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx index 51445ec1bdb..108bae977e1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/agents/_components/add_agent_form.tsx @@ -361,6 +361,7 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok const objectPermission: Record = { ...(mcpServersAndGroups.servers?.length ? { mcp_servers: mcpServersAndGroups.servers } : {}), ...(mcpServersAndGroups.accessGroups?.length ? { mcp_access_groups: mcpServersAndGroups.accessGroups } : {}), + ...(mcpServersAndGroups.toolsets?.length ? { mcp_toolsets: mcpServersAndGroups.toolsets } : {}), ...(Object.keys(toolPermissions).length ? { mcp_tool_permissions: toolPermissions } : {}), ...(entitlementModels.length ? { models: entitlementModels } : {}), ...(entitlementAgents.length ? { agents: entitlementAgents } : {}), @@ -520,6 +521,8 @@ const AddAgentForm: React.FC = ({ visible, onClose, accessTok ) => form.setValue("mcp_tool_permissions", toolPerms)} /> diff --git a/ui/litellm-dashboard/src/components/Teams.test.tsx b/ui/litellm-dashboard/src/components/Teams.test.tsx index 2bceb00aae1..c7df35197e6 100644 --- a/ui/litellm-dashboard/src/components/Teams.test.tsx +++ b/ui/litellm-dashboard/src/components/Teams.test.tsx @@ -17,6 +17,22 @@ import { import Teams from "./Teams"; import { chooseSelectOption } from "../../tests/test-utils"; +vi.mock("./mcp_server_management/MCPServerSelector", () => ({ + default: ({ + onChange, + }: { + onChange: (selection: { servers: string[]; accessGroups: string[]; toolsets: string[] }) => void; + }) => ( + + ), +})); + const can = vi.fn(); vi.mock("@/app/(dashboard)/hooks/useCan", () => ({ default: (...args: unknown[]) => can(...args), @@ -1343,6 +1359,16 @@ describe("Teams - the exact bytes the create call sends", () => { }); }); + it("includes selected MCP toolsets in the create object permission", async () => { + await openCreateModal(); + await openSection("MCP Settings", /Allowed MCP Servers/); + fireEvent.click(screen.getByTestId("select-mcp-toolset")); + + const payload = await submit(); + + expect(payload.object_permission).toStrictEqual({ mcp_toolsets: ["ts-1"] }); + }); + it.each([ ["MCP Settings", /Allowed MCP Servers/, ["allowed_mcp_servers_and_groups", "mcp_tool_permissions"]], ["Agent Settings", /Allowed Agents/, ["allowed_agents_and_groups"]], diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index 4be91f22339..c4060163c78 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -443,6 +443,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser (formValues.allowed_mcp_servers_and_groups && (formValues.allowed_mcp_servers_and_groups.servers?.length > 0 || formValues.allowed_mcp_servers_and_groups.accessGroups?.length > 0 || + formValues.allowed_mcp_servers_and_groups.toolsets?.length > 0 || formValues.allowed_mcp_servers_and_groups.toolPermissions)) ) { if (!formValues.object_permission) { @@ -453,13 +454,16 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser delete formValues.allowed_vector_store_ids; } if (formValues.allowed_mcp_servers_and_groups) { - const { servers, accessGroups } = formValues.allowed_mcp_servers_and_groups; + const { servers, accessGroups, toolsets } = formValues.allowed_mcp_servers_and_groups; if (servers && servers.length > 0) { formValues.object_permission.mcp_servers = servers; } if (accessGroups && accessGroups.length > 0) { formValues.object_permission.mcp_access_groups = accessGroups; } + if (toolsets && toolsets.length > 0) { + formValues.object_permission.mcp_toolsets = toolsets; + } delete formValues.allowed_mcp_servers_and_groups; } @@ -1086,6 +1090,8 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser form.setValue("mcp_tool_permissions", toolPerms)} /> diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx index 91f1a45f858..69a761b4723 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.test.tsx @@ -2,9 +2,11 @@ import { useState } from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { renderWithProviders } from "../../../tests/test-utils"; +import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; import MCPToolPermissions from "./MCPToolPermissions"; import * as networking from "../networking"; +import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; +import type { MCPToolset } from "../mcp_tools/types"; vi.mock("../networking"); @@ -15,6 +17,8 @@ describe("MCPToolPermissions", () => { beforeEach(() => { vi.clearAllMocks(); + testQueryClient.clear(); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); }); it("should update tool permissions when user selects a tool", async () => { @@ -71,9 +75,10 @@ describe("MCPToolPermissions", () => { await userEvent.click(screen.getByRole("checkbox", { name: "read_wiki_structure" })); // Verify onChange was called with read_wiki_structure removed - expect(mockOnChange).toHaveBeenCalledWith({ + const expectedToolPermissions = { [mockServerId]: ["read_wiki_contents", "ask_question"], - }); + }; + expect(mockOnChange).toHaveBeenCalledWith(expectedToolPermissions); // Verify API calls // Note: useMCPServers uses useAuthorized() internally, which returns "123" from global mock @@ -184,6 +189,654 @@ describe("MCPToolPermissions", () => { }); }); + describe("servers reached indirectly", () => { + const groupServer = { + server_id: "srv-group-1", + server_name: "Group Server", + alias: "Group Server", + mcp_access_groups: ["production-group"], + }; + const groupTools = [ + { name: "list_issues", description: "List issues" }, + { name: "delete_issue", description: "Delete an issue" }, + ]; + + it("renders the tool matrix for a server granted only through an access group", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + expect(await screen.findByText("Group Server")).toBeInTheDocument(); + expect(await screen.findByText("list_issues")).toBeInTheDocument(); + expect(screen.getByText("delete_issue")).toBeInTheDocument(); + expect(networking.listMCPTools).toHaveBeenCalledWith(mockAccessToken, groupServer.server_id); + }); + + it("shows every tool selected in flat view for an unrestricted access-group server", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + await screen.findByText("Group Server"); + await userEvent.click(screen.getByText("Flat List")); + + const [listIssues, deleteIssue] = screen.getAllByRole("checkbox"); + expect(listIssues).toBeChecked(); + expect(deleteIssue).toBeChecked(); + + await userEvent.click(listIssues); + expect(mockOnChange).toHaveBeenCalledWith({ [groupServer.server_id]: ["delete_issue"] }); + }); + + it("marks an access-group server as inherited and leaves a directly selected one unmarked", async () => { + const directServer = { server_id: "srv-direct-1", server_name: "Direct Server", alias: "Direct Server" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue([directServer, groupServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + renderWithProviders( + , + ); + + expect(await screen.findByText("Direct Server")).toBeInTheDocument(); + expect(await screen.findByText("Group Server")).toBeInTheDocument(); + expect(screen.getByText("Via access group: production-group")).toBeInTheDocument(); + expect(screen.queryAllByText(/^Via /)).toHaveLength(1); + }); + + it("renders a toolset server as inherited from that toolset", async () => { + const toolsetServer = { server_id: "srv-toolset-1", server_name: "Toolset Server", alias: "Toolset Server" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue([toolsetServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([ + { + toolset_id: "ts-1", + toolset_name: "Support Toolset", + tools: [{ server_id: toolsetServer.server_id, tool_name: "list_issues" }], + }, + ]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + renderWithProviders( + , + ); + + expect(await screen.findByText("Toolset Server")).toBeInTheDocument(); + expect(screen.getByText("Via toolset: Support Toolset")).toBeInTheDocument(); + }); + + // The backend adds a toolset's tools to whatever mcp_tool_permissions holds, so showing the + // server as unrestricted would invite a deselection that grants every other tool on it. + it("shows a toolset's own tools as the allowed set and locks them", async () => { + const toolsetServer = { server_id: "srv-toolset-1", server_name: "Toolset Server", alias: "Toolset Server" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue([toolsetServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([ + { + toolset_id: "ts-1", + toolset_name: "Support Toolset", + tools: [{ server_id: toolsetServer.server_id, tool_name: "list_issues" }], + }, + ]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + expect(await screen.findByText("list_issues")).toBeInTheDocument(); + expect( + screen.getByText( + "list_issues is granted by a selected toolset, so it stays allowed here; edit the toolset to revoke it", + ), + ).toBeInTheDocument(); + + await userEvent.click(screen.getByText("Flat List")); + const [listIssues, deleteIssue] = screen.getAllByRole("checkbox"); + expect(listIssues).toBeChecked(); + expect(listIssues).toBeDisabled(); + expect(deleteIssue).not.toBeChecked(); + + await userEvent.click(listIssues); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it("ignores a click on a locked tool in the risk-group view", async () => { + const toolsetServer = { server_id: "srv-toolset-1", server_name: "Toolset Server", alias: "Toolset Server" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue([toolsetServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([ + { + toolset_id: "ts-1", + toolset_name: "Support Toolset", + tools: [{ server_id: toolsetServer.server_id, tool_name: "list_issues" }], + }, + ]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + await userEvent.click(await screen.findByText("list_issues")); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + // Turning a risk group off must not drop a tool the entry grants in its own right, which the + // toolset happens to grant too: that tool outlives the toolset and the admin did not clear it. + it("keeps a locked tool the entry also grants when its risk group is turned off", async () => { + const toolsetServer = { server_id: "srv-toolset-1", server_name: "Toolset Server", alias: "Toolset Server" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue([toolsetServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([ + { + toolset_id: "ts-1", + toolset_name: "Support Toolset", + tools: [{ server_id: toolsetServer.server_id, tool_name: "list_issues" }], + }, + ]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + expect(await screen.findByText("list_issues")).toBeInTheDocument(); + // First checkbox is the header toggle of the group holding list_issues. + await userEvent.click(screen.getAllByRole("checkbox")[0]); + + expect(mockOnChange).toHaveBeenCalledWith({ [toolsetServer.server_id]: ["list_issues"] }); + }); + + // Copying the toolset's tools into the entry would outlive the toolset, so a write keeps only + // what this level grants on its own. + it("leaves a toolset's tools out of the entry a Select All writes", async () => { + const toolsetServer = { server_id: "srv-toolset-1", server_name: "Toolset Server", alias: "Toolset Server" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue([toolsetServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([ + { + toolset_id: "ts-1", + toolset_name: "Support Toolset", + tools: [{ server_id: toolsetServer.server_id, tool_name: "list_issues" }], + }, + ]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + expect(await screen.findByText("list_issues")).toBeInTheDocument(); + await userEvent.click(screen.getByText("Select All")); + + expect(mockOnChange).toHaveBeenCalledWith({ [toolsetServer.server_id]: ["delete_issue"] }); + }); + + // The default narrows an unrestricted server; against a toolset-restricted one it would widen + // the grant to every non-delete tool the server exposes. + it("does not write the delete-blocked default for a directly selected server a toolset restricts", async () => { + const directServer = { server_id: "srv-direct-1", server_name: "Direct Server", alias: "Direct Server" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue([directServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([ + { + toolset_id: "ts-1", + toolset_name: "Support Toolset", + tools: [{ server_id: directServer.server_id, tool_name: "list_issues" }], + }, + ]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + expect(await screen.findByText("list_issues")).toBeInTheDocument(); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it("waits for toolsets before writing the delete-blocked default", async () => { + const directServer = { server_id: "srv-direct-1", server_name: "Direct Server", alias: "Direct Server" }; + let resolveToolsets: (toolsets: MCPToolset[]) => void = () => {}; + const pendingToolsets = new Promise((resolve) => { + resolveToolsets = resolve; + }); + vi.mocked(networking.fetchMCPServers).mockResolvedValue([directServer]); + vi.mocked(networking.fetchMCPToolsets).mockReturnValue(pendingToolsets); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + await screen.findByText("Direct Server"); + expect(mockOnChange).not.toHaveBeenCalled(); + + resolveToolsets([ + { + toolset_id: "ts-1", + toolset_name: "Support Toolset", + tools: [{ server_id: directServer.server_id, tool_name: "list_issues" }], + }, + ]); + + await screen.findByText("list_issues"); + expect(screen.getByRole("checkbox", { name: "list_issues" })).toHaveAttribute("aria-disabled", "true"); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + // The backend resolves a selection that is a registry id to that server alone. Rendering the + // server merely named after it would fire the default write against a server nobody granted, + // and a tool-permission entry is itself a grant. + it.each([ + { label: "id owner first", idOwnerFirst: true }, + { label: "name twin first", idOwnerFirst: false }, + ])("does not offer a server merely named after a selected id ($label)", async ({ idOwnerFirst }) => { + const idOwner = { server_id: "srv-collide", server_name: "Payments", alias: "Payments" }; + const nameTwin = { server_id: "srv-twin", server_name: "srv-collide", alias: "srv-collide" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue(idOwnerFirst ? [idOwner, nameTwin] : [nameTwin, idOwner]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + expect(await screen.findByText("Payments")).toBeInTheDocument(); + expect(screen.queryByText("srv-collide")).not.toBeInTheDocument(); + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith({ "srv-collide": ["list_issues"] }); + }); + expect(mockOnChange.mock.calls.every(([written]) => !Object.hasOwn(written, "srv-twin"))).toBe(true); + expect(networking.listMCPTools).not.toHaveBeenCalledWith(mockAccessToken, "srv-twin"); + }); + + it("does not write a default allowlist for an inherited server", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + expect(await screen.findByText("list_issues")).toBeInTheDocument(); + expect(mockOnChange).not.toHaveBeenCalled(); + }); + + it("keeps blocking delete tools by default for a directly selected server", async () => { + const directServer = { server_id: "srv-direct-1", server_name: "Direct Server", alias: "Direct Server" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue([directServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + await waitFor(() => { + expect(mockOnChange).toHaveBeenCalledWith({ [directServer.server_id]: ["list_issues"] }); + }); + }); + + it("shows a server that only a stale tool-permission entry still entitles", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + renderWithProviders( + , + ); + + expect(await screen.findByText("Group Server")).toBeInTheDocument(); + expect(screen.getByText("Via tool permissions")).toBeInTheDocument(); + }); + + it("shows nothing for a principal blocked from every MCP server", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([groupServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: groupTools, error: false }); + + const { container } = renderWithProviders( + , + ); + + expect(container).toBeEmptyDOMElement(); + expect(networking.listMCPTools).not.toHaveBeenCalled(); + }); + + it("warns instead of showing no inherited servers when the server list cannot be loaded", async () => { + vi.mocked(networking.fetchMCPServers).mockRejectedValue(new Error("boom")); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + + renderWithProviders( + , + ); + + expect(await screen.findByText("Unable to load MCP servers")).toBeInTheDocument(); + }); + + it("warns when the selected toolsets cannot be resolved to servers", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([]); + vi.mocked(networking.fetchMCPToolsets).mockRejectedValue(new Error("boom")); + + renderWithProviders( + , + ); + + expect(await screen.findByText("Unable to load toolsets")).toBeInTheDocument(); + }); + }); + + describe("grants keyed by server name", () => { + const namedServer = { + server_id: "1f4bd6c1-0000-4000-8000-000000000001", + server_name: "github_mcp", + alias: "GitHub", + }; + const namedTools = [ + { name: "list_issues", description: "List issues" }, + { name: "delete_issue", description: "Delete an issue" }, + ]; + + it("renders the tool matrix for a grant that names the server instead of its id", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([namedServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: namedTools, error: false }); + + renderWithProviders( + , + ); + + expect(await screen.findByText("github_mcp")).toBeInTheDocument(); + expect(await screen.findByText("list_issues")).toBeInTheDocument(); + expect(screen.getByText("delete_issue")).toBeInTheDocument(); + expect(networking.listMCPTools).toHaveBeenCalledWith(mockAccessToken, namedServer.server_id); + }); + + it("writes an edit back to the name key instead of adding a second id-keyed entry", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([namedServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: namedTools, error: false }); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + expect(await screen.findByText("list_issues")).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: "Deselect All" })); + + expect(mockOnChange).toHaveBeenCalledWith({ github_mcp: [] }); + }); + }); + + describe("a server named by several equivalent keys", () => { + const namedServer = { + server_id: "1f4bd6c1-0000-4000-8000-000000000001", + server_name: "github_mcp", + alias: "GitHub", + mcp_access_groups: ["production-group"], + }; + const namedTools = [ + { name: "list_issues", description: "List issues" }, + { name: "create_issue", description: "Open an issue" }, + { name: "delete_issue", description: "Delete an issue" }, + ]; + + const renderWithBothKeys = (onChange: () => void) => + renderWithProviders( + , + ); + + beforeEach(() => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([namedServer]); + vi.mocked(networking.fetchMCPToolsets).mockResolvedValue([]); + vi.mocked(networking.listMCPTools).mockResolvedValue({ tools: namedTools, error: false }); + }); + + it("renders one card showing the union both keys grant", async () => { + renderWithBothKeys(vi.fn()); + + expect(await screen.findByText("github_mcp")).toBeInTheDocument(); + expect(screen.getAllByText("github_mcp")).toHaveLength(1); + expect(await screen.findByText("list_issues")).toBeInTheDocument(); + + // Flat view keeps checkbox order identical to the fetched tool order. + await userEvent.click(screen.getByText("Flat List")); + const [listIssues, createIssue, deleteIssue] = screen.getAllByRole("checkbox"); + expect(listIssues).toBeChecked(); + expect(createIssue).toBeChecked(); + expect(deleteIssue).not.toBeChecked(); + }); + + it("removes a deselected tool from every equivalent key, leaving one entry for the server", async () => { + const mockOnChange = vi.fn(); + renderWithBothKeys(mockOnChange); + + expect(await screen.findByText("list_issues")).toBeInTheDocument(); + await userEvent.click(screen.getByText("Flat List")); + await userEvent.click(screen.getAllByRole("checkbox")[0]); + + const written = mockOnChange.mock.calls.at(-1)?.[0] as Record; + expect(Object.keys(written)).toEqual([namedServer.server_id]); + expect(written[namedServer.server_id]).not.toContain("list_issues"); + expect(written[namedServer.server_id]).toContain("create_issue"); + }); + + // Both catalog orders, because a name resolves to two servers here and a first-match + // implementation is only wrong in one of them. + it.each([ + { label: "edited server first", editedFirst: true }, + { label: "twin first", editedFirst: false }, + ])( + "says on the card when a key names another server too, since its tools cannot be revoked here ($label)", + async ({ editedFirst }) => { + const twin = { server_id: "1f4bd6c1-0000-4000-8000-000000000002", server_name: "github_mcp", alias: "Twin" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue( + editedFirst ? [namedServer, twin] : [twin, namedServer], + ); + + renderWithProviders( + , + ); + + // Both cards say it: the shared key grants on either server and neither card can revoke it, + // so an admin looking at either one has to be told the same thing. + expect( + await screen.findAllByText( + 'Also granted by "github_mcp", which names another server too. Those tools stay allowed here until the servers no longer share that name', + ), + ).toHaveLength(2); + }, + ); + + // The shared key is the twin's only entry, so it would otherwise be the key an edit writes, + // and writing it would move the allowlist of the server the admin is not looking at. + it.each([ + { label: "edited server first", editedFirst: true }, + { label: "twin first", editedFirst: false }, + ])("edits the twin through its own id rather than the shared key ($label)", async ({ editedFirst }) => { + const twin = { server_id: "1f4bd6c1-0000-4000-8000-000000000002", server_name: "github_mcp", alias: "Twin" }; + vi.mocked(networking.fetchMCPServers).mockResolvedValue(editedFirst ? [namedServer, twin] : [twin, namedServer]); + + const mockOnChange = vi.fn(); + renderWithProviders( + , + ); + + // The directly selected twin is the first card; both share the display name "github_mcp". + expect(await screen.findAllByText("list_issues")).toHaveLength(2); + await userEvent.click(screen.getAllByText("Select All")[0]); + + const written = mockOnChange.mock.calls.at(-1)?.[0] as Record; + expect(written["github_mcp"]).toEqual(["list_issues"]); + expect(written[twin.server_id]).toEqual(["list_issues", "create_issue", "delete_issue"]); + }); + + it("says nothing about shared names when every key names one server", async () => { + renderWithBothKeys(vi.fn()); + + expect(await screen.findByText("github_mcp")).toBeInTheDocument(); + expect(screen.queryByText(/names another server too/)).not.toBeInTheDocument(); + }); + + it("badges the server once, by its strongest grant, when a key and a group both name it", async () => { + renderWithProviders( + , + ); + + expect(await screen.findByText("github_mcp")).toBeInTheDocument(); + expect(screen.getByText("Via access group: production-group")).toBeInTheDocument(); + expect(screen.queryByText("Via tool permissions")).not.toBeInTheDocument(); + expect(screen.queryAllByText(/^Via /)).toHaveLength(1); + }); + }); + describe("risk-group (CRUD) view", () => { const crudTools = [ { name: "list_documents", description: "List every document" }, diff --git a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx index 9d7c8cd452b..c866e9cc011 100644 --- a/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx +++ b/ui/litellm-dashboard/src/components/mcp_server_management/MCPToolPermissions.tsx @@ -1,28 +1,62 @@ import React, { useEffect, useRef, useState, useMemo } from "react"; import { listMCPTools } from "../networking"; -import { MCPTool, MCPServer } from "../mcp_tools/types"; +import { MCPTool } from "../mcp_tools/types"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner"; import { useMCPServers } from "../../app/(dashboard)/hooks/mcpServers/useMCPServers"; +import { useMCPToolsets } from "../../app/(dashboard)/hooks/mcpServers/useMCPToolsets"; import McpCrudPermissionPanel from "../mcp_tools/McpCrudPermissionPanel"; import { classifyToolOp } from "../../utils/mcpToolCrudClassification"; +import { NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; +import { + EffectiveMcpServer, + McpGrantSource, + applyToolPermissionWrite, + mcpAllowedToolsFor, + resolveEffectiveMcpServers, +} from "./effectiveMcpServers"; interface MCPToolPermissionsProps { accessToken: string; - selectedServers: string[]; + selectedServers: readonly string[]; + selectedAccessGroups?: readonly string[]; + selectedToolsets?: readonly string[]; toolPermissions: Record; onChange: (toolPermissions: Record) => void; disabled?: boolean; } +const NO_SELECTION: readonly string[] = []; + +interface InheritedBadge { + readonly label: string; + readonly className: string; +} + +const inheritedBadgeFor = (source: McpGrantSource): InheritedBadge | null => { + switch (source.kind) { + case "direct": + return null; + case "accessGroup": + return { label: `Via access group: ${source.name}`, className: "text-green-700 bg-green-50 border-green-200" }; + case "toolset": + return { label: `Via toolset: ${source.name}`, className: "text-purple-700 bg-purple-50 border-purple-200" }; + case "toolPermission": + return { label: "Via tool permissions", className: "text-amber-700 bg-amber-50 border-amber-200" }; + } +}; + const MCPToolPermissions: React.FC = ({ accessToken, selectedServers, + selectedAccessGroups = NO_SELECTION, + selectedToolsets = NO_SELECTION, toolPermissions, onChange, disabled = false, }) => { - const { data: allServers = [] } = useMCPServers(); + const { data: allServers = [], isError: serversFailed, isLoading: serversLoading } = useMCPServers(); + const { data: toolsets = [], isError: toolsetsFailed, isLoading: toolsetsLoading } = useMCPToolsets(); const [serverTools, setServerTools] = useState>({}); const [loadingTools, setLoadingTools] = useState>({}); const [toolErrors, setToolErrors] = useState>({}); @@ -36,15 +70,25 @@ const MCPToolPermissions: React.FC = ({ toolPermissionsRef.current = toolPermissions; }, [toolPermissions]); - // Filter servers based on selectedServers - const servers = useMemo(() => { - if (selectedServers.length === 0) return []; - return allServers.filter((server: MCPServer) => selectedServers.includes(server.server_id)); - }, [allServers, selectedServers]); + // Every server this permission level reaches, not just the directly selected ones: a server + // reached through an access group or a toolset needs its allowlist visible and editable too. + const effectiveMcpInput = { + allServers, + selectedServers, + selectedAccessGroups, + selectedToolsets, + toolsets, + toolPermissions, + }; + const servers = useMemo( + () => resolveEffectiveMcpServers(effectiveMcpInput), + [allServers, selectedServers, selectedAccessGroups, selectedToolsets, toolsets, toolPermissions], + ); // Fetch tools for a specific server; applies delete-blocked-by-default for new servers. // `token` is passed explicitly so the closure never captures a stale accessToken. - const fetchToolsForServer = async (serverId: string, token: string) => { + const fetchToolsForServer = async (entry: EffectiveMcpServer, token: string) => { + const serverId = entry.server.server_id; setLoadingTools((prev) => ({ ...prev, [serverId]: true })); setToolErrors((prev) => ({ ...prev, [serverId]: "" })); @@ -58,14 +102,18 @@ const MCPToolPermissions: React.FC = ({ const fetchedTools: MCPTool[] = response.tools || []; setServerTools((prev) => ({ ...prev, [serverId]: fetchedTools })); - // For servers that have no permissions stored yet, block delete tools by default. + // Default only unrestricted direct servers to non-delete tools. // Read latest permissions from the ref to avoid clobbering concurrent results. const latestPermissions = toolPermissionsRef.current; - if (!latestPermissions[serverId] && fetchedTools.length > 0) { + const isDirect = entry.source.kind === "direct"; + const unrestricted = + mcpAllowedToolsFor(entry.server, latestPermissions, allServers) === undefined && + entry.toolsetTools === undefined; + if (isDirect && unrestricted && (selectedToolsets.length === 0 || !toolsetsFailed) && fetchedTools.length > 0) { const nonDeleteTools = fetchedTools .filter((t) => classifyToolOp(t.name, t.description || "") !== "delete") .map((t) => t.name); - onChange({ ...latestPermissions, [serverId]: nonDeleteTools }); + onChange(applyToolPermissionWrite({ toolPermissions: latestPermissions, entry, allowed: nonDeleteTools })); } } } catch (err) { @@ -79,58 +127,124 @@ const MCPToolPermissions: React.FC = ({ // Auto-fetch tools when servers or accessToken change useEffect(() => { - servers.forEach((server) => { - if (!serverTools[server.server_id] && !loadingTools[server.server_id]) { - fetchToolsForServer(server.server_id, accessToken); + if (toolsetsLoading) return; + servers.forEach((entry) => { + const serverId = entry.server.server_id; + if (!serverTools[serverId] && !loadingTools[serverId]) { + fetchToolsForServer(entry, accessToken); } }); // fetchToolsForServer is defined in this render scope but receives `accessToken` // as an explicit argument, so it is safe to omit from deps here. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [servers, accessToken]); + }, [servers, accessToken, toolsetsLoading]); - const handleCrudPanelChange = (serverId: string, allowed: string[]) => { - onChange({ ...toolPermissions, [serverId]: allowed }); + // Every write goes through here so an edit is authoritative for the SERVER, not for one of the + // equivalent keys that may name it. + const writeAllowedTools = (entry: EffectiveMcpServer, allowed: string[]) => { + onChange(applyToolPermissionWrite({ toolPermissions, entry, allowed })); }; - const handleSelectAll = (serverId: string) => { - const tools = serverTools[serverId] || []; - onChange({ ...toolPermissions, [serverId]: tools.map((t) => t.name) }); + const handleSelectAll = (entry: EffectiveMcpServer) => { + const tools = serverTools[entry.server.server_id] || []; + writeAllowedTools( + entry, + tools.map((t) => t.name), + ); }; - const handleDeselectAll = (serverId: string) => { - onChange({ ...toolPermissions, [serverId]: [] }); - }; + // The opt-out sentinel short-circuits the backend resolver to zero servers, so nothing stored + // here is in force and showing a tool matrix would claim otherwise. + if (selectedServers.includes(NO_MCP_SERVERS_SENTINEL)) { + return null; + } - if (selectedServers.length === 0) { + const selectionSizes = [ + selectedServers.length, + selectedAccessGroups.length, + selectedToolsets.length, + Object.keys(toolPermissions).length, + ]; + if (!selectionSizes.some((size) => size > 0)) { return null; } return (
    - {servers.map((server) => { - const serverName = server.server_name || server.alias || server.server_id; - const tools = serverTools[server.server_id] || []; - const selectedTools = toolPermissions[server.server_id] || []; - const isLoading = loadingTools[server.server_id]; - const error = toolErrors[server.server_id]; - const viewMode = viewModes[server.server_id] ?? "crud"; + {serversFailed && ( +
    +

    Unable to load MCP servers

    +

    + This list is incomplete; servers granted directly or through an access group may be missing. Reload before + changing tool permissions +

    +
    + )} + + {toolsetsFailed && selectedToolsets.length > 0 && ( +
    +

    Unable to load toolsets

    +

    + Servers reached through the selected toolsets are not listed below +

    +
    + )} + + {serversLoading && ( +
    + +

    Loading MCP servers...

    +
    + )} + + {servers.map((entry) => { + const server = entry.server; + const serverId = server.server_id; + const serverName = server.server_name || server.alias || serverId; + const tools = serverTools[serverId] || []; + const selectedTools = entry.allowedTools ?? tools.map((t) => t.name); + const isLoading = loadingTools[serverId]; + const error = toolErrors[serverId]; + const viewMode = viewModes[serverId] ?? "crud"; + const inherited = inheritedBadgeFor(entry.source); + // The backend adds a toolset's tools to whatever this map allows, so these stay on however + // the boxes are ticked. Locking them is what keeps the matrix an honest picture of the grant. + const toolsetTools = entry.toolsetTools ?? []; return ( -
    +
    {/* Header */}
    -

    {serverName}

    +
    +

    {serverName}

    + {inherited && ( + + {inherited.label} + + )} +
    {server.description &&

    {server.description}

    } + {entry.ambiguousKeys.length > 0 && ( +

    + {`Also granted by ${entry.ambiguousKeys.map((key) => `"${key}"`).join(", ")}, which names another server too. Those tools stay allowed here until the servers no longer share that name`} +

    + )} + {toolsetTools.length > 0 && ( +

    + {toolsetTools.length === 1 + ? `${toolsetTools[0]} is granted by a selected toolset, so it stays allowed here; edit the toolset to revoke it` + : `${toolsetTools.join(", ")} are granted by a selected toolset, so they stay allowed here; edit the toolset to revoke them`} +

    + )}
    {!disabled && tools.length > 0 && ( - setViewModes((prev) => ({ ...prev, [server.server_id]: next as "crud" | "flat" })) - } + onValueChange={(next) => setViewModes((prev) => ({ ...prev, [serverId]: next as "crud" | "flat" }))} className="flex w-auto items-center gap-4" >