From 03a4e8bfb57ec69cc184b5114b0f66f1480672c6 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:09:23 +0000 Subject: [PATCH 01/90] fix(azure/realtime): authenticate realtime websocket with Azure AD token when no api-key --- litellm/llms/azure/realtime/handler.py | 21 ++- litellm/realtime_api/main.py | 15 +- .../realtime/test_azure_realtime_handler.py | 178 ++++++++++++++++++ 3 files changed, 207 insertions(+), 7 deletions(-) diff --git a/litellm/llms/azure/realtime/handler.py b/litellm/llms/azure/realtime/handler.py index 86c1ed51b68..51f9ef5989c 100644 --- a/litellm/llms/azure/realtime/handler.py +++ b/litellm/llms/azure/realtime/handler.py @@ -30,6 +30,21 @@ async def forward_messages(client_ws: Any, backend_ws: Any): class AzureOpenAIRealtime(AzureChatCompletion): + @staticmethod + def get_auth_headers(api_key: str | None, azure_ad_token: str | None) -> dict[str, str]: + """ + Build the websocket handshake auth headers, preferring a static api-key and falling back to + an Azure AD (Entra ID) bearer token. Never sends both. + """ + if api_key: + return {"api-key": api_key} + if azure_ad_token: + return {"Authorization": f"Bearer {azure_ad_token}"} + raise ValueError( + "Missing Azure credentials for the realtime endpoint. Set an api_key, or configure Azure AD auth " + "(azure_ad_token, tenant_id/client_id/client_secret, or a managed identity)" + ) + def _construct_url( self, api_base: str, @@ -117,13 +132,13 @@ class AzureOpenAIRealtime(AzureChatCompletion): query_params=query_params, ) + auth_headers = self.get_auth_headers(api_key=api_key, azure_ad_token=azure_ad_token) + try: ssl_context = get_shared_realtime_ssl_context() async with websockets.connect( # type: ignore url, - additional_headers={ - "api-key": api_key, # type: ignore - }, + additional_headers=auth_headers, max_size=REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES, ssl=ssl_context, ) as backend_ws: diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index 5ecf4d91ff6..e9175917f9a 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -23,6 +23,7 @@ from litellm.utils import ProviderConfigManager from ..litellm_core_utils.get_litellm_params import get_litellm_params from ..litellm_core_utils.litellm_logging import Logging as LiteLLMLogging +from ..llms.azure.common_utils import get_azure_ad_token from ..llms.azure.realtime.handler import AzureOpenAIRealtime from ..llms.bedrock.realtime.handler import BedrockRealtime from ..llms.custom_httpx.http_handler import get_shared_realtime_ssl_context @@ -376,7 +377,7 @@ async def _arealtime( api_base=api_base, api_key=api_key, api_version=api_version, - azure_ad_token=None, + azure_ad_token=(None if api_key else get_azure_ad_token(litellm_params)), client=None, timeout=timeout, logging_obj=litellm_logging_obj, @@ -536,6 +537,7 @@ async def _realtime_health_check( import websockets url: Optional[str] = None + auth_headers: dict[str, str | None] = {"api-key": api_key} if custom_llm_provider == "azure": url = azure_realtime._construct_url( api_base=api_base or "", @@ -543,6 +545,13 @@ async def _realtime_health_check( api_version=api_version or "2024-10-01-preview", realtime_protocol=realtime_protocol, ) + azure_litellm_params = GenericLiteLLMParams(**(model_params or {})) + auth_headers = dict( + azure_realtime.get_auth_headers( + api_key=api_key, + azure_ad_token=(None if api_key else get_azure_ad_token(azure_litellm_params)), + ) + ) elif custom_llm_provider == "openai": url = openai_realtime._construct_url( api_base=api_base or "https://api.openai.com/", @@ -584,9 +593,7 @@ async def _realtime_health_check( ssl_context = get_shared_realtime_ssl_context() async with websockets.connect( # type: ignore url, - additional_headers={ - "api-key": api_key, # type: ignore - }, + additional_headers=auth_headers, max_size=REALTIME_WEBSOCKET_MAX_MESSAGE_SIZE_BYTES, ssl=ssl_context, ): diff --git a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py index 4638bc4df0f..d9c49947f19 100644 --- a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py +++ b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py @@ -563,3 +563,181 @@ async def test_async_realtime_default_maintains_backwards_compatibility(): mock_realtime_streaming.call_args.kwargs["backend_uses_beta_protocol"] is True ) + + +class _DummyAsyncContextManager: + def __init__(self, value): + self.value = value + + async def __aenter__(self): + return self.value + + async def __aexit__(self, exc_type, exc, tb): + return None + + +@pytest.mark.asyncio +async def test_async_realtime_uses_bearer_token_when_no_api_key(): + """ + Entra ID-only Azure realtime deployments have no static api-key, so the handshake must + authenticate with `Authorization: Bearer ` and must not send `api-key`. + + Regression test for https://github.com/BerriAI/litellm/issues/34654 + """ + from litellm.llms.azure.realtime.handler import AzureOpenAIRealtime + + handler = AzureOpenAIRealtime() + mock_backend_ws = AsyncMock() + + with ( + patch( + "websockets.connect", + return_value=_DummyAsyncContextManager(mock_backend_ws), + ) as mock_ws_connect, + patch("litellm.llms.azure.realtime.handler.RealTimeStreaming") as mock_realtime_streaming, + ): + mock_realtime_streaming.return_value.bidirectional_forward = AsyncMock() + + await handler.async_realtime( + model="gpt-realtime-whisper", + websocket=AsyncMock(), + logging_obj=MagicMock(), + api_base="https://my-endpoint.openai.azure.com", + api_key=None, + api_version="2024-10-01-preview", + azure_ad_token="my-entra-token", + ) + + headers = mock_ws_connect.call_args.kwargs["additional_headers"] + assert headers == {"Authorization": "Bearer my-entra-token"} + + +def test_get_auth_headers_prefers_api_key_and_never_sends_both(): + from litellm.llms.azure.realtime.handler import AzureOpenAIRealtime + + assert AzureOpenAIRealtime.get_auth_headers(api_key="test-key", azure_ad_token="my-entra-token") == { + "api-key": "test-key" + } + + +def test_get_auth_headers_without_credentials_raises(): + from litellm.llms.azure.realtime.handler import AzureOpenAIRealtime + + with pytest.raises(ValueError, match="Missing Azure credentials"): + AzureOpenAIRealtime.get_auth_headers(api_key=None, azure_ad_token=None) + + +@pytest.mark.asyncio +async def test_arealtime_resolves_azure_ad_token_when_no_api_key(monkeypatch): + """ + `_arealtime` must resolve an Azure AD token (managed identity, service principal, etc.) + and forward it to the handler when the deployment has no api_key. + + Regression test for https://github.com/BerriAI/litellm/issues/34654 + """ + from litellm.realtime_api import main as realtime_main + + mock_async_realtime = AsyncMock() + monkeypatch.setattr(realtime_main, "azure_realtime", MagicMock(async_realtime=mock_async_realtime)) + monkeypatch.setattr( + realtime_main, + "get_llm_provider", + lambda model, api_base=None, api_key=None: ( + "gpt-realtime-whisper", + "azure", + None, + "https://my-endpoint.openai.azure.com", + ), + ) + monkeypatch.delenv("AZURE_API_KEY", raising=False) + + captured_params = {} + + def fake_get_azure_ad_token(litellm_params): + captured_params["tenant_id"] = litellm_params.get("tenant_id") + return "my-entra-token" + + monkeypatch.setattr(realtime_main, "get_azure_ad_token", fake_get_azure_ad_token) + + await realtime_main._arealtime( + model="azure/gpt-realtime-whisper", + websocket=MagicMock(), + api_version="2024-10-01-preview", + litellm_logging_obj=MagicMock(), + tenant_id="my-tenant", + client_id="my-client", + client_secret="my-secret", + ) + + assert mock_async_realtime.call_args.kwargs["azure_ad_token"] == "my-entra-token" + assert captured_params["tenant_id"] == "my-tenant" + + +@pytest.mark.asyncio +async def test_arealtime_does_not_resolve_azure_ad_token_when_api_key_present(monkeypatch): + from litellm.realtime_api import main as realtime_main + + mock_async_realtime = AsyncMock() + monkeypatch.setattr(realtime_main, "azure_realtime", MagicMock(async_realtime=mock_async_realtime)) + monkeypatch.setattr( + realtime_main, + "get_llm_provider", + lambda model, api_base=None, api_key=None: ( + "gpt-realtime-whisper", + "azure", + "test-key", + "https://my-endpoint.openai.azure.com", + ), + ) + + def fail_get_azure_ad_token(litellm_params): + raise AssertionError("should not resolve an AD token when an api_key is configured") + + monkeypatch.setattr(realtime_main, "get_azure_ad_token", fail_get_azure_ad_token) + + await realtime_main._arealtime( + model="azure/gpt-realtime-whisper", + websocket=MagicMock(), + api_key="test-key", + api_version="2024-10-01-preview", + litellm_logging_obj=MagicMock(), + ) + + assert mock_async_realtime.call_args.kwargs["azure_ad_token"] is None + + +@pytest.mark.asyncio +async def test_realtime_health_check_uses_bearer_token_when_no_api_key(monkeypatch): + """ + An Entra ID-only realtime deployment must also pass its realtime health check. + + Regression test for https://github.com/BerriAI/litellm/issues/34654 + """ + from litellm.realtime_api import main as realtime_main + + connect_calls = [] + + monkeypatch.setattr( + realtime_main, + "get_azure_ad_token", + lambda litellm_params: "my-entra-token", + ) + + def fake_connect(url, **kwargs): + connect_calls.append(kwargs) + return _DummyAsyncContextManager(MagicMock()) + + monkeypatch.setattr("websockets.connect", fake_connect) + + assert ( + await realtime_main._realtime_health_check( + model="gpt-realtime-whisper", + custom_llm_provider="azure", + api_key=None, + api_base="https://my-endpoint.openai.azure.com", + api_version="2024-10-01-preview", + model_params={"tenant_id": "my-tenant"}, + ) + is True + ) + assert connect_calls[0]["additional_headers"] == {"Authorization": "Bearer my-entra-token"} From b930169f39ff81b34b9088d32596f2fc07241839 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:21:30 +0000 Subject: [PATCH 02/90] fix(azure/realtime): resolve AD token from deployment azure_ad_token param and kwargs --- litellm/realtime_api/main.py | 7 +++- .../realtime/test_azure_realtime_handler.py | 36 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index e9175917f9a..e981db216af 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -371,13 +371,18 @@ async def _arealtime( if realtime_protocol is None and (query_params or {}).get("intent") == "transcription": realtime_protocol = "GA" realtime_protocol = realtime_protocol or "beta" + resolved_azure_ad_token = ( + None + if api_key + else get_azure_ad_token(GenericLiteLLMParams(**{**kwargs, "azure_ad_token": azure_ad_token})) + ) await azure_realtime.async_realtime( model=model, websocket=websocket, api_base=api_base, api_key=api_key, api_version=api_version, - azure_ad_token=(None if api_key else get_azure_ad_token(litellm_params)), + azure_ad_token=resolved_azure_ad_token, client=None, timeout=timeout, logging_obj=litellm_logging_obj, diff --git a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py index d9c49947f19..bf2e89de44c 100644 --- a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py +++ b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py @@ -741,3 +741,39 @@ async def test_realtime_health_check_uses_bearer_token_when_no_api_key(monkeypat is True ) assert connect_calls[0]["additional_headers"] == {"Authorization": "Bearer my-entra-token"} + + +@pytest.mark.asyncio +async def test_arealtime_forwards_deployment_azure_ad_token(monkeypatch): + """ + The router binds a deployment's `azure_ad_token` to `_arealtime`'s named parameter rather than + **kwargs, so it must still reach the handler. + + Regression test for https://github.com/BerriAI/litellm/issues/34654 + """ + from litellm.realtime_api import main as realtime_main + + mock_async_realtime = AsyncMock() + monkeypatch.setattr(realtime_main, "azure_realtime", MagicMock(async_realtime=mock_async_realtime)) + monkeypatch.setattr( + realtime_main, + "get_llm_provider", + lambda model, api_base=None, api_key=None: ( + "gpt-realtime-whisper", + "azure", + None, + "https://my-endpoint.openai.azure.com", + ), + ) + monkeypatch.delenv("AZURE_API_KEY", raising=False) + monkeypatch.setattr(realtime_main.litellm, "api_key", None) + + await realtime_main._arealtime( + model="azure/gpt-realtime-whisper", + websocket=MagicMock(), + api_version="2024-10-01-preview", + azure_ad_token="deployment-entra-token", + litellm_logging_obj=MagicMock(), + ) + + assert mock_async_realtime.call_args.kwargs["azure_ad_token"] == "deployment-entra-token" From 4f5e290f60f518e9e1f28333169901d7207efa96 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:08:42 +0000 Subject: [PATCH 03/90] refactor(proxy): type the per-model budget plumbing added yesterday Drops a pyright suppression, getattr string access, and bare dict annotations from the model_max_budget code, and trims a comment referencing its own PR. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- basedpyright-code-budget.json | 10 +++++----- litellm/llms/anthropic/common_utils.py | 4 ++-- .../context_management/editors/compact.py | 4 ++-- litellm/proxy/_types.py | 6 +++--- litellm/proxy/auth/user_api_key_auth.py | 11 +++++------ type-discipline-budget.json | 2 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 +++- 7 files changed, 21 insertions(+), 20 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 664e1669834..9dcc46ebcee 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,6 +1,6 @@ { "reportAny": { - "limit": 19955 + "limit": 19954 }, "reportArgumentType": { "limit": 2566 @@ -57,7 +57,7 @@ "limit": 5663 }, "reportMissingTypeArgument": { - "limit": 15555 + "limit": 15553 }, "reportMissingTypeStubs": { "limit": 40 @@ -105,13 +105,13 @@ "limit": 109 }, "reportUnknownMemberType": { - "limit": 39011 + "limit": 39007 }, "reportUnknownParameterType": { - "limit": 19885 + "limit": 19883 }, "reportUnknownVariableType": { - "limit": 30569 + "limit": 30568 }, "reportUnnecessaryCast": { "limit": 117 diff --git a/litellm/llms/anthropic/common_utils.py b/litellm/llms/anthropic/common_utils.py index 3297aa95715..c1a2384e693 100644 --- a/litellm/llms/anthropic/common_utils.py +++ b/litellm/llms/anthropic/common_utils.py @@ -4,7 +4,7 @@ This file contains common utils for anthropic calls. import copy import re -from collections.abc import Mapping, Sequence +from collections.abc import Mapping, MutableMapping, Sequence from datetime import datetime, timezone from types import MappingProxyType from typing import Any, Final, Literal @@ -443,7 +443,7 @@ class AnthropicModelInfo(BaseLLMModelInfo): @staticmethod def maybe_drop_disabled_thinking( model: str, - optional_params: dict, # mutable-ok: in-place out-param, same contract as AnthropicConfig._maybe_drop_speed_param + optional_params: MutableMapping[str, object], # mutable-ok: in-place out-param, as in _maybe_drop_speed_param custom_llm_provider: str, ) -> None: """Omit ``thinking={'type': 'disabled'}`` for always-on-thinking models diff --git a/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py b/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py index 2a87afb5990..c8cbbba8784 100644 --- a/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py +++ b/litellm/llms/anthropic/experimental_pass_through/context_management/editors/compact.py @@ -352,8 +352,8 @@ async def _check_summary_model_budget( ) return False - user_model_max_budget: Final = getattr(user_api_key_auth, "user_model_max_budget", None) - user_id: Final = getattr(user_api_key_auth, "user_id", None) + user_model_max_budget: Final = user_api_key_auth.user_model_max_budget + user_id: Final = user_api_key_auth.user_id if isinstance(user_model_max_budget, dict) and user_model_max_budget and user_id is not None: try: await model_max_budget_limiter.is_user_within_model_budget( diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 0840d37ffa1..d5338811a1f 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -2808,7 +2808,7 @@ class UserAPIKeyAuth(LiteLLM_VerificationTokenView): # the expected response ob # Values stay `object` rather than BudgetConfig: this is the raw JSON column, # and validating it here would make one malformed row fail auth outright. # resolve_model_budget validates the single entry a request actually needs. - user_model_max_budget: dict[str, object] | None = None + user_model_max_budget: Mapping[str, object] | None = None request_route: str | None = None is_session_token: bool = False # Server-only marker set exclusively by the MCP gateway admission path @@ -2986,8 +2986,8 @@ class UserInfoV2Response(LiteLLMPydanticObjectBase): sso_user_id: str | None = None teams: list[str] = [] # Just team IDs, not full team objects object_permission: LiteLLM_ObjectPermissionTable | None = None - model_max_budget: dict | None = None - model_max_budget_usage: dict | None = None + model_max_budget: Mapping[str, object] | None = None + model_max_budget_usage: Mapping[str, Mapping[str, object]] | None = None from litellm.models.config import LiteLLM_Config as LiteLLM_Config # noqa: E402 diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index fe4f1ee4ae5..d4fc091cc84 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -197,9 +197,9 @@ async def _read_user_model_max_budget( user_id: str | None, prisma_client: PrismaClient | None, user_api_key_cache: UserApiKeyCache, - parent_otel_span: object, + parent_otel_span: Span | None, proxy_logging_obj: ProxyLogging, -) -> dict | None: +) -> Mapping[str, object] | None: """The user row's `model_max_budget`, or None when the row cannot be read. A user whose row is missing must not be refused: this is a budget lookup, @@ -213,13 +213,13 @@ async def _read_user_model_max_budget( prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, user_id_upsert=False, - parent_otel_span=parent_otel_span, # pyright: ignore[reportArgumentType] # Span is a runtime union, not usable in an annotation here + parent_otel_span=parent_otel_span, proxy_logging_obj=proxy_logging_obj, ) except Exception as e: # noqa: BLE001 # mirrors the main path's tolerance verbose_logger.debug("Unable to read user for the per-model budget check: %s", e) return None - return getattr(user_obj, "model_max_budget", None) + return user_obj.model_max_budget if user_obj is not None else None async def _check_user_model_budget( @@ -3168,8 +3168,7 @@ async def _run_post_custom_auth_checks( # loaded the user row yet. The attach is unconditional because the post-call # spend hook reads this field off the token: gating it on the same condition # as enforcement would leave the user's counter uncharged whenever this - # request was not itself enforceable, which is the untracked-spend bug this - # PR exists to fix. + # request was not itself enforceable, so its spend would go untracked. user_budget: Final = await _read_user_model_max_budget( user_id=valid_token.user_id, prisma_client=prisma_client, diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 627811a7f1d..81f7c6aa40b 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,6 +1,6 @@ { "LIT001": { - "limit": 22805 + "limit": 22801 }, "LIT002": { "limit": 26873 diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index cf55dc69e86..156238b99e2 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -36200,7 +36200,9 @@ export interface components { } | null; /** Model Max Budget Usage */ model_max_budget_usage?: { - [key: string]: unknown; + [key: string]: { + [key: string]: unknown; + }; } | null; /** * Models From e45c084c1c06862b9a5e9e3c089fca36beb00ed1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:05:32 +0000 Subject: [PATCH 04/90] chore(typing): replace Any and bare containers added in the last day Type the annotations that landed in the last 24 hours and ratchet the lint budgets down accordingly. No behavior change. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/integrations/otel/plumbing/routing.py | 5 +++-- litellm/proxy/common_request_processing.py | 9 ++++++--- litellm/proxy/common_utils/reset_budget_job.py | 2 +- litellm/proxy/spend_tracking/budget_reservation.py | 2 +- ruff-strict-budget.json | 4 ++-- ..._experimental_pass_through_adapters_transformation.py | 2 +- .../proxy/common_utils/test_reset_budget_job.py | 4 ++-- 7 files changed, 16 insertions(+), 12 deletions(-) diff --git a/litellm/integrations/otel/plumbing/routing.py b/litellm/integrations/otel/plumbing/routing.py index 6a04dbb9bc8..d2457b9ce57 100644 --- a/litellm/integrations/otel/plumbing/routing.py +++ b/litellm/integrations/otel/plumbing/routing.py @@ -15,7 +15,7 @@ from collections import OrderedDict from collections.abc import Mapping from dataclasses import dataclass from types import MappingProxyType -from typing import Any, Final, TypeAlias +from typing import Final, TypeAlias from urllib.parse import quote from opentelemetry.sdk.trace import TracerProvider @@ -32,6 +32,7 @@ from litellm.integrations.otel.presets import ( dynamic_otlp_headers, project_routing_headers, ) +from litellm.types.utils import StandardCallbackDynamicParams # Exporter kinds that ignore headers — never rewritten with dynamic credentials. _NON_OTLP_KINDS: Final = ("console", "in_memory", "inmemory", "memory") @@ -166,7 +167,7 @@ class TenantTracerCache: def route_for( self, default: Tracer, - dynamic_params: Any, + dynamic_params: StandardCallbackDynamicParams | None, auth_metadata: Mapping[str, str] | None = None, ) -> TenantRoute: """Return the tracer (and trace-detachment flag) for this request. diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index dbbf9cb673e..3d097051f2f 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -4,7 +4,7 @@ import json import logging import math import traceback -from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping +from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping, Sequence from datetime import datetime from functools import lru_cache from types import MappingProxyType @@ -279,7 +279,7 @@ def _deferred_stream_logging_is_armed(request_data: dict) -> bool: ) -def _assembled_model_came_from_a_later_chunk(chunks: list, assembled_model: object) -> bool: +def _assembled_model_came_from_a_later_chunk(chunks: Sequence[object], assembled_model: object) -> bool: """Report whether stream_chunk_builder picked a model the first chunk did not carry. Azure Model Router puts the routed model on the chunks after the first one, and the @@ -301,7 +301,10 @@ def _assembled_model_came_from_a_later_chunk(chunks: list, assembled_model: obje ) -def _assembled_model_is_the_name_the_client_asked_for(request_data: dict, assembled_model: object) -> bool: +def _assembled_model_is_the_name_the_client_asked_for( + request_data: Mapping[str, object], + assembled_model: object, +) -> bool: """Report whether the assembled model is the public name the proxy stamps onto chunks. That stamp is what leaves an unpriced alias on the partial response, so the deployment's diff --git a/litellm/proxy/common_utils/reset_budget_job.py b/litellm/proxy/common_utils/reset_budget_job.py index 8fcb184b26a..bc750243574 100644 --- a/litellm/proxy/common_utils/reset_budget_job.py +++ b/litellm/proxy/common_utils/reset_budget_job.py @@ -1182,7 +1182,7 @@ class ResetBudgetJob: if not raw: continue row_id: str = row[source.id_column] - windows: list = raw if isinstance(raw, list) else json.loads(raw) + windows: list[dict[str, object]] = raw if isinstance(raw, list) else json.loads(raw) changed = False for window in windows: counter_key = f"{source.counter_prefix}:{row_id}:window:{window['budget_duration']}" diff --git a/litellm/proxy/spend_tracking/budget_reservation.py b/litellm/proxy/spend_tracking/budget_reservation.py index ce6c9330620..87d9aa01a08 100644 --- a/litellm/proxy/spend_tracking/budget_reservation.py +++ b/litellm/proxy/spend_tracking/budget_reservation.py @@ -1261,7 +1261,7 @@ def _count_input_tokens_for_models( _INPUT_SIZE_FIELDS: Final = ("messages", "prompt", "input", "query", "documents", "tools", "tool_choice") -def _approximate_input_size(request_body: dict) -> int: +def _approximate_input_size(request_body: Mapping[str, object]) -> int: """Length of the request's input text, a cheap stand-in for tokenizing cost. Every field _count_input_tokens hands the tokenizer is sized here, and diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index a990f7c3830..e651600eb8f 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -24,7 +24,7 @@ "limit": 133 }, "ANN401": { - "limit": 1188 + "limit": 1187 }, "ASYNC230": { "limit": 11 @@ -234,7 +234,7 @@ "limit": 5 }, "TID251": { - "limit": 1212 + "limit": 1211 }, "TRY002": { "limit": 524 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 e4dacc308dc..d4168d88818 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 @@ -635,7 +635,7 @@ def test_translate_anthropic_to_openai_orders_top_level_and_midturn_system(): def _translate_with_metadata( - model: str, metadata: dict[str, Any], custom_llm_provider: str | None + model: str, metadata: dict[str, str], custom_llm_provider: str | None ) -> dict[str, Any]: openai_request, _ = LiteLLMAnthropicMessagesAdapter().translate_anthropic_to_openai( anthropic_message_request={ diff --git a/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py b/tests/test_litellm/proxy/common_utils/test_reset_budget_job.py index 25c177a308d..1d045732c76 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 @@ -1948,14 +1948,14 @@ class FakePodLockManager: if self.redis_cache is not None: self.redis_cache.async_get_cache = AsyncMock(return_value="another-pod" if held_by_other else None) self._acquired = acquired - self.acquire_calls: List[Dict[str, Any]] = [] + self.acquire_calls: List[Dict[str, str | int | None]] = [] self.release_calls: List[str] = [] @staticmethod def get_redis_lock_key(cronjob_id: str) -> str: return f"cronjob_lock:{cronjob_id}" - async def acquire_lock(self, cronjob_id: str, ttl: Any = None) -> bool: + async def acquire_lock(self, cronjob_id: str, ttl: int | None = None) -> bool: self.acquire_calls.append({"cronjob_id": cronjob_id, "ttl": ttl}) return self._acquired From d6feb35a0429d786d68c0818ae151523bac78bc1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:15:27 +0000 Subject: [PATCH 05/90] chore(typing): tighten annotations added in the last day and ratchet budgets Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/batches/batch_utils.py | 4 ++-- litellm/integrations/prometheus.py | 4 +++- .../proxy/management_endpoints/key_management_endpoints.py | 3 +-- litellm/repositories/model_repository.py | 2 +- ruff-strict-budget.json | 6 +++--- 5 files changed, 10 insertions(+), 9 deletions(-) diff --git a/litellm/batches/batch_utils.py b/litellm/batches/batch_utils.py index 6eb13d2cba7..2bc61aed771 100644 --- a/litellm/batches/batch_utils.py +++ b/litellm/batches/batch_utils.py @@ -551,7 +551,7 @@ def _get_batch_job_usage_from_response_body( return usage -def _get_anthropic_result_from_batch_results_line(batch_results_line: Mapping[str, Any]) -> dict: +def _get_anthropic_result_from_batch_results_line(batch_results_line: Mapping[str, Any]) -> Mapping[str, Any]: """ Get the ``result`` object from a line of an Anthropic message batch results JSONL file. @@ -563,7 +563,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" -) -> Any: +) -> Mapping[str, Any]: """ Get the response from the batch job output file """ diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index f9195db1d67..66756be6a6d 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -3552,7 +3552,9 @@ class PrometheusLogger(CustomLogger): except Exception as e: verbose_logger.exception("Error initializing user/team count metrics: %s", e) - async def _set_key_list_budget_metrics(self, keys: list[str | UserAPIKeyAuth | LiteLLM_DeletedVerificationToken]): + async def _set_key_list_budget_metrics( + self, keys: list[str | UserAPIKeyAuth | LiteLLM_DeletedVerificationToken] + ) -> None: """Helper function to set budget metrics for a list of keys""" for key in keys: if isinstance(key, UserAPIKeyAuth): diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 54f567b7aa2..5fe5dda0ca4 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -2326,10 +2326,9 @@ async def _process_single_key_update( prisma_client=prisma_client, ) - _existing_row_metadata: Final = getattr(existing_key_row, "metadata", None) enforce_batch_enqueued_token_limit_is_admin_only( data=update_key_request, - existing_metadata=_existing_row_metadata if isinstance(_existing_row_metadata, dict) else None, + existing_metadata=existing_key_row.metadata, user_api_key_dict=user_api_key_dict, entity="key", ) diff --git a/litellm/repositories/model_repository.py b/litellm/repositories/model_repository.py index 27e23a39cc9..3965aeb2d49 100644 --- a/litellm/repositories/model_repository.py +++ b/litellm/repositories/model_repository.py @@ -36,7 +36,7 @@ class _ProxyModelActions(Protocol): class ModelRepository(BaseRepository[LiteLLM_ProxyModelTable]): """Repository for proxy model database operations with encryption support.""" - def __init__(self, prisma_client: object, encryption_key: str | None = None): + def __init__(self, prisma_client: object, encryption_key: str | None = None) -> None: super().__init__(prisma_client) self._encryption_key = encryption_key diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index e651600eb8f..3e037e5bedc 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -9,13 +9,13 @@ "limit": 827 }, "ANN201": { - "limit": 2017 + "limit": 2016 }, "ANN202": { - "limit": 852 + "limit": 851 }, "ANN204": { - "limit": 711 + "limit": 710 }, "ANN205": { "limit": 112 From f44e7ad9fb6d88d2a9f66f4f1b5965bdb7b39c74 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 07:54:07 +0000 Subject: [PATCH 06/90] chore(typing): drop fresh tech debt suppressions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- basedpyright-code-budget.json | 12 +++---- litellm/llms/custom_httpx/llm_http_handler.py | 6 ++-- litellm/proxy/litellm_pre_call_utils.py | 4 +-- .../openai_files_endpoints/common_utils.py | 9 +++--- litellm/proxy/proxy_server.py | 6 ++-- .../transformation.py | 32 +++++++++---------- ruff-strict-budget.json | 8 ++--- type-discipline-budget.json | 6 ++-- 8 files changed, 41 insertions(+), 42 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 9dcc46ebcee..3f0011c80d2 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,6 +1,6 @@ { "reportAny": { - "limit": 19954 + "limit": 19945 }, "reportArgumentType": { "limit": 2566 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 6049 + "limit": 6048 }, "reportFunctionMemberAccess": { "limit": 7 @@ -57,7 +57,7 @@ "limit": 5663 }, "reportMissingTypeArgument": { - "limit": 15553 + "limit": 15545 }, "reportMissingTypeStubs": { "limit": 40 @@ -105,13 +105,13 @@ "limit": 109 }, "reportUnknownMemberType": { - "limit": 39007 + "limit": 38998 }, "reportUnknownParameterType": { - "limit": 19883 + "limit": 19876 }, "reportUnknownVariableType": { - "limit": 30568 + "limit": 30554 }, "reportUnnecessaryCast": { "limit": 117 diff --git a/litellm/llms/custom_httpx/llm_http_handler.py b/litellm/llms/custom_httpx/llm_http_handler.py index ed079197513..cdd81b24ca3 100644 --- a/litellm/llms/custom_httpx/llm_http_handler.py +++ b/litellm/llms/custom_httpx/llm_http_handler.py @@ -5594,10 +5594,9 @@ class BaseLLMHTTPHandler: kwargs=hook_kwargs, ) except Exception as e: - _call_id = getattr(logging_obj, "litellm_call_id", "unknown") verbose_logger.exception( "LiteLLM.AgenticHookError: Exception in async_should_run_agentic_loop [call_id=%s model=%s]: %s", - _call_id, + logging_obj.litellm_call_id, model, str(e), ) @@ -5619,10 +5618,9 @@ class BaseLLMHTTPHandler: except AgenticLoopSafetyError as e: if not self._can_replace_turn_with_terminal_response(stream, api_surface): raise - _call_id = getattr(logging_obj, "litellm_call_id", "unknown") verbose_logger.warning( "LiteLLM.AgenticLoopRefused: ending turn [call_id=%s model=%s]: %s", - _call_id, + logging_obj.litellm_call_id, model, str(e), ) diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index cb5002e431b..064b53e07b7 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -4,7 +4,7 @@ import json import re import time from collections import OrderedDict -from collections.abc import Mapping +from collections.abc import Mapping, MutableMapping from types import MappingProxyType from typing import TYPE_CHECKING, Any, Final @@ -1629,7 +1629,7 @@ class LiteLLMProxyRequestSetup: def refresh_proxy_server_request_body_snapshot( - data: dict, # mutable-ok: mutates proxy_server_request.body in place on the shared request dict + data: MutableMapping[str, object], ) -> None: """ Re-snapshot ``data["proxy_server_request"]["body"]`` from the current state of ``data``. diff --git a/litellm/proxy/openai_files_endpoints/common_utils.py b/litellm/proxy/openai_files_endpoints/common_utils.py index 142aced4a38..134ed74ae65 100644 --- a/litellm/proxy/openai_files_endpoints/common_utils.py +++ b/litellm/proxy/openai_files_endpoints/common_utils.py @@ -1346,11 +1346,12 @@ def _completed_batch_safe_to_retire(response: "LiteLLMBatch") -> bool: reports no successful request lines. When counts are unknown, stay eligible so the next poller pass revisits it. (#37713) """ - if getattr(response, "output_file_id", None) is not None: + if response.output_file_id is not None: return True - request_counts = getattr(response, "request_counts", None) - completed = getattr(request_counts, "completed", None) - return completed == 0 + request_counts = response.request_counts + if request_counts is None: + return False + return request_counts.completed == 0 async def update_batch_in_database( diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7dced4e26b6..e14a64a9ff8 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -4084,7 +4084,7 @@ def resolve_complexity_router_plugins( complexity_router_config["classifier_plugin"] = resolved_classifier # rebind-ok: out-param, resolved in place -def validate_deployment_max_agentic_loops(model: Mapping[str, Any]) -> None: +def validate_deployment_max_agentic_loops(model: Mapping[str, object]) -> None: """ Reject a per-deployment `max_agentic_loops` the agentic loop cannot honor. @@ -4094,7 +4094,9 @@ def validate_deployment_max_agentic_loops(model: Mapping[str, Any]) -> None: start. Left unchecked entirely, a `0` used to read as the default ceiling of 3 and a non-integer failed every request to that model instead. """ - litellm_params: Final = model.get("litellm_params") or {} + litellm_params: Final = model.get("litellm_params") + if not isinstance(litellm_params, Mapping): + return if "max_agentic_loops" not in litellm_params: return diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index b8d7b726a28..db12361f70f 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1272,16 +1272,14 @@ class LiteLLMCompletionResponsesConfig: if isinstance(content, str) and content.strip(): return content if isinstance(content, list): - text_parts: Final[list[str]] = [] # mutable-ok: text accumulator - for block in content: - if not isinstance(block, Mapping): - continue - block_type = block.get("type") - if block_type in ("encrypted_content", "redacted_thinking"): - continue - text = block.get("text") - if isinstance(text, str) and text.strip(): - text_parts.append(text.strip()) + text_parts: Final = tuple( + text.strip() + for block in content + if isinstance(block, Mapping) + and block.get("type") not in ("encrypted_content", "redacted_thinking") + and isinstance(text := block.get("text"), str) + and text.strip() + ) if text_parts: return "\n".join(text_parts) return None @@ -1297,13 +1295,13 @@ class LiteLLMCompletionResponsesConfig: summary: Final[object] = input_item.get("summary") if not isinstance(summary, list): return None - text_parts: Final[list[str]] = [] # mutable-ok: text accumulator - for block in summary: - if not isinstance(block, Mapping): - continue - text = block.get("text") - if isinstance(text, str) and text.strip(): - text_parts.append(text.strip()) + text_parts: Final = tuple( + text.strip() + for block in summary + if isinstance(block, Mapping) + and isinstance(text := block.get("text"), str) + and text.strip() + ) return "\n".join(text_parts) if text_parts else None @staticmethod diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 3e037e5bedc..3585c7a7bd3 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -12,10 +12,10 @@ "limit": 2016 }, "ANN202": { - "limit": 851 + "limit": 850 }, "ANN204": { - "limit": 710 + "limit": 709 }, "ANN205": { "limit": 112 @@ -24,7 +24,7 @@ "limit": 133 }, "ANN401": { - "limit": 1187 + "limit": 1185 }, "ASYNC230": { "limit": 11 @@ -234,7 +234,7 @@ "limit": 5 }, "TID251": { - "limit": 1211 + "limit": 1210 }, "TRY002": { "limit": 524 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 81f7c6aa40b..4f2314b2a0a 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22801 + "limit": 22795 }, "LIT002": { - "limit": 26873 + "limit": 26872 }, "LIT003": { "limit": 269 @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16673 + "limit": 16672 }, "LIT011": { "limit": 5588 From cb2f5c664163798bb348b88563c3f41ce8a1e96c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:07:22 +0000 Subject: [PATCH 07/90] style(typing): format reasoning extraction Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../litellm_completion_transformation/transformation.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/litellm/responses/litellm_completion_transformation/transformation.py b/litellm/responses/litellm_completion_transformation/transformation.py index db12361f70f..3cc8db3f357 100644 --- a/litellm/responses/litellm_completion_transformation/transformation.py +++ b/litellm/responses/litellm_completion_transformation/transformation.py @@ -1298,9 +1298,7 @@ class LiteLLMCompletionResponsesConfig: text_parts: Final = tuple( text.strip() for block in summary - if isinstance(block, Mapping) - and isinstance(text := block.get("text"), str) - and text.strip() + if isinstance(block, Mapping) and isinstance(text := block.get("text"), str) and text.strip() ) return "\n".join(text_parts) if text_parts else None From 11061d13c9b22d0933b1a153b627a1420d93b0ef Mon Sep 17 00:00:00 2001 From: eugene-yao-zocdoc Date: Mon, 24 Aug 2026 12:22:45 -0400 Subject: [PATCH 08/90] fix(redis): support credential providers across clients --- litellm/_redis.py | 115 +++++--- litellm/caching/redis_cache.py | 20 +- litellm/caching/redis_cluster_cache.py | 17 +- pyproject.toml | 1 + tests/test_litellm/test_redis.py | 391 +++++++++++++++++++++++-- uv.lock | 2 + 6 files changed, 461 insertions(+), 85 deletions(-) diff --git a/litellm/_redis.py b/litellm/_redis.py index 58f37cf569d..0f9716a5396 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -50,6 +50,7 @@ def _get_redis_kwargs(): include_args: Final = { "url", "redis_connect_func", + "credential_provider", "gcp_service_account", "gcp_ssl_ca_certs", "azure_redis_ad_token", @@ -155,7 +156,8 @@ def _get_redis_cluster_kwargs(client=None): def _get_redis_env_kwarg_mapping(): PREFIX: Final = "REDIS_" - return {f"{PREFIX}{x.upper()}": x for x in _get_redis_kwargs()} + exclude_from_environment: Final = {"credential_provider"} + return {f"{PREFIX}{x.upper()}": x for x in _get_redis_kwargs() if x not in exclude_from_environment} def _redis_kwargs_from_environment(): @@ -410,54 +412,58 @@ def _get_redis_client_logic(**env_overrides): if _service_name is not None: redis_kwargs["service_name"] = _service_name - # Handle GCP IAM authentication - _gcp_service_account: Final = redis_kwargs.get("gcp_service_account") or get_secret_str("REDIS_GCP_SERVICE_ACCOUNT") - _gcp_ssl_ca_certs: Final = redis_kwargs.get("gcp_ssl_ca_certs") or get_secret_str("REDIS_GCP_SSL_CA_CERTS") - - if _gcp_service_account is not None: - verbose_logger.debug("Setting up GCP IAM authentication for Redis with service account.") - redis_kwargs["redis_connect_func"] = create_gcp_iam_redis_connect_func( - service_account=_gcp_service_account, ssl_ca_certs=_gcp_ssl_ca_certs + if redis_kwargs.get("credential_provider") is None: + # Handle GCP IAM authentication + _gcp_service_account: Final = redis_kwargs.get("gcp_service_account") or get_secret_str( + "REDIS_GCP_SERVICE_ACCOUNT" ) - # Store GCP service account in redis_connect_func for async cluster access - redis_kwargs["redis_connect_func"]._gcp_service_account = _gcp_service_account + _gcp_ssl_ca_certs: Final = redis_kwargs.get("gcp_ssl_ca_certs") or get_secret_str("REDIS_GCP_SSL_CA_CERTS") - # Remove GCP-specific kwargs that shouldn't be passed to Redis client - redis_kwargs.pop("gcp_service_account", None) - redis_kwargs.pop("gcp_ssl_ca_certs", None) + if _gcp_service_account is not None: + verbose_logger.debug("Setting up GCP IAM authentication for Redis with service account.") + redis_kwargs["redis_connect_func"] = create_gcp_iam_redis_connect_func( + service_account=_gcp_service_account, ssl_ca_certs=_gcp_ssl_ca_certs + ) + # Store GCP service account in redis_connect_func for async cluster access + redis_kwargs["redis_connect_func"]._gcp_service_account = _gcp_service_account - # Only enable SSL if explicitly requested AND SSL CA certs are provided - if _gcp_ssl_ca_certs and redis_kwargs.get("ssl", False): - redis_kwargs["ssl_ca_certs"] = _gcp_ssl_ca_certs + # Only enable SSL if explicitly requested AND SSL CA certs are provided + if _gcp_ssl_ca_certs and redis_kwargs.get("ssl", False): + redis_kwargs["ssl_ca_certs"] = _gcp_ssl_ca_certs - # Handle Azure AD authentication (after GCP IAM block) - _azure_redis_ad_token: Final = redis_kwargs.get("azure_redis_ad_token") or get_secret("REDIS_AZURE_AD_TOKEN") + # Handle Azure AD authentication (after GCP IAM block) + _azure_redis_ad_token: Final = redis_kwargs.get("azure_redis_ad_token") or get_secret("REDIS_AZURE_AD_TOKEN") - _azure_ad_enabled: Final = _azure_redis_ad_token is not None and str(_azure_redis_ad_token).lower() == "true" + _azure_ad_enabled: Final = _azure_redis_ad_token is not None and str(_azure_redis_ad_token).lower() == "true" - if _azure_ad_enabled and _gcp_service_account is not None: - verbose_logger.warning( - "Both GCP IAM (gcp_service_account) and Azure AD (azure_redis_ad_token) are configured for Redis. " - "Using GCP IAM. Remove one to avoid misconfiguration." - ) + if _azure_ad_enabled and _gcp_service_account is not None: + verbose_logger.warning( + "Both GCP IAM (gcp_service_account) and Azure AD (azure_redis_ad_token) are configured for Redis. " + "Using GCP IAM. Remove one to avoid misconfiguration." + ) - if _azure_ad_enabled and _gcp_service_account is None: - _azure_client_id: Final = redis_kwargs.get("azure_client_id") or get_secret_str("AZURE_CLIENT_ID") - _azure_tenant_id: Final = redis_kwargs.get("azure_tenant_id") or get_secret_str("AZURE_TENANT_ID") - _azure_client_secret: Final = redis_kwargs.get("azure_client_secret") or get_secret_str("AZURE_CLIENT_SECRET") + if _azure_ad_enabled and _gcp_service_account is None: + _azure_client_id: Final = redis_kwargs.get("azure_client_id") or get_secret_str("AZURE_CLIENT_ID") + _azure_tenant_id: Final = redis_kwargs.get("azure_tenant_id") or get_secret_str("AZURE_TENANT_ID") + _azure_client_secret: Final = redis_kwargs.get("azure_client_secret") or get_secret_str( + "AZURE_CLIENT_SECRET" + ) - verbose_logger.debug("Setting up Azure AD authentication for Redis.") - redis_kwargs["redis_connect_func"] = create_azure_ad_redis_connect_func( - azure_client_id=_azure_client_id, - azure_tenant_id=_azure_tenant_id, - azure_client_secret=_azure_client_secret, - ) - # Marker for async paths to detect Azure AD auth. The live credential - # object is attached separately as `_azure_credential` by - # `create_azure_ad_redis_connect_func`; the raw client_id/tenant_id/secret - # are intentionally NOT exposed on the function to avoid leaking - # credentials via inspection or logging. - redis_kwargs["redis_connect_func"]._azure_redis_ad_token = True + verbose_logger.debug("Setting up Azure AD authentication for Redis.") + redis_kwargs["redis_connect_func"] = create_azure_ad_redis_connect_func( + azure_client_id=_azure_client_id, + azure_tenant_id=_azure_tenant_id, + azure_client_secret=_azure_client_secret, + ) + # Marker for async paths to detect Azure AD auth. The live credential + # object is attached separately as `_azure_credential` by + # `create_azure_ad_redis_connect_func`; the raw client_id/tenant_id/secret + # are intentionally NOT exposed on the function to avoid leaking + # credentials via inspection or logging. + redis_kwargs["redis_connect_func"]._azure_redis_ad_token = True + + redis_kwargs.pop("gcp_service_account", None) + redis_kwargs.pop("gcp_ssl_ca_certs", None) # Always remove Azure-specific kwargs that shouldn't be passed to Redis client redis_kwargs.pop("azure_redis_ad_token", None) @@ -465,6 +471,16 @@ def _get_redis_client_logic(**env_overrides): redis_kwargs.pop("azure_tenant_id", None) redis_kwargs.pop("azure_client_secret", None) + if redis_kwargs.get("credential_provider") is not None: + redis_kwargs.pop("redis_connect_func", None) + redis_kwargs.pop("username", None) + redis_kwargs.pop("password", None) + if redis_kwargs.get("url") is not None: + from urllib.parse import urlsplit, urlunsplit + + parsed_url = urlsplit(redis_kwargs["url"]) + redis_kwargs["url"] = urlunsplit(parsed_url._replace(netloc=parsed_url.netloc.rsplit("@", 1)[-1])) + if "url" in redis_kwargs and redis_kwargs["url"] is not None: # Only strip host/port/db/password when not routing to a cluster. # When startup_nodes is also present the cluster path takes priority and @@ -474,6 +490,8 @@ def _get_redis_client_logic(**env_overrides): redis_kwargs.pop("port", None) redis_kwargs.pop("db", None) redis_kwargs.pop("password", None) + if redis_kwargs.get("credential_provider") is not None: + redis_kwargs.pop("username", None) elif ( "startup_nodes" in redis_kwargs and redis_kwargs["startup_nodes"] is not None @@ -532,8 +550,7 @@ def _init_redis_sentinel(redis_kwargs) -> redis.Redis: service_name: Final = redis_kwargs.get("service_name") connection_kwargs: Final = _get_redis_sentinel_connection_kwargs(redis_kwargs) connection_kwargs.setdefault("socket_timeout", REDIS_SOCKET_TIMEOUT) - sentinel_kwargs: Final = dict(connection_kwargs) - sentinel_kwargs["password"] = sentinel_password + sentinel_kwargs: Final = _sentinel_auth_kwargs(connection_kwargs, sentinel_password) if not sentinel_nodes or not service_name: raise ValueError("Both 'sentinel_nodes' and 'service_name' are required for Redis Sentinel.") @@ -605,13 +622,19 @@ def _async_credential_provider(redis_connect_func: object | None) -> CredentialP def _async_auth_kwargs(redis_kwargs: dict) -> dict: """Swaps a connect func an async path cannot run for the equivalent credential provider, which supersedes any static username or password redis-py would otherwise reject it with.""" + explicit_provider: Final = redis_kwargs.get("credential_provider") + if explicit_provider is not None: + superseded: Final = frozenset({"redis_connect_func", "username", "password"}) + kept: Final = ((k, v) for k, v in redis_kwargs.items() if k not in superseded) + return dict(kept) + credential_provider: Final = _async_credential_provider(redis_kwargs.get("redis_connect_func")) if credential_provider is None: return redis_kwargs - superseded: Final = frozenset({"redis_connect_func", "username", "password"}) - kept: Final = ((k, v) for k, v in redis_kwargs.items() if k not in superseded) - return dict(kept, credential_provider=credential_provider) # mutable-ok: the branches below mutate these kwargs + automatic_superseded: Final = frozenset({"redis_connect_func", "username", "password"}) + automatic_kept: Final = ((k, v) for k, v in redis_kwargs.items() if k not in automatic_superseded) + return dict(automatic_kept, credential_provider=credential_provider) def get_redis_client(**env_overrides): diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index 934ba500ef9..991b6c8c6c5 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -401,9 +401,21 @@ class RedisCache(BaseCache): """ # Create a stable representation of redis_kwargs for hashing # Sort keys to ensure consistent hash regardless of parameter order - sorted_kwargs: Final = sorted(self.redis_kwargs.items()) + redis_kwargs: Final[dict[str, object]] = self.redis_kwargs + provider: Final = redis_kwargs.get("credential_provider") + redis_connect_func: Final = redis_kwargs.get("redis_connect_func") + sorted_kwargs: Final = sorted( + item for item in redis_kwargs.items() if item[0] not in {"credential_provider", "redis_connect_func"} + ) kwargs_str: Final = json.dumps(sorted_kwargs, sort_keys=True) - kwargs_hash: Final = hashlib.sha256(kwargs_str.encode()).hexdigest()[:16] + identity_suffix: Final = ( + "" + if provider is None and redis_connect_func is None + else f":provider-{id(provider)}" + if provider is not None + else f":connect-func-{id(redis_connect_func)}" + ) + kwargs_hash: Final = hashlib.sha256(f"{kwargs_str}{identity_suffix}".encode()).hexdigest()[:16] return f"async-redis-client-{kwargs_hash}" def init_async_client( @@ -1384,10 +1396,10 @@ class RedisCache(BaseCache): dict: {"status": "success" | "failed", "message": str, "error": Optional[str]} """ try: - import redis.asyncio as redis_async + from .._redis import get_redis_async_client # Create a fresh Redis client with current settings - redis_client: Final = redis_async.Redis(**self.redis_kwargs) + redis_client: Final = get_redis_async_client(**self.redis_kwargs) # Test the connection ping_result: Final = await redis_client.ping() diff --git a/litellm/caching/redis_cluster_cache.py b/litellm/caching/redis_cluster_cache.py index b6dd8047fd4..12d285ca5a8 100644 --- a/litellm/caching/redis_cluster_cache.py +++ b/litellm/caching/redis_cluster_cache.py @@ -64,22 +64,9 @@ class RedisClusterCache(RedisCache): dict: {"status": "success" | "failed", "message": str, "error": Optional[str]} """ try: - import redis.asyncio as redis_async - from redis.cluster import ClusterNode + from .._redis import get_redis_async_client - # Create ClusterNode objects from startup_nodes - cluster_kwargs: Final = self.redis_kwargs.copy() - startup_nodes: Final = cluster_kwargs.pop("startup_nodes", []) - - new_startup_nodes: Final[list[ClusterNode]] = [] - for item in startup_nodes: - new_startup_nodes.append(ClusterNode(**item)) - - # Create a fresh Redis Cluster client with current settings - redis_client: Final = redis_async.RedisCluster( - startup_nodes=new_startup_nodes, - **cluster_kwargs, - ) + redis_client: Final = get_redis_async_client(**self.redis_kwargs) # Test the connection ping_result: Final = await redis_client.ping() diff --git a/pyproject.toml b/pyproject.toml index fca5c7da1e2..c80ba143512 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,7 @@ proxy = [ "backoff>=2.2.1,<3.0", "pyyaml>=6.0.3,<7.0", "rq>=2.7.0,<3.0", + "redis>=5.3.1,<6.0", "orjson>=3.11.6,<4.0", # redis-py's C response parser. It arrives with redis (via rq) either way; naming # it here is what makes redis-py select _HiredisParser instead of the Python one. diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index 3762181f5c3..38b2bd5296a 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -1,13 +1,19 @@ import json from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest import redis import redis.asyncio as async_redis +from redis.credentials import CredentialProvider +import litellm from litellm._redis import ( + _get_redis_client_logic, _get_redis_cluster_kwargs, + _get_redis_env_kwarg_mapping, + _get_redis_kwargs, + _get_redis_url_kwargs, get_redis_async_client, get_redis_client, get_redis_connection_pool, @@ -18,9 +24,69 @@ from litellm._redis_credential_provider import ( GCPIAMCredentialProvider, _token_cache, ) +from litellm.caching.redis_cache import RedisCache +from litellm.caching.redis_cluster_cache import RedisClusterCache from litellm.constants import REDIS_CLUSTER_HEALTH_CHECK_INTERVAL +class _StubCredentialProvider(CredentialProvider): + def __init__(self, token: str = "stub-token") -> None: + self._token = token + + def get_credentials(self): + return (self._token,) + + async def get_credentials_async(self): + return (self._token,) + + +class _HostileCredentialProvider(CredentialProvider): + def __init__(self, secret: str) -> None: + self._payload = secret + + def get_credentials(self): + return (self._payload,) + + async def get_credentials_async(self): + return (self._payload,) + + def __repr__(self): + raise AssertionError("provider repr must never be invoked") + + def __str__(self): + raise AssertionError("provider str must never be invoked") + + def __reduce__(self): + raise AssertionError("provider must never be serialized") + + def __getstate__(self): + raise AssertionError("provider state must never be inspected") + + +def _gcp_marker_callback() -> MagicMock: + callback = MagicMock() + callback._gcp_service_account = "projects/-/serviceAccounts/sa@project.iam.gserviceaccount.com" + return callback + + +@pytest.fixture +def clean_redis_environment(monkeypatch): + for var in ( + "REDIS_URL", + "REDIS_CLUSTER_NODES", + "REDIS_SENTINEL_NODES", + *_get_redis_env_kwarg_mapping(), + ): + monkeypatch.delenv(var, raising=False) + + +@pytest.fixture +def clear_llm_client_cache(): + litellm.in_memory_llm_clients_cache.flush_cache() + yield + litellm.in_memory_llm_clients_cache.flush_cache() + + @pytest.fixture(autouse=True) def clear_gcp_iam_token_cache(): """Reset the module-level GCP IAM token cache between tests.""" @@ -29,6 +95,289 @@ def clear_gcp_iam_token_cache(): _token_cache.clear() +def test_redis_uses_the_hiredis_response_parser(): + """The proxy extra must keep redis-py's C response parser available.""" + from redis._parsers import _HiredisParser + from redis.connection import HIREDIS_AVAILABLE, DefaultParser + + if not HIREDIS_AVAILABLE: + pytest.skip("hiredis is not installed in this test environment") + + assert DefaultParser is _HiredisParser + + client = get_redis_client(host="redis-host", port=6379) + connection = client.connection_pool.make_connection() + assert isinstance(connection._parser, _HiredisParser) + + +def test_redis_allowlists_include_credential_provider(): + assert "credential_provider" in _get_redis_kwargs() + assert "credential_provider" in _get_redis_url_kwargs() + assert "credential_provider" in _get_redis_cluster_kwargs() + + +def test_credential_provider_is_not_environment_derived(): + mapping = _get_redis_env_kwarg_mapping() + assert "REDIS_CREDENTIAL_PROVIDER" not in mapping + assert "credential_provider" not in mapping.values() + + +def test_sync_direct_preserves_credential_provider_identity(clean_redis_environment): + provider = _StubCredentialProvider() + + client = get_redis_client(host="redis-host", port=6379, credential_provider=provider) + + assert client.connection_pool.connection_kwargs["credential_provider"] is provider + + +def test_sync_direct_provider_supersedes_static_credentials(clean_redis_environment): + provider = _StubCredentialProvider() + + client = get_redis_client( + host="redis-host", + port=6379, + username="redis-user", + password="redis-password", + credential_provider=provider, + ) + connection = client.connection_pool.make_connection() + + assert connection.credential_provider is provider + assert connection.username is None + assert connection.password is None + + +def test_sync_direct_provider_supersedes_environment_credentials(clean_redis_environment, monkeypatch): + provider = _StubCredentialProvider() + monkeypatch.setenv("REDIS_USERNAME", "redis-user") + monkeypatch.setenv("REDIS_PASSWORD", "redis-password") + + client = get_redis_client(host="redis-host", port=6379, credential_provider=provider) + connection = client.connection_pool.make_connection() + + assert connection.credential_provider is provider + assert connection.username is None + assert connection.password is None + + +def test_sync_url_preserves_credential_provider_identity(clean_redis_environment): + provider = _StubCredentialProvider() + + client = get_redis_client(url="redis://redis-host:6379", credential_provider=provider) + + assert client.connection_pool.connection_kwargs["credential_provider"] is provider + + +def test_async_direct_preserves_credential_provider_identity(clean_redis_environment): + provider = _StubCredentialProvider() + + client = get_redis_async_client(host="redis-host", port=6379, credential_provider=provider) + + assert client.connection_pool.connection_kwargs["credential_provider"] is provider + + +def test_async_url_preserves_credential_provider_identity(clean_redis_environment): + provider = _StubCredentialProvider() + + client = get_redis_async_client(url="redis://redis-host:6379", credential_provider=provider) + + assert client.connection_pool.connection_kwargs["credential_provider"] is provider + + +def test_sync_url_credentials_do_not_replace_explicit_provider(clean_redis_environment): + provider = _StubCredentialProvider() + + client = get_redis_client( + url="redis://url-user:url-pass@redis-host:6379", + credential_provider=provider, + ) + connection = client.connection_pool.make_connection() + + assert connection.credential_provider is provider + assert connection.username is None + assert connection.password is None + + +def test_async_url_credentials_do_not_replace_explicit_provider(clean_redis_environment): + provider = _StubCredentialProvider() + + client = get_redis_async_client( + url="redis://url-user:url-pass@redis-host:6379", + credential_provider=provider, + ) + connection = client.connection_pool.make_connection() + + assert connection.credential_provider is provider + assert connection.username is None + assert connection.password is None + + +def test_async_host_port_pool_preserves_credential_provider_identity(clean_redis_environment): + provider = _StubCredentialProvider() + + pool = get_redis_connection_pool(host="redis-host", port=6379, credential_provider=provider) + + assert pool is not None + assert pool.connection_kwargs["credential_provider"] is provider + + +def test_async_url_pool_preserves_credential_provider_identity(clean_redis_environment): + provider = _StubCredentialProvider() + + pool = get_redis_connection_pool(url="redis://redis-host:6379", credential_provider=provider) + + assert pool is not None + assert pool.connection_kwargs["credential_provider"] is provider + + +def test_sync_cluster_preserves_credential_provider_identity(clean_redis_environment): + provider = _StubCredentialProvider() + startup_nodes = [{"host": "cluster-node", "port": 6379}] + + with patch("litellm._redis.redis.RedisCluster", autospec=True) as mock_cluster_cls: + get_redis_client(startup_nodes=startup_nodes, credential_provider=provider) + + mock_cluster_cls.assert_called_once() + assert mock_cluster_cls.call_args[1].get("credential_provider") is provider + + +def test_async_cluster_preserves_credential_provider_identity(clean_redis_environment): + provider = _StubCredentialProvider() + startup_nodes = [{"host": "cluster-node", "port": 6379}] + + with patch("litellm.caching.redis_cluster_node_isolation.get_litellm_async_redis_cluster_class") as mock_class: + get_redis_async_client(startup_nodes=startup_nodes, credential_provider=provider) + + call_kwargs = mock_class.return_value.call_args[1] + assert call_kwargs.get("credential_provider") is provider + + +def test_explicit_provider_skips_automatic_auth_and_callback(clean_redis_environment, monkeypatch): + provider = _StubCredentialProvider() + monkeypatch.setenv("REDIS_GCP_SERVICE_ACCOUNT", "service-account@example.com") + monkeypatch.setenv("REDIS_AZURE_AD_TOKEN", "true") + + with ( + patch("litellm._redis.create_gcp_iam_redis_connect_func") as mock_gcp, + patch("litellm._redis.create_azure_ad_redis_connect_func") as mock_azure, + ): + redis_kwargs = _get_redis_client_logic( + host="redis-host", + port=6379, + credential_provider=provider, + redis_connect_func=_gcp_marker_callback(), + ) + + mock_gcp.assert_not_called() + mock_azure.assert_not_called() + assert redis_kwargs["credential_provider"] is provider + assert "redis_connect_func" not in redis_kwargs + + +def test_async_direct_explicit_provider_is_preserved_when_normalization_is_bypassed(): + provider = _StubCredentialProvider() + redis_kwargs = { + "host": "redis-host", + "port": 6379, + "credential_provider": provider, + "redis_connect_func": _gcp_marker_callback(), + } + + with ( + patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs), + patch("litellm._redis.async_redis.Redis", autospec=True) as mock_redis, + ): + get_redis_async_client() + + call_kwargs = mock_redis.call_args[1] + assert call_kwargs["credential_provider"] is provider + assert "redis_connect_func" not in call_kwargs + + +def test_async_pool_explicit_provider_is_preserved_when_normalization_is_bypassed(): + provider = _StubCredentialProvider() + redis_kwargs = { + "host": "redis-host", + "port": 6379, + "credential_provider": provider, + "redis_connect_func": _gcp_marker_callback(), + } + + with ( + patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs), + patch("litellm._redis.async_redis.BlockingConnectionPool", autospec=True) as mock_pool, + ): + get_redis_connection_pool() + + call_kwargs = mock_pool.call_args[1] + assert call_kwargs["credential_provider"] is provider + assert "redis_connect_func" not in call_kwargs + + +@pytest.mark.asyncio +async def test_redis_cache_test_connection_uses_shared_factory(clean_redis_environment): + provider = _StubCredentialProvider() + client = MagicMock(spec=async_redis.Redis) + client.ping = AsyncMock(return_value=True) + client.aclose = AsyncMock() + + with patch("litellm._redis.get_redis_async_client", return_value=client) as mock_factory: + cache = RedisCache(host="redis-host", port=6379, credential_provider=provider) + result = await cache.test_connection() + + assert result["status"] == "success" + call_kwargs = mock_factory.call_args.kwargs + assert call_kwargs["credential_provider"] is provider + + +@pytest.mark.asyncio +async def test_redis_cluster_cache_test_connection_uses_shared_factory(clean_redis_environment): + provider = _StubCredentialProvider() + client = MagicMock(spec=async_redis.RedisCluster) + client.ping = AsyncMock(return_value=True) + client.aclose = AsyncMock() + + with patch("litellm._redis.get_redis_async_client", return_value=client) as mock_factory: + with patch("litellm._redis.get_redis_client", return_value=MagicMock(spec=redis.RedisCluster)): + cache = RedisClusterCache( + startup_nodes=[{"host": "redis-host", "port": 6379}], credential_provider=provider + ) + result = await cache.test_connection() + + assert result["status"] == "success" + call_kwargs = mock_factory.call_args.kwargs + assert call_kwargs["credential_provider"] is provider + + +def test_redis_cache_key_does_not_inspect_provider(clear_llm_client_cache): + provider = _HostileCredentialProvider("synthetic-secret") + second_provider = _StubCredentialProvider("another-token") + sync_client = MagicMock(spec=redis.Redis) + async_pool = MagicMock(spec=async_redis.BlockingConnectionPool) + + with ( + patch("litellm._redis.get_redis_client", return_value=sync_client), + patch("litellm._redis.get_redis_connection_pool", return_value=async_pool), + ): + cache = RedisCache(host="redis-host", port=6379, credential_provider=provider) + second_cache = RedisCache(host="redis-host", port=6379, credential_provider=second_provider) + + first_key = cache._get_async_client_cache_key() + assert first_key == cache._get_async_client_cache_key() + assert first_key != second_cache._get_async_client_cache_key() + + +def test_redis_cache_key_does_not_serialize_connect_func(): + def connect(connection): + return None + + cache = RedisCache.__new__(RedisCache) + cache.redis_kwargs = {"host": "redis-host", "port": 6379, "redis_connect_func": connect} + + first_key = cache._get_async_client_cache_key() + assert first_key == cache._get_async_client_cache_key() + + def test_get_redis_url_from_environment_single_url(monkeypatch): """Test when REDIS_URL is directly provided""" # Set the environment variable @@ -500,6 +849,27 @@ def test_sync_sentinel_uses_sentinel_password_and_master_password(mock_sentinel_ ) +@patch("litellm._redis.redis.Sentinel") +def test_sync_sentinel_keeps_provider_off_monitors_and_on_master(mock_sentinel_cls): + provider = _StubCredentialProvider() + mock_sentinel = MagicMock() + mock_sentinel_cls.return_value = mock_sentinel + + get_redis_client( + sentinel_nodes=[("sentinel-1", 26379)], + sentinel_password="sentinel-secret", + service_name="mymaster", + password="redis-secret", + credential_provider=provider, + ) + + sentinel_kwargs = mock_sentinel_cls.call_args.kwargs["sentinel_kwargs"] + assert sentinel_kwargs["password"] == "sentinel-secret" + assert "credential_provider" not in sentinel_kwargs + assert mock_sentinel.master_for.call_args.kwargs["credential_provider"] is provider + assert "password" not in mock_sentinel.master_for.call_args.kwargs + + @patch("litellm._redis.async_redis.Sentinel") def test_async_sentinel_uses_sentinel_password_and_master_password( mock_sentinel_cls, @@ -814,25 +1184,6 @@ def test_url_config_drops_kwargs_the_connection_cannot_accept(client_only_kwarg, assert pool.connection_kwargs.get("socket_timeout") == 5.0 -def test_redis_uses_the_hiredis_response_parser(): - """The C parser must be the one redis-py actually picks. - - hiredis is declared in the `proxy` extra purely for speed; nothing imports it, so - dropping it from pyproject.toml would silently fall back to the pure-Python parser - with no other symptom. redis-py selects it at import time, so asserting on the - selection is what catches that. - """ - from redis._parsers import _HiredisParser - from redis.connection import HIREDIS_AVAILABLE, DefaultParser - - assert HIREDIS_AVAILABLE, "hiredis is not installed; redis-py fell back to the pure-Python parser" - assert DefaultParser is _HiredisParser, f"redis-py selected {DefaultParser.__name__}, expected _HiredisParser" - - client = get_redis_client(host="redis-host", port=6379) - connection = client.connection_pool.make_connection() - assert isinstance(connection._parser, _HiredisParser) - - def test_init_arg_names_sees_through_decorated_inits(): """redis-py >= 7.4 wraps AbstractConnection.__init__ with @deprecated_args, whose wrapper is declared (self, *args, **kwargs). Introspecting the wrapper directly diff --git a/uv.lock b/uv.lock index 628483c0117..f8af2f60b11 100644 --- a/uv.lock +++ b/uv.lock @@ -4345,6 +4345,7 @@ proxy = [ { name = "restrictedpython" }, { name = "rich" }, { name = "rq" }, + { name = "redis" }, { name = "soundfile" }, { name = "starlette" }, { name = "uvicorn" }, @@ -4557,6 +4558,7 @@ requires-dist = [ { name = "restrictedpython", marker = "extra == 'proxy'", specifier = ">=8.1,<9.0" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13.9.4,<14.0" }, { name = "rich", marker = "extra == 'proxy'", specifier = ">=13.9.4,<14.0" }, + { name = "redis", marker = "extra == 'proxy'", specifier = ">=5.3.1,<6.0" }, { name = "rq", marker = "extra == 'proxy'", specifier = ">=2.7.0,<3.0" }, { name = "semantic-router", marker = "python_full_version < '3.14' and extra == 'semantic-router'", specifier = ">=0.1.15,<1.0" }, { name = "sentry-sdk", marker = "extra == 'proxy-runtime'", specifier = ">=2.21.0,<3.0" }, From a4be6a9a6fdbd85b5dbc369abb0f2247be89cfc2 Mon Sep 17 00:00:00 2001 From: eugene-yao-zocdoc Date: Mon, 24 Aug 2026 16:36:23 -0400 Subject: [PATCH 09/90] fix(redis): address credential provider review findings Generated with AI Co-Authored-By: Claude Code --- litellm/_redis.py | 35 ++++++----- litellm/caching/redis_cache.py | 26 +++----- pyproject.toml | 4 +- tests/test_litellm/test_redis.py | 103 ++++++++++++++++++++++++++----- uv.lock | 4 +- 5 files changed, 121 insertions(+), 51 deletions(-) diff --git a/litellm/_redis.py b/litellm/_redis.py index 0f9716a5396..6ff4c292c47 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -14,6 +14,7 @@ import json import os from collections.abc import Callable from typing import Final +from urllib.parse import urlsplit, urlunsplit import redis import redis.asyncio as async_redis @@ -156,7 +157,7 @@ def _get_redis_cluster_kwargs(client=None): def _get_redis_env_kwarg_mapping(): PREFIX: Final = "REDIS_" - exclude_from_environment: Final = {"credential_provider"} + exclude_from_environment: Final = frozenset({"credential_provider"}) return {f"{PREFIX}{x.upper()}": x for x in _get_redis_kwargs() if x not in exclude_from_environment} @@ -355,6 +356,14 @@ def get_redis_url_from_environment(): return f"{redis_protocol}://{auth_part}{os.environ['REDIS_HOST']}:{os.environ['REDIS_PORT']}" +def _url_without_userinfo(url: str) -> str: + """redis-py rejects a url that carries its own username or password next to a credential + provider, so the provider's credentials replace whatever userinfo the url was configured with.""" + parts: Final = urlsplit(url) + netloc: Final = parts.netloc.rsplit("@", 1)[-1] + return urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment)) + + def _get_redis_client_logic(**env_overrides): """ Common functionality across sync + async redis client implementations @@ -476,10 +485,7 @@ def _get_redis_client_logic(**env_overrides): redis_kwargs.pop("username", None) redis_kwargs.pop("password", None) if redis_kwargs.get("url") is not None: - from urllib.parse import urlsplit, urlunsplit - - parsed_url = urlsplit(redis_kwargs["url"]) - redis_kwargs["url"] = urlunsplit(parsed_url._replace(netloc=parsed_url.netloc.rsplit("@", 1)[-1])) + redis_kwargs["url"] = _url_without_userinfo(redis_kwargs["url"]) if "url" in redis_kwargs and redis_kwargs["url"] is not None: # Only strip host/port/db/password when not routing to a cluster. @@ -490,8 +496,6 @@ def _get_redis_client_logic(**env_overrides): redis_kwargs.pop("port", None) redis_kwargs.pop("db", None) redis_kwargs.pop("password", None) - if redis_kwargs.get("credential_provider") is not None: - redis_kwargs.pop("username", None) elif ( "startup_nodes" in redis_kwargs and redis_kwargs["startup_nodes"] is not None @@ -623,18 +627,17 @@ def _async_auth_kwargs(redis_kwargs: dict) -> dict: """Swaps a connect func an async path cannot run for the equivalent credential provider, which supersedes any static username or password redis-py would otherwise reject it with.""" explicit_provider: Final = redis_kwargs.get("credential_provider") - if explicit_provider is not None: - superseded: Final = frozenset({"redis_connect_func", "username", "password"}) - kept: Final = ((k, v) for k, v in redis_kwargs.items() if k not in superseded) - return dict(kept) - - credential_provider: Final = _async_credential_provider(redis_kwargs.get("redis_connect_func")) + credential_provider: Final = ( + explicit_provider + if explicit_provider is not None + else _async_credential_provider(redis_kwargs.get("redis_connect_func")) + ) if credential_provider is None: return redis_kwargs - automatic_superseded: Final = frozenset({"redis_connect_func", "username", "password"}) - automatic_kept: Final = ((k, v) for k, v in redis_kwargs.items() if k not in automatic_superseded) - return dict(automatic_kept, credential_provider=credential_provider) + superseded: Final = frozenset({"redis_connect_func", "username", "password"}) + kept: Final = ((k, v) for k, v in redis_kwargs.items() if k not in superseded) + return dict(kept, credential_provider=credential_provider) # mutable-ok: the branches below mutate these kwargs def get_redis_client(**env_overrides): diff --git a/litellm/caching/redis_cache.py b/litellm/caching/redis_cache.py index 991b6c8c6c5..0207a571dd6 100644 --- a/litellm/caching/redis_cache.py +++ b/litellm/caching/redis_cache.py @@ -175,6 +175,10 @@ _RedisCallResult = TypeVar("_RedisCallResult") _swallowed_redis_failures: Final[ContextVar[int]] = ContextVar("litellm_swallowed_redis_failures", default=0) +def _opaque_kwarg_key(value: object) -> str: + return f"{type(value).__name__}-{id(value)}" + + @functools.lru_cache(maxsize=1) def _redis_health_error_types() -> tuple[type, ...]: """Exception types that mean the Redis backend itself is unhealthy. @@ -398,24 +402,14 @@ class RedisCache(BaseCache): """ Generate a cache key for the async Redis client based on connection parameters. This ensures different Redis configurations use different cached clients. + + Kwargs the caller hands over as live objects (a credential provider, a connect func) are not + JSON-serializable and carry no stable value identity, so they key on instance identity. """ - # Create a stable representation of redis_kwargs for hashing # Sort keys to ensure consistent hash regardless of parameter order - redis_kwargs: Final[dict[str, object]] = self.redis_kwargs - provider: Final = redis_kwargs.get("credential_provider") - redis_connect_func: Final = redis_kwargs.get("redis_connect_func") - sorted_kwargs: Final = sorted( - item for item in redis_kwargs.items() if item[0] not in {"credential_provider", "redis_connect_func"} - ) - kwargs_str: Final = json.dumps(sorted_kwargs, sort_keys=True) - identity_suffix: Final = ( - "" - if provider is None and redis_connect_func is None - else f":provider-{id(provider)}" - if provider is not None - else f":connect-func-{id(redis_connect_func)}" - ) - kwargs_hash: Final = hashlib.sha256(f"{kwargs_str}{identity_suffix}".encode()).hexdigest()[:16] + sorted_kwargs: Final = sorted(self.redis_kwargs.items()) + kwargs_str: Final = json.dumps(sorted_kwargs, sort_keys=True, default=_opaque_kwarg_key) + kwargs_hash: Final = hashlib.sha256(kwargs_str.encode()).hexdigest()[:16] return f"async-redis-client-{kwargs_hash}" def init_async_client( diff --git a/pyproject.toml b/pyproject.toml index c80ba143512..b9c514dd88b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,8 +54,8 @@ proxy = [ "rq>=2.7.0,<3.0", "redis>=5.3.1,<6.0", "orjson>=3.11.6,<4.0", - # redis-py's C response parser. It arrives with redis (via rq) either way; naming - # it here is what makes redis-py select _HiredisParser instead of the Python one. + # redis-py's C response parser. Nothing imports it; naming it here is what makes + # redis-py select _HiredisParser instead of the pure-Python one. "hiredis>=3.0.0,<4.0", "apscheduler>=3.11.2,<4.0", "fastapi-sso>=0.19.0,<1.0", diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index 38b2bd5296a..1476650ac3f 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -95,21 +95,6 @@ def clear_gcp_iam_token_cache(): _token_cache.clear() -def test_redis_uses_the_hiredis_response_parser(): - """The proxy extra must keep redis-py's C response parser available.""" - from redis._parsers import _HiredisParser - from redis.connection import HIREDIS_AVAILABLE, DefaultParser - - if not HIREDIS_AVAILABLE: - pytest.skip("hiredis is not installed in this test environment") - - assert DefaultParser is _HiredisParser - - client = get_redis_client(host="redis-host", port=6379) - connection = client.connection_pool.make_connection() - assert isinstance(connection._parser, _HiredisParser) - - def test_redis_allowlists_include_credential_provider(): assert "credential_provider" in _get_redis_kwargs() assert "credential_provider" in _get_redis_url_kwargs() @@ -230,6 +215,19 @@ def test_async_url_pool_preserves_credential_provider_identity(clean_redis_envir assert pool.connection_kwargs["credential_provider"] is provider +def test_async_url_pool_strips_userinfo_for_the_provider(clean_redis_environment): + """The url allowlist has to carry the provider through, and redis-py rejects it next to userinfo.""" + provider = _StubCredentialProvider() + + pool = get_redis_connection_pool(url="rediss://url-user:url-pass@redis-host:6379/3", credential_provider=provider) + + connection = pool.make_connection() + assert connection.credential_provider is provider + assert connection.username is None + assert connection.password is None + assert connection.db == 3 + + def test_sync_cluster_preserves_credential_provider_identity(clean_redis_environment): provider = _StubCredentialProvider() startup_nodes = [{"host": "cluster-node", "port": 6379}] @@ -274,6 +272,47 @@ def test_explicit_provider_skips_automatic_auth_and_callback(clean_redis_environ assert "redis_connect_func" not in redis_kwargs +@pytest.mark.parametrize( + "overrides", + [ + {"gcp_ssl_ca_certs": "/tmp/ca.pem"}, + {"gcp_service_account": "sa@example.com", "gcp_ssl_ca_certs": "/tmp/ca.pem"}, + ], + ids=["certs-without-service-account", "both-alongside-a-provider"], +) +def test_gcp_kwargs_never_survive_client_logic(clean_redis_environment, overrides): + """redis.Redis has no gcp_* parameters, so anything left behind raises TypeError on connect.""" + redis_kwargs = _get_redis_client_logic( + host="redis-host", + port=6379, + credential_provider=_StubCredentialProvider() if "gcp_service_account" in overrides else None, + **overrides, + ) + + assert "gcp_service_account" not in redis_kwargs + assert "gcp_ssl_ca_certs" not in redis_kwargs + + +def test_provider_keeps_the_rest_of_the_url_intact(clean_redis_environment): + """Stripping the userinfo must not take the database path, query, or scheme with it.""" + provider = _StubCredentialProvider() + + redis_kwargs = _get_redis_client_logic( + url="rediss://url-user:url-pass@redis-host:6379/3?protocol=3", + credential_provider=provider, + ) + + assert redis_kwargs["url"] == "rediss://redis-host:6379/3?protocol=3" + + +def test_provider_free_url_is_left_untouched(clean_redis_environment): + url = "redis://url-user:url-pass@redis-host:6379/3" + + redis_kwargs = _get_redis_client_logic(url=url) + + assert redis_kwargs["url"] == url + + def test_async_direct_explicit_provider_is_preserved_when_normalization_is_bypassed(): provider = _StubCredentialProvider() redis_kwargs = { @@ -378,6 +417,21 @@ def test_redis_cache_key_does_not_serialize_connect_func(): assert first_key == cache._get_async_client_cache_key() +def test_redis_cache_key_keys_opaque_kwargs_by_identity(): + """Any object a caller passes through must key by identity rather than crash the JSON dump.""" + + class _Opaque: + pass + + first = RedisCache.__new__(RedisCache) + first.redis_kwargs = {"host": "redis-host", "retry": _Opaque()} + second = RedisCache.__new__(RedisCache) + second.redis_kwargs = {"host": "redis-host", "retry": _Opaque()} + + assert first._get_async_client_cache_key() == first._get_async_client_cache_key() + assert first._get_async_client_cache_key() != second._get_async_client_cache_key() + + def test_get_redis_url_from_environment_single_url(monkeypatch): """Test when REDIS_URL is directly provided""" # Set the environment variable @@ -1184,6 +1238,25 @@ def test_url_config_drops_kwargs_the_connection_cannot_accept(client_only_kwarg, assert pool.connection_kwargs.get("socket_timeout") == 5.0 +def test_redis_uses_the_hiredis_response_parser(): + """The C parser must be the one redis-py actually picks. + + hiredis is declared in the `proxy` extra purely for speed; nothing imports it, so + dropping it from pyproject.toml would silently fall back to the pure-Python parser + with no other symptom. redis-py selects it at import time, so asserting on the + selection is what catches that. + """ + from redis._parsers import _HiredisParser + from redis.connection import HIREDIS_AVAILABLE, DefaultParser + + assert HIREDIS_AVAILABLE, "hiredis is not installed; redis-py fell back to the pure-Python parser" + assert DefaultParser is _HiredisParser, f"redis-py selected {DefaultParser.__name__}, expected _HiredisParser" + + client = get_redis_client(host="redis-host", port=6379) + connection = client.connection_pool.make_connection() + assert isinstance(connection._parser, _HiredisParser) + + def test_init_arg_names_sees_through_decorated_inits(): """redis-py >= 7.4 wraps AbstractConnection.__init__ with @deprecated_args, whose wrapper is declared (self, *args, **kwargs). Introspecting the wrapper directly diff --git a/uv.lock b/uv.lock index f8af2f60b11..fae22e3759e 100644 --- a/uv.lock +++ b/uv.lock @@ -4342,10 +4342,10 @@ proxy = [ { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, { name = "python-multipart" }, { name = "pyyaml" }, + { name = "redis" }, { name = "restrictedpython" }, { name = "rich" }, { name = "rq" }, - { name = "redis" }, { name = "soundfile" }, { name = "starlette" }, { name = "uvicorn" }, @@ -4552,13 +4552,13 @@ requires-dist = [ { name = "python3-saml", marker = "extra == 'saml'", specifier = ">=1.16.0,<2.0" }, { name = "pyyaml", marker = "extra == 'cli'", specifier = ">=6.0.3,<7.0" }, { name = "pyyaml", marker = "extra == 'proxy'", specifier = ">=6.0.3,<7.0" }, + { name = "redis", marker = "extra == 'proxy'", specifier = ">=5.3.1,<6.0" }, { name = "redisvl", marker = "extra == 'extra-proxy'", specifier = ">=0.4.1,<1.0" }, { name = "requests", marker = "extra == 'cli'", specifier = ">=2.32.0,<3.0" }, { name = "resend", marker = "extra == 'extra-proxy'", specifier = ">=2.23.0,<3.0" }, { name = "restrictedpython", marker = "extra == 'proxy'", specifier = ">=8.1,<9.0" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13.9.4,<14.0" }, { name = "rich", marker = "extra == 'proxy'", specifier = ">=13.9.4,<14.0" }, - { name = "redis", marker = "extra == 'proxy'", specifier = ">=5.3.1,<6.0" }, { name = "rq", marker = "extra == 'proxy'", specifier = ">=2.7.0,<3.0" }, { name = "semantic-router", marker = "python_full_version < '3.14' and extra == 'semantic-router'", specifier = ">=0.1.15,<1.0" }, { name = "sentry-sdk", marker = "extra == 'proxy-runtime'", specifier = ">=2.21.0,<3.0" }, From 0ebebeaab9702775c7cdf2f1f71f46e384c2815d Mon Sep 17 00:00:00 2001 From: eugene-yao-zocdoc Date: Mon, 24 Aug 2026 17:41:37 -0400 Subject: [PATCH 10/90] fix(redis): drop direct dependency and suppress test-quality violations --- pyproject.toml | 5 ++- tests/test_litellm/test_redis.py | 58 +++++++++++++++++++++++--------- uv.lock | 2 -- 3 files changed, 45 insertions(+), 20 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b9c514dd88b..fca5c7da1e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,10 +52,9 @@ proxy = [ "backoff>=2.2.1,<3.0", "pyyaml>=6.0.3,<7.0", "rq>=2.7.0,<3.0", - "redis>=5.3.1,<6.0", "orjson>=3.11.6,<4.0", - # redis-py's C response parser. Nothing imports it; naming it here is what makes - # redis-py select _HiredisParser instead of the pure-Python one. + # redis-py's C response parser. It arrives with redis (via rq) either way; naming + # it here is what makes redis-py select _HiredisParser instead of the Python one. "hiredis>=3.0.0,<4.0", "apscheduler>=3.11.2,<4.0", "fastapi-sso>=0.19.0,<1.0", diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index 1476650ac3f..449f2d8dc14 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -228,11 +228,15 @@ def test_async_url_pool_strips_userinfo_for_the_provider(clean_redis_environment assert connection.db == 3 -def test_sync_cluster_preserves_credential_provider_identity(clean_redis_environment): +def test_sync_cluster_preserves_credential_provider_identity( # test-quality-ok: constructor kwargs are the only seam + clean_redis_environment, +): provider = _StubCredentialProvider() startup_nodes = [{"host": "cluster-node", "port": 6379}] - with patch("litellm._redis.redis.RedisCluster", autospec=True) as mock_cluster_cls: + with patch( # test-quality-ok: RedisCluster slot-discovers in its constructor + "litellm._redis.redis.RedisCluster", autospec=True + ) as mock_cluster_cls: get_redis_client(startup_nodes=startup_nodes, credential_provider=provider) mock_cluster_cls.assert_called_once() @@ -243,7 +247,9 @@ def test_async_cluster_preserves_credential_provider_identity(clean_redis_enviro provider = _StubCredentialProvider() startup_nodes = [{"host": "cluster-node", "port": 6379}] - with patch("litellm.caching.redis_cluster_node_isolation.get_litellm_async_redis_cluster_class") as mock_class: + with patch( # test-quality-ok: the async cluster class is the only injection point here + "litellm.caching.redis_cluster_node_isolation.get_litellm_async_redis_cluster_class" + ) as mock_class: get_redis_async_client(startup_nodes=startup_nodes, credential_provider=provider) call_kwargs = mock_class.return_value.call_args[1] @@ -256,8 +262,12 @@ def test_explicit_provider_skips_automatic_auth_and_callback(clean_redis_environ monkeypatch.setenv("REDIS_AZURE_AD_TOKEN", "true") with ( - patch("litellm._redis.create_gcp_iam_redis_connect_func") as mock_gcp, - patch("litellm._redis.create_azure_ad_redis_connect_func") as mock_azure, + patch( # test-quality-ok: the assertion is that this builder is never reached + "litellm._redis.create_gcp_iam_redis_connect_func" + ) as mock_gcp, + patch( # test-quality-ok: the assertion is that this builder is never reached + "litellm._redis.create_azure_ad_redis_connect_func" + ) as mock_azure, ): redis_kwargs = _get_redis_client_logic( host="redis-host", @@ -323,8 +333,12 @@ def test_async_direct_explicit_provider_is_preserved_when_normalization_is_bypas } with ( - patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs), - patch("litellm._redis.async_redis.Redis", autospec=True) as mock_redis, + patch( # test-quality-ok: bypassing normalization is what this test pins + "litellm._redis._get_redis_client_logic", return_value=redis_kwargs + ), + patch( # test-quality-ok: the constructor kwargs are the only observable + "litellm._redis.async_redis.Redis", autospec=True + ) as mock_redis, ): get_redis_async_client() @@ -343,8 +357,12 @@ def test_async_pool_explicit_provider_is_preserved_when_normalization_is_bypasse } with ( - patch("litellm._redis._get_redis_client_logic", return_value=redis_kwargs), - patch("litellm._redis.async_redis.BlockingConnectionPool", autospec=True) as mock_pool, + patch( # test-quality-ok: bypassing normalization is what this test pins + "litellm._redis._get_redis_client_logic", return_value=redis_kwargs + ), + patch( # test-quality-ok: the constructor kwargs are the only observable + "litellm._redis.async_redis.BlockingConnectionPool", autospec=True + ) as mock_pool, ): get_redis_connection_pool() @@ -360,7 +378,9 @@ async def test_redis_cache_test_connection_uses_shared_factory(clean_redis_envir client.ping = AsyncMock(return_value=True) client.aclose = AsyncMock() - with patch("litellm._redis.get_redis_async_client", return_value=client) as mock_factory: + with patch( # test-quality-ok: the factory call is what routing through it means + "litellm._redis.get_redis_async_client", return_value=client + ) as mock_factory: cache = RedisCache(host="redis-host", port=6379, credential_provider=provider) result = await cache.test_connection() @@ -376,8 +396,12 @@ async def test_redis_cluster_cache_test_connection_uses_shared_factory(clean_red client.ping = AsyncMock(return_value=True) client.aclose = AsyncMock() - with patch("litellm._redis.get_redis_async_client", return_value=client) as mock_factory: - with patch("litellm._redis.get_redis_client", return_value=MagicMock(spec=redis.RedisCluster)): + with patch( # test-quality-ok: the factory call is what routing through it means + "litellm._redis.get_redis_async_client", return_value=client + ) as mock_factory: + with patch( # test-quality-ok: a real RedisCluster would slot-discover here + "litellm._redis.get_redis_client", return_value=MagicMock(spec=redis.RedisCluster) + ): cache = RedisClusterCache( startup_nodes=[{"host": "redis-host", "port": 6379}], credential_provider=provider ) @@ -395,8 +419,12 @@ def test_redis_cache_key_does_not_inspect_provider(clear_llm_client_cache): async_pool = MagicMock(spec=async_redis.BlockingConnectionPool) with ( - patch("litellm._redis.get_redis_client", return_value=sync_client), - patch("litellm._redis.get_redis_connection_pool", return_value=async_pool), + patch( # test-quality-ok: the hostile provider must not reach a real client + "litellm._redis.get_redis_client", return_value=sync_client + ), + patch( # test-quality-ok: the hostile provider must not reach a real pool + "litellm._redis.get_redis_connection_pool", return_value=async_pool + ), ): cache = RedisCache(host="redis-host", port=6379, credential_provider=provider) second_cache = RedisCache(host="redis-host", port=6379, credential_provider=second_provider) @@ -903,7 +931,7 @@ def test_sync_sentinel_uses_sentinel_password_and_master_password(mock_sentinel_ ) -@patch("litellm._redis.redis.Sentinel") +@patch("litellm._redis.redis.Sentinel") # test-quality-ok: sentinel discovery needs live sentinels def test_sync_sentinel_keeps_provider_off_monitors_and_on_master(mock_sentinel_cls): provider = _StubCredentialProvider() mock_sentinel = MagicMock() diff --git a/uv.lock b/uv.lock index fae22e3759e..628483c0117 100644 --- a/uv.lock +++ b/uv.lock @@ -4342,7 +4342,6 @@ proxy = [ { name = "pyroscope-io", marker = "sys_platform != 'win32'" }, { name = "python-multipart" }, { name = "pyyaml" }, - { name = "redis" }, { name = "restrictedpython" }, { name = "rich" }, { name = "rq" }, @@ -4552,7 +4551,6 @@ requires-dist = [ { name = "python3-saml", marker = "extra == 'saml'", specifier = ">=1.16.0,<2.0" }, { name = "pyyaml", marker = "extra == 'cli'", specifier = ">=6.0.3,<7.0" }, { name = "pyyaml", marker = "extra == 'proxy'", specifier = ">=6.0.3,<7.0" }, - { name = "redis", marker = "extra == 'proxy'", specifier = ">=5.3.1,<6.0" }, { name = "redisvl", marker = "extra == 'extra-proxy'", specifier = ">=0.4.1,<1.0" }, { name = "requests", marker = "extra == 'cli'", specifier = ">=2.32.0,<3.0" }, { name = "resend", marker = "extra == 'extra-proxy'", specifier = ">=2.23.0,<3.0" }, From 01a1a3090ba4a66f52319e4f636a2d213049c29e Mon Sep 17 00:00:00 2001 From: eugene-yao-zocdoc Date: Mon, 24 Aug 2026 17:46:58 -0400 Subject: [PATCH 11/90] test(redis): remove internal mocking from regressions --- tests/test_litellm/test_redis.py | 159 ++++++++++++++----------------- 1 file changed, 70 insertions(+), 89 deletions(-) diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index 449f2d8dc14..ed2045ba76f 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -9,6 +9,7 @@ from redis.credentials import CredentialProvider import litellm from litellm._redis import ( + _async_auth_kwargs, _get_redis_client_logic, _get_redis_cluster_kwargs, _get_redis_env_kwarg_mapping, @@ -228,32 +229,28 @@ def test_async_url_pool_strips_userinfo_for_the_provider(clean_redis_environment assert connection.db == 3 -def test_sync_cluster_preserves_credential_provider_identity( # test-quality-ok: constructor kwargs are the only seam - clean_redis_environment, -): +def test_sync_cluster_preserves_credential_provider_identity(clean_redis_environment): provider = _StubCredentialProvider() startup_nodes = [{"host": "cluster-node", "port": 6379}] - with patch( # test-quality-ok: RedisCluster slot-discovers in its constructor - "litellm._redis.redis.RedisCluster", autospec=True - ) as mock_cluster_cls: - get_redis_client(startup_nodes=startup_nodes, credential_provider=provider) + with patch("redis.RedisCluster", autospec=True) as mock_cluster_cls: + get_redis_client(startup_nodes=startup_nodes, credential_provider=provider, password="redis-secret") - mock_cluster_cls.assert_called_once() - assert mock_cluster_cls.call_args[1].get("credential_provider") is provider + cluster_kwargs = mock_cluster_cls.call_args.kwargs + assert cluster_kwargs["credential_provider"] is provider + assert "password" not in cluster_kwargs + assert [(node.host, node.port) for node in cluster_kwargs["startup_nodes"]] == [("cluster-node", 6379)] def test_async_cluster_preserves_credential_provider_identity(clean_redis_environment): provider = _StubCredentialProvider() startup_nodes = [{"host": "cluster-node", "port": 6379}] - with patch( # test-quality-ok: the async cluster class is the only injection point here - "litellm.caching.redis_cluster_node_isolation.get_litellm_async_redis_cluster_class" - ) as mock_class: - get_redis_async_client(startup_nodes=startup_nodes, credential_provider=provider) + client = get_redis_async_client(startup_nodes=startup_nodes, credential_provider=provider) - call_kwargs = mock_class.return_value.call_args[1] - assert call_kwargs.get("credential_provider") is provider + assert client.connection_kwargs["credential_provider"] is provider + assert client.connection_kwargs["socket_keepalive"] is True + assert client.connection_kwargs["health_check_interval"] == REDIS_CLUSTER_HEALTH_CHECK_INTERVAL def test_explicit_provider_skips_automatic_auth_and_callback(clean_redis_environment, monkeypatch): @@ -262,10 +259,10 @@ def test_explicit_provider_skips_automatic_auth_and_callback(clean_redis_environ monkeypatch.setenv("REDIS_AZURE_AD_TOKEN", "true") with ( - patch( # test-quality-ok: the assertion is that this builder is never reached + patch( # test-quality-ok: an auto-auth callback built here is popped again by the provider branch, so the builders are the only place the wasted work is visible "litellm._redis.create_gcp_iam_redis_connect_func" ) as mock_gcp, - patch( # test-quality-ok: the assertion is that this builder is never reached + patch( # test-quality-ok: same as above, and reaching this one also builds an Azure credential the caller never asked for "litellm._redis.create_azure_ad_redis_connect_func" ) as mock_azure, ): @@ -323,108 +320,92 @@ def test_provider_free_url_is_left_untouched(clean_redis_environment): assert redis_kwargs["url"] == url -def test_async_direct_explicit_provider_is_preserved_when_normalization_is_bypassed(): +def test_async_auth_kwargs_supersedes_credentials_an_explicit_provider_replaces(): + """The shared seam both async entry points run through: a provider outranks every other + credential, and redis-py rejects a provider that arrives next to a username or password.""" provider = _StubCredentialProvider() - redis_kwargs = { - "host": "redis-host", - "port": 6379, - "credential_provider": provider, - "redis_connect_func": _gcp_marker_callback(), - } - with ( - patch( # test-quality-ok: bypassing normalization is what this test pins - "litellm._redis._get_redis_client_logic", return_value=redis_kwargs - ), - patch( # test-quality-ok: the constructor kwargs are the only observable - "litellm._redis.async_redis.Redis", autospec=True - ) as mock_redis, - ): - get_redis_async_client() + auth_kwargs = _async_auth_kwargs( + { + "host": "redis-host", + "port": 6379, + "credential_provider": provider, + "redis_connect_func": _gcp_marker_callback(), + "username": "url-user", + "password": "url-pass", + } + ) - call_kwargs = mock_redis.call_args[1] - assert call_kwargs["credential_provider"] is provider - assert "redis_connect_func" not in call_kwargs + assert auth_kwargs["credential_provider"] is provider + assert auth_kwargs["host"] == "redis-host" + assert auth_kwargs["port"] == 6379 + assert "redis_connect_func" not in auth_kwargs + assert "username" not in auth_kwargs + assert "password" not in auth_kwargs -def test_async_pool_explicit_provider_is_preserved_when_normalization_is_bypassed(): - provider = _StubCredentialProvider() - redis_kwargs = { - "host": "redis-host", - "port": 6379, - "credential_provider": provider, - "redis_connect_func": _gcp_marker_callback(), - } +def test_async_auth_kwargs_leaves_provider_free_kwargs_alone(): + redis_kwargs = {"host": "redis-host", "port": 6379, "username": "url-user", "password": "url-pass"} - with ( - patch( # test-quality-ok: bypassing normalization is what this test pins - "litellm._redis._get_redis_client_logic", return_value=redis_kwargs - ), - patch( # test-quality-ok: the constructor kwargs are the only observable - "litellm._redis.async_redis.BlockingConnectionPool", autospec=True - ) as mock_pool, - ): - get_redis_connection_pool() - - call_kwargs = mock_pool.call_args[1] - assert call_kwargs["credential_provider"] is provider - assert "redis_connect_func" not in call_kwargs + assert _async_auth_kwargs(redis_kwargs) == redis_kwargs @pytest.mark.asyncio async def test_redis_cache_test_connection_uses_shared_factory(clean_redis_environment): provider = _StubCredentialProvider() - client = MagicMock(spec=async_redis.Redis) - client.ping = AsyncMock(return_value=True) - client.aclose = AsyncMock() - with patch( # test-quality-ok: the factory call is what routing through it means - "litellm._redis.get_redis_async_client", return_value=client - ) as mock_factory: - cache = RedisCache(host="redis-host", port=6379, credential_provider=provider) + with ( + patch("redis.Redis", autospec=True), + patch("redis.asyncio.BlockingConnectionPool", autospec=True), + patch("redis.asyncio.Redis", autospec=True) as mock_async_redis, + ): + mock_async_redis.return_value.ping = AsyncMock(return_value=True) + mock_async_redis.return_value.aclose = AsyncMock() + cache = RedisCache(host="redis-host", port=6379, credential_provider=provider, password="redis-secret") result = await cache.test_connection() + client_kwargs = mock_async_redis.call_args.kwargs assert result["status"] == "success" - call_kwargs = mock_factory.call_args.kwargs - assert call_kwargs["credential_provider"] is provider + assert client_kwargs["credential_provider"] is provider + assert "password" not in client_kwargs @pytest.mark.asyncio async def test_redis_cluster_cache_test_connection_uses_shared_factory(clean_redis_environment): provider = _StubCredentialProvider() - client = MagicMock(spec=async_redis.RedisCluster) - client.ping = AsyncMock(return_value=True) - client.aclose = AsyncMock() + recorder = MagicMock() - with patch( # test-quality-ok: the factory call is what routing through it means - "litellm._redis.get_redis_async_client", return_value=client - ) as mock_factory: - with patch( # test-quality-ok: a real RedisCluster would slot-discover here - "litellm._redis.get_redis_client", return_value=MagicMock(spec=redis.RedisCluster) - ): - cache = RedisClusterCache( - startup_nodes=[{"host": "redis-host", "port": 6379}], credential_provider=provider - ) + class _StubAsyncCluster: + """A real base class, because the production path subclasses this at call time.""" + + def __init__(self, **kwargs): + recorder(**kwargs) + + async def ping(self): + return True + + async def aclose(self): + return None + + with ( + patch("redis.RedisCluster", autospec=True), + patch("redis.asyncio.cluster.RedisCluster", _StubAsyncCluster), + ): + cache = RedisClusterCache(startup_nodes=[{"host": "redis-host", "port": 6379}], credential_provider=provider) result = await cache.test_connection() + cluster_kwargs = recorder.call_args.kwargs assert result["status"] == "success" - call_kwargs = mock_factory.call_args.kwargs - assert call_kwargs["credential_provider"] is provider + assert cluster_kwargs["credential_provider"] is provider def test_redis_cache_key_does_not_inspect_provider(clear_llm_client_cache): provider = _HostileCredentialProvider("synthetic-secret") second_provider = _StubCredentialProvider("another-token") - sync_client = MagicMock(spec=redis.Redis) - async_pool = MagicMock(spec=async_redis.BlockingConnectionPool) with ( - patch( # test-quality-ok: the hostile provider must not reach a real client - "litellm._redis.get_redis_client", return_value=sync_client - ), - patch( # test-quality-ok: the hostile provider must not reach a real pool - "litellm._redis.get_redis_connection_pool", return_value=async_pool - ), + patch("redis.Redis", autospec=True), + patch("redis.asyncio.BlockingConnectionPool", autospec=True), ): cache = RedisCache(host="redis-host", port=6379, credential_provider=provider) second_cache = RedisCache(host="redis-host", port=6379, credential_provider=second_provider) @@ -931,7 +912,7 @@ def test_sync_sentinel_uses_sentinel_password_and_master_password(mock_sentinel_ ) -@patch("litellm._redis.redis.Sentinel") # test-quality-ok: sentinel discovery needs live sentinels +@patch("redis.Sentinel") def test_sync_sentinel_keeps_provider_off_monitors_and_on_master(mock_sentinel_cls): provider = _StubCredentialProvider() mock_sentinel = MagicMock() From 442175c4dc9c0de4200ff3e940389bc009ae14b3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 07:58:20 +0000 Subject: [PATCH 12/90] chore(typing): clear fresh tech debt from the Aug 24 window type the strategy-router health check params instead of a bare dict, annotate the new interactions usage locals Final, drop a reportUnnecessaryIsInstance suppression by narrowing the grounding tool list before iterating it, and delete the duplicated file-id decode comment Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- basedpyright-code-budget.json | 12 +++--- .../usage_object_transformation.py | 37 ++++++++++--------- .../prompt_templates/common_utils.py | 6 --- litellm/proxy/health_check.py | 2 +- ruff-strict-budget.json | 8 ++-- type-discipline-budget.json | 6 +-- 6 files changed, 34 insertions(+), 37 deletions(-) diff --git a/basedpyright-code-budget.json b/basedpyright-code-budget.json index 3f0011c80d2..5ec49f48dc7 100644 --- a/basedpyright-code-budget.json +++ b/basedpyright-code-budget.json @@ -1,6 +1,6 @@ { "reportAny": { - "limit": 19945 + "limit": 19936 }, "reportArgumentType": { "limit": 2566 @@ -24,7 +24,7 @@ "limit": 19 }, "reportExplicitAny": { - "limit": 6048 + "limit": 6047 }, "reportFunctionMemberAccess": { "limit": 7 @@ -57,7 +57,7 @@ "limit": 5663 }, "reportMissingTypeArgument": { - "limit": 15545 + "limit": 15536 }, "reportMissingTypeStubs": { "limit": 40 @@ -105,13 +105,13 @@ "limit": 109 }, "reportUnknownMemberType": { - "limit": 38998 + "limit": 38990 }, "reportUnknownParameterType": { - "limit": 19876 + "limit": 19868 }, "reportUnknownVariableType": { - "limit": 30554 + "limit": 30540 }, "reportUnnecessaryCast": { "limit": 117 diff --git a/litellm/litellm_core_utils/llm_cost_calc/usage_object_transformation.py b/litellm/litellm_core_utils/llm_cost_calc/usage_object_transformation.py index df436ef7611..f11f6d46fb2 100644 --- a/litellm/litellm_core_utils/llm_cost_calc/usage_object_transformation.py +++ b/litellm/litellm_core_utils/llm_cost_calc/usage_object_transformation.py @@ -1,6 +1,6 @@ from collections.abc import Mapping, Sequence from types import MappingProxyType -from typing import Any +from typing import Any, Final from litellm.types.utils import ( CompletionTokensDetailsWrapper, @@ -39,7 +39,7 @@ class TranscriptionUsageObjectTransformation: return None -_INTERACTIONS_MODALITY_FIELDS: Mapping[str, str] = MappingProxyType( +_INTERACTIONS_MODALITY_FIELDS: Final[Mapping[str, str]] = MappingProxyType( { "text": "text_tokens", "audio": "audio_tokens", @@ -59,7 +59,7 @@ def _token_count(value: object) -> int: def _modality_token_sums(entries: Sequence[Mapping[str, Any]]) -> Mapping[str, int]: - fields = frozenset(field for entry in entries if (field := _modality_field(entry)) is not None) + fields: Final = frozenset(field for entry in entries if (field := _modality_field(entry)) is not None) return MappingProxyType( { field: sum(_token_count(entry.get("tokens")) for entry in entries if _modality_field(entry) == field) @@ -69,10 +69,13 @@ def _modality_token_sums(entries: Sequence[Mapping[str, Any]]) -> Mapping[str, i def _google_search_query_count(usage_object: Mapping[str, Any]) -> int: + entries: Final = usage_object.get("grounding_tool_count") + if not isinstance(entries, Sequence): + return 0 return sum( _token_count(entry.get("count")) - for entry in tuple(usage_object.get("grounding_tool_count") or ()) - if isinstance(entry, Mapping) and entry.get("type") == "google_search" # pyright: ignore[reportUnnecessaryIsInstance] # provider JSON, not the empty tuple inferred from `or ()` + for entry in entries + if isinstance(entry, Mapping) and entry.get("type") == "google_search" ) @@ -112,30 +115,30 @@ class InteractionsUsageObjectTransformation: @staticmethod def transform_interactions_usage_object(usage_object: Mapping[str, Any]) -> Usage: - input_entries = tuple(usage_object.get("input_tokens_by_modality") or ()) + tuple( + input_entries: Final = tuple(usage_object.get("input_tokens_by_modality") or ()) + tuple( usage_object.get("tool_use_tokens_by_modality") or () ) - cached_sums = _modality_token_sums(tuple(usage_object.get("cached_tokens_by_modality") or ())) - output_sums = _modality_token_sums(tuple(usage_object.get("output_tokens_by_modality") or ())) + cached_sums: Final = _modality_token_sums(tuple(usage_object.get("cached_tokens_by_modality") or ())) + output_sums: Final = _modality_token_sums(tuple(usage_object.get("output_tokens_by_modality") or ())) - total_cached_tokens = _token_count(usage_object.get("total_cached_tokens")) - input_sums = _subtract_cached_from_input( + total_cached_tokens: Final = _token_count(usage_object.get("total_cached_tokens")) + input_sums: Final = _subtract_cached_from_input( input_sums=_modality_token_sums(input_entries), cached_sums=cached_sums, total_cached_tokens=total_cached_tokens, ) - reasoning_tokens = _token_count(usage_object.get("total_reasoning_tokens")) or _token_count( + reasoning_tokens: Final = _token_count(usage_object.get("total_reasoning_tokens")) or _token_count( usage_object.get("total_thought_tokens") ) - prompt_tokens = _token_count(usage_object.get("total_input_tokens")) + _token_count( + prompt_tokens: Final = _token_count(usage_object.get("total_input_tokens")) + _token_count( usage_object.get("total_tool_use_tokens") ) - completion_tokens = _token_count(usage_object.get("total_output_tokens")) + reasoning_tokens - total_tokens = _token_count(usage_object.get("total_tokens")) or (prompt_tokens + completion_tokens) + completion_tokens: Final = _token_count(usage_object.get("total_output_tokens")) + reasoning_tokens + total_tokens: Final = _token_count(usage_object.get("total_tokens")) or (prompt_tokens + completion_tokens) - web_search_requests = _google_search_query_count(usage_object) - prompt_tokens_details = ( + web_search_requests: Final = _google_search_query_count(usage_object) + prompt_tokens_details: Final = ( PromptTokensDetailsWrapper( cached_tokens=total_cached_tokens or None, web_search_requests=web_search_requests or None, @@ -144,7 +147,7 @@ class InteractionsUsageObjectTransformation: if input_sums or total_cached_tokens or web_search_requests else None ) - completion_tokens_details = ( + completion_tokens_details: Final = ( CompletionTokensDetailsWrapper( reasoning_tokens=reasoning_tokens or None, **output_sums, diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 72ea85dfa33..748347fe938 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -511,9 +511,6 @@ def update_messages_with_model_file_ids( if "llm_output_file_id," in unified_file_id: provider_file_id = unified_file_id.split("llm_output_file_id,")[1].split(";")[0] if not provider_file_id and is_model_embedded_id(file_id): - # `litellm:;model,` encoding from the - # x-litellm-model upload path. Strip the wrapper - # so the provider sees its own ID. provider_file_id = get_original_file_id(file_id) file_object_file_field["file_id"] = provider_file_id or file_id if format: @@ -588,9 +585,6 @@ def update_responses_input_with_model_file_ids( updated_content_item["file_id"] = provider_file_id updated_content.append(updated_content_item) elif is_model_embedded_id(file_id): - # `litellm:;model,` encoding from the - # x-litellm-model upload path. Strip the wrapper - # so the provider sees its own ID. updated_content_item = content_item.copy() updated_content_item["file_id"] = get_original_file_id(file_id) updated_content.append(updated_content_item) diff --git a/litellm/proxy/health_check.py b/litellm/proxy/health_check.py index d8fde8ca5dc..4e12974189f 100644 --- a/litellm/proxy/health_check.py +++ b/litellm/proxy/health_check.py @@ -183,7 +183,7 @@ async def run_with_timeout(task, timeout): return {"error": "Timeout exceeded", "exception": timeout_exception} -def _is_strategy_router_deployment(litellm_params: dict) -> bool: +def _is_strategy_router_deployment(litellm_params: Mapping[str, object]) -> bool: """True for strategy-router deployments.""" model: Final[object] = litellm_params.get("model", "") return isinstance(model, str) and classify_strategy_router_model(model) is not None diff --git a/ruff-strict-budget.json b/ruff-strict-budget.json index 69902ecfbbb..1cf302a98c9 100644 --- a/ruff-strict-budget.json +++ b/ruff-strict-budget.json @@ -12,10 +12,10 @@ "limit": 2016 }, "ANN202": { - "limit": 850 + "limit": 849 }, "ANN204": { - "limit": 709 + "limit": 708 }, "ANN205": { "limit": 112 @@ -24,7 +24,7 @@ "limit": 133 }, "ANN401": { - "limit": 1185 + "limit": 1183 }, "ASYNC230": { "limit": 11 @@ -231,7 +231,7 @@ "limit": 5 }, "TID251": { - "limit": 1210 + "limit": 1209 }, "TRY002": { "limit": 524 diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 4f2314b2a0a..4054af17d1e 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -1,9 +1,9 @@ { "LIT001": { - "limit": 22795 + "limit": 22788 }, "LIT002": { - "limit": 26872 + "limit": 26871 }, "LIT003": { "limit": 269 @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16672 + "limit": 16657 }, "LIT011": { "limit": 5588 From a18dfb2a9bf57d43f714b6b3485efa296f5c966e Mon Sep 17 00:00:00 2001 From: eugene-yao-zocdoc Date: Tue, 25 Aug 2026 10:58:51 -0400 Subject: [PATCH 13/90] fix(redis): redact provider objects in debug logs --- litellm/_redis.py | 16 ++++++++++++++-- tests/test_litellm/test_redis.py | 20 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/litellm/_redis.py b/litellm/_redis.py index 6ff4c292c47..c33ae45e988 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -764,8 +764,20 @@ def get_redis_connection_pool( return async_redis.BlockingConnectionPool(timeout=REDIS_CONNECTION_POOL_TIMEOUT, **redis_kwargs) +def _redis_kwargs_for_logging(redis_kwargs: dict) -> dict: + return { + key: "" + if key == "credential_provider" and value is not None + else "" + if key == "redis_connect_func" and value is not None + else value + for key, value in redis_kwargs.items() + } + + def _pretty_print_redis_config(redis_kwargs: dict) -> None: """Pretty print the Redis configuration using rich with sensitive data masking""" + redis_kwargs_for_logging: Final = _redis_kwargs_for_logging(redis_kwargs) try: import logging @@ -783,7 +795,7 @@ def _pretty_print_redis_config(redis_kwargs: dict) -> None: masker = SensitiveDataMasker() # Mask sensitive data in redis_kwargs - masked_redis_kwargs = masker.mask_dict(redis_kwargs) + masked_redis_kwargs = masker.mask_dict(redis_kwargs_for_logging) # Create main panel title title: Final = Text("Redis Configuration", style="bold blue") @@ -846,7 +858,7 @@ def _pretty_print_redis_config(redis_kwargs: dict) -> None: except ImportError: # Fallback to simple logging if rich is not available masker = SensitiveDataMasker() - masked_redis_kwargs = masker.mask_dict(redis_kwargs) + masked_redis_kwargs = masker.mask_dict(redis_kwargs_for_logging) verbose_logger.info("Redis configuration: %s", masked_redis_kwargs) except Exception as e: verbose_logger.error("Error pretty printing Redis configuration: %s", e) diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index ed2045ba76f..0961357731d 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -15,6 +15,7 @@ from litellm._redis import ( _get_redis_env_kwarg_mapping, _get_redis_kwargs, _get_redis_url_kwargs, + _pretty_print_redis_config, get_redis_async_client, get_redis_client, get_redis_connection_pool, @@ -415,6 +416,25 @@ def test_redis_cache_key_does_not_inspect_provider(clear_llm_client_cache): assert first_key != second_cache._get_async_client_cache_key() +def test_pretty_print_never_expands_credential_provider(capsys): + secret = "aaaa-UNIQUE-SENTINEL-bbbb" + + with patch("litellm._redis.verbose_logger.isEnabledFor", return_value=True): + _pretty_print_redis_config( + redis_kwargs={ + "host": "redis-host", + "port": 6379, + "credential_provider": _HostileCredentialProvider(secret), + } + ) + + output = capsys.readouterr().out + assert secret not in output + assert "UNIQUE" not in output + assert "_payload" not in output + assert "credential_provider" in output + + def test_redis_cache_key_does_not_serialize_connect_func(): def connect(connection): return None From f304b2ba7b18b6352c804f56ac0443e3b57ba0cc Mon Sep 17 00:00:00 2001 From: eugene-yao-zocdoc Date: Tue, 25 Aug 2026 11:08:07 -0400 Subject: [PATCH 14/90] fix(redis): satisfy lint budget for log helper --- litellm/_redis.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/_redis.py b/litellm/_redis.py index c33ae45e988..4cf903c4a2e 100644 --- a/litellm/_redis.py +++ b/litellm/_redis.py @@ -12,7 +12,7 @@ import json # s/o [@Frank Colson](https://www.linkedin.com/in/frank-colson-422b9b183/) for this redis implementation import os -from collections.abc import Callable +from collections.abc import Callable, Mapping from typing import Final from urllib.parse import urlsplit, urlunsplit @@ -764,7 +764,7 @@ def get_redis_connection_pool( return async_redis.BlockingConnectionPool(timeout=REDIS_CONNECTION_POOL_TIMEOUT, **redis_kwargs) -def _redis_kwargs_for_logging(redis_kwargs: dict) -> dict: +def _redis_kwargs_for_logging(redis_kwargs: Mapping[str, object]) -> Mapping[str, object]: return { key: "" if key == "credential_provider" and value is not None From 6dff830343b257e11365ec816a660b0eac83aaa5 Mon Sep 17 00:00:00 2001 From: eugene-yao-zocdoc Date: Tue, 25 Aug 2026 11:18:23 -0400 Subject: [PATCH 15/90] test(redis): explain debug logger patch --- tests/test_litellm/test_redis.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/test_litellm/test_redis.py b/tests/test_litellm/test_redis.py index 0961357731d..70b972d3259 100644 --- a/tests/test_litellm/test_redis.py +++ b/tests/test_litellm/test_redis.py @@ -419,7 +419,9 @@ def test_redis_cache_key_does_not_inspect_provider(clear_llm_client_cache): def test_pretty_print_never_expands_credential_provider(capsys): secret = "aaaa-UNIQUE-SENTINEL-bbbb" - with patch("litellm._redis.verbose_logger.isEnabledFor", return_value=True): + with patch( # test-quality-ok: enable the debug-only printer without changing process-wide logger state + "litellm._redis.verbose_logger.isEnabledFor", return_value=True + ): _pretty_print_redis_config( redis_kwargs={ "host": "redis-host", From 1e2645203b348082cb0536bdca5d9b6152231f97 Mon Sep 17 00:00:00 2001 From: eugene-yao-zocdoc Date: Mon, 24 Aug 2026 19:17:14 -0400 Subject: [PATCH 16/90] fix(anthropic-responses): default structured output strict to caller value Read strict from the caller's output_format/output_config.format instead of hardcoding true, defaulting to false to match OpenAI's API default. Explicit true/false values are preserved and output_format still takes precedence over output_config.format. --- .../responses_adapters/transformation.py | 2 +- .../test_responses_adapters_transformation.py | 56 +++++++++++++++++-- 2 files changed, 51 insertions(+), 7 deletions(-) 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 6d47d0de19f..01917cd9a59 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -520,7 +520,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: "type": "json_schema", "name": "structured_output", "schema": schema, - "strict": True, + "strict": bool(output_format.get("strict")), } } 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 8225e7cff39..a7efff6aa33 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 @@ -132,14 +132,14 @@ class TestOutputConfigStructuredOutput: } def test_output_config_format_json_schema_converted(self): - """output_config.format.json_schema is converted to OpenAI text.format.""" + """output_config.format.json_schema is converted to OpenAI text.format, defaulting strict to False.""" req = _make_request(output_config={"format": {"type": "json_schema", "schema": self._SCHEMA}}) kwargs = _ADAPTER.translate_request(req) assert "text" in kwargs fmt = kwargs["text"]["format"] assert fmt["type"] == "json_schema" assert fmt["schema"] == self._SCHEMA - assert fmt["strict"] is True + assert fmt["strict"] is False assert fmt["name"] == "structured_output" def test_output_config_without_format_does_not_set_text(self): @@ -149,21 +149,65 @@ class TestOutputConfigStructuredOutput: assert "text" not in kwargs def test_output_format_still_works(self): - """The original output_format field still takes precedence when present.""" + """The original output_format field still takes precedence when present, defaulting strict to False.""" req = _make_request(output_format={"type": "json_schema", "schema": self._SCHEMA}) kwargs = _ADAPTER.translate_request(req) assert "text" in kwargs assert kwargs["text"]["format"]["type"] == "json_schema" + assert kwargs["text"]["format"]["strict"] is False + + def test_output_format_explicit_strict_false_is_preserved(self): + """output_format with an explicit strict=False is preserved as False.""" + req = _make_request(output_format={"type": "json_schema", "schema": self._SCHEMA, "strict": False}) + kwargs = _ADAPTER.translate_request(req) + assert kwargs["text"]["format"]["strict"] is False + + def test_output_format_explicit_strict_true_is_preserved(self): + """output_format with an explicit strict=True is preserved as True.""" + req = _make_request(output_format={"type": "json_schema", "schema": self._SCHEMA, "strict": True}) + kwargs = _ADAPTER.translate_request(req) + assert kwargs["text"]["format"]["strict"] is True def test_output_format_takes_precedence_over_output_config(self): - """output_format takes precedence over output_config.format.""" + """output_format takes precedence over output_config.format, for both schema and strict.""" other_schema = {"type": "object", "properties": {"id": {"type": "integer"}}} req = _make_request( - output_format={"type": "json_schema", "schema": self._SCHEMA}, - output_config={"format": {"type": "json_schema", "schema": other_schema}}, + output_format={"type": "json_schema", "schema": self._SCHEMA, "strict": False}, + output_config={"format": {"type": "json_schema", "schema": other_schema, "strict": True}}, ) kwargs = _ADAPTER.translate_request(req) assert kwargs["text"]["format"]["schema"] == self._SCHEMA + assert kwargs["text"]["format"]["strict"] is False + + def test_optional_property_stays_out_of_required_list(self): + """A property absent from required must stay absent from required in the translated schema.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "nickname": {"type": "string"}, + }, + "required": ["name"], + "additionalProperties": False, + } + req = _make_request(output_format={"type": "json_schema", "schema": schema}) + kwargs = _ADAPTER.translate_request(req) + fmt_schema = kwargs["text"]["format"]["schema"] + assert fmt_schema["required"] == ["name"] + assert "nickname" not in fmt_schema["required"] + assert fmt_schema["additionalProperties"] is False + + def test_translate_request_does_not_mutate_input_schema(self): + """translate_request must not mutate the caller's output_format or schema dicts.""" + schema = {"type": "object", "properties": {"x": {"type": "number"}}, "required": ["x"]} + output_format = {"type": "json_schema", "schema": schema, "strict": False} + req = _make_request(output_format=output_format) + snapshot = json.loads(json.dumps(output_format)) + + _ADAPTER.translate_request(req) + + assert output_format == snapshot + assert req["output_format"] == snapshot # --------------------------------------------------------------------------- From 690656e2b3804ea25aaca3c9ad3828dd834a66bb Mon Sep 17 00:00:00 2001 From: eugene-yao-zocdoc Date: Mon, 24 Aug 2026 19:26:22 -0400 Subject: [PATCH 17/90] fix(anthropic-responses): preserve nested strict setting --- .../responses_adapters/transformation.py | 2 +- .../test_responses_adapters_transformation.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) 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 01917cd9a59..5ed8f26afca 100644 --- a/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/responses_adapters/transformation.py @@ -520,7 +520,7 @@ class LiteLLMAnthropicToResponsesAPIAdapter: "type": "json_schema", "name": "structured_output", "schema": schema, - "strict": bool(output_format.get("strict")), + "strict": output_format.get("strict", False), } } 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 a7efff6aa33..4057706f297 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 @@ -142,6 +142,14 @@ class TestOutputConfigStructuredOutput: assert fmt["strict"] is False assert fmt["name"] == "structured_output" + 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}} + ) + kwargs = _ADAPTER.translate_request(req) + assert kwargs["text"]["format"]["strict"] is True + def test_output_config_without_format_does_not_set_text(self): """output_config with only non-format keys doesn't produce text.format.""" req = _make_request(output_config={"effort": "high"}) From 482e712da183d9d75c2d9a4caa7bfdbae248be59 Mon Sep 17 00:00:00 2001 From: eugene-yao-zocdoc Date: Tue, 25 Aug 2026 12:29:18 -0400 Subject: [PATCH 18/90] fix(anthropic-responses): type structured output strictness --- litellm/types/llms/anthropic.py | 1 + 1 file changed, 1 insertion(+) diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index cc6eccbf3e0..4ce04dd0d69 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -36,6 +36,7 @@ AnthropicInputSchema = TypedDict( class AnthropicOutputSchema(TypedDict, total=False): type: Required[Literal["json_schema"]] schema: Required[dict] + strict: ReadOnly[bool] class AnthropicOutputConfig(TypedDict, total=False): From 4b5e3db8906625ba2128d8702d37e8d4ee95995e Mon Sep 17 00:00:00 2001 From: mateo-berri Date: Tue, 25 Aug 2026 10:15:29 -0700 Subject: [PATCH 19/90] test(e2e): cover the Bedrock provider-feature cells customers run Adds live e2e coverage for the Bedrock combinations behind recent customer incidents: llm_provider-* response-header forwarding on /chat/completions (nonstream and stream), regional us.anthropic.* inference-profile ids over the invoke route, and the Admin UI Test Connection probe for a responses-mode Bedrock Mantle deployment. Registers the matching cells in the coverage registry and publishes the provider x feature matrix table in its README. --- tests/e2e/coverage_registry/README.md | 18 ++ .../coverage_registry/llm_conversational.yaml | 4 + tests/e2e/coverage_registry/mgmt.yaml | 1 + tests/e2e/coverage_registry/schema.py | 1 + .../test_bedrock_provider_matrix_e2e.py | 157 ++++++++++++++++++ tests/e2e/management/management_client.py | 13 ++ .../test_model_test_connection_e2e.py | 42 +++++ tests/e2e/models.py | 20 +++ 8 files changed, 256 insertions(+) create mode 100644 tests/e2e/llm_translation/test_bedrock_provider_matrix_e2e.py create mode 100644 tests/e2e/management/test_model_test_connection_e2e.py diff --git a/tests/e2e/coverage_registry/README.md b/tests/e2e/coverage_registry/README.md index 5627c88dee4..da6aee84cc4 100644 --- a/tests/e2e/coverage_registry/README.md +++ b/tests/e2e/coverage_registry/README.md @@ -77,6 +77,24 @@ Strict mode exits non-zero on `@pytest.mark.covers(...)` ids that are not checke the registry. Add `--fail-on-collection-errors` when the job should also fail on pytest collection errors. +## Provider x feature matrix: customer-run Bedrock combinations + +The provider and feature combinations customers actually run get explicit cells, expanded +here as incidents surface new ones. The current Bedrock set, seeded from a customer's +production shape (regional `us.anthropic.*` inference-profile ids over both chat routes, +provider response headers for AWS-side correlation, and the Test Connection probe for a +responses-mode Bedrock Mantle deployment): + +| Cell | Feature | Covering test | +|------|---------|---------------| +| `llm.chat_completions.bedrock_converse.basic.nonstream.works` | regional `us.` id, Converse | `llm_translation/test_chat_completions_regression_e2e.py` | +| `llm.chat_completions.bedrock_converse.basic.stream.works` | regional `us.` id, Converse stream | `llm_translation/test_chat_completions_regression_e2e.py` | +| `llm.chat_completions.bedrock_invoke.basic.nonstream.works` | regional `us.` id, Invoke | `llm_translation/test_bedrock_provider_matrix_e2e.py` | +| `llm.chat_completions.bedrock_invoke.basic.stream.works` | regional `us.` id, Invoke stream | `llm_translation/test_bedrock_provider_matrix_e2e.py` | +| `llm.chat_completions.bedrock_converse.response_headers.nonstream.works` | `llm_provider-*` headers | `llm_translation/test_bedrock_provider_matrix_e2e.py` | +| `llm.chat_completions.bedrock_converse.response_headers.stream.works` | `llm_provider-*` headers, stream | `llm_translation/test_bedrock_provider_matrix_e2e.py` | +| `mgmt.model.test_connection.happy_path` | Test Connection, Bedrock Mantle | `management/test_model_test_connection_e2e.py` | + ## Status: this is a draft for review The cells were enumerated from the codebase and the tiers are a first proposal. Known diff --git a/tests/e2e/coverage_registry/llm_conversational.yaml b/tests/e2e/coverage_registry/llm_conversational.yaml index 1d4e1e028ca..d13f17e7eb6 100644 --- a/tests/e2e/coverage_registry/llm_conversational.yaml +++ b/tests/e2e/coverage_registry/llm_conversational.yaml @@ -29,6 +29,10 @@ - {id: llm.chat_completions.bedrock_converse.vision.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_converse, capability: vision, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Bedrock vision (Anthropic/Nova)"} - {id: llm.chat_completions.bedrock_converse.prompt_cache_5m.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: bedrock_converse, capability: prompt_cache_5m, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Anthropic-on-Bedrock caching"} - {id: llm.chat_completions.bedrock_converse.thinking.nonstream.works, module: llm, tier: P1, subject_endpoint: chat_completions, route: bedrock_converse, capability: thinking, streaming: nonstream, assertions: [works], source: "model_prices json", rationale: "Anthropic thinking on Bedrock"} +- {id: llm.chat_completions.bedrock_converse.response_headers.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_converse, capability: response_headers, streaming: nonstream, assertions: [works], source: "llms/bedrock/chat/converse_handler.py:248", rationale: "Bedrock request ids must surface as llm_provider-* response headers on /chat/completions so callers can correlate calls with AWS-side logs (#37003)", fail_before_fix: proven} +- {id: llm.chat_completions.bedrock_converse.response_headers.stream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_converse, capability: response_headers, streaming: stream, assertions: [works], source: "llms/bedrock/chat/converse_handler.py:154", rationale: "The llm_provider-* headers must also surface on streaming /chat/completions, where CustomStreamWrapper carries them instead of the nonstream setter"} +- {id: llm.chat_completions.bedrock_invoke.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_invoke, capability: basic, streaming: nonstream, assertions: [works], source: "proxy_server.py:8455", rationale: "Regional inference-profile ids (us.anthropic.*) over the invoke route, the deployment shape behind a customer timeout report on v1.90.0"} +- {id: llm.chat_completions.bedrock_invoke.basic.stream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: bedrock_invoke, capability: basic, streaming: stream, assertions: [works], source: "proxy_server.py:8455", rationale: "Streaming with regional inference-profile ids over the invoke route"} - {id: llm.chat_completions.vertex.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: vertex, capability: basic, streaming: nonstream, assertions: [works], source: "proxy_server.py:8455", rationale: "P0 route; Vertex AI"} - {id: llm.chat_completions.gemini.basic.nonstream.works, module: llm, tier: P0, subject_endpoint: chat_completions, route: gemini, capability: basic, streaming: nonstream, assertions: [works], source: "test_chat_completions_regression_e2e.py", rationale: "Gemini OpenAI-compatible chat translation"} - {id: llm.chat_completions.gemini.basic.nonstream.cost_logged, module: llm, tier: P0, subject_endpoint: chat_completions, route: gemini, capability: basic, streaming: nonstream, assertions: [works, cost_logged], source: "test_chat_completions_regression_e2e.py", rationale: "Gemini chat cost lands in SpendLogs"} diff --git a/tests/e2e/coverage_registry/mgmt.yaml b/tests/e2e/coverage_registry/mgmt.yaml index d8788d7fcb0..1e6de0c3d6a 100644 --- a/tests/e2e/coverage_registry/mgmt.yaml +++ b/tests/e2e/coverage_registry/mgmt.yaml @@ -75,3 +75,4 @@ - {id: mgmt.workflow.list.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "workflow_management_endpoints.py", rationale: "Workflow tracking (smoke)"} - {id: mgmt.credential_migration.check.happy_path, module: mgmt, tier: P2, surface: api, assertions: [happy_path], source: "key_management_endpoints.py:4252", rationale: "Encryption migration (smoke)"} - {id: mgmt.credential.new.serves_request, module: mgmt, tier: P1, surface: api, assertions: [serves_request], source: "credential_endpoints/endpoints.py:42", rationale: "Stored credential resolves into a deployment and serves a live /messages request"} +- {id: mgmt.model.test_connection.happy_path, module: mgmt, tier: P0, surface: api, assertions: [happy_path], source: "_health_endpoints.py:1785", rationale: "Test Connection for a responses-mode Bedrock Mantle deployment reaches the live provider and reports success; this exact shape 500ed on an acompletion partial before v1.91.0", fail_before_fix: proven} diff --git a/tests/e2e/coverage_registry/schema.py b/tests/e2e/coverage_registry/schema.py index a5c723f8965..03d15f532b8 100644 --- a/tests/e2e/coverage_registry/schema.py +++ b/tests/e2e/coverage_registry/schema.py @@ -71,6 +71,7 @@ LlmCapability = Literal[ "pdf_input", "prompt_cache_1h", "prompt_cache_5m", + "response_headers", "service_tier", "structured_output", "thinking", diff --git a/tests/e2e/llm_translation/test_bedrock_provider_matrix_e2e.py b/tests/e2e/llm_translation/test_bedrock_provider_matrix_e2e.py new file mode 100644 index 00000000000..3c6aaa75ab3 --- /dev/null +++ b/tests/e2e/llm_translation/test_bedrock_provider_matrix_e2e.py @@ -0,0 +1,157 @@ +"""Live e2e for the Bedrock cells of the provider-feature matrix: provider +response headers on /chat/completions and regional inference-profile model ids +(us.anthropic.*) over the invoke route. + +Header forwarding is the #37003 contract: the proxy surfaces Bedrock's response +headers prefixed llm_provider- (llm_provider-x-amzn-requestid above all) so a +caller can hand AWS support the request id behind a completion. Regional +inference-profile ids are the deployment shape most Bedrock customers run; a +v1.90.0 regression timed them out, and the Converse route keeps them covered in +test_chat_completions_regression_e2e.py, so the invoke route carries its own +rows here. +""" + +from __future__ import annotations + +import pytest +from pydantic import BaseModel + +from e2e_config import unique_marker +from e2e_http import StreamingResponse, unwrap +from lifecycle import ResourceManager +from models import ChatBody, ChatMessage, ChatResponse, LiteLLMParamsBody +from passthrough_client import PassthroughClient + +pytestmark = pytest.mark.e2e + +CONVERSE_REGIONAL_BACKEND = "bedrock/us.anthropic.claude-haiku-4-5-20251001-v1:0" +INVOKE_REGIONAL_BACKEND = "bedrock/invoke/us.anthropic.claude-haiku-4-5-20251001-v1:0" +PROVIDER_HEADER_PREFIX = "llm_provider-" +BEDROCK_REQUEST_ID_HEADER = "llm_provider-x-amzn-requestid" + + +class _StreamDelta(BaseModel): + content: str | None = None + + +class _StreamChoice(BaseModel): + delta: _StreamDelta = _StreamDelta() + + +class _StreamChunk(BaseModel): + choices: list[_StreamChoice] = [] + + +def _streamed_text(events: list[str]) -> str: + chunks = [_StreamChunk.model_validate_json(event) for event in events] + return "".join(choice.delta.content or "" for chunk in chunks for choice in chunk.choices) + + +def _assert_streamed_completion(result: StreamingResponse) -> None: + assert result.ok and result.is_streaming, f"stream was not established: {result}" + assert result.stream_error is None, f"stream carried an error event: {result.stream_error}" + assert len(result.stream_events) > 1, f"stream did not deliver multiple data events: {result}" + assert _streamed_text(result.stream_events).strip(), ( + f"stream completed with no content deltas: {result.stream_events[:3]}" + ) + + +def _assert_request_id_header(result: StreamingResponse) -> None: + forwarded = [name for name in result.headers if name.startswith(PROVIDER_HEADER_PREFIX)] + assert result.headers.get(BEDROCK_REQUEST_ID_HEADER), ( + f"missing {BEDROCK_REQUEST_ID_HEADER}; forwarded provider headers: {forwarded}" + ) + + +def _assert_completion(response: ChatResponse) -> None: + assert response.choices, f"completion returned no choices: {response}" + message = response.choices[0].message + content = (message.content if message else None) or "" + assert content.strip(), f"completion carried no content: {response}" + + +def _register_bedrock_model( + client: PassthroughClient, resources: ResourceManager, prefix: str, backend: str +) -> str: + model = f"{prefix}-{unique_marker()}" + model_id = client.proxy.create_model( + model, + LiteLLMParamsBody( + model=backend, + aws_access_key_id="os.environ/AWS_ACCESS_KEY_ID", + aws_secret_access_key="os.environ/AWS_SECRET_ACCESS_KEY", + aws_region_name="os.environ/AWS_REGION", + ), + ) + resources.defer(lambda: client.proxy.delete_model(model_id)) + return model + + +def _prompt() -> list[ChatMessage]: + return [ChatMessage(role="user", content="reply with one word")] + + +class TestBedrockResponseHeaders: + @pytest.mark.covers( + "llm.chat_completions.bedrock_converse.response_headers.nonstream.works", + exercised_on=[], + ) + def test_bedrock_request_id_header_surfaces( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = _register_bedrock_model(client, resources, "e2e-bedrock-headers", CONVERSE_REGIONAL_BACKEND) + key = resources.key() + + result = client.proxy.transport.send( + "/chat/completions", + headers=client.proxy.transport.bearer(key), + json=ChatBody(model=model, messages=_prompt(), max_tokens=64), + ) + + assert result.ok, f"chat call failed: {result.status_code} {result.body[:300]}" + _assert_request_id_header(result) + + @pytest.mark.covers( + "llm.chat_completions.bedrock_converse.response_headers.stream.works", + exercised_on=[], + ) + def test_bedrock_request_id_header_surfaces_on_stream( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = _register_bedrock_model( + client, resources, "e2e-bedrock-headers-stream", CONVERSE_REGIONAL_BACKEND + ) + key = resources.key() + + result = client.proxy.chat_stream( + key, ChatBody(model=model, messages=_prompt(), stream=True, max_tokens=64) + ) + + _assert_streamed_completion(result) + _assert_request_id_header(result) + + +class TestBedrockInvokeRegionalModelIds: + @pytest.mark.covers("llm.chat_completions.bedrock_invoke.basic.nonstream.works", exercised_on=[]) + def test_invoke_regional_id_completes( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = _register_bedrock_model(client, resources, "e2e-bedrock-invoke", INVOKE_REGIONAL_BACKEND) + key = resources.key() + + response = unwrap(client.proxy.chat(key, ChatBody(model=model, messages=_prompt(), max_tokens=64))) + + _assert_completion(response) + + @pytest.mark.covers("llm.chat_completions.bedrock_invoke.basic.stream.works", exercised_on=[]) + def test_invoke_regional_id_streams( + self, client: PassthroughClient, resources: ResourceManager + ) -> None: + model = _register_bedrock_model(client, resources, "e2e-bedrock-invoke-stream", INVOKE_REGIONAL_BACKEND) + key = resources.key() + + result = client.proxy.chat_stream( + key, ChatBody(model=model, messages=_prompt(), stream=True, max_tokens=64) + ) + + _assert_streamed_completion(result) diff --git a/tests/e2e/management/management_client.py b/tests/e2e/management/management_client.py index cdc31aeea79..b2bd41e19ba 100644 --- a/tests/e2e/management/management_client.py +++ b/tests/e2e/management/management_client.py @@ -14,6 +14,8 @@ from e2e_http import NoBody, ProbeResult, Result, StreamingResponse, Success, Un from models import ( ChatBody, ChatMessage, + ConnectionTestBody, + ConnectionTestResponse, CustomerDeleteBody, CustomerInfoParams, CustomerNewBody, @@ -118,6 +120,17 @@ class ManagementClient: ) ) + def connection_test(self, body: ConnectionTestBody) -> Result[ConnectionTestResponse]: + """POST /health/test_connection, the call behind the Admin UI's Test + Connection button, probing the live provider with the supplied params.""" + return self.proxy.transport.post( + "/health/test_connection", + headers=self.proxy.transport.master, + json=body, + response_type=ConnectionTestResponse, + timeout=120.0, + ) + def block_key(self, key: str) -> None: _ = unwrap( self.proxy.transport.post( diff --git a/tests/e2e/management/test_model_test_connection_e2e.py b/tests/e2e/management/test_model_test_connection_e2e.py new file mode 100644 index 00000000000..a1f714df4c8 --- /dev/null +++ b/tests/e2e/management/test_model_test_connection_e2e.py @@ -0,0 +1,42 @@ +"""Live e2e for POST /health/test_connection, the API behind the Admin UI's +Test Connection button on the add-model form. + +The covered cell is a responses-mode Bedrock Mantle deployment: exactly this +shape 500ed on a functools.partial acompletion conflict before v1.91.0 while +every chat-mode probe stayed green, so the happy path asserts a real success +verdict from the live provider rather than just a 200 envelope. The region is a +literal because the endpoint rejects request-supplied os.environ/ references; +credentials fall through to the proxy's own environment (bearer token locally, +pod identity in CI). +""" + +from __future__ import annotations + +import pytest + +from e2e_http import unwrap +from management_client import ManagementClient +from models import ConnectionTestBody, LiteLLMParamsBody + +pytestmark = pytest.mark.e2e + +MANTLE_RESPONSES_BACKEND = "bedrock_mantle/openai.gpt-5.6-luna" +MANTLE_REGION = "us-east-1" + + +class TestModelTestConnection: + @pytest.mark.covers("mgmt.model.test_connection.happy_path") + def test_bedrock_mantle_responses_connection_succeeds(self, client: ManagementClient) -> None: + response = unwrap( + client.connection_test( + ConnectionTestBody( + litellm_params=LiteLLMParamsBody( + model=MANTLE_RESPONSES_BACKEND, aws_region_name=MANTLE_REGION + ), + mode="responses", + ) + ) + ) + + error = response.result.error if response.result else None + assert response.status == "success", f"test_connection reported an error: {error}" diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 5e2cb90958e..e6bde9770a6 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -820,6 +820,26 @@ class ModelDeleteBody(BaseModel): id: str +class ConnectionTestBody(BaseModel): + """POST /health/test_connection body, the API behind the Admin UI's Test + Connection button: the deployment params as typed into the add-model form and + the health-check mode picking which endpoint the probe calls. The endpoint + rejects `os.environ/` references, so credentials are either literal values or + omitted to fall through to the proxy's own environment.""" + + litellm_params: LiteLLMParamsBody + mode: Literal["chat", "completion", "embedding", "responses"] + + +class ConnectionTestResult(BaseModel): + error: str | None = None + + +class ConnectionTestResponse(BaseModel): + status: Literal["success", "error"] + result: ConnectionTestResult | None = None + + class CredentialCreateBody(BaseModel): credential_name: str credential_values: dict[str, str] From 5470645f87d1f9a6367121cfa99b8672e57ca350 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:32:21 +0000 Subject: [PATCH 20/90] refactor(azure/realtime): keep auth header build within lint budgets after merge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/llms/azure/realtime/handler.py | 10 +++--- litellm/realtime_api/main.py | 33 ++++++++++++------- .../realtime/test_azure_realtime_handler.py | 4 ++- 3 files changed, 30 insertions(+), 17 deletions(-) diff --git a/litellm/llms/azure/realtime/handler.py b/litellm/llms/azure/realtime/handler.py index 70e39f47d63..88492ef996e 100644 --- a/litellm/llms/azure/realtime/handler.py +++ b/litellm/llms/azure/realtime/handler.py @@ -4,6 +4,8 @@ This file contains the calling Azure OpenAI's `/openai/realtime` endpoint. 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 litellm._logging import _redact_string, verbose_proxy_logger @@ -31,15 +33,15 @@ async def forward_messages(client_ws: Any, backend_ws: Any): class AzureOpenAIRealtime(AzureChatCompletion): @staticmethod - def get_auth_headers(api_key: str | None, azure_ad_token: str | None) -> dict[str, str]: + def get_auth_headers(api_key: str | None, azure_ad_token: str | None) -> Mapping[str, str]: """ Build the websocket handshake auth headers, preferring a static api-key and falling back to an Azure AD (Entra ID) bearer token. Never sends both. """ if api_key: - return {"api-key": api_key} + return MappingProxyType({"api-key": api_key}) if azure_ad_token: - return {"Authorization": f"Bearer {azure_ad_token}"} + return MappingProxyType({"Authorization": f"Bearer {azure_ad_token}"}) raise ValueError( "Missing Azure credentials for the realtime endpoint. Set an api_key, or configure Azure AD auth " "(azure_ad_token, tenant_id/client_id/client_secret, or a managed identity)" @@ -132,7 +134,7 @@ class AzureOpenAIRealtime(AzureChatCompletion): query_params=query_params, ) - auth_headers = self.get_auth_headers(api_key=api_key, azure_ad_token=azure_ad_token) + auth_headers: Final = self.get_auth_headers(api_key=api_key, azure_ad_token=azure_ad_token) try: ssl_context: Final = get_shared_realtime_ssl_context() diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index 8933d5e4506..e5f6c8328f4 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -2,6 +2,8 @@ import asyncio import os +from collections.abc import Mapping +from types import MappingProxyType from typing import Any, Final, Literal, cast import litellm @@ -45,6 +47,7 @@ bedrock_realtime: Final = BedrockRealtime() xai_realtime: Final = XAIRealtime() vertex_llm_base: Final = VertexBase() 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]: @@ -412,10 +415,8 @@ async def _arealtime( if realtime_protocol is None and (query_params or {}).get("intent") == "transcription": realtime_protocol = "GA" realtime_protocol = realtime_protocol or "beta" - resolved_azure_ad_token = ( - None - if api_key - else get_azure_ad_token(GenericLiteLLMParams(**{**kwargs, "azure_ad_token": azure_ad_token})) + resolved_azure_ad_token: Final = ( + None if api_key else get_azure_ad_token(GenericLiteLLMParams(**kwargs, azure_ad_token=azure_ad_token)) ) await azure_realtime.async_realtime( model=model, @@ -556,6 +557,17 @@ async def _arealtime( raise ValueError(f"Unsupported model: {model}") +def _realtime_health_check_auth_headers( + custom_llm_provider: str, api_key: str | None, model_params: Mapping[str, Any] +) -> Mapping[str, str | None]: + if custom_llm_provider != "azure": + return MappingProxyType({"api-key": api_key}) + return azure_realtime.get_auth_headers( + api_key=api_key, + azure_ad_token=(None if api_key else get_azure_ad_token(GenericLiteLLMParams(**model_params))), + ) + + async def _realtime_health_check( model: str, custom_llm_provider: str, @@ -584,7 +596,11 @@ async def _realtime_health_check( import websockets url: str | None = None - auth_headers: dict[str, str | None] = {"api-key": api_key} + auth_headers: Final = _realtime_health_check_auth_headers( + custom_llm_provider=custom_llm_provider, + api_key=api_key, + model_params=model_params or _EMPTY_MODEL_PARAMS, + ) if custom_llm_provider == "azure": url = azure_realtime._construct_url( api_base=api_base or "", @@ -592,13 +608,6 @@ async def _realtime_health_check( api_version=api_version or "2024-10-01-preview", realtime_protocol=realtime_protocol, ) - azure_litellm_params = GenericLiteLLMParams(**(model_params or {})) - auth_headers = dict( - azure_realtime.get_auth_headers( - api_key=api_key, - azure_ad_token=(None if api_key else get_azure_ad_token(azure_litellm_params)), - ) - ) elif custom_llm_provider == "openai": url = openai_realtime._construct_url( api_base=api_base or "https://api.openai.com/", diff --git a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py index 570843da7a8..7d24e604569 100644 --- a/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py +++ b/tests/test_litellm/llms/azure/realtime/test_azure_realtime_handler.py @@ -590,7 +590,9 @@ async def test_async_realtime_uses_bearer_token_when_no_api_key(): "websockets.connect", return_value=_DummyAsyncContextManager(mock_backend_ws), ) as mock_ws_connect, - patch("litellm.llms.azure.realtime.handler.RealTimeStreaming") as mock_realtime_streaming, + patch( # test-quality-ok: handler owns the streaming loop, only the handshake headers are under test + "litellm.llms.azure.realtime.handler.RealTimeStreaming" + ) as mock_realtime_streaming, ): mock_realtime_streaming.return_value.bidirectional_forward = AsyncMock() From 90f9a8bfda7aff99e1c969f573931cc86eee2103 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:05:38 -0700 Subject: [PATCH 21/90] test(e2e): retry timeout-shaped Mantle test_connection probes The endpoint answers a probe that exceeds HEALTH_CHECK_TIMEOUT_SECONDS with HTTP 200 and an in-body "Timeout exceeded", which the harness's status-code rerun policy cannot see. The suite's parallel Bedrock load can push a Mantle probe past that cap transiently, so only that exact error is retried, three bounded attempts with visible prints; any other error verdict still fails immediately. --- .../test_model_test_connection_e2e.py | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/tests/e2e/management/test_model_test_connection_e2e.py b/tests/e2e/management/test_model_test_connection_e2e.py index a1f714df4c8..25b0b4f24e6 100644 --- a/tests/e2e/management/test_model_test_connection_e2e.py +++ b/tests/e2e/management/test_model_test_connection_e2e.py @@ -8,35 +8,60 @@ verdict from the live provider rather than just a 200 envelope. The region is a literal because the endpoint rejects request-supplied os.environ/ references; credentials fall through to the proxy's own environment (bearer token locally, pod identity in CI). + +The endpoint caps every probe at HEALTH_CHECK_TIMEOUT_SECONDS and answers a +timed-out probe with HTTP 200 and an in-body "Timeout exceeded", which the +harness's status-code retry policy cannot see. A Mantle probe can hit that cap +transiently while the rest of the suite saturates the same AWS account, so only +that exact error is retried here; any other error verdict fails immediately. """ from __future__ import annotations +import time + import pytest from e2e_http import unwrap from management_client import ManagementClient -from models import ConnectionTestBody, LiteLLMParamsBody +from models import ConnectionTestBody, ConnectionTestResponse, LiteLLMParamsBody pytestmark = pytest.mark.e2e MANTLE_RESPONSES_BACKEND = "bedrock_mantle/openai.gpt-5.6-luna" MANTLE_REGION = "us-east-1" +PROBE_TIMEOUT_ERROR = "Timeout exceeded" +PROBE_ATTEMPTS = 3 +PROBE_RETRY_SLEEP_SECONDS = 30 + + +def _probe_mantle(client: ManagementClient) -> ConnectionTestResponse: + return unwrap( + client.connection_test( + ConnectionTestBody( + litellm_params=LiteLLMParamsBody( + model=MANTLE_RESPONSES_BACKEND, aws_region_name=MANTLE_REGION + ), + mode="responses", + ) + ) + ) class TestModelTestConnection: @pytest.mark.covers("mgmt.model.test_connection.happy_path") def test_bedrock_mantle_responses_connection_succeeds(self, client: ManagementClient) -> None: - response = unwrap( - client.connection_test( - ConnectionTestBody( - litellm_params=LiteLLMParamsBody( - model=MANTLE_RESPONSES_BACKEND, aws_region_name=MANTLE_REGION - ), - mode="responses", + for attempt in range(1, PROBE_ATTEMPTS + 1): + response = _probe_mantle(client) + if response.status == "success": + return + error = response.result.error if response.result else None + assert error == PROBE_TIMEOUT_ERROR, f"test_connection reported an error: {error}" + if attempt < PROBE_ATTEMPTS: + print( + f"test_connection probe timed out; retry {attempt}/{PROBE_ATTEMPTS - 1}" + f" in {PROBE_RETRY_SLEEP_SECONDS}s", + flush=True, ) - ) - ) - - error = response.result.error if response.result else None - assert response.status == "success", f"test_connection reported an error: {error}" + time.sleep(PROBE_RETRY_SLEEP_SECONDS) + pytest.fail(f"test_connection timed out on all {PROBE_ATTEMPTS} attempts") From e14f485827ab78808f3a0e6bafda3ddfd1da2474 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:11:42 +0000 Subject: [PATCH 22/90] fix(anthropic): raise missing-credential error on /v1/messages passthrough Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../messages/transformation.py | 14 +++++- .../anthropic/test_anthropic_common_utils.py | 44 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py index 032bf0130ce..75146922a39 100644 --- a/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py +++ b/litellm/llms/anthropic/experimental_pass_through/messages/transformation.py @@ -8,6 +8,7 @@ from litellm.constants import ( DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET, DEFAULT_REASONING_EFFORT_XHIGH_THINKING_BUDGET, ) +from litellm.exceptions import AuthenticationError from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.litellm_logging import verbose_logger from litellm.llms.base_llm.anthropic_messages.transformation import ( @@ -309,8 +310,17 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig): if "x-api-key" not in headers and "authorization" not in headers: auth_header: Final = AnthropicModelInfo.get_auth_header(api_key) - if auth_header is not None: - headers.update(auth_header) + if auth_header is None: + raise AuthenticationError( + message=( + "Missing Anthropic API Key - A call is being made to anthropic but no key is set " + "either in the environment variables or via params. Please set `ANTHROPIC_API_KEY` " + "or `ANTHROPIC_AUTH_TOKEN` in your environment vars" + ), + llm_provider=self._resolved_provider, + model=model, + ) + headers.update(auth_header) if "anthropic-version" not in headers: headers["anthropic-version"] = DEFAULT_ANTHROPIC_API_VERSION if "content-type" not in headers: diff --git a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py index c27362bf49f..a96c44ac5c0 100644 --- a/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py +++ b/tests/test_litellm/llms/anthropic/test_anthropic_common_utils.py @@ -1227,6 +1227,50 @@ class TestPassthroughAuthToken: assert updated_headers["x-api-key"] == FAKE_REGULAR_KEY assert "authorization" not in updated_headers + def test_passthrough_missing_credentials_raises_authentication_error(self): + """Passthrough endpoint should raise locally instead of forwarding an unauthenticated request.""" + from unittest.mock import patch as mock_patch + + import litellm + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + config = AnthropicMessagesConfig() + with mock_patch.dict("os.environ", {}, clear=True): + with pytest.raises(litellm.AuthenticationError, match="Missing Anthropic API Key"): + config.validate_anthropic_messages_environment( + headers={}, + model="claude-sonnet-4-5-20250929", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=None, + ) + + def test_passthrough_client_x_api_key_header_is_kept(self): + """A client-forwarded x-api-key header should satisfy validation without env credentials.""" + from unittest.mock import patch as mock_patch + + from litellm.llms.anthropic.experimental_pass_through.messages.transformation import ( + AnthropicMessagesConfig, + ) + + config = AnthropicMessagesConfig() + with mock_patch.dict("os.environ", {}, clear=True): + updated_headers, _ = config.validate_anthropic_messages_environment( + headers={"x-api-key": FAKE_REGULAR_KEY}, + model="claude-sonnet-4-5-20250929", + messages=[{"role": "user", "content": "Hello"}], + optional_params={}, + litellm_params={}, + api_key=None, + api_base=None, + ) + + assert updated_headers["x-api-key"] == FAKE_REGULAR_KEY + def test_passthrough_get_complete_url_honours_base_url_env(self): """get_complete_url should use ANTHROPIC_BASE_URL when api_base is None.""" from unittest.mock import patch as mock_patch From 0fc042e37613b8e9b72c354c18d99e4b5fc2dddb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:28:52 +0000 Subject: [PATCH 23/90] test(anthropic): pass explicit api_key where passthrough env validation now raises Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../llms/anthropic/chat/test_anthropic_chat_transformation.py | 1 + .../messages/test_anthropic_messages_speed.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py index 4f340ee0f3f..0933638635b 100644 --- a/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py +++ b/tests/test_litellm/llms/anthropic/chat/test_anthropic_chat_transformation.py @@ -1050,6 +1050,7 @@ def test_anthropic_messages_validate_adds_beta_header(): messages=[{"role": "user", "content": [{"type": "text", "text": "Hi"}]}], optional_params={"context_management": _sample_context_management_payload()}, litellm_params={}, + api_key="fake-anthropic-key", ) assert headers["anthropic-beta"] == "context-management-2025-06-27" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_speed.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_speed.py index 6900f1062bf..efd49962ac8 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_speed.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_messages_speed.py @@ -28,6 +28,7 @@ def test_messages_drop_params_strips_speed_for_unsupported_models(): messages=[{"role": "user", "content": "Hello"}], optional_params=dict(optional_params), litellm_params={}, + api_key="fake-anthropic-key", ) result = config.transform_anthropic_messages_request( model="claude-sonnet-4-6", @@ -60,6 +61,7 @@ def test_messages_drop_params_keeps_speed_for_supporting_models(): messages=[{"role": "user", "content": "Hello"}], optional_params=dict(optional_params), litellm_params={}, + api_key="fake-anthropic-key", ) result = config.transform_anthropic_messages_request( model="claude-opus-4-6", From 5ab9e63628088f13024f5a949259bec69855481e Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:24:58 -0700 Subject: [PATCH 24/90] fix(together_ai): fail open on response_format instead of dropping it for unregistered models --- .../llms/together_ai/chat/transformation.py | 79 ++++++--- .../test_together_ai_chat_transformation.py | 158 ++++++++++++++++-- 2 files changed, 195 insertions(+), 42 deletions(-) diff --git a/litellm/llms/together_ai/chat/transformation.py b/litellm/llms/together_ai/chat/transformation.py index 3162a34f1b9..5f0ab5e56af 100644 --- a/litellm/llms/together_ai/chat/transformation.py +++ b/litellm/llms/together_ai/chat/transformation.py @@ -4,32 +4,47 @@ Translates from OpenAI's `/v1/chat/completions` to Together AI's `/v1/chat/compl Docs: https://docs.together.ai/docs/chat-overview """ -from collections.abc import Container -from types import MappingProxyType +from collections.abc import Callable, Container from typing import Final import litellm from litellm._logging import verbose_logger from litellm.exceptions import UnsupportedParamsError -from litellm.utils import supports_function_calling +from litellm.utils import supports_function_calling, supports_response_schema from ...openai.chat.gpt_transformation import OpenAIGPTConfig TOOL_CALLING_PARAMS: Final = ("tools", "tool_choice", "function_call") -PLAIN_TEXT_RESPONSE_FORMAT: Final = MappingProxyType({"type": "text"}) FUNCTION_CALLING_DOCS_URL: Final = "https://docs.together.ai/docs/function-calling" +STRUCTURED_OUTPUTS_DOCS_URL: Final = "https://docs.together.ai/docs/inference/chat/structured-outputs" + + +def _registry_verdict(model: str, flag: str, check: Callable[[str], bool]) -> bool | None: + try: + if check(model): + return True + except Exception as e: + verbose_logger.debug("Error checking together_ai %s for %s: %s", flag, model, e) + registry_entry: Final = litellm.model_cost.get(f"together_ai/{model}") + if isinstance(registry_entry, dict) and registry_entry.get(flag) is False: + return False + return None def _function_calling_verdict(model: str) -> bool | None: - try: - if supports_function_calling(model, custom_llm_provider="together_ai"): - return True - except Exception as e: - verbose_logger.debug("Error checking together_ai function calling support for %s: %s", model, e) - registry_entry: Final = litellm.model_cost.get(f"together_ai/{model}") - if isinstance(registry_entry, dict) and registry_entry.get("supports_function_calling") is False: - return False - return None + return _registry_verdict( + model, + "supports_function_calling", + lambda checked_model: supports_function_calling(checked_model, custom_llm_provider="together_ai"), + ) + + +def _response_schema_verdict(model: str) -> bool | None: + return _registry_verdict( + model, + "supports_response_schema", + lambda checked_model: supports_response_schema(checked_model, custom_llm_provider="together_ai"), + ) def _tool_params_to_drop(passed_params: Container[str], model: str, drop_params: bool) -> tuple[str, ...]: @@ -61,19 +76,33 @@ def _tool_params_to_drop(passed_params: Container[str], model: str, drop_params: ) -class TogetherAIChatConfig(OpenAIGPTConfig): - def get_supported_openai_params(self, model: str) -> list: - supports_fc: Final = _function_calling_verdict(model) - supported_params: Final = super().get_supported_openai_params(model) - if supports_fc is True: - return supported_params - verbose_logger.debug( - "Only some together models support response_format. Docs - https://docs.together.ai/docs/function-calling" +def _drop_response_format(passed_params: Container[str], model: str, drop_params: bool) -> bool: + if "response_format" not in passed_params: + return False + verdict: Final = _response_schema_verdict(model) + if verdict is True: + return False + if verdict is None: + verbose_logger.warning( + "together_ai model %s has no structured outputs entry in the model registry; passing response_format through for Together to validate. Docs - %s", + model, + STRUCTURED_OUTPUTS_DOCS_URL, ) - return [ # mutable-ok: the inherited contract returns a plain list; building fresh avoids mutating the base class's value - param for param in supported_params if param != "response_format" - ] + return False + if drop_params or litellm.drop_params: + verbose_logger.warning( + "together_ai model %s does not support structured outputs per the model registry; dropping response_format. Docs - %s", + model, + STRUCTURED_OUTPUTS_DOCS_URL, + ) + return True + raise UnsupportedParamsError( + status_code=500, + message=f"together_ai does not support parameters: response_format, for model={model}. To drop it from the call, set `litellm.drop_params = True`.", + ) + +class TogetherAIChatConfig(OpenAIGPTConfig): def map_openai_params( self, non_default_params: dict, @@ -84,6 +113,6 @@ class TogetherAIChatConfig(OpenAIGPTConfig): mapped_openai_params: Final = super().map_openai_params(non_default_params, optional_params, model, drop_params) for param in _tool_params_to_drop(mapped_openai_params, model, drop_params): mapped_openai_params.pop(param) - if mapped_openai_params.get("response_format") == PLAIN_TEXT_RESPONSE_FORMAT: + if _drop_response_format(mapped_openai_params, model, drop_params): mapped_openai_params.pop("response_format") return mapped_openai_params diff --git a/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py b/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py index 0b9fd5364f9..3848a9c7e6c 100644 --- a/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py +++ b/tests/test_litellm/llms/together_ai/chat/test_together_ai_chat_transformation.py @@ -18,11 +18,24 @@ TOOL_CALLING_MODEL = "openai/gpt-oss-20b" REASONING_MODEL = "deepseek-ai/DeepSeek-V3.1" UNMAPPED_MODEL = "example-org/brand-new-model" NO_TOOLS_MODEL = "example-org/no-tools-model" +NO_SCHEMA_MODEL = "example-org/no-schema-model" TOOL_PARAMS = ("tools", "tool_choice", "function_call") WEATHER_TOOLS = [{"type": "function", "function": {"name": "get_weather", "parameters": {}}}] +VOICE_NOTE_SCHEMA = { + "type": "object", + "properties": {"title": {"type": "string"}, "summary": {"type": "string"}}, + "required": ["title", "summary"], + "additionalProperties": False, +} +JSON_SCHEMA_RESPONSE_FORMAT = { + "type": "json_schema", + "json_schema": {"name": "voice_note", "schema": VOICE_NOTE_SCHEMA, "strict": True}, +} +REGEX_RESPONSE_FORMAT = {"type": "regex", "pattern": "(positive|neutral|negative)"} + @pytest.fixture(autouse=True) def force_local_model_cost(monkeypatch): @@ -41,6 +54,15 @@ def registry_disables_function_calling(monkeypatch): ) +@pytest.fixture +def registry_disables_response_schema(monkeypatch): + monkeypatch.setitem( + litellm.model_cost, + f"together_ai/{NO_SCHEMA_MODEL}", + {"litellm_provider": "together_ai", "mode": "chat", "supports_response_schema": False}, + ) + + @pytest.fixture def together_warning_log(caplog): from litellm._logging import verbose_logger @@ -63,7 +85,7 @@ def test_supported_params_unmapped_model_keeps_tool_params(): for param in TOOL_PARAMS: assert param in supported - assert "response_format" not in supported + assert "response_format" in supported assert "stream" in supported assert "temperature" in supported @@ -73,7 +95,7 @@ def test_supported_params_no_tools_model_keeps_tool_params(registry_disables_fun for param in TOOL_PARAMS: assert param in supported - assert "response_format" not in supported + assert "response_format" in supported def test_map_openai_params_tool_calling_model_passes_tools(): @@ -141,21 +163,17 @@ def test_map_openai_params_reasoning_model_passes_sampling_params(): assert mapped["max_tokens"] == 512 -def test_map_openai_params_drops_text_response_format(): - mapped = TogetherAIChatConfig().map_openai_params( - non_default_params={"response_format": {"type": "text"}, "temperature": 0.5}, - optional_params={}, - model=REASONING_MODEL, - drop_params=False, - ) - - assert "response_format" not in mapped - assert mapped["temperature"] == 0.5 - - -def test_map_openai_params_keeps_json_response_format(): - response_format = {"type": "json_object"} - +@pytest.mark.parametrize( + "response_format", + [ + {"type": "text"}, + {"type": "json_object"}, + {"type": "json_object", "schema": VOICE_NOTE_SCHEMA}, + JSON_SCHEMA_RESPONSE_FORMAT, + REGEX_RESPONSE_FORMAT, + ], +) +def test_map_openai_params_schema_model_passes_response_format_through(response_format): mapped = TogetherAIChatConfig().map_openai_params( non_default_params={"response_format": response_format}, optional_params={}, @@ -166,6 +184,46 @@ def test_map_openai_params_keeps_json_response_format(): assert mapped["response_format"] == response_format +@pytest.mark.parametrize("drop_params", [False, True]) +def test_map_openai_params_unmapped_model_passes_response_format_through(drop_params, together_warning_log): + mapped = TogetherAIChatConfig().map_openai_params( + non_default_params={"response_format": JSON_SCHEMA_RESPONSE_FORMAT}, + optional_params={}, + model=UNMAPPED_MODEL, + drop_params=drop_params, + ) + + assert mapped["response_format"] == JSON_SCHEMA_RESPONSE_FORMAT + assert UNMAPPED_MODEL in together_warning_log.text + assert "passing response_format through" in together_warning_log.text + + +def test_map_openai_params_no_schema_model_drops_response_format_with_warning( + registry_disables_response_schema, together_warning_log +): + mapped = TogetherAIChatConfig().map_openai_params( + non_default_params={"response_format": JSON_SCHEMA_RESPONSE_FORMAT, "temperature": 0.5}, + optional_params={}, + model=NO_SCHEMA_MODEL, + drop_params=True, + ) + + assert "response_format" not in mapped + assert mapped["temperature"] == 0.5 + assert NO_SCHEMA_MODEL in together_warning_log.text + assert "dropping response_format" in together_warning_log.text + + +def test_map_openai_params_no_schema_model_raises_without_drop_params(registry_disables_response_schema): + with pytest.raises(UnsupportedParamsError, match="response_format"): + TogetherAIChatConfig().map_openai_params( + non_default_params={"response_format": JSON_SCHEMA_RESPONSE_FORMAT}, + optional_params={}, + model=NO_SCHEMA_MODEL, + drop_params=False, + ) + + def _transform_response(message: dict) -> ModelResponse: raw_response_json = { "id": "chatcmpl-test", @@ -385,3 +443,69 @@ def test_completion_unmapped_model_sends_tools_to_together(): tool_call = response.choices[0].message.tool_calls[0] assert tool_call.function.name == "get_weather" assert json.loads(tool_call.function.arguments) == {"city": "San Francisco"} + + +def _capture_completion_request(model: str, **completion_kwargs) -> dict: + from litellm.llms.custom_httpx.http_handler import HTTPHandler + + captured_requests = [] + + def respond(request: httpx.Request) -> httpx.Response: + captured_requests.append(request) + return httpx.Response( + 200, + json={ + "id": "chatcmpl-together-structured", + "object": "chat.completion", + "created": 1234567890, + "model": model, + "choices": [ + { + "index": 0, + "message": {"role": "assistant", "content": '{"title": "t", "summary": "s"}'}, + "finish_reason": "stop", + } + ], + "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, + }, + ) + + client = HTTPHandler(client=httpx.Client(transport=httpx.MockTransport(respond))) + litellm.completion( + model=f"together_ai/{model}", + messages=[{"role": "user", "content": "Summarize with a title and summary."}], + api_key="fake-key", + client=client, + **completion_kwargs, + ) + return json.loads(captured_requests[0].content) + + +def test_completion_unmapped_model_sends_json_schema_to_together(): + request_body = _capture_completion_request( + UNMAPPED_MODEL, response_format=JSON_SCHEMA_RESPONSE_FORMAT, drop_params=True + ) + + assert request_body["response_format"] == JSON_SCHEMA_RESPONSE_FORMAT + + +def test_completion_pydantic_response_format_sends_json_schema_to_together(): + from pydantic import BaseModel + + class VoiceNote(BaseModel): + title: str + summary: str + + request_body = _capture_completion_request(TOOL_CALLING_MODEL, response_format=VoiceNote) + + sent = request_body["response_format"] + assert sent["type"] == "json_schema" + assert sent["json_schema"]["name"] == "VoiceNote" + assert sent["json_schema"]["strict"] is True + assert sent["json_schema"]["schema"]["required"] == ["title", "summary"] + + +def test_completion_regex_response_format_sends_pattern_to_together(): + request_body = _capture_completion_request(TOOL_CALLING_MODEL, response_format=REGEX_RESPONSE_FORMAT) + + assert request_body["response_format"] == REGEX_RESPONSE_FORMAT From c673bcd970af772a2dac0fa98440646597367d55 Mon Sep 17 00:00:00 2001 From: Daniel Meismer Date: Tue, 25 Aug 2026 15:51:59 -0400 Subject: [PATCH 25/90] fix(mcp): preserve provider access token lifetime Co-Authored-By: Codex --- .../mcp_server/bridge_token_flow.py | 24 +++++++--- .../bridge_credentials.py | 2 +- .../outbound_credentials/envelope.py | 44 ++++++++++++------- .../outbound_credentials/session_token.py | 6 +-- .../outbound_credentials/test_envelope.py | 38 +++++++++++----- .../mcp_server/test_discoverable_endpoints.py | 24 ++++++++++ 6 files changed, 101 insertions(+), 37 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py index b8c25236b0d..09a3703e904 100644 --- a/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py +++ b/litellm/proxy/_experimental/mcp_server/bridge_token_flow.py @@ -306,15 +306,15 @@ _UpstreamGrantRejection = Literal["no_access_token", "expired_lifetime"] - ``expired_lifetime``: the response reports a parseable, non-positive ``expires_in``, i.e. an upstream token that is already dead, so sealing it would forward a bearer the edge cannot use An absent or unparseable ``expires_in`` is NOT a rejection; the lifetime is merely unknown and the -envelope caps it, the by-design behaviour for an upstream that omits the field.""" +envelope uses its fallback lifetime, the by-design behaviour for an upstream that omits the field.""" def _classify_upstream_lifetime(raw_expires_in: object) -> "int | Literal['unspecified', 'expired']": """Classify an upstream ``expires_in`` into a positive number of seconds, ``"unspecified"`` (absent - or unparseable, so the envelope caps it), or ``"expired"`` (a non-positive value the upstream reports + or unparseable, so the envelope uses its fallback), or ``"expired"`` (a non-positive value the upstream reports as already elapsed). Telling "we do not know the lifetime" apart from "the upstream says it is - already dead" is what stops an explicitly-expired token from silently receiving the envelope's 1h - cap. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a + already dead" is what stops an explicitly-expired token from silently receiving the envelope's + one-hour fallback. The expired decision is made on the parsed numeric value, not on ``int(...)`` of it, so a positive sub-second lifetime in ``(0, 1)`` is not truncated to ``0`` and misread as elapsed; the envelope works in whole seconds, so such a lifetime clamps up to its 1s floor. ``bool`` is excluded (an ``int`` subclass but never a real lifetime), and the conversions can raise on ``NaN`` / @@ -335,7 +335,7 @@ def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenG """Validate an upstream OAuth token response into a typed grant, or say why it cannot back an envelope. Each field is isinstance-checked so nothing untyped from ``response.json()`` reaches the grant. ``expires_in`` is read three ways (see :func:`_classify_upstream_lifetime`): an unknown - lifetime leaves the grant ``expires_in`` ``None`` for the envelope to cap, a positive value is + lifetime leaves the grant ``expires_in`` ``None`` for the envelope fallback, a positive value is honoured, and an explicit already-elapsed value is a rejection rather than a silent fall-through to the cap.""" from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import @@ -357,8 +357,8 @@ def _bridge_grant_from_token_response(token_response: object) -> "UpstreamTokenG token_type=token_type if isinstance(token_type, str) and token_type else "Bearer", # The upstream refresh_token is deliberately NOT sealed: the edge never consumes it (it forwards # only token_type + access_token), so it would be dead weight embedding a long-lived upstream - # credential in the client-held bearer, and it enlarges the envelope. Refresh support is a - # follow-up (a dedicated refresh-envelope); the client re-runs authorization_code at the cap. + # credential in the client-held bearer, and it enlarges the envelope. The dedicated refresh + # envelope carries that credential separately. refresh_token=None, scope=scope if isinstance(scope, str) and scope else None, expires_in=lifetime if isinstance(lifetime, int) else None, @@ -387,6 +387,7 @@ _BridgeMintError = Literal[ "not_configured", "no_upstream_token", "upstream_token_expired", + "upstream_lifetime_unrepresentable", "too_large", ] @@ -456,6 +457,12 @@ def _bridge_mint_error_response(error: _BridgeMintError) -> JSONResponse: "server_error", "the upstream token response reports an already-expired lifetime", ) + case "upstream_lifetime_unrepresentable": + status, code, desc = ( + 502, + "server_error", + "the upstream token response reports an unrepresentable lifetime", + ) case "too_large": status, code, desc = ( 502, @@ -619,6 +626,7 @@ def _finish_bridge_mint( build_bridge_token_response, ) from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import ( # noqa: PLC0415 # inline import avoids a module-load circular import + EnvelopeLifetimeUnrepresentable, SealedEnvelope, UpstreamTokenGrant, ) @@ -627,6 +635,8 @@ def _finish_bridge_mint( if not isinstance(grant, UpstreamTokenGrant): return _upstream_rejection_to_mint_error(grant) sealed: Final = build_bridge_token_response(ready.identity, grant, ready.keys, now) + if isinstance(sealed, EnvelopeLifetimeUnrepresentable): + return "upstream_lifetime_unrepresentable" if not isinstance(sealed, SealedEnvelope): return "too_large" # Report expires_in from the JWT's own second-truncated exp, rounding the elapsed portion up, so the diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py index 69feaaff195..3ec267c1a06 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/bridge_credentials.py @@ -92,7 +92,7 @@ def build_bridge_token_response( The producer mirror of :func:`resolve_bridge_envelope`: a thin, pure wrapper over :func:`mint_envelope` that returns the sealed envelope, or the mint error as a value - (an oversized grant) for the caller to map onto an OAuth error response. + for the caller to map onto an OAuth error response. """ return mint_envelope(identity, grant, keys, now) diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py index f91bdb9c9c2..df883d5a208 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/envelope.py @@ -19,17 +19,16 @@ in plaintext anywhere in the envelope. Failures are values: :func:`open_envelope` returns one of the frozen ``EnvelopeOpenError`` variants (discriminated on ``tag``) for invalid, expired, -tampered, or undecryptable input, and :func:`mint_envelope` returns -``EnvelopeTooLarge`` for oversized grants. Error values carry tags and sizes only, -never token material. +tampered, or undecryptable input, and :func:`mint_envelope` returns a typed error +for oversized grants or an unrepresentable provider lifetime. Error values carry +tags and metadata only, never token material. The pydantic input models reject programmer errors at construction (e.g. a non-positive ``expires_in`` or an empty required field). :func:`open_envelope` is additionally total over hostile, attacker-controlled input: it never raises, only returns an ``EnvelopeOpenError``. :func:`mint_envelope` operates on a gateway-supplied grant (an upstream IdP's UTF-8 JSON token response), so it does not -defend against non-UTF-8 field content that cannot survive JSON parsing; its only -value-typed failure is ``EnvelopeTooLarge``. +defend against non-UTF-8 field content that cannot survive JSON parsing. """ from __future__ import annotations @@ -57,10 +56,11 @@ ENVELOPE_ISSUER: Final = "litellm-mcp-bridge" """``iss`` claim stamped into every envelope and required back on open.""" MAX_ENVELOPE_TTL_SECONDS: Final = 3600 -"""Hard ceiling on ACCESS envelope lifetime. ``exp`` is ``min(upstream expires_in, this cap)`` -(the cap alone when the upstream omits ``expires_in``), matching the 1h lifetime of the -BYOK session bearer this module's signing approach is borrowed from: a client-held -credential should never outlive a bounded window even when the upstream token does.""" +"""Fallback ACCESS envelope lifetime when the upstream omits ``expires_in``. + +The historical exported name is retained for import compatibility. When the upstream +reports a positive lifetime, the envelope matches it so a renewal does not consume a +still-valid provider refresh grant.""" MAX_REFRESH_ENVELOPE_TTL_SECONDS: Final = 1209600 """Hard ceiling on REFRESH envelope lifetime (14 days). A refresh envelope only renews the short-lived @@ -202,7 +202,15 @@ class EnvelopeTooLarge(BaseModel): max_bytes: int -EnvelopeMintError: TypeAlias = EnvelopeTooLarge +class EnvelopeLifetimeUnrepresentable(BaseModel): + """A positive provider lifetime cannot be represented as a Python datetime.""" + + model_config = ConfigDict(frozen=True) + tag: Literal["envelope_lifetime_unrepresentable"] = "envelope_lifetime_unrepresentable" + expires_in: int + + +EnvelopeMintError: TypeAlias = EnvelopeTooLarge | EnvelopeLifetimeUnrepresentable class NotAnEnvelope(BaseModel): @@ -307,11 +315,17 @@ def mint_envelope( ) -> SealedEnvelope | EnvelopeMintError: """Seal ``grant`` for ``identity`` into a client-held envelope. - ``exp`` is ``min(grant.expires_in, MAX_ENVELOPE_TTL_SECONDS)`` seconds from ``now`` - (the cap alone when ``expires_in`` is absent). Returns ``EnvelopeTooLarge`` when the - serialized envelope exceeds ``MAX_ENVELOPE_BYTES``. + ``exp`` is ``grant.expires_in`` seconds from ``now`` when the upstream reports a + lifetime, or ``MAX_ENVELOPE_TTL_SECONDS`` when it does not. Returns + ``EnvelopeLifetimeUnrepresentable`` when that positive lifetime cannot be represented + as a Python datetime, or ``EnvelopeTooLarge`` when the serialized envelope exceeds + ``MAX_ENVELOPE_BYTES``. """ - expires_at: Final = now + timedelta(seconds=_envelope_ttl_seconds(grant.expires_in)) + ttl_seconds: Final = _envelope_ttl_seconds(grant.expires_in) + try: + expires_at: Final = now + timedelta(seconds=ttl_seconds) + except OverflowError: + return EnvelopeLifetimeUnrepresentable(expires_in=ttl_seconds) return _seal( kind="access", prefix=ENVELOPE_PREFIX, @@ -457,7 +471,7 @@ def _open_claims( def _envelope_ttl_seconds(upstream_expires_in: int | None) -> int: if upstream_expires_in is None: return MAX_ENVELOPE_TTL_SECONDS - return min(upstream_expires_in, MAX_ENVELOPE_TTL_SECONDS) + return upstream_expires_in def _refresh_ttl_seconds(upstream_refresh_expires_in: int | None) -> int: diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_token.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_token.py index d6b0a462062..2c7b970ca0e 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_token.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/session_token.py @@ -54,9 +54,9 @@ the envelope issuer so a token of one family can never validate in the other eve hypothetical shared signing key.""" SESSION_TTL_SECONDS: Final = 3600 -"""Session ACCESS token lifetime (1h), matching the access-envelope and BYOK session bearer -windows: a client-held credential never outlives a bounded window, and each refresh -re-validates the live user before re-minting.""" +"""Session ACCESS token lifetime (1h), matching the BYOK session bearer window: a +client-held credential never outlives a bounded window, and each refresh re-validates +the live user before re-minting.""" SESSION_REFRESH_TTL_SECONDS: Final = 1209600 """Session REFRESH token lifetime (14 days), matching the refresh-envelope bound. Each diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py index ae196c9080b..bd310339a1d 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_envelope.py @@ -3,9 +3,9 @@ The envelope is the single client-held bearer carrying both a litellm identity and the encrypted upstream grant, with zero server-side storage. These tests pin the security contract: an envelope opens only under the exact keys that minted it, tampering with any -signed byte is detected, expiry is enforced against the injected clock (capped by the -module TTL ceiling), oversized envelopes are rejected rather than truncated, and no -error value, model repr, or raised exception ever contains the inner access token. +signed byte is detected, expiry is enforced against the injected clock and provider +lifetime, oversized envelopes are rejected rather than truncated, and no error value, +model repr, or raised exception ever contains the inner access token. """ import base64 @@ -30,6 +30,8 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.envelope import DecryptFailed, EnvelopeIdentity, EnvelopeKeys, + EnvelopeLifetimeUnrepresentable, + EnvelopeMintError, EnvelopeTooLarge, Expired, MalformedPayload, @@ -159,6 +161,20 @@ def test_claim_layout_and_no_plaintext_token_in_envelope(): assert _REFRESH_TOKEN not in json.dumps(claims) +def test_unrepresentable_access_lifetime_is_a_typed_mint_error(): + grant = UpstreamTokenGrant( + access_token=SecretStr(_ACCESS_TOKEN), + token_type="Bearer", + expires_in=10**30, + ) + + result = mint_envelope(_IDENTITY, grant, _KEYS, _NOW) + + assert isinstance(result, EnvelopeLifetimeUnrepresentable) + assert result.tag == "envelope_lifetime_unrepresentable" + assert result.expires_in == 10**30 + + def _refresh_credential() -> RefreshCredential: return RefreshCredential(refresh_token=SecretStr(_REFRESH_TOKEN), scope="read:tools", expires_in=None) @@ -243,11 +259,11 @@ def test_refresh_envelope_never_leaks_the_refresh_token_in_plaintext(): "expires_in, expected_ttl", [ (600, 600), - (MAX_ENVELOPE_TTL_SECONDS + 82800, MAX_ENVELOPE_TTL_SECONDS), + (MAX_ENVELOPE_TTL_SECONDS + 82800, MAX_ENVELOPE_TTL_SECONDS + 82800), (None, MAX_ENVELOPE_TTL_SECONDS), ], ) -def test_exp_is_min_of_upstream_expires_in_and_cap(expires_in, expected_ttl): +def test_exp_matches_upstream_lifetime_or_uses_missing_lifetime_fallback(expires_in: int | None, expected_ttl: int): grant = UpstreamTokenGrant(access_token=SecretStr(_ACCESS_TOKEN), token_type="Bearer", expires_in=expires_in) sealed = mint_envelope(_IDENTITY, grant, _KEYS, _NOW) assert isinstance(sealed, SealedEnvelope) @@ -261,13 +277,13 @@ def test_expiry_honored_against_injected_clock(): assert isinstance(open_envelope(token, _KEYS, _NOW + timedelta(seconds=601)), Expired) -def test_ttl_cap_enforced_on_open_even_when_upstream_token_lives_longer(): +def test_upstream_token_lifetime_is_enforced_on_open(): grant = UpstreamTokenGrant(access_token=SecretStr(_ACCESS_TOKEN), token_type="Bearer", expires_in=86400) token = _sealed_token(grant) - just_before_cap = _NOW + timedelta(seconds=MAX_ENVELOPE_TTL_SECONDS - 1) - at_cap = _NOW + timedelta(seconds=MAX_ENVELOPE_TTL_SECONDS) - assert isinstance(open_envelope(token, _KEYS, just_before_cap), OpenedEnvelope) - assert isinstance(open_envelope(token, _KEYS, at_cap), Expired) + just_before_expiry = _NOW + timedelta(seconds=86399) + at_expiry = _NOW + timedelta(seconds=86400) + assert isinstance(open_envelope(token, _KEYS, just_before_expiry), OpenedEnvelope) + assert isinstance(open_envelope(token, _KEYS, at_expiry), Expired) def test_tampering_any_payload_or_signature_byte_is_bad_signature(): @@ -420,7 +436,7 @@ def test_decryptable_blob_that_is_not_a_grant_is_malformed_payload(): assert isinstance(open_envelope(forged, _KEYS, _NOW), MalformedPayload) -def _mint_with_token_len(n: int) -> SealedEnvelope | EnvelopeTooLarge: +def _mint_with_token_len(n: int) -> SealedEnvelope | EnvelopeMintError: grant = UpstreamTokenGrant(access_token=SecretStr("a" * n), token_type="Bearer") return mint_envelope(_IDENTITY, grant, _KEYS, _NOW) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index bcac27a4a14..828d2785d0b 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -5539,6 +5539,30 @@ async def test_bridge_envelope_too_large_upstream_token_is_502(): assert json.loads(response.body)["error"] == "server_error" +@pytest.mark.asyncio +async def test_bridge_envelope_unrepresentable_upstream_lifetime_is_502(): + from litellm.types.mcp import MCPAuth + + server = _bridge_server(auth_type=MCPAuth.oauth_delegate) + upstream = { + "access_token": "UPSTREAM-SECRET-TOKEN", + "token_type": "Bearer", + "expires_in": 10**30, + } + + response = await _exchange_for_bridge_server( + server, + upstream, + key_hash="hashed-litellm-key-77", + ) + + assert response.status_code == 502 + assert json.loads(response.body) == { + "error": "server_error", + "error_description": "the upstream token response reports an unrepresentable lifetime", + } + + @pytest.mark.asyncio async def test_bridge_access_envelope_never_carries_upstream_refresh_token(): """The upstream refresh token is never sealed into the ACCESS envelope, the bearer forwarded upstream From 31f7b9409ad91ab61b51d4b96bccd56f5bf6d5b7 Mon Sep 17 00:00:00 2001 From: Daniel Meismer Date: Tue, 25 Aug 2026 15:51:59 -0400 Subject: [PATCH 26/90] fix(router): resolve hidden aliases for explicit lookup Co-Authored-By: Codex --- litellm/router.py | 5 +-- .../test_router_order_fallback.py | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index d07effd0d90..4609440a5cd 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -10644,10 +10644,9 @@ class Router: _router_model_name: str = model_value elif isinstance(model_value, dict): _model_value = RouterModelGroupAliasItem(**model_value) - if _model_value["hidden"] is True: + if _model_value["hidden"] is True and model_name is None: continue - else: - _router_model_name = _model_value["model"] + _router_model_name = _model_value["model"] else: continue diff --git a/tests/test_litellm/test_router_order_fallback.py b/tests/test_litellm/test_router_order_fallback.py index 083f35456a3..7743cb005d0 100644 --- a/tests/test_litellm/test_router_order_fallback.py +++ b/tests/test_litellm/test_router_order_fallback.py @@ -367,6 +367,45 @@ async def test_router_order_fallback_with_wildcard_model_group(): assert response._hidden_params["model_id"] == "2" +@pytest.mark.asyncio +async def test_router_order_fallback_with_hidden_model_group_alias(): + router = Router( + model_list=[ + { + "model_name": "canonical-model", + "litellm_params": { + "model": "gpt-4o", + "api_key": "bad", + "mock_response": Exception("fail order 1"), + "order": 1, + }, + "model_info": {"id": "1"}, + }, + { + "model_name": "canonical-model", + "litellm_params": { + "model": "gpt-4o", + "api_key": "good", + "mock_response": "success from order 2", + "order": 2, + }, + "model_info": {"id": "2"}, + }, + ], + model_group_alias={"hidden-alias": {"model": "canonical-model", "hidden": True}}, + num_retries=0, + ) + + assert "hidden-alias" not in {deployment["model_name"] for deployment in router.get_model_list() or []} + + response = await router.acompletion( + model="hidden-alias", + messages=[{"role": "user", "content": "hi"}], + ) + + assert response._hidden_params["model_id"] == "2" + + def test_check_non_standard_fallback_format(): from litellm.router_utils.fallback_event_handlers import ( _check_non_standard_fallback_format, From 74e86d3c0d16f50f541f155e41d4f1968ce8bf82 Mon Sep 17 00:00:00 2001 From: Matthew Lapointe Date: Tue, 25 Aug 2026 19:20:00 -0400 Subject: [PATCH 27/90] fix(bedrock): route reasoning_effort to reasoning.effort for OpenAI GPT-5.x on Converse OpenAI GPT-5.x models on Bedrock Converse expect reasoning effort under additionalModelRequestFields as {"reasoning": {"effort": ...}}. They were falling into the Anthropic branch and emitting a `thinking` block, which Converse rejects with unknown_parameter. The bedrock_converse gpt-5.6 entries were also missing supports_reasoning, so reasoning_effort was dropped before mapping. Setting the flag lets the existing config-driven supported-params path accept it, rather than adding another model-name branch. --- .../bedrock/chat/converse_transformation.py | 6 ++++ ...odel_prices_and_context_window_backup.json | 6 ++++ model_prices_and_context_window.json | 6 ++++ .../chat/test_converse_transformation.py | 31 +++++++++++++++++++ 4 files changed, 49 insertions(+) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 767677cbcbf..38dabafa441 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -418,12 +418,18 @@ class AmazonConverseConfig(BaseConfig): Handle the reasoning_effort parameter based on the model type. - GPT-OSS models: passed through unchanged via additionalModelRequestFields. + - OpenAI GPT-5.x models: mapped to ``reasoning.effort`` via additionalModelRequestFields. - Nova 2 models: transformed to reasoningConfig. - Anthropic models: mapped to ``thinking`` (and ``output_config.effort`` on adaptive Claude 4.6 / 4.7). """ if "gpt-oss" in model: optional_params["reasoning_effort"] = reasoning_effort + elif "openai.gpt-5" in model: + # Converse rejects Anthropic's `thinking` for OpenAI GPT-5.x; effort goes + # under additionalModelRequestFields as {"reasoning": {"effort": ...}}. + optional_params.pop("thinking", None) + optional_params["reasoning"] = {"effort": reasoning_effort} elif self._is_nova_2_model(model): reasoning_config: Final = self._transform_reasoning_effort_to_reasoning_config(reasoning_effort) optional_params.update(reasoning_config) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 9ca8d9e1bac..7fa0e8b73d8 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -49473,6 +49473,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "global.openai.gpt-5.6-sol": { @@ -49498,6 +49499,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "us.openai.gpt-5.6-terra": { @@ -49523,6 +49525,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "global.openai.gpt-5.6-terra": { @@ -49548,6 +49551,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "us.openai.gpt-5.6-luna": { @@ -49573,6 +49577,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "global.openai.gpt-5.6-luna": { @@ -49598,6 +49603,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "bedrock_mantle/openai.gpt-5.5": { diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 9ca8d9e1bac..7fa0e8b73d8 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -49473,6 +49473,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "global.openai.gpt-5.6-sol": { @@ -49498,6 +49499,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "us.openai.gpt-5.6-terra": { @@ -49523,6 +49525,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "global.openai.gpt-5.6-terra": { @@ -49548,6 +49551,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "us.openai.gpt-5.6-luna": { @@ -49573,6 +49577,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "global.openai.gpt-5.6-luna": { @@ -49598,6 +49603,7 @@ ], "supports_function_calling": true, "supports_tool_choice": true, + "supports_reasoning": true, "supports_vision": true }, "bedrock_mantle/openai.gpt-5.5": { diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 4d2c077b548..66b588168e9 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -284,6 +284,37 @@ def test_reasoning_with_forced_tool_choice_switches_to_auto(): assert optional_params["tool_choice"] == {"auto": {}} +@pytest.mark.parametrize( + "model", + [ + "us.openai.gpt-5.6-sol", + "global.openai.gpt-5.6-terra", + "bedrock/converse/us.openai.gpt-5.6-luna", + ], +) +def test_reasoning_effort_maps_to_reasoning_effort_for_openai_gpt5_converse(model, local_model_cost_map): + """OpenAI GPT-5.x on Bedrock Converse routes reasoning_effort to + ``additionalModelRequestFields.reasoning.effort`` rather than Anthropic ``thinking``.""" + config = AmazonConverseConfig() + + assert "reasoning_effort" in config.get_supported_openai_params(model=model) + + optional_params = config.map_openai_params( + non_default_params={"reasoning_effort": "high"}, + optional_params={}, + model=model, + drop_params=False, + ) + + assert optional_params["reasoning"] == {"effort": "high"} + assert "thinking" not in optional_params + assert "reasoning_effort" not in optional_params + + _, additional_request_params, _, _ = config._prepare_request_params(optional_params, model) + assert additional_request_params["reasoning"] == {"effort": "high"} + assert "thinking" not in additional_request_params + + @pytest.mark.parametrize( "model", [ From 9cc276a96eb77768b6db4f8c08b7c015f8af496a Mon Sep 17 00:00:00 2001 From: Matthew Lapointe Date: Tue, 25 Aug 2026 19:32:54 -0400 Subject: [PATCH 28/90] fix(bedrock): never forward Anthropic thinking for OpenAI GPT-5.x Converse Stop advertising thinking/output_config as supported for OpenAI GPT-5.x and skip the thinking mapping for these models, so a request combining thinking with reasoning_effort can no longer leak a thinking block into additionalModelRequestFields regardless of parameter order, which Bedrock rejects with unknown_parameter. --- .../bedrock/chat/converse_transformation.py | 6 ++-- .../chat/test_converse_transformation.py | 34 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 38dabafa441..b995a948dd8 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -426,8 +426,6 @@ class AmazonConverseConfig(BaseConfig): if "gpt-oss" in model: optional_params["reasoning_effort"] = reasoning_effort elif "openai.gpt-5" in model: - # Converse rejects Anthropic's `thinking` for OpenAI GPT-5.x; effort goes - # under additionalModelRequestFields as {"reasoning": {"effort": ...}}. optional_params.pop("thinking", None) optional_params["reasoning"] = {"effort": reasoning_effort} elif self._is_nova_2_model(model): @@ -561,7 +559,7 @@ class AmazonConverseConfig(BaseConfig): # only anthropic and mistral support tool choice config. otherwise (E.g. cohere) will fail the call - https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ToolChoice.html supported_params.append("tool_choice") - if "gpt-oss" in model: + if "gpt-oss" in model or "openai.gpt-5" in model or "openai.gpt-5" in base_model: supported_params.append("reasoning_effort") elif self._is_nova_2_model(model): # Nova 2 models support reasoning_effort (transformed to reasoningConfig) @@ -909,7 +907,7 @@ class AmazonConverseConfig(BaseConfig): optional_params["_parallel_tool_use_config"] = { "tool_choice": {"type": "auto", "disable_parallel_tool_use": not value} } - if param == "thinking": + if param == "thinking" and "openai.gpt-5" not in model: if ( isinstance(value, dict) and value.get("type") == "adaptive" diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 66b588168e9..c5ab6027f45 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -315,6 +315,40 @@ def test_reasoning_effort_maps_to_reasoning_effort_for_openai_gpt5_converse(mode assert "thinking" not in additional_request_params +@pytest.mark.parametrize( + "model", + [ + "us.openai.gpt-5.6-sol", + "bedrock/converse/global.openai.gpt-5.6-luna", + ], +) +def test_openai_gpt5_converse_never_forwards_thinking(model, local_model_cost_map): + """GPT-5.x on Converse must never send Anthropic ``thinking``/``output_config`` (Bedrock rejects them). + + Regression: ``thinking`` is not advertised as supported, and even when supplied alongside + ``reasoning_effort`` in either order it never survives into the request.""" + config = AmazonConverseConfig() + + supported = config.get_supported_openai_params(model=model) + assert "thinking" not in supported + assert "output_config" not in supported + + thinking_block = {"type": "enabled", "budget_tokens": 2048} + for non_default_params in ( + {"reasoning_effort": "high", "thinking": thinking_block}, + {"thinking": thinking_block, "reasoning_effort": "high"}, + ): + optional_params = config.map_openai_params( + non_default_params=dict(non_default_params), + optional_params={}, + model=model, + drop_params=False, + ) + _, additional_request_params, _, _ = config._prepare_request_params(optional_params, model) + assert additional_request_params["reasoning"] == {"effort": "high"} + assert "thinking" not in additional_request_params + + @pytest.mark.parametrize( "model", [ From 418012aac5e1f4959e91deb574c7a88629f22d17 Mon Sep 17 00:00:00 2001 From: Matthew Lapointe Date: Tue, 25 Aug 2026 20:06:53 -0400 Subject: [PATCH 29/90] fix(bedrock): type GPT-5 reasoning field and update capability test Type the GPT-5.x reasoning payload with a ReadOnly TypedDict so the dict literal satisfies the type-discipline budget, and drop the now-redundant thinking pop (the thinking mapping is already skipped for these models). Update the cross-region capability test to expect reasoning_effort offered and thinking/output_config withheld for GPT-5.x on Converse. --- litellm/llms/bedrock/chat/converse_transformation.py | 4 ++-- litellm/types/llms/bedrock.py | 6 +++++- .../test_cross_region_inference_profile_mapping.py | 11 ++++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index b995a948dd8..a4d37810f82 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -426,8 +426,8 @@ class AmazonConverseConfig(BaseConfig): if "gpt-oss" in model: optional_params["reasoning_effort"] = reasoning_effort elif "openai.gpt-5" in model: - optional_params.pop("thinking", None) - optional_params["reasoning"] = {"effort": reasoning_effort} + reasoning: Final[BedrockConverseGptReasoningEffortBlock] = {"effort": reasoning_effort} + optional_params["reasoning"] = reasoning elif self._is_nova_2_model(model): reasoning_config: Final = self._transform_reasoning_effort_to_reasoning_config(reasoning_effort) optional_params.update(reasoning_config) diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index 6ae2e31fe60..ed23db597ae 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -3,7 +3,7 @@ from collections.abc import Sequence from enum import Enum from typing import TYPE_CHECKING, Any, Final, Literal -from typing_extensions import Required, TypedDict, override +from typing_extensions import ReadOnly, Required, TypedDict, override from .openai import ChatCompletionToolCallChunk @@ -97,6 +97,10 @@ class BedrockConverseReasoningContentBlockDelta(TypedDict, total=False): text: str +class BedrockConverseGptReasoningEffortBlock(TypedDict): + effort: ReadOnly[str] + + class GuardrailConverseTextBlock(TypedDict, total=False): text: str diff --git a/tests/test_litellm/llms/bedrock/test_cross_region_inference_profile_mapping.py b/tests/test_litellm/llms/bedrock/test_cross_region_inference_profile_mapping.py index dbd31c7e81b..1388073381e 100644 --- a/tests/test_litellm/llms/bedrock/test_cross_region_inference_profile_mapping.py +++ b/tests/test_litellm/llms/bedrock/test_cross_region_inference_profile_mapping.py @@ -293,15 +293,16 @@ def test_bedrock_gpt_5_6_advertises_only_converse_supported_features( @pytest.mark.parametrize("profile", GPT_5_6_PROFILES, ids=lambda p: p.model_id) -def test_bedrock_gpt_5_6_offers_tools_but_not_reasoning(profile, local_model_cost_map): - """Converse rejects the Anthropic-shaped thinking block LiteLLM emits for - reasoning_effort, so neither reasoning param may be offered yet, while the tool - params these models do accept must be.""" +def test_bedrock_gpt_5_6_offers_tools_and_reasoning_effort_but_not_thinking(profile, local_model_cost_map): + """GPT-5.x on Converse maps reasoning_effort to reasoning.effort, so reasoning_effort + is offered while the Anthropic-only thinking/output_config are not, alongside the tool + params these models accept.""" supported = AmazonConverseConfig().get_supported_openai_params( model=f"bedrock/{profile.model_id}" ) assert "tools" in supported assert "tool_choice" in supported - assert "reasoning_effort" not in supported + assert "reasoning_effort" in supported assert "thinking" not in supported + assert "output_config" not in supported From 5c8852c0d6717fcce5bd6913660fd35b4d7a4219 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 26 Aug 2026 02:14:44 +0000 Subject: [PATCH 30/90] fix: support image_edit health checks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../health_check_helpers.py | 15 ++++++ .../test_health_check_helpers.py | 49 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/litellm/litellm_core_utils/health_check_helpers.py b/litellm/litellm_core_utils/health_check_helpers.py index 3a79eb78b17..43b2eca2fd5 100644 --- a/litellm/litellm_core_utils/health_check_helpers.py +++ b/litellm/litellm_core_utils/health_check_helpers.py @@ -2,6 +2,7 @@ Helper functions for health check calls. """ +import base64 from collections.abc import Callable from typing import TYPE_CHECKING, Final, Literal @@ -13,6 +14,14 @@ if TYPE_CHECKING: # 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 solid-gray PNG +TEST_IMAGE_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAIAAAB7GkOtAAAFlklEQVR42u3VMQEAAAzCMKQjHQ97l0jo0xSAlyIBgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAUgAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGACAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgBgAAAYAAAGAIABAGAAABgAAAYAgAEAYAAAGAAABgCAAQBgAAAYAAAGAIABAGAAABgAADcDrctaAb6XeXAAAAAASUVORK5CYII=" + + +def get_image_file_for_health_check() -> bytes: + """Return the image used for health checks.""" + return base64.b64decode(TEST_IMAGE_BASE64) + class HealthCheckHelpers: @staticmethod @@ -127,6 +136,7 @@ class HealthCheckHelpers: "audio_speech", "audio_transcription", "image_generation", + "image_edit", "video_generation", "rerank", "realtime", @@ -185,6 +195,11 @@ class HealthCheckHelpers: **_filter_model_params(model_params=model_params), prompt=prompt, ), + "image_edit": lambda: litellm.aimage_edit( + **_filter_model_params(model_params=model_params), + image=get_image_file_for_health_check(), + prompt=prompt or "test", + ), "video_generation": lambda: litellm.avideo_generation( **_filter_model_params(model_params=model_params), prompt=prompt or "test video generation", 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 8f4799e3e7d..c2e2f92ad8a 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 @@ -12,6 +12,55 @@ from litellm.proxy._types import UserAPIKeyAuth from litellm.types.utils import LIST_BATCHES_SUPPORTED_PROVIDERS +@pytest.mark.asyncio +async def test_image_edit_health_check_handler_uses_png_and_prompt(): + model_params = {"model": "openai/gpt-image-1", "api_key": "sk-test"} + mode_handlers = HealthCheckHelpers.get_mode_handlers( + model="gpt-image-1", + custom_llm_provider="openai", + model_params=model_params, + ) + + assert "image_edit" in mode_handlers + + with patch( # test-quality-ok: the public health-check path has no dependency injection seam + "litellm.aimage_edit", new_callable=AsyncMock, return_value={} + ) as mock_aimage_edit: + await mode_handlers["image_edit"]() + await HealthCheckHelpers.get_mode_handlers( + model="gpt-image-1", + custom_llm_provider="openai", + model_params=model_params, + prompt="edit this image", + )["image_edit"]() + + assert mock_aimage_edit.call_count == 2 + default_call = mock_aimage_edit.call_args_list[0].kwargs + explicit_call = mock_aimage_edit.call_args_list[1].kwargs + assert default_call["model"] == "openai/gpt-image-1" + assert default_call["prompt"] == "test" + assert explicit_call["prompt"] == "edit this image" + image = default_call["image"] + assert isinstance(image, bytes) + assert image.startswith(b"\x89PNG") + assert int.from_bytes(image[16:20], "big") == 512 + assert int.from_bytes(image[20:24], "big") == 512 + + +@pytest.mark.asyncio +async def test_ahealth_check_supports_image_edit_mode(): + with patch( # test-quality-ok: the public health-check path has no dependency injection seam + "litellm.aimage_edit", new_callable=AsyncMock, return_value={} + ): + result = await ahealth_check( + {"model": "gpt-image-1", "api_key": "sk-test"}, + mode="image_edit", + ) + + assert "error" not in result + assert "Mode image_edit not supported" not in str(result) + + def test_update_model_params_with_health_check_tracking_information(): """Test _update_model_params_with_health_check_tracking_information adds required tracking info.""" initial_model_params = {"model": "gpt-3.5-turbo", "api_key": "test_key"} From a66e091cd7c4f3acd0e6f5066d44ebb4d28432f9 Mon Sep 17 00:00:00 2001 From: Daniel Meismer Date: Tue, 18 Aug 2026 17:05:40 -0400 Subject: [PATCH 31/90] fix(mcp): complete DCR bridge OAuth challenges --- .../mcp_server/auth/user_api_key_auth_mcp.py | 54 +++++--- .../auth/test_user_api_key_auth_mcp.py | 124 ++++++++++++++---- 2 files changed, 138 insertions(+), 40 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 7d85f3c4908..882ded78e2f 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -1,6 +1,7 @@ import re from collections.abc import Sequence from datetime import datetime, timezone +from types import MappingProxyType from typing import TYPE_CHECKING, Final, cast from fastapi import HTTPException @@ -13,6 +14,7 @@ import litellm from litellm._logging import verbose_logger from litellm.proxy._experimental.mcp_server.oauth_utils import ( get_passthrough_resource_metadata_url, + get_passthrough_www_authenticate, get_request_base_url, well_known_root_suffix, ) @@ -437,24 +439,23 @@ class MCPRequestHandler: path=request_route, mcp_servers=mcp_servers, client_ip=IPAddressUtils.get_mcp_client_ip(request), + ) or ( + MCPRequestHandler._single_dcr_bridge_delegate_target( + path=request_route, + mcp_servers=mcp_servers, + client_ip=IPAddressUtils.get_mcp_client_ip(request), + ) + is not None + and not oauth2_headers ): validated_user_api_key_auth = UserAPIKeyAuth() elif ( - ( - bridge_delegate_target := MCPRequestHandler._single_dcr_bridge_delegate_target( - path=request_route, - mcp_servers=mcp_servers, - client_ip=IPAddressUtils.get_mcp_client_ip(request), - ) + bridge_delegate_target := MCPRequestHandler._single_dcr_bridge_delegate_target( + path=request_route, + mcp_servers=mcp_servers, + client_ip=IPAddressUtils.get_mcp_client_ip(request), ) - is not None - and oauth2_headers - and is_bridge_envelope_shaped(oauth2_headers["Authorization"]) - ): - # A single DCR-bridge oauth_delegate target carrying an envelope-shaped - # Authorization: open the envelope, admit under its recovered identity, and - # inject the inner upstream token for egress. A non-envelope bearer on the same - # server is NOT admitted here — it falls through to the oauth2 arm, which 401s. + ) is not None and oauth2_headers: validated_user_api_key_auth, mcp_server_auth_headers = await MCPRequestHandler._admit_dcr_bridge_delegate( server=bridge_delegate_target, authorization_value=oauth2_headers["Authorization"], @@ -740,7 +741,10 @@ class MCPRequestHandler: if len(target_names) != 1: return None server: Final = global_mcp_server_manager.get_mcp_server_by_name(target_names[0], client_ip=client_ip) - if server is None or not server.is_oauth_delegate or not server.is_dcr_bridge: + # Both flags are security-sensitive opt-ins. Require literal booleans so + # partially populated objects and truthy proxy values cannot enable bridge + # admission accidentally. + if server is None or server.is_oauth_delegate is not True or server.is_dcr_bridge is not True: return None # Egress resolves the injected per-server token only by alias / server_name; a server with # neither cannot receive the forwarded token, so fail closed rather than admit-and-drop. @@ -798,7 +802,25 @@ class MCPRequestHandler: new_headers: Final = {**(mcp_server_auth_headers or {}), **injected} return admitted, new_headers case BridgeEnvelopeInvalid() | NotBridgeEnvelope(): - raise HTTPException(status_code=401, detail="Invalid or expired credential") + resource_name: Final = server.alias or server.server_name + if resource_name is None: + raise HTTPException( + status_code=500, + detail="Server misconfigured: MCP server has no routable name", + ) + raise HTTPException( + status_code=401, + detail="Invalid or expired credential", + headers=MappingProxyType( + { + "www-authenticate": get_passthrough_www_authenticate( + scope=request.scope, + server_name=resource_name, + invalid_token=True, + ) + } + ), + ) case _: assert_never(result) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 697c9b018ec..0fa840e4dd3 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -5097,13 +5097,10 @@ class TestMCPDcrBridgeDelegateAdmission: """Admission-side arm for a DCR-bridge ``oauth_delegate`` client that authenticates with a single envelope bearer (LIT-4338). - The arm fires only for a single ``is_dcr_bridge`` ``is_oauth_delegate`` target carrying an - envelope-shaped Authorization. It opens the litellm-signed envelope, reloads the live key - record the sealed ``key_hash`` references so the caller is admitted under the key's current - authorization context (team/org/object-permission) and revocation state, and injects the inner - upstream token under the server's per-server auth-header key so egress forwards it. A key that - is missing, blocked, or expired fails closed with a 401. Everything else must stay on its - existing admission path. + A credential-free request reaches the named MCP handler so it can issue the initial OAuth + challenge. Every bearer on that same route enters envelope resolution. A valid envelope opens + under its live authorization context, while invalid envelopes and non-envelope bearers receive + a named ``invalid_token`` challenge. Everything else stays on its existing admission path. """ _MASTER_KEY = "sk-bridge-master-key-for-envelope-derivation" @@ -5272,6 +5269,57 @@ class TestMCPDcrBridgeDelegateAdmission: request.body = mock_body return request + async def test_bridge_target_requires_literal_boolean_opt_ins(self): + """Truthy proxy values must not opt an unresolved server into bridge admission.""" + for delegate_value, bridge_value in ((MagicMock(), True), (True, MagicMock())): + server = MagicMock() + server.is_oauth_delegate = delegate_value + server.is_dcr_bridge = bridge_value + server.server_name = "bridge_delegate_server" + server.alias = None + + with patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr: + mock_mgr.get_mcp_server_by_name.return_value = server + assert ( + MCPRequestHandler._single_dcr_bridge_delegate_target( + path="/mcp/bridge_delegate_server", + mcp_servers=None, + client_ip=None, + ) + is None + ) + + async def test_credential_free_named_bridge_request_reaches_mcp_handler(self): + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": [], + } + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + ) as mock_auth, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + ( + auth_result, + _mcp_auth_header, + _mcp_servers, + mcp_server_auth_headers, + _oauth2_headers, + _raw_headers, + ) = await MCPRequestHandler.process_mcp_request(scope) + + mock_auth.assert_not_called() + assert auth_result == UserAPIKeyAuth() + assert mcp_server_auth_headers == {} + async def test_valid_envelope_reloads_live_key_and_admits_its_authorization_context(self): """A valid envelope admits under the LIVE key record the sealed key_hash references, not a blank identity: the reload is keyed by that exact hash, and the admitted auth carries the @@ -5887,8 +5935,7 @@ class TestMCPDcrBridgeDelegateAdmission: mock_auth.assert_called_once() async def test_expired_envelope_fails_closed_401(self): - """An envelope whose exp is in the past must fail closed with a 401, never fall through to - anonymous admission.""" + """An expired envelope fails closed and tells the client where to reauthorize.""" expired = self._mint_bridge_envelope( expires_in=60, minted_at=datetime.now(timezone.utc) - timedelta(hours=2), @@ -5897,7 +5944,10 @@ class TestMCPDcrBridgeDelegateAdmission: "type": "http", "method": "POST", "path": "/mcp/bridge_delegate_server", - "headers": [(b"authorization", f"Bearer {expired}".encode("latin-1"))], + "headers": [ + (b"host", b"testserver"), + (b"authorization", f"Bearer {expired}".encode("latin-1")), + ], } with ( @@ -5914,6 +5964,12 @@ class TestMCPDcrBridgeDelegateAdmission: assert exc_info.value.status_code == 401 mock_auth.assert_not_called() + assert exc_info.value.headers == { + "www-authenticate": ( + 'Bearer error="invalid_token", ' + 'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/bridge_delegate_server"' + ) + } async def test_envelope_minted_for_a_different_server_fails_closed_401(self): """An envelope sealed for another server_id must be rejected when presented to this server, @@ -5924,7 +5980,10 @@ class TestMCPDcrBridgeDelegateAdmission: "type": "http", "method": "POST", "path": "/mcp/bridge_delegate_server", - "headers": [(b"authorization", f"Bearer {wrong_server}".encode("latin-1"))], + "headers": [ + (b"host", b"testserver"), + (b"authorization", f"Bearer {wrong_server}".encode("latin-1")), + ], } with ( @@ -5941,6 +6000,12 @@ class TestMCPDcrBridgeDelegateAdmission: assert exc_info.value.status_code == 401 mock_auth.assert_not_called() + assert exc_info.value.headers == { + "www-authenticate": ( + 'Bearer error="invalid_token", ' + 'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/bridge_delegate_server"' + ) + } async def test_envelope_under_wrong_master_key_fails_closed_401(self): """An envelope-shaped bearer whose signature does not verify under the proxy's derived keys @@ -5950,7 +6015,10 @@ class TestMCPDcrBridgeDelegateAdmission: "type": "http", "method": "POST", "path": "/mcp/bridge_delegate_server", - "headers": [(b"authorization", f"Bearer {foreign}".encode("latin-1"))], + "headers": [ + (b"host", b"testserver"), + (b"authorization", f"Bearer {foreign}".encode("latin-1")), + ], } with ( @@ -5967,26 +6035,29 @@ class TestMCPDcrBridgeDelegateAdmission: assert exc_info.value.status_code == 401 mock_auth.assert_not_called() + assert exc_info.value.headers == { + "www-authenticate": ( + 'Bearer error="invalid_token", ' + 'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/bridge_delegate_server"' + ) + } - async def test_non_envelope_bearer_on_bridge_server_falls_through_to_oauth2_arm(self): - """A plain (non-envelope) bearer on the same bridge server must NOT be admitted by the - envelope arm: it falls through to the oauth2 arm, which validates it as a LiteLLM key and - 401s here. Proves the arm is gated on envelope shape, not merely on the target being a - bridge server.""" + async def test_non_envelope_bearer_on_bridge_server_returns_named_challenge(self): + """A raw provider bearer cannot authorize a bridge route and triggers reauthorization.""" scope = { "type": "http", "method": "POST", "path": "/mcp/bridge_delegate_server", - "headers": [(b"authorization", b"Bearer plain-upstream-bearer-not-an-envelope")], + "headers": [ + (b"host", b"testserver"), + (b"authorization", b"Bearer plain-upstream-bearer-not-an-envelope"), + ], } - async def mock_user_api_key_auth_fails(api_key, request): - raise HTTPException(status_code=401, detail="Invalid API key") - with ( patch( "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", - side_effect=mock_user_api_key_auth_fails, + new_callable=AsyncMock, ) as mock_auth, patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), @@ -5996,8 +6067,13 @@ class TestMCPDcrBridgeDelegateAdmission: await MCPRequestHandler.process_mcp_request(scope) assert exc_info.value.status_code == 401 - # The envelope arm was skipped, so the oauth2 arm ran and validated the bearer. - mock_auth.assert_called_once() + mock_auth.assert_not_called() + assert exc_info.value.headers == { + "www-authenticate": ( + 'Bearer error="invalid_token", ' + 'resource_metadata="http://testserver/.well-known/oauth-protected-resource/mcp/bridge_delegate_server"' + ) + } async def test_explicit_litellm_key_wins_over_envelope_arm(self): """An explicit x-litellm-api-key is always a LiteLLM credential and its arm precedes the From da036ad0f0da3fa60cfc56e7d3697f184e2bb443 Mon Sep 17 00:00:00 2001 From: Daniel Meismer Date: Tue, 25 Aug 2026 22:29:42 -0400 Subject: [PATCH 32/90] fix(mcp): harden DCR bridge admission Preserve standard Authorization key validation while preventing client MCP credentials from receiving anonymous bridge admission. Generated with AI Co-Authored-By: Codex --- .../mcp_server/auth/user_api_key_auth_mcp.py | 75 +++++++++++----- .../auth/test_user_api_key_auth_mcp.py | 90 ++++++++++++++++++- 2 files changed, 144 insertions(+), 21 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 882ded78e2f..4d999919f2f 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -447,6 +447,8 @@ class MCPRequestHandler: ) is not None and not oauth2_headers + and not mcp_server_auth_headers + and not mcp_auth_header ): validated_user_api_key_auth = UserAPIKeyAuth() elif ( @@ -456,9 +458,13 @@ class MCPRequestHandler: client_ip=IPAddressUtils.get_mcp_client_ip(request), ) ) is not None and oauth2_headers: - validated_user_api_key_auth, mcp_server_auth_headers = await MCPRequestHandler._admit_dcr_bridge_delegate( + ( + validated_user_api_key_auth, + mcp_server_auth_headers, + ) = await MCPRequestHandler._admit_dcr_bridge_authorization( server=bridge_delegate_target, authorization_value=oauth2_headers["Authorization"], + litellm_api_key=litellm_api_key, mcp_server_auth_headers=mcp_server_auth_headers, request=request, route=request_route, @@ -802,28 +808,57 @@ class MCPRequestHandler: new_headers: Final = {**(mcp_server_auth_headers or {}), **injected} return admitted, new_headers case BridgeEnvelopeInvalid() | NotBridgeEnvelope(): - resource_name: Final = server.alias or server.server_name - if resource_name is None: - raise HTTPException( - status_code=500, - detail="Server misconfigured: MCP server has no routable name", - ) - raise HTTPException( - status_code=401, - detail="Invalid or expired credential", - headers=MappingProxyType( - { - "www-authenticate": get_passthrough_www_authenticate( - scope=request.scope, - server_name=resource_name, - invalid_token=True, - ) - } - ), - ) + raise MCPRequestHandler._dcr_bridge_invalid_token_challenge(server=server, request=request) case _: assert_never(result) + @staticmethod + async def _admit_dcr_bridge_authorization( + server: MCPServer, + authorization_value: str, + litellm_api_key: str, + mcp_server_auth_headers: dict[str, dict[str, str]] | None, + request: Request, + route: str, + ) -> tuple[UserAPIKeyAuth, dict[str, dict[str, str]] | None]: + if is_bridge_envelope_shaped(authorization_value): + return await MCPRequestHandler._admit_dcr_bridge_delegate( + server=server, + authorization_value=authorization_value, + mcp_server_auth_headers=mcp_server_auth_headers, + request=request, + route=route, + ) + try: + admitted: Final = await user_api_key_auth(api_key=litellm_api_key, request=request) + except (HTTPException, ProxyException) as exc: + if not _is_litellm_auth_admission_error(exc): + raise + raise MCPRequestHandler._dcr_bridge_invalid_token_challenge(server=server, request=request) from exc + return admitted, mcp_server_auth_headers + + @staticmethod + def _dcr_bridge_invalid_token_challenge(server: MCPServer, request: Request) -> HTTPException: + resource_name: Final = server.alias or server.server_name + if resource_name is None: + raise HTTPException( + status_code=500, + detail="Server misconfigured: MCP server has no routable name", + ) + return HTTPException( + status_code=401, + detail="Invalid or expired credential", + headers=MappingProxyType( + { + "www-authenticate": get_passthrough_www_authenticate( + scope=request.scope, + server_name=resource_name, + invalid_token=True, + ) + } + ), + ) + @staticmethod async def _admit_gateway_session( authorization_value: str, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 0fa840e4dd3..52badaba5ac 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -5320,6 +5320,37 @@ class TestMCPDcrBridgeDelegateAdmission: assert auth_result == UserAPIKeyAuth() assert mcp_server_auth_headers == {} + @pytest.mark.parametrize( + "headers", + ( + [(b"x-mcp-auth", b"Bearer upstream-token")], + [(b"x-mcp-bridge_delegate_server-authorization", b"Bearer upstream-token")], + ), + ids=("deprecated-mcp-auth", "per-server-auth"), + ) + async def test_client_mcp_credentials_do_not_receive_keyless_bridge_admission(self, headers): + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": headers, + } + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + side_effect=HTTPException(status_code=401, detail="Invalid key"), + ) as mock_auth, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 401 + mock_auth.assert_awaited_once() + async def test_valid_envelope_reloads_live_key_and_admits_its_authorization_context(self): """A valid envelope admits under the LIVE key record the sealed key_hash references, not a blank identity: the reload is keyed by that exact hash, and the admitted auth carries the @@ -6058,6 +6089,7 @@ class TestMCPDcrBridgeDelegateAdmission: patch( "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, + side_effect=HTTPException(status_code=401, detail="Invalid key"), ) as mock_auth, patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), @@ -6067,7 +6099,7 @@ class TestMCPDcrBridgeDelegateAdmission: await MCPRequestHandler.process_mcp_request(scope) assert exc_info.value.status_code == 401 - mock_auth.assert_not_called() + mock_auth.assert_awaited_once() assert exc_info.value.headers == { "www-authenticate": ( 'Bearer error="invalid_token", ' @@ -6075,6 +6107,62 @@ class TestMCPDcrBridgeDelegateAdmission: ) } + async def test_valid_litellm_authorization_key_uses_standard_admission(self): + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": [(b"authorization", b"Bearer sk-valid-litellm-key")], + } + admitted = UserAPIKeyAuth(api_key="hashed-key", user_id="litellm-key-user") + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + return_value=admitted, + ) as mock_auth, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + ( + auth_result, + _mcp_auth, + _servers, + mcp_server_auth_headers, + _oauth, + _raw, + ) = await MCPRequestHandler.process_mcp_request(scope) + + assert auth_result is admitted + assert mcp_server_auth_headers == {} + assert mock_auth.await_args.kwargs["api_key"] == "Bearer sk-valid-litellm-key" + + async def test_non_401_litellm_key_failure_is_not_converted_to_oauth_challenge(self): + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/bridge_delegate_server", + "headers": [(b"authorization", b"Bearer sk-blocked-litellm-key")], + } + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + side_effect=HTTPException(status_code=403, detail="Key blocked"), + ), + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + ): + mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + + assert exc_info.value.status_code == 403 + assert not exc_info.value.headers + async def test_explicit_litellm_key_wins_over_envelope_arm(self): """An explicit x-litellm-api-key is always a LiteLLM credential and its arm precedes the envelope arm: user_api_key_auth validates the key and NO inner token is injected, even From 1a2a24ecc695ad277e34dc4b19b1db0cdcf93e27 Mon Sep 17 00:00:00 2001 From: Daniel Meismer Date: Tue, 25 Aug 2026 22:40:01 -0400 Subject: [PATCH 33/90] chore(mcp): document mutable bridge header shape Generated with AI Co-Authored-By: Codex --- .../_experimental/mcp_server/auth/user_api_key_auth_mcp.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 4d999919f2f..8bd0d4efae8 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -817,10 +817,10 @@ class MCPRequestHandler: server: MCPServer, authorization_value: str, litellm_api_key: str, - mcp_server_auth_headers: dict[str, dict[str, str]] | None, + mcp_server_auth_headers: dict[str, dict[str, str]] | None, # mutable-ok: existing MCP sink shape request: Request, route: str, - ) -> tuple[UserAPIKeyAuth, dict[str, dict[str, str]] | None]: + ) -> tuple[UserAPIKeyAuth, dict[str, dict[str, str]] | None]: # mutable-ok: existing MCP sink shape if is_bridge_envelope_shaped(authorization_value): return await MCPRequestHandler._admit_dcr_bridge_delegate( server=server, From c74a8df52df939e1e6735c69815687a9aa2ef4e0 Mon Sep 17 00:00:00 2001 From: Daniel Meismer Date: Tue, 25 Aug 2026 22:48:22 -0400 Subject: [PATCH 34/90] chore(mcp): satisfy test quality lint Document the intentional internal seams used by the DCR bridge admission tests and normalize import ordering.\n\nGenerated with AI\n\nCo-Authored-By: Codex --- .../auth/test_user_api_key_auth_mcp.py | 43 ++++++++++++------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 52badaba5ac..cb3133502f7 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -7,8 +7,6 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException from fastapi.testclient import TestClient - - from starlette.datastructures import Headers from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import ( @@ -5135,6 +5133,8 @@ class TestMCPDcrBridgeDelegateAdmission: minted_at=None, master_key=None, ): + from pydantic import SecretStr + from litellm.proxy._experimental.mcp_server.outbound_credentials.bridge_credentials import ( envelope_keys_from_master_key, ) @@ -5145,7 +5145,6 @@ class TestMCPDcrBridgeDelegateAdmission: mint_envelope, user_identity, ) - from pydantic import SecretStr identity = ( user_identity(server_id=server_id, user_id=user_id) @@ -5278,7 +5277,7 @@ class TestMCPDcrBridgeDelegateAdmission: server.server_name = "bridge_delegate_server" server.alias = None - with patch( + with patch( # test-quality-ok: isolate the MCP registry when testing target selection "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" ) as mock_mgr: mock_mgr.get_mcp_server_by_name.return_value = server @@ -5300,11 +5299,13 @@ class TestMCPDcrBridgeDelegateAdmission: } with ( - patch( + patch( # test-quality-ok: observe the auth boundary while testing admission orchestration "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, ) as mock_auth, - patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch( # test-quality-ok: isolate the MCP registry used by request admission + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() ( @@ -5337,12 +5338,14 @@ class TestMCPDcrBridgeDelegateAdmission: } with ( - patch( + patch( # test-quality-ok: force credential rejection through request admission "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, side_effect=HTTPException(status_code=401, detail="Invalid key"), ) as mock_auth, - patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + patch( # test-quality-ok: isolate the MCP registry used by request admission + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() with pytest.raises(HTTPException) as exc_info: @@ -6117,13 +6120,17 @@ class TestMCPDcrBridgeDelegateAdmission: admitted = UserAPIKeyAuth(api_key="hashed-key", user_id="litellm-key-user") with ( - patch( + patch( # test-quality-ok: supply standard key admission through the auth boundary "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, return_value=admitted, ) as mock_auth, - patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, - patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + patch( # test-quality-ok: isolate the MCP registry used by request admission + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + patch( # test-quality-ok: configure key classification for the orchestration test + "litellm.proxy.proxy_server.master_key", self._MASTER_KEY + ), ): mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() ( @@ -6148,13 +6155,17 @@ class TestMCPDcrBridgeDelegateAdmission: } with ( - patch( + patch( # test-quality-ok: force a non-401 auth result through request admission "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, side_effect=HTTPException(status_code=403, detail="Key blocked"), ), - patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, - patch("litellm.proxy.proxy_server.master_key", self._MASTER_KEY), + patch( # test-quality-ok: isolate the MCP registry used by request admission + "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" + ) as mock_mgr, + patch( # test-quality-ok: configure key classification for the orchestration test + "litellm.proxy.proxy_server.master_key", self._MASTER_KEY + ), ): mock_mgr.get_mcp_server_by_name.return_value = self._bridge_delegate_server() with pytest.raises(HTTPException) as exc_info: @@ -6644,8 +6655,8 @@ class TestGatewaySessionAdmission: ) from litellm.proxy._experimental.mcp_server.outbound_credentials.session_token import ( SessionPrincipal, - mint_session_token, mint_session_refresh_token, + mint_session_token, ) keys = session_keys_from_master_key(self._MASTER_KEY) @@ -7111,8 +7122,8 @@ class TestUserSubjectTeamUnion: def _manager_with(self, server_ids, allow_all=()): from litellm.proxy._experimental.mcp_server.mcp_server_manager import MCPServerManager - from litellm.types.mcp_server.mcp_server_manager import MCPServer from litellm.types.mcp import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer manager = MCPServerManager() for sid in server_ids: From 728b73d1b37efe118751752bfeedcf8d6e21faf4 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:04:27 -0700 Subject: [PATCH 35/90] fix(proxy): strip only the authenticating secret on credential-less Vertex passthrough PR #38114 dropped whichever header user_api_key_auth would read the caller's key from, by precedence. Under custom_auth, JWT auth, or no master key that header is the caller's own Google token, so the bring-your-own-credentials Vertex branch answered 401 to every valid request. A header value is now dropped only when it is the master key or when its hash is the api_key that authenticated the request, so a Google token that auth never consumed keeps flowing while a LiteLLM key still never reaches Google. test_passthrough_post_call_guardrails.py no longer plants a MagicMock proxy_server module in sys.modules at import, which poisoned sibling tests that read module globals at call time. --- bootstrap.log | 271 ++++++++++++++++++ .../llm_passthrough_endpoints.py | 116 ++++---- .../test_llm_pass_through_endpoints.py | 83 +++++- .../test_passthrough_post_call_guardrails.py | 16 -- .../test_vertex_passthrough_load_balancing.py | 3 + 5 files changed, 398 insertions(+), 91 deletions(-) create mode 100644 bootstrap.log diff --git a/bootstrap.log b/bootstrap.log new file mode 100644 index 00000000000..864cbf2d57e --- /dev/null +++ b/bootstrap.log @@ -0,0 +1,271 @@ +uv sync --inexact --frozen --extra proxy --group proxy-dev --group e2e-dev +Using CPython 3.12.13 +Creating virtual environment at: .venv + Building litellm @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix + Building litellm-proxy-extras @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/litellm-proxy-extras + Building litellm-enterprise @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/enterprise + Built litellm-enterprise @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/enterprise + Built litellm-proxy-extras @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/litellm-proxy-extras + Built litellm @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix +Prepared 3 packages in 1m 00s +Installed 229 packages in 637ms + + a2a-sdk==1.1.0 + + aiohappyeyeballs==2.6.1 + + aiohttp==3.14.3 + + aiologic==0.17.0 + + aiosignal==1.4.0 + + annotated-doc==0.0.4 + + annotated-types==0.7.0 + + anyio==4.13.0 + + apscheduler==3.11.2 + + asgiref==3.11.1 + + attrs==26.1.0 + + azure-core==1.41.0 + + azure-identity==1.25.2 + + azure-storage-blob==12.28.0 + + backoff==2.2.1 + + basedpyright==1.39.7 + + bidict==0.23.1 + + blinker==1.9.0 + + boto3==1.43.1 + + botocore==1.43.6 + + botocore-stubs==1.43.14 + + brotli==1.2.0 + + certifi==2026.4.22 + + cffi==2.0.0 + + chardet==7.4.3 + + charset-normalizer==3.4.7 + + click==8.3.3 + + configargparse==1.7.5 + + coverage==7.14.0 + + croniter==6.2.2 + + cryptography==50.0.0 + + culsans==0.11.0 + + deprecated==1.3.1 + + diff-cover==9.7.2 + + distro==1.9.0 + + dnspython==2.8.0 + + email-validator==2.3.0 + + execnet==2.1.2 + + expression==5.6.0 + + fakeredis==2.34.1 + + fastapi==0.136.3 + + fastapi-offline==1.7.6 + + fastapi-sso==0.19.0 + + fastuuid==0.14.0 + + filelock==3.29.0 + + flask==3.1.3 + + flask-cors==6.0.2 + + flask-login==0.6.3 + + frozenlist==1.8.0 + + fsspec==2026.4.0 + + gevent==25.9.1 + + geventhttpclient==2.3.9 + + google-api-core==2.30.3 + + google-auth==2.52.0 + + googleapis-common-protos==1.75.0 + + granian==2.7.4 + + greenlet==3.5.0 + + grpcio==1.78.0 + + gunicorn==23.0.0 + + h11==0.16.0 + + h2==4.4.1 + + hf-xet==1.5.0 + + hiredis==3.4.0 + + hpack==4.2.0 + + httpcore==1.0.9 + + httpx==0.28.1 + + httpx-sse==0.4.3 + + huggingface-hub==1.14.0 + + hypercorn==0.17.3 + + hyperframe==6.1.0 + + idna==3.15 + + importlib-metadata==8.5.0 + + iniconfig==2.3.0 + + inquirerpy==0.3.4 + + isodate==0.7.2 + + itsdangerous==2.2.0 + + jaraco-classes==3.4.0 + + jaraco-context==6.1.2 + + jaraco-functools==4.6.0 + + jinja2==3.1.6 + + jiter==0.14.0 + + jmespath==1.1.0 + + json-rpc==1.15.0 + + jsonschema==4.26.0 + + jsonschema-path==0.3.4 + + jsonschema-specifications==2025.9.1 + + keyring==25.7.0 + + langfuse==2.59.7 + + lazy-object-proxy==1.12.0 + + litellm==1.100.0 (from file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix) + + litellm-enterprise==0.1.60 (from file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/enterprise) + + litellm-proxy-extras==0.4.89 (from file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/litellm-proxy-extras) + + locust==2.45.0 + + markdown-it-py==4.2.0 + + markupsafe==3.0.3 + + mcp==1.28.1 + + mdurl==0.1.2 + + mirakuru==3.0.2 + + more-itertools==11.0.2 + + msal==1.37.0 + + msal-extensions==1.3.1 + + msgpack==1.2.1 + + multidict==6.7.1 + + nodeenv==1.10.0 + + nodejs-wheel-binaries==24.16.0 + + oauthlib==3.3.1 + + openai==2.33.0 + + openapi-core==0.22.0 + + openapi-schema-validator==0.6.3 + + openapi-spec-validator==0.7.2 + + opentelemetry-api==1.28.0 + + opentelemetry-exporter-otlp==1.28.0 + + opentelemetry-exporter-otlp-proto-common==1.28.0 + + opentelemetry-exporter-otlp-proto-grpc==1.28.0 + + opentelemetry-exporter-otlp-proto-http==1.28.0 + + opentelemetry-instrumentation==0.49b0 + + opentelemetry-instrumentation-asgi==0.49b0 + + opentelemetry-instrumentation-fastapi==0.49b0 + + opentelemetry-proto==1.28.0 + + opentelemetry-sdk==1.28.0 + + opentelemetry-semantic-conventions==0.49b0 + + opentelemetry-util-http==0.49b0 + + orjson==3.11.6 + + packaging==26.2 + + parameterized==0.9.0 + + pathable==0.4.4 + + pfzy==0.3.4 + + playwright==1.61.0 + + pluggy==1.6.0 + + polars==1.38.1 + + polars-runtime-32==1.38.1 + + port-for==1.0.0 + + priority==2.0.0 + + prisma==0.11.0 + + prometheus-client==0.20.0 + + prompt-toolkit==3.0.52 + + propcache==0.5.2 + + proto-plus==1.28.0 + + protobuf==5.29.6 + + psutil==7.2.2 + + psycopg==3.3.3 + + psycopg-binary==3.3.3 + + pyasn1==0.6.4 + + pyasn1-modules==0.4.2 + + pycparser==3.0 + + pydantic==2.13.4 + + pydantic-core==2.46.4 + + pydantic-settings==2.14.2 + + pyee==13.0.1 + + pygments==2.20.0 + + pyjwt==2.13.0 + + pynacl==1.6.2 + + pyroscope-io==0.8.16 + + pytest==9.0.3 + + pytest-asyncio==1.3.0 + + pytest-cov==5.0.0 + + pytest-mock==3.15.1 + + pytest-postgresql==7.0.2 + + pytest-recording==0.13.4 + + pytest-rerunfailures==15.1 + + pytest-timeout==2.4.0 + + pytest-xdist==3.8.0 + + python-dateutil==2.9.0.post0 + + python-dotenv==1.2.2 + + python-engineio==4.13.3 + + python-multipart==0.0.32 + + python-socketio==5.16.3 + + pyyaml==6.0.3 + + pyzmq==27.1.0 + + redis==5.3.1 + + referencing==0.36.2 + + regex==2026.5.9 + + requests==2.34.0 + + requests-mock==1.12.1 + + responses==0.26.0 + + respx==0.22.0 + + restrictedpython==8.1 + + rfc3339-validator==0.1.4 + + rich==13.9.4 + + rpds-py==0.30.0 + + rq==2.7.0 + + ruff==0.15.3 + + s3transfer==0.17.0 + + shellingham==1.5.4 + + simple-websocket==1.1.0 + + six==1.17.0 + + sniffio==1.3.1 + + sortedcontainers==2.4.0 + + soundfile==0.12.1 + + sse-starlette==3.4.4 + + starlette==1.3.1 + + tiktoken==0.12.0 + + tokenizers==0.23.1 + + tomlkit==0.15.0 + + tqdm==4.67.3 + + typer==0.25.1 + + types-awscrt==0.34.1 + + types-boto3==1.43.30 + + types-boto3-bedrock==1.43.26 + + types-boto3-bedrock-agent==1.43.0 + + types-boto3-bedrock-runtime==1.43.30 + + types-boto3-kms==1.43.12 + + types-boto3-s3==1.43.14 + + types-boto3-sagemaker-runtime==1.43.29 + + types-boto3-sts==1.43.0 + + types-cffi==2.0.0.20260508 + + types-pyopenssl==24.1.0.20240722 + + types-pyyaml==6.0.12.20250915 + + types-redis==4.6.0.20241004 + + types-requests==2.32.4.20260107 + + types-s3transfer==0.16.0 + + types-setuptools==75.8.0.20250225 + + typing-extensions==4.15.0 + + typing-inspection==0.4.2 + + tzlocal==5.3.1 + + urllib3==2.7.0 + + uvicorn==0.51.0 + + uvloop==0.21.0 + + vcrpy==8.2.1 + + wcwidth==0.7.0 + + websocket-client==1.9.0 + + websockets==15.0.1 + + werkzeug==3.1.8 + + wrapt==1.17.3 + + wsproto==1.3.2 + + yarl==1.23.0 + + zipp==3.23.1 + + zope-event==6.2 + + zope-interface==8.5 +uv run --no-sync python scripts/prisma_generate_if_needed.py +Prisma schema loaded from litellm/proxy/schema.prisma + +Some types are disabled by default due to being incompatible with Mypy, it is highly recommended +to use Pyright instead and configure Prisma Python to use recursive types. To re-enable certain types: + +generator client { + provider = "prisma-client-py" + recursive_type_depth = -1 +} + +If you need to use Mypy, you can also disable this message by explicitly setting the default value: + +generator client { + provider = "prisma-client-py" + recursive_type_depth = 5 +} + +For more information see: https://prisma-client-py.readthedocs.io/en/stable/reference/limitations/#default-type-limitations + + +Warning: The binaryTargets option is not officially supported by Prisma Client Python. + +✔ Generated Prisma Client Python (v0.11.0) to ./.venv/lib/python3.12/site-packages/prisma in 401ms + +cd ui/litellm-dashboard && ../../scripts/with_dashboard_node.sh npm install --no-audit --no-fund +npm warn deprecated node-domexception@1.0.0: Use your platform's native DOMException instead + +added 738 packages in 9s +bootstrap: copied .env from /Users/mateo/Development/litellm +bootstrap: done diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 850b50227f4..0234e38c921 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -6,6 +6,7 @@ Provider-specific Pass-Through Endpoints Use litellm with Anthropic SDK, Vertex AI SDK, Cohere SDK, etc. """ +import hmac import json import os import re @@ -48,7 +49,7 @@ from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( create_websocket_passthrough_route, websocket_passthrough_request, ) -from litellm.proxy.utils import is_known_model +from litellm.proxy.utils import hash_token, is_known_model from litellm.proxy.vector_store_endpoints.utils import ( assert_proxy_admin_for_vector_store_index_management, assert_user_can_access_vector_store, @@ -1791,28 +1792,17 @@ _HEADERS_NEVER_FORWARDED_TO_VERTEX: Final = frozenset({"content-length", "host"} ) -_VERTEX_CALLER_KEY_HEADER_PRECEDENCE: Final = ( - SpecialHeaders.custom_litellm_api_key.value.lower(), - SpecialHeaders.openai_authorization.value.lower(), - SpecialHeaders.azure_authorization.value.lower(), - SpecialHeaders.anthropic_authorization.value.lower(), - SpecialHeaders.google_ai_studio_authorization.value.lower(), - SpecialHeaders.azure_apim_authorization.value.lower(), -) - _MAPPED_ROUTE_CALLER_KEY_HEADER: Final = "litellm_user_api_key" -def _operator_configured_caller_key_header_names() -> tuple[tuple[str, ...], tuple[str, ...]]: - """Operator-configured caller-key header names, as (override, pass_through). +def _operator_configured_caller_key_header_names() -> tuple[str, ...]: + """Operator-configured caller-key header names. - ``user_api_key_auth`` accepts the caller's key from two runtime-configured - header sources beyond the built-in ones, at opposite ends of its precedence. - ``general_settings.litellm_key_header_name`` overrides every built-in source - (it replaces the resolved key after ``get_api_key`` runs), so it is highest - precedence. Each ``general_settings.pass_through_endpoints`` entry's - ``headers.litellm_user_api_key`` is checked last inside ``get_api_key``, so it - is lowest. Google never consumes either, so both are also dropped by name. + ``user_api_key_auth`` also reads the caller's key from + ``general_settings.litellm_key_header_name`` and from each + ``general_settings.pass_through_endpoints`` entry's + ``headers.litellm_user_api_key``. Google never consumes either, so both are + dropped by name. """ from litellm.proxy.proxy_server import general_settings @@ -1829,42 +1819,34 @@ def _operator_configured_caller_key_header_names() -> tuple[tuple[str, ...], tup if isinstance(headers, dict) and isinstance(headers.get("litellm_user_api_key"), str) ) ) - return override, pass_through + return override + pass_through -def _authenticated_caller_key_values(request: Request) -> frozenset[str]: - """The value ``user_api_key_auth`` would accept as this caller's LiteLLM key. +def _is_authenticated_caller_secret(value: str, user_api_key_dict: UserAPIKeyAuth) -> bool: + """Whether a header value is a LiteLLM secret that authenticated this caller. - The Vertex route authenticates through ``Depends(user_api_key_auth)``, which - resolves the key by precedence, matched here exactly. The ``/vertex_ai`` route - is a mapped pass-through route, so a header literally named - ``litellm_user_api_key`` overrides every other source (``user_api_key_auth`` - applies it last), making it highest precedence. Then an operator - ``litellm_key_header_name``, then the built-in headers in ``get_api_key`` order, - then a ``pass_through_endpoints`` ``litellm_user_api_key`` header which - ``get_api_key`` checks last. Some of those headers (``Authorization``, - ``x-goog-api-key``) are also kept as genuine bring-your-own Google credentials, - so returning only the value that actually authenticated lets the filter strip - that value wherever it appears while leaving a real Google credential in place. - An empty set means no caller key was found, so nothing is value-stripped. + That is the proxy master key, or a LiteLLM-issued key whose ``hash_token`` is + the ``api_key`` ``user_api_key_auth`` resolved (a DB virtual key, or a custom + auth returning the key it validated). A value auth only echoed unhashed (a + custom auth's own identifier, the no-master-key path returning the raw + header) or never consumed (JWT auth resolves ``api_key=None``) is not a + LiteLLM secret, so a bring-your-own Google credential in that position is + still forwarded. """ - incoming: Final = _safe_get_request_headers(request) - override_headers, pass_through_headers = _operator_configured_caller_key_header_names() - ordered_names: Final = ( - (_MAPPED_ROUTE_CALLER_KEY_HEADER,) - + override_headers - + _VERTEX_CALLER_KEY_HEADER_PRECEDENCE - + pass_through_headers - ) - present_values: Final = (incoming[name] for name in ordered_names if incoming.get(name)) - authenticated_key: Final = next( - (stripped for value in present_values if (stripped := _normalize_credential_value(value))), - "", - ) - return frozenset({authenticated_key}) if authenticated_key else frozenset() + from litellm.proxy.proxy_server import master_key + + normalized: Final = _normalize_credential_value(value) + if master_key is not None and hmac.compare_digest(normalized.encode(), master_key.encode()): + return True + authenticated_key: Final = user_api_key_dict.api_key + if authenticated_key is None: + return False + return hmac.compare_digest(hash_token(normalized).encode(), authenticated_key.encode()) -def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) -> Mapping[str, str]: +def _forwarded_headers_for_credentialless_vertex_passthrough( + request: Request, user_api_key_dict: UserAPIKeyAuth +) -> Mapping[str, str]: """ Header set to forward on the bring-your-own-credentials Vertex passthrough branch, used when the proxy has no Vertex credential configured. @@ -1878,31 +1860,25 @@ def _forwarded_headers_for_credentialless_vertex_passthrough(request: Request) - ``api-key`` / ``x-api-key`` / ``Ocp-Apim-Subscription-Key``, plus the mapped pass-through ``litellm_user_api_key`` header and any operator-configured ``litellm_key_header_name`` / ``pass_through_endpoints`` key header) are dropped - by name. ``Authorization`` and ``x-goog-api-key`` may - instead carry a genuine bring-your-own Google credential, so they are kept - unless their value is the caller's authenticated LiteLLM key, which is dropped - by value (normalizing any ``Bearer`` / ``Basic`` / ``AWS4`` auth-scheme prefix - the same way authentication does). Because the value that authenticated is - resolved by the same precedence ``user_api_key_auth`` uses, a virtual key sent - only in ``x-goog-api-key`` (or in an operator-configured key header) is dropped - too, while a real Google key in ``x-goog-api-key`` alongside a virtual key in a - higher-precedence header is preserved. When neither a surviving - ``Authorization`` nor ``x-goog-api-key`` remains the request is rejected so the - virtual key cannot leak upstream. + by name. ``Authorization`` and ``x-goog-api-key`` may instead carry a genuine + bring-your-own Google credential, so they are kept unless their value is the + LiteLLM secret that authenticated this caller (``_is_authenticated_caller_secret``), + which is dropped by value wherever it appears. Matching on what actually + authenticated, rather than on header precedence, keeps a caller's own Google + token in ``Authorization`` flowing when a custom auth or JWT authenticated + them without consuming it. When neither a surviving ``Authorization`` nor + ``x-goog-api-key`` remains the request is rejected so the virtual key cannot + leak upstream. """ incoming: Final = _safe_get_request_headers(request) - caller_key_values: Final = _authenticated_caller_key_values(request) - override_headers, pass_through_headers = _operator_configured_caller_key_header_names() - never_forwarded: Final = ( - _HEADERS_NEVER_FORWARDED_TO_VERTEX.union((_MAPPED_ROUTE_CALLER_KEY_HEADER,)) - .union(override_headers) - .union(pass_through_headers) + never_forwarded: Final = _HEADERS_NEVER_FORWARDED_TO_VERTEX.union( + (_MAPPED_ROUTE_CALLER_KEY_HEADER, *_operator_configured_caller_key_header_names()) ) forwarded: Final = MappingProxyType( { name: value for name, value in incoming.items() - if name not in never_forwarded and _normalize_credential_value(value) not in caller_key_values + if name not in never_forwarded and not _is_authenticated_caller_secret(value, user_api_key_dict) } ) if "authorization" not in forwarded and "x-goog-api-key" not in forwarded: @@ -1918,6 +1894,7 @@ async def _prepare_vertex_auth_headers( vertex_location: str | None, base_target_url: str | None, get_vertex_pass_through_handler: BaseVertexAIPassThroughHandler, + user_api_key_dict: UserAPIKeyAuth, ) -> tuple[Mapping[str, str], str | None, bool, str | None, str | None]: """ Prepare authentication headers for Vertex AI pass-through requests. @@ -1930,6 +1907,8 @@ async def _prepare_vertex_auth_headers( vertex_location: Vertex location base_target_url: Base URL for the Vertex AI service get_vertex_pass_through_handler: Handler for the specific Vertex AI service + user_api_key_dict: The caller's resolved authentication, so only the secret that + authenticated them is stripped on the credential-less branch Returns: Tuple containing: @@ -1944,7 +1923,7 @@ async def _prepare_vertex_auth_headers( # Use headers from the incoming request if no vertex credentials are found if (vertex_credentials is None or vertex_credentials.vertex_project is None) and router_credentials is None: - headers = _forwarded_headers_for_credentialless_vertex_passthrough(request) + headers = _forwarded_headers_for_credentialless_vertex_passthrough(request, user_api_key_dict) headers_passed_through = True verbose_proxy_logger.debug( "default_vertex_config not set, forwarding caller-provided headers %s", tuple(headers.keys()) @@ -2104,6 +2083,7 @@ async def _base_vertex_proxy_route( vertex_location=vertex_location, base_target_url=base_target_url, get_vertex_pass_through_handler=get_vertex_pass_through_handler, + user_api_key_dict=user_api_key_dict, ) if base_target_url is None: diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py index a1725005dbf..27f2521702a 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_llm_pass_through_endpoints.py @@ -16,6 +16,7 @@ from starlette.datastructures import FormData import litellm +from litellm.constants import LITELLM_PROXY_MASTER_KEY_ALIAS from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( BaseOpenAIPassThroughHandler, RouteChecks, @@ -592,7 +593,7 @@ class TestVertexAIPassThroughHandler: "method": "POST", "path": endpoint, "headers": [ - (b"authorization", b"Bearer test-creds"), + (b"authorization", b"Bearer sk-test-creds"), ], } ) @@ -617,7 +618,7 @@ class TestVertexAIPassThroughHandler: ): mock_ensure_token.return_value = ("test-auth-header", test_project) mock_get_token.return_value = (test_token, "") - mock_auth.return_value = MagicMock() + mock_auth.return_value = UserAPIKeyAuth(api_key="sk-test-creds") with pytest.raises(HTTPException) as exc_info: await vertex_proxy_route( @@ -3342,14 +3343,14 @@ class TestVertexRawPredictStreamingClassification: ), mock.patch(f"{module}.create_pass_through_route", side_effect=fake_create_pass_through_route), mock.patch(f"{module}.get_litellm_virtual_key", return_value="Bearer test-key"), - mock.patch(f"{module}.user_api_key_auth", new=AsyncMock(return_value={"api_key": "test-key"})), + mock.patch(f"{module}.user_api_key_auth", new=AsyncMock(return_value=UserAPIKeyAuth(api_key="test-key"))), mock.patch(f"{module}.get_vertex_pass_through_handler", return_value=mock_handler), ): await vertex_proxy_route( endpoint=endpoint, request=request, fastapi_response=Response(), - user_api_key_dict=UserAPIKeyAuth(token="test-key"), + user_api_key_dict=UserAPIKeyAuth(api_key="test-key"), ) assert captured, "create_pass_through_route was never called" @@ -3466,6 +3467,12 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: genuine bring-your-own Google credential that must still pass through. The by-value strip also covers a virtual key sent in the operator-configured ``general_settings.litellm_key_header_name``, whatever that header is named. + + The by-value strip keys off what actually authenticated the caller (the + master key, or the LiteLLM key whose hash ``user_api_key_auth`` resolved as + ``api_key``), never off header precedence: a custom auth or JWT that + authenticated the caller without consuming ``Authorization`` leaves the + caller's own Google token there, and it must keep flowing. """ VKEY = "sk-litellm-victim-key" @@ -3475,8 +3482,12 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: ) async def _run( - self, monkeypatch, headers: list[tuple[bytes, bytes]] + self, + monkeypatch, + headers: list[tuple[bytes, bytes]], + authenticated: UserAPIKeyAuth | None = None, ) -> tuple[HTTPException | None, dict | None]: + caller: Final = authenticated if authenticated is not None else UserAPIKeyAuth(api_key=self.VKEY) from litellm.proxy.pass_through_endpoints.passthrough_endpoint_router import ( PassthroughEndpointRouter, ) @@ -3509,7 +3520,7 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: raised: HTTPException | None = None with ( mock.patch(f"{module}.create_pass_through_route", side_effect=fake_create_pass_through_route), - mock.patch(f"{module}.user_api_key_auth", new=AsyncMock(return_value=UserAPIKeyAuth(token="hashed"))), + mock.patch(f"{module}.user_api_key_auth", new=AsyncMock(return_value=caller)), mock.patch(f"{module}.get_vertex_pass_through_handler", return_value=mock_handler), ): try: @@ -3517,7 +3528,7 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: endpoint=self.ENDPOINT, request=request, fastapi_response=Response(), - user_api_key_dict=UserAPIKeyAuth(token="hashed"), + user_api_key_dict=caller, ) except HTTPException as exc: raised = exc @@ -3767,6 +3778,64 @@ class TestVertexCredentiallessPassthroughVirtualKeyLeak: assert forwarded is None, "a virtual key in the mapped-route litellm_user_api_key header must be dropped, not forwarded" assert raised is not None and raised.status_code == 401 + GOOGLE_OAUTH_TOKEN = "ya29.byo-google-oauth-token" + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "authenticated", + [ + pytest.param( + UserAPIKeyAuth(api_key="best-api-key-ever", user_role=LitellmUserRoles.PROXY_ADMIN), + id="custom-auth-returning-its-own-identifier", + ), + pytest.param(UserAPIKeyAuth(api_key=None, user_id="jwt-subject"), id="jwt-auth"), + pytest.param(UserAPIKeyAuth(api_key=GOOGLE_OAUTH_TOKEN), id="no-master-key-echoes-raw-header"), + ], + ) + async def test_google_token_in_authorization_is_forwarded_when_auth_did_not_consume_it( + self, monkeypatch, authenticated: UserAPIKeyAuth + ): + raised, forwarded = await self._run( + monkeypatch, + [ + (b"authorization", f"Bearer {self.GOOGLE_OAUTH_TOKEN}".encode()), + (b"content-type", b"application/json"), + ], + authenticated=authenticated, + ) + assert raised is None, f"the caller's own Google token must not be mistaken for a LiteLLM key: {raised}" + assert forwarded is not None + assert forwarded.get("authorization") == f"Bearer {self.GOOGLE_OAUTH_TOKEN}" + + @pytest.mark.asyncio + async def test_master_key_in_authorization_alone_is_rejected(self, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-master-1234") + raised, forwarded = await self._run( + monkeypatch, + [(b"authorization", b"Bearer sk-master-1234"), (b"content-type", b"application/json")], + authenticated=UserAPIKeyAuth(api_key=LITELLM_PROXY_MASTER_KEY_ALIAS, user_role=LitellmUserRoles.PROXY_ADMIN), + ) + assert forwarded is None, "the master key must never reach the upstream forwarder" + assert raised is not None and raised.status_code == 401 + + @pytest.mark.asyncio + async def test_master_key_is_stripped_and_byo_x_goog_api_key_forwards(self, monkeypatch): + monkeypatch.setattr("litellm.proxy.proxy_server.master_key", "sk-master-1234") + raised, forwarded = await self._run( + monkeypatch, + [ + (b"authorization", b"Bearer sk-master-1234"), + (b"x-goog-api-key", b"AIza-real-google-api-key"), + (b"content-type", b"application/json"), + ], + authenticated=UserAPIKeyAuth(api_key=LITELLM_PROXY_MASTER_KEY_ALIAS, user_role=LitellmUserRoles.PROXY_ADMIN), + ) + assert raised is None + assert forwarded is not None + assert forwarded.get("x-goog-api-key") == "AIza-real-google-api-key" + assert "authorization" not in forwarded + assert "sk-master-1234" not in " ".join(f"{name}:{value}" for name, value in forwarded.items()) + class TestGetAzureAISearchIndexFromEndpoint: """The operable index is only the segment right after ``indexes``. diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py index a48e9e9e17f..9d1975513a1 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_passthrough_post_call_guardrails.py @@ -6,7 +6,6 @@ non-streaming pass-through responses. Addresses issue #20270. """ import json -import sys from contextlib import ExitStack from unittest.mock import AsyncMock, MagicMock, patch @@ -66,21 +65,6 @@ def _make_mock_request(): return mock_request -def _ensure_proxy_server_mock(): - """Insert a mock proxy_server module if the real one can't import.""" - key = "litellm.proxy.proxy_server" - if key not in sys.modules: - mock_mod = MagicMock() - mock_mod.proxy_logging_obj = MagicMock() - sys.modules[key] = mock_mod - import litellm.proxy - - if not hasattr(litellm.proxy, "proxy_server"): - litellm.proxy.proxy_server = sys.modules[key] - - -_ensure_proxy_server_mock() - from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( pass_through_request, ) diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py index 8e973fc3771..961479c0393 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_vertex_passthrough_load_balancing.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import ( _base_vertex_proxy_route, ) @@ -323,6 +324,7 @@ async def test_vertex_passthrough_forwards_anthropic_beta_header(): vertex_location="us-central1", base_target_url="https://us-central1-aiplatform.googleapis.com", get_vertex_pass_through_handler=mock_handler, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-litellm-secret-key"), ) # Verify that allowlisted headers are preserved @@ -417,6 +419,7 @@ async def test_vertex_passthrough_does_not_forward_litellm_auth_token(): vertex_location="us-central1", base_target_url="https://us-central1-aiplatform.googleapis.com", get_vertex_pass_through_handler=mock_handler, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-litellm-secret-key"), ) # The ONLY Authorization header should be the Vertex token From 54aebc5696be343d953c8a50262fc024225dee94 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 25 Aug 2026 21:05:10 -0700 Subject: [PATCH 36/90] chore: drop stray bootstrap.log --- bootstrap.log | 271 -------------------------------------------------- 1 file changed, 271 deletions(-) delete mode 100644 bootstrap.log diff --git a/bootstrap.log b/bootstrap.log deleted file mode 100644 index 864cbf2d57e..00000000000 --- a/bootstrap.log +++ /dev/null @@ -1,271 +0,0 @@ -uv sync --inexact --frozen --extra proxy --group proxy-dev --group e2e-dev -Using CPython 3.12.13 -Creating virtual environment at: .venv - Building litellm @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix - Building litellm-proxy-extras @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/litellm-proxy-extras - Building litellm-enterprise @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/enterprise - Built litellm-enterprise @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/enterprise - Built litellm-proxy-extras @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/litellm-proxy-extras - Built litellm @ file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix -Prepared 3 packages in 1m 00s -Installed 229 packages in 637ms - + a2a-sdk==1.1.0 - + aiohappyeyeballs==2.6.1 - + aiohttp==3.14.3 - + aiologic==0.17.0 - + aiosignal==1.4.0 - + annotated-doc==0.0.4 - + annotated-types==0.7.0 - + anyio==4.13.0 - + apscheduler==3.11.2 - + asgiref==3.11.1 - + attrs==26.1.0 - + azure-core==1.41.0 - + azure-identity==1.25.2 - + azure-storage-blob==12.28.0 - + backoff==2.2.1 - + basedpyright==1.39.7 - + bidict==0.23.1 - + blinker==1.9.0 - + boto3==1.43.1 - + botocore==1.43.6 - + botocore-stubs==1.43.14 - + brotli==1.2.0 - + certifi==2026.4.22 - + cffi==2.0.0 - + chardet==7.4.3 - + charset-normalizer==3.4.7 - + click==8.3.3 - + configargparse==1.7.5 - + coverage==7.14.0 - + croniter==6.2.2 - + cryptography==50.0.0 - + culsans==0.11.0 - + deprecated==1.3.1 - + diff-cover==9.7.2 - + distro==1.9.0 - + dnspython==2.8.0 - + email-validator==2.3.0 - + execnet==2.1.2 - + expression==5.6.0 - + fakeredis==2.34.1 - + fastapi==0.136.3 - + fastapi-offline==1.7.6 - + fastapi-sso==0.19.0 - + fastuuid==0.14.0 - + filelock==3.29.0 - + flask==3.1.3 - + flask-cors==6.0.2 - + flask-login==0.6.3 - + frozenlist==1.8.0 - + fsspec==2026.4.0 - + gevent==25.9.1 - + geventhttpclient==2.3.9 - + google-api-core==2.30.3 - + google-auth==2.52.0 - + googleapis-common-protos==1.75.0 - + granian==2.7.4 - + greenlet==3.5.0 - + grpcio==1.78.0 - + gunicorn==23.0.0 - + h11==0.16.0 - + h2==4.4.1 - + hf-xet==1.5.0 - + hiredis==3.4.0 - + hpack==4.2.0 - + httpcore==1.0.9 - + httpx==0.28.1 - + httpx-sse==0.4.3 - + huggingface-hub==1.14.0 - + hypercorn==0.17.3 - + hyperframe==6.1.0 - + idna==3.15 - + importlib-metadata==8.5.0 - + iniconfig==2.3.0 - + inquirerpy==0.3.4 - + isodate==0.7.2 - + itsdangerous==2.2.0 - + jaraco-classes==3.4.0 - + jaraco-context==6.1.2 - + jaraco-functools==4.6.0 - + jinja2==3.1.6 - + jiter==0.14.0 - + jmespath==1.1.0 - + json-rpc==1.15.0 - + jsonschema==4.26.0 - + jsonschema-path==0.3.4 - + jsonschema-specifications==2025.9.1 - + keyring==25.7.0 - + langfuse==2.59.7 - + lazy-object-proxy==1.12.0 - + litellm==1.100.0 (from file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix) - + litellm-enterprise==0.1.60 (from file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/enterprise) - + litellm-proxy-extras==0.4.89 (from file:///private/tmp/claude-501/-Users-mateo-Development-litellm/5f605a1a-6687-495a-8f8f-e1f96a73b6da/scratchpad/vertexpt-fix/litellm-proxy-extras) - + locust==2.45.0 - + markdown-it-py==4.2.0 - + markupsafe==3.0.3 - + mcp==1.28.1 - + mdurl==0.1.2 - + mirakuru==3.0.2 - + more-itertools==11.0.2 - + msal==1.37.0 - + msal-extensions==1.3.1 - + msgpack==1.2.1 - + multidict==6.7.1 - + nodeenv==1.10.0 - + nodejs-wheel-binaries==24.16.0 - + oauthlib==3.3.1 - + openai==2.33.0 - + openapi-core==0.22.0 - + openapi-schema-validator==0.6.3 - + openapi-spec-validator==0.7.2 - + opentelemetry-api==1.28.0 - + opentelemetry-exporter-otlp==1.28.0 - + opentelemetry-exporter-otlp-proto-common==1.28.0 - + opentelemetry-exporter-otlp-proto-grpc==1.28.0 - + opentelemetry-exporter-otlp-proto-http==1.28.0 - + opentelemetry-instrumentation==0.49b0 - + opentelemetry-instrumentation-asgi==0.49b0 - + opentelemetry-instrumentation-fastapi==0.49b0 - + opentelemetry-proto==1.28.0 - + opentelemetry-sdk==1.28.0 - + opentelemetry-semantic-conventions==0.49b0 - + opentelemetry-util-http==0.49b0 - + orjson==3.11.6 - + packaging==26.2 - + parameterized==0.9.0 - + pathable==0.4.4 - + pfzy==0.3.4 - + playwright==1.61.0 - + pluggy==1.6.0 - + polars==1.38.1 - + polars-runtime-32==1.38.1 - + port-for==1.0.0 - + priority==2.0.0 - + prisma==0.11.0 - + prometheus-client==0.20.0 - + prompt-toolkit==3.0.52 - + propcache==0.5.2 - + proto-plus==1.28.0 - + protobuf==5.29.6 - + psutil==7.2.2 - + psycopg==3.3.3 - + psycopg-binary==3.3.3 - + pyasn1==0.6.4 - + pyasn1-modules==0.4.2 - + pycparser==3.0 - + pydantic==2.13.4 - + pydantic-core==2.46.4 - + pydantic-settings==2.14.2 - + pyee==13.0.1 - + pygments==2.20.0 - + pyjwt==2.13.0 - + pynacl==1.6.2 - + pyroscope-io==0.8.16 - + pytest==9.0.3 - + pytest-asyncio==1.3.0 - + pytest-cov==5.0.0 - + pytest-mock==3.15.1 - + pytest-postgresql==7.0.2 - + pytest-recording==0.13.4 - + pytest-rerunfailures==15.1 - + pytest-timeout==2.4.0 - + pytest-xdist==3.8.0 - + python-dateutil==2.9.0.post0 - + python-dotenv==1.2.2 - + python-engineio==4.13.3 - + python-multipart==0.0.32 - + python-socketio==5.16.3 - + pyyaml==6.0.3 - + pyzmq==27.1.0 - + redis==5.3.1 - + referencing==0.36.2 - + regex==2026.5.9 - + requests==2.34.0 - + requests-mock==1.12.1 - + responses==0.26.0 - + respx==0.22.0 - + restrictedpython==8.1 - + rfc3339-validator==0.1.4 - + rich==13.9.4 - + rpds-py==0.30.0 - + rq==2.7.0 - + ruff==0.15.3 - + s3transfer==0.17.0 - + shellingham==1.5.4 - + simple-websocket==1.1.0 - + six==1.17.0 - + sniffio==1.3.1 - + sortedcontainers==2.4.0 - + soundfile==0.12.1 - + sse-starlette==3.4.4 - + starlette==1.3.1 - + tiktoken==0.12.0 - + tokenizers==0.23.1 - + tomlkit==0.15.0 - + tqdm==4.67.3 - + typer==0.25.1 - + types-awscrt==0.34.1 - + types-boto3==1.43.30 - + types-boto3-bedrock==1.43.26 - + types-boto3-bedrock-agent==1.43.0 - + types-boto3-bedrock-runtime==1.43.30 - + types-boto3-kms==1.43.12 - + types-boto3-s3==1.43.14 - + types-boto3-sagemaker-runtime==1.43.29 - + types-boto3-sts==1.43.0 - + types-cffi==2.0.0.20260508 - + types-pyopenssl==24.1.0.20240722 - + types-pyyaml==6.0.12.20250915 - + types-redis==4.6.0.20241004 - + types-requests==2.32.4.20260107 - + types-s3transfer==0.16.0 - + types-setuptools==75.8.0.20250225 - + typing-extensions==4.15.0 - + typing-inspection==0.4.2 - + tzlocal==5.3.1 - + urllib3==2.7.0 - + uvicorn==0.51.0 - + uvloop==0.21.0 - + vcrpy==8.2.1 - + wcwidth==0.7.0 - + websocket-client==1.9.0 - + websockets==15.0.1 - + werkzeug==3.1.8 - + wrapt==1.17.3 - + wsproto==1.3.2 - + yarl==1.23.0 - + zipp==3.23.1 - + zope-event==6.2 - + zope-interface==8.5 -uv run --no-sync python scripts/prisma_generate_if_needed.py -Prisma schema loaded from litellm/proxy/schema.prisma - -Some types are disabled by default due to being incompatible with Mypy, it is highly recommended -to use Pyright instead and configure Prisma Python to use recursive types. To re-enable certain types: - -generator client { - provider = "prisma-client-py" - recursive_type_depth = -1 -} - -If you need to use Mypy, you can also disable this message by explicitly setting the default value: - -generator client { - provider = "prisma-client-py" - recursive_type_depth = 5 -} - -For more information see: https://prisma-client-py.readthedocs.io/en/stable/reference/limitations/#default-type-limitations - - -Warning: The binaryTargets option is not officially supported by Prisma Client Python. - -✔ Generated Prisma Client Python (v0.11.0) to ./.venv/lib/python3.12/site-packages/prisma in 401ms - -cd ui/litellm-dashboard && ../../scripts/with_dashboard_node.sh npm install --no-audit --no-fund -npm warn deprecated node-domexception@1.0.0: Use your platform's native DOMException instead - -added 738 packages in 9s -bootstrap: copied .env from /Users/mateo/Development/litellm -bootstrap: done From a5b6f31585cebb717e7f62967602a48c89b60210 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 25 Aug 2026 21:24:07 -0700 Subject: [PATCH 37/90] refactor(ui): install the shadcn alert primitive components/shared/Alert.tsx was base-vega's own alert.tsx copied in by hand, carrying the same four exports and the same class strings, so npx shadcn add could never reach it and it would drift from every upstream fix silently. It also still wrapped each part in forwardRef, which React 19 no longer needs. Install the primitive into components/ui/ where the CLI can update it, and reduce the shared file to a wrapper that adds the four status variants (info, success, warning, error) the dashboard actually uses on top of upstream's default and destructive. Rendered output is unchanged: every variant produces byte-identical classes, role and data-variant, so all 45 call sites look the same. --- ui/litellm-dashboard/eslint-suppressions.json | 5 ++ .../src/components/shared/Alert.tsx | 85 +++++-------------- .../src/components/ui/alert.tsx | 56 ++++++++++++ 3 files changed, 82 insertions(+), 64 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/ui/alert.tsx diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index a647eedc354..e22812df465 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -2133,6 +2133,11 @@ "count": 1 } }, + "src/components/ui/alert.tsx": { + "local/filename-pascal-case": { + "count": 1 + } + }, "src/components/ui/avatar.tsx": { "local/filename-pascal-case": { "count": 1 diff --git a/ui/litellm-dashboard/src/components/shared/Alert.tsx b/ui/litellm-dashboard/src/components/shared/Alert.tsx index 490fcba6ef5..1a895c204de 100644 --- a/ui/litellm-dashboard/src/components/shared/Alert.tsx +++ b/ui/litellm-dashboard/src/components/shared/Alert.tsx @@ -1,75 +1,32 @@ -import * as React from "react"; - -import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; +import { Alert as AlertPrimitive, AlertAction, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { cn } from "@/lib/cva.config"; -const alertVariants = cva( - "group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", - { - variants: { - variant: { - default: "bg-card text-card-foreground", - destructive: - "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", - info: "border-info/20 bg-info/5 text-info *:[svg]:text-current", - success: "border-success/20 bg-success/5 text-success *:[svg]:text-current", - warning: "border-warning/20 bg-warning/5 text-warning *:[svg]:text-current", - error: - "border-destructive/20 bg-destructive/10 text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-destructive", - }, - }, - defaultVariants: { - variant: "default", - }, - }, -); +const STATUS_VARIANT_CLASSES = { + info: "border-info/20 bg-info/5 text-info *:[svg]:text-current", + success: "border-success/20 bg-success/5 text-success *:[svg]:text-current", + warning: "border-warning/20 bg-warning/5 text-warning *:[svg]:text-current", + error: + "border-destructive/20 bg-destructive/10 text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-destructive", +} as const; -type AlertProps = React.ComponentProps<"div"> & VariantProps; +type StatusVariant = keyof typeof STATUS_VARIANT_CLASSES; +type AlertVariant = NonNullable["variant"]> | StatusVariant; -const Alert = React.forwardRef(({ className, variant = "default", ...props }, ref) => ( -
, "variant"> & { + variant?: AlertVariant; +}; + +const isStatusVariant = (variant: AlertVariant): variant is StatusVariant => variant in STATUS_VARIANT_CLASSES; + +const Alert = ({ variant = "default", className, ...props }: AlertProps) => ( + -)); -Alert.displayName = "Alert"; - -const AlertTitle = React.forwardRef>(({ className, ...props }, ref) => ( -
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", - className, - )} - {...props} - /> -)); -AlertTitle.displayName = "AlertTitle"; - -const AlertDescription = React.forwardRef>( - ({ className, ...props }, ref) => ( -
- ), ); -AlertDescription.displayName = "AlertDescription"; - -const AlertAction = React.forwardRef>(({ className, ...props }, ref) => ( -
-)); -AlertAction.displayName = "AlertAction"; export { Alert, AlertTitle, AlertDescription, AlertAction }; diff --git a/ui/litellm-dashboard/src/components/ui/alert.tsx b/ui/litellm-dashboard/src/components/ui/alert.tsx new file mode 100644 index 00000000000..01d86e0961a --- /dev/null +++ b/ui/litellm-dashboard/src/components/ui/alert.tsx @@ -0,0 +1,56 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/cva.config"; + +const alertVariants = cva( + "group/alert relative grid w-full gap-0.5 rounded-lg border px-4 py-3 text-left text-sm has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current", + }, + }, + defaultVariants: { + variant: "default", + }, + }, +); + +function Alert({ className, variant, ...props }: React.ComponentProps<"div"> & VariantProps) { + return
; +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground", + className, + )} + {...props} + /> + ); +} + +function AlertDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function AlertAction({ className, ...props }: React.ComponentProps<"div">) { + return
; +} + +export { Alert, AlertTitle, AlertDescription, AlertAction }; From d2aea2d4e7eff9aaa720a6d6b5ec3884daf94052 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Tue, 25 Aug 2026 22:56:59 -0700 Subject: [PATCH 38/90] refactor(ui): re-pull label, textarea, separator and skeleton from the registry These four primitives still wrapped their body in React.forwardRef, which the dashboard has not needed since it moved to React 19: a function component receives ref as an ordinary prop and the existing {...props} spread already hands it to the DOM node. Re-pulling each from base-vega drops the wrapper and its displayName. These four were picked because the ref plumbing is their only divergence from current upstream, so the class strings, data-slot values and exports are untouched and nothing renders differently. The other seven primitives that still carry forwardRef have also drifted on their class strings, so re-pulling them would ship a visual change alongside the cleanup and they are left alone here. Textarea is the one with real ref call sites, roughly seventeen of them through react-hook-form's field.ref, and ref-forwarding.test.tsx did not cover it. Add that case next to the Label, Separator and Skeleton ones already there. --- ui/litellm-dashboard/src/components/ui/label.tsx | 10 ++++------ .../src/components/ui/ref-forwarding.test.tsx | 7 +++++++ ui/litellm-dashboard/src/components/ui/separator.tsx | 11 ++++------- ui/litellm-dashboard/src/components/ui/skeleton.tsx | 11 +++-------- ui/litellm-dashboard/src/components/ui/textarea.tsx | 10 ++++------ 5 files changed, 22 insertions(+), 27 deletions(-) diff --git a/ui/litellm-dashboard/src/components/ui/label.tsx b/ui/litellm-dashboard/src/components/ui/label.tsx index 1ac4eed0d4e..ded2dfc1a7b 100644 --- a/ui/litellm-dashboard/src/components/ui/label.tsx +++ b/ui/litellm-dashboard/src/components/ui/label.tsx @@ -4,10 +4,9 @@ import * as React from "react"; import { cn } from "@/lib/cva.config"; -const Label = React.forwardRef>( - ({ className, ...props }, ref) => ( +function Label({ className, ...props }: React.ComponentProps<"label">) { + return (