From 66f012a06bb448f459bb8f7c90a044abfaabc8d3 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Tue, 14 Jul 2026 18:16:45 -0700 Subject: [PATCH 01/12] fix(mcp): discover missing OAuth scopes and token_url when authorization_url is set manually --- .../mcp_server/mcp_server_manager.py | 5 +- .../mcp_server/test_mcp_server_manager.py | 60 ++++++++++++++++++- 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index e6e265abb61..fda6ae9bef5 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -1447,8 +1447,9 @@ class MCPServerManager: auth_type = cast(MCPAuthType, mcp_server.auth_type) server_url = mcp_server.url + has_all_upstream_oauth_fields = bool(mcp_server.authorization_url and mcp_server.token_url and scopes) needs_discovery = bool(server_url) and ( - (auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and not mcp_server.authorization_url) + (auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and not has_all_upstream_oauth_fields) or self._obo_needs_endpoint_discovery( auth_type, mcp_server.token_exchange_endpoint @@ -1467,7 +1468,7 @@ class MCPServerManager: if needs_discovery and mcp_oauth_metadata is None: verbose_logger.warning( "MCP OAuth discovery yielded no metadata for server %s (%s); " - "OAuth endpoints stay unresolved until a rebuild succeeds", + "OAuth endpoints/scopes stay unresolved until a rebuild succeeds", mcp_server.server_id, server_url, ) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index adcfff6fe9d..6af86b9bda3 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -1026,7 +1026,6 @@ class TestMCPServerManager: """The gateway's relayed authorize flow (used by the browser-only Authorize) needs the upstream's authorization_url on the registry entry, and these rows never persist one, so the DB build must discover it the same way oauth2 rows do.""" - from types import SimpleNamespace manager = MCPServerManager() row = LiteLLM_MCPServerTable( @@ -1053,6 +1052,65 @@ class TestMCPServerManager: assert built.authorization_url == "https://idp.example.com/authorize" assert built.token_url == "https://idp.example.com/token" + @pytest.mark.asyncio + async def test_build_from_table_discovers_scopes_when_authorization_url_is_manual(self): + """An admin-typed authorization_url must not switch off discovery for the fields left + blank: without the scopes_supported backfill the authorize redirect goes out scope-less + and IdPs like Google hard-fail it with 400 "Missing required parameter: scope".""" + manager = MCPServerManager() + row = LiteLLM_MCPServerTable( + server_id="manual-auth-url-1", + alias="manual_auth_url", + description="manual authorization_url, blank scopes", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://idp.example.com/manual-authorize", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + metadata = MCPOAuthMetadata( + authorization_url="https://idp.example.com/discovered-authorize", + token_url="https://idp.example.com/token", + registration_url=None, + scopes=["calendar.read", "calendar.write"], + ) + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)) as mock_discovery: + built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) + + mock_discovery.assert_awaited_once() + assert built.authorization_url == "https://idp.example.com/manual-authorize" + assert built.token_url == "https://idp.example.com/token" + assert built.scopes == ["calendar.read", "calendar.write"] + + @pytest.mark.asyncio + async def test_build_from_table_skips_discovery_when_all_upstream_oauth_fields_present(self): + """A fully hand-configured server (authorization_url, token_url, and scopes all set) has + nothing left for discovery to fill, so the build must not fetch upstream metadata.""" + manager = MCPServerManager() + row = LiteLLM_MCPServerTable( + server_id="fully-manual-1", + alias="fully_manual", + description="all upstream oauth fields set by the admin", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://idp.example.com/manual-authorize", + token_url="https://idp.example.com/manual-token", + credentials={"scopes": ["calendar.read"]}, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=None)) as mock_discovery: + built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) + + mock_discovery.assert_not_awaited() + assert built.authorization_url == "https://idp.example.com/manual-authorize" + assert built.token_url == "https://idp.example.com/manual-token" + assert built.scopes == ["calendar.read"] + async def _capture_subject_token(self, call) -> Optional[str]: """Run a manager method (via ``call(manager)``) and return the subject_token it threaded into ``_create_mcp_client``.""" From a81c6ce350c2fd38d43a15c22edc5c4078f19675 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 15 Jul 2026 10:22:24 -0700 Subject: [PATCH 02/12] fix(mcp): reject discovered token endpoints uncorroborated by the manual authorization_url Discovery is rooted at the MCP resource, so a compromised upstream can advertise an attacker-run authorization server. When authorization_url is manually configured and another field is blank, the per-field merge would combine the trusted authorize endpoint with the advertised token_url, and the gateway would redeem authorization codes (with the stored client secret and PKCE verifier) at that endpoint, then persist it. Discovered token_url and registration_url are now accepted only when the same metadata document advertises an authorization_endpoint matching the configured value (scheme+host+path). Scope backfill is unaffected. Applies to both the DB and config build paths. --- .../mcp_server/mcp_server_manager.py | 90 +++++++++-- .../mcp_server/test_mcp_server_manager.py | 144 +++++++++++++++++- 2 files changed, 221 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index fda6ae9bef5..284bf6a0926 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -213,6 +213,57 @@ def _carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_serv new_server.scopes = previous_server.scopes +def _normalized_authorize_endpoint(url: str) -> str: + parsed = urlparse(url) + return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{parsed.path.rstrip('/')}" + + +def _gate_discovered_endpoints_against_manual_authorization_url( + metadata: MCPOAuthMetadata | None, + manual_authorization_url: str | None, + server_identifier: str, + is_dcr_bridge: bool, +) -> MCPOAuthMetadata | None: + """Refuse discovered token/registration endpoints the pinned authorize endpoint cannot vouch for. + + Discovery is rooted at the MCP resource (RFC 9728), so a compromised upstream can advertise an + attacker-run authorization server. When the admin manually configured ``authorization_url``, + filling a blank ``token_url`` from that advertisement recreates the RFC 9700 mix-up attack at + configuration time: users sign in at the trusted authorize endpoint while the gateway redeems + the code, with the stored client secret and PKCE verifier, at the attacker's token endpoint. + Endpoints from one metadata document are only trustworthy together, so the discovered + ``token_url`` and ``registration_url`` are accepted only when that same document's + ``authorization_endpoint`` matches the pinned value (scheme+host+path; query and trailing slash + are not identity). Scope discovery stays ungated: scopes steer the redirect to the trusted + authorize endpoint and carry no credentials. + """ + if metadata is None or not manual_authorization_url: + return metadata + if not metadata.token_url and not metadata.registration_url: + return metadata + if metadata.authorization_url and _normalized_authorize_endpoint( + manual_authorization_url + ) == _normalized_authorize_endpoint(metadata.authorization_url): + return metadata + bridge_note = ( + " The discovered registration_url is rejected with it, so this dcr_bridge server stays on the" + " short-circuit registration arm." + if is_dcr_bridge and metadata.registration_url + else "" + ) + verbose_logger.warning( + "MCP OAuth discovery for server %s advertised authorization_endpoint %s, which does not match the " + "manually configured authorization_url %s; rejecting the discovered token_url/registration_url so " + "authorization codes and client credentials only go to endpoints vouched for by the configured " + "authorization server. Configure Token URL manually if the mismatch is intentional.%s", + server_identifier, + _normalized_authorize_endpoint(metadata.authorization_url) if metadata.authorization_url else "", + _normalized_authorize_endpoint(manual_authorization_url), + bridge_note, + ) + return metadata.model_copy(update={"token_url": None, "registration_url": None}) + + def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None: """Drop a cached entry after the user stores or clears their env var values so the next request reads the fresh value instead of a stale one.""" @@ -1041,20 +1092,31 @@ class MCPServerManager: else: mcp_oauth_metadata = None + gated_oauth_metadata = ( + _gate_discovered_endpoints_against_manual_authorization_url( + mcp_oauth_metadata, + server_config.get("authorization_url"), + server_name or server_id, + bool(server_config.get("dcr_bridge")), + ) + if auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES + else mcp_oauth_metadata + ) + # Filter blank scopes (e.g. YAML ``scopes: [""]``) the same way the DB-build path does, so # an all-blank list normalizes to None rather than a ``("",)`` tuple that skips the # entra_obo fail-closed scope precondition and POSTs an empty scope to the IdP. resolved_scopes = self._extract_scopes(server_config.get("scopes")) or ( - mcp_oauth_metadata.scopes if mcp_oauth_metadata else None + gated_oauth_metadata.scopes if gated_oauth_metadata else None ) resolved_authorization_url = server_config.get("authorization_url") or ( - mcp_oauth_metadata.authorization_url if mcp_oauth_metadata else None + gated_oauth_metadata.authorization_url if gated_oauth_metadata else None ) resolved_token_url = server_config.get("token_url") or ( - mcp_oauth_metadata.token_url if mcp_oauth_metadata else None + gated_oauth_metadata.token_url if gated_oauth_metadata else None ) resolved_registration_url = server_config.get("registration_url") or ( - mcp_oauth_metadata.registration_url if mcp_oauth_metadata else None + gated_oauth_metadata.registration_url if gated_oauth_metadata else None ) config_oauth2_flow = server_config.get("oauth2_flow", None) @@ -1472,8 +1534,18 @@ class MCPServerManager: mcp_server.server_id, server_url, ) + gated_oauth_metadata = ( + _gate_discovered_endpoints_against_manual_authorization_url( + mcp_oauth_metadata, + mcp_server.authorization_url, + mcp_server.server_id, + bool(getattr(mcp_server, "dcr_bridge", None)), + ) + if auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES + else mcp_oauth_metadata + ) - resolved_scopes = scopes or (mcp_oauth_metadata.scopes if mcp_oauth_metadata else None) + resolved_scopes = scopes or (gated_oauth_metadata.scopes if gated_oauth_metadata else None) new_server = MCPServer( server_id=mcp_server.server_id, @@ -1493,9 +1565,9 @@ class MCPServerManager: client_secret=client_secret_value or getattr(mcp_server, "client_secret", None), oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)), scopes=resolved_scopes, - authorization_url=mcp_server.authorization_url or getattr(mcp_oauth_metadata, "authorization_url", None), - token_url=mcp_server.token_url or getattr(mcp_oauth_metadata, "token_url", None), - registration_url=mcp_server.registration_url or getattr(mcp_oauth_metadata, "registration_url", None), + authorization_url=mcp_server.authorization_url or getattr(gated_oauth_metadata, "authorization_url", None), + token_url=mcp_server.token_url or getattr(gated_oauth_metadata, "token_url", None), + registration_url=mcp_server.registration_url or getattr(gated_oauth_metadata, "registration_url", None), token_endpoint_auth_method=( credentials_dict.get("token_endpoint_auth_method") if credentials_dict else None ), @@ -1555,7 +1627,7 @@ class MCPServerManager: existing_authorization_url=mcp_server.authorization_url, existing_token_url=mcp_server.token_url, existing_scopes=scopes, - metadata=mcp_oauth_metadata, + metadata=gated_oauth_metadata, ) return new_server diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 6af86b9bda3..8091675090e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -356,6 +356,55 @@ class TestMCPServerManager: assert server.oauth2_flow == "authorization_code" assert server.needs_user_oauth_token is True + @pytest.mark.asyncio + async def test_load_servers_from_config_rejects_discovered_token_url_on_authorization_endpoint_mismatch(self): + """The config loader always runs discovery and or-merges per field, so a yaml server with a + manual authorization_url has the same config-time mix-up exposure as a DB row: a discovered + token_url from a document advertising a different authorize endpoint must not be combined + with the pinned one. Scopes still backfill.""" + manager = MCPServerManager() + + metadata = MCPOAuthMetadata( + authorization_url="https://attacker.example.com/authorize", + token_url="https://attacker.example.com/token", + scopes=["read"], + ) + config = self._oauth2_config( + oauth2_flow="authorization_code", + authorization_url="https://idp.example.com/authorize", + token_url=None, + ) + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)): + await manager.load_servers_from_config(config) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.authorization_url == "https://idp.example.com/authorize" + assert server.token_url is None + assert server.scopes == ["read"] + + @pytest.mark.asyncio + async def test_load_servers_from_config_fills_token_url_when_metadata_corroborates_manual_authorization_url(self): + """Corroborated metadata keeps the self-heal on the config path: when the discovered + document advertises the same authorize endpoint the admin pinned, its token_url fills the + blank field.""" + manager = MCPServerManager() + + metadata = MCPOAuthMetadata( + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + scopes=["read"], + ) + config = self._oauth2_config( + oauth2_flow="authorization_code", + authorization_url="https://idp.example.com/authorize/", + token_url=None, + ) + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)): + await manager.load_servers_from_config(config) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.token_url == "https://idp.example.com/token" + @pytest.mark.asyncio async def test_load_servers_from_config_non_oauth2_needs_no_flow(self): manager = MCPServerManager() @@ -1056,7 +1105,9 @@ class TestMCPServerManager: async def test_build_from_table_discovers_scopes_when_authorization_url_is_manual(self): """An admin-typed authorization_url must not switch off discovery for the fields left blank: without the scopes_supported backfill the authorize redirect goes out scope-less - and IdPs like Google hard-fail it with 400 "Missing required parameter: scope".""" + and IdPs like Google hard-fail it with 400 "Missing required parameter: scope". Scope + backfill works even when the advertised authorization_endpoint differs from the manual + value, because scopes only steer the redirect to the trusted authorize endpoint.""" manager = MCPServerManager() row = LiteLLM_MCPServerTable( server_id="manual-auth-url-1", @@ -1081,9 +1132,90 @@ class TestMCPServerManager: mock_discovery.assert_awaited_once() assert built.authorization_url == "https://idp.example.com/manual-authorize" - assert built.token_url == "https://idp.example.com/token" + assert built.token_url is None assert built.scopes == ["calendar.read", "calendar.write"] + @pytest.mark.asyncio + async def test_build_from_table_fills_endpoints_when_metadata_corroborates_manual_authorization_url(self): + """A discovered token_url is only trusted next to a manual authorization_url when the same + metadata document advertises that authorize endpoint, and the comparison must tolerate + formatting-only differences (host case, trailing slash, query params like ?prompt=consent) + so hand-copied URLs still self-heal.""" + manager = MCPServerManager() + row = LiteLLM_MCPServerTable( + server_id="manual-auth-url-2", + alias="manual_auth_url_match", + description="manual authorization_url matching discovery, blank token_url", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://IDP.example.com/authorize/?prompt=consent", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + metadata = MCPOAuthMetadata( + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + registration_url="https://idp.example.com/register", + scopes=["read"], + ) + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)): + built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) + + assert built.authorization_url == "https://IDP.example.com/authorize/?prompt=consent" + assert built.token_url == "https://idp.example.com/token" + assert built.registration_url == "https://idp.example.com/register" + assert built.scopes == ["read"] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "advertised_authorization_url", + ["https://attacker.example.com/authorize", None], + ) + async def test_build_from_table_rejects_discovered_token_url_on_authorization_endpoint_mismatch( + self, advertised_authorization_url + ): + """Resource-rooted discovery lets a compromised upstream advertise its own authorization + server. With a manual authorization_url pinned, accepting that document's token_url would + send the authorization code, stored client secret, and PKCE verifier to the attacker's + token endpoint (config-time RFC 9700 mix-up), and the persist hook would make the hostile + endpoint durable. Both the in-memory merge and the persisted metadata must drop the + uncorroborated token_url and registration_url.""" + manager = MCPServerManager() + row = LiteLLM_MCPServerTable( + server_id="manual-auth-url-3", + alias="manual_auth_url_mismatch", + description="manual authorization_url, hostile discovery document", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://idp.example.com/authorize", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + metadata = MCPOAuthMetadata( + authorization_url=advertised_authorization_url, + token_url="https://attacker.example.com/token", + registration_url="https://attacker.example.com/register", + scopes=["read"], + ) + with ( + patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)), + patch.object(manager, "_persist_discovered_oauth_endpoints", new=AsyncMock()) as mock_persist, + ): + built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) + + assert built.authorization_url == "https://idp.example.com/authorize" + assert built.token_url is None + assert built.registration_url is None + assert built.scopes == ["read"] + persisted_metadata = mock_persist.await_args.kwargs["metadata"] + assert persisted_metadata.token_url is None + assert persisted_metadata.registration_url is None + assert persisted_metadata.scopes == ["read"] + @pytest.mark.asyncio async def test_build_from_table_skips_discovery_when_all_upstream_oauth_fields_present(self): """A fully hand-configured server (authorization_url, token_url, and scopes all set) has @@ -2310,6 +2442,10 @@ class TestMCPServerManager: @pytest.mark.asyncio async def test_load_servers_from_config_overrides_discovery_metadata(self): + """Config values win per field. The discovered token_url/registration_url do NOT fill the + blanks here: the document advertises a different authorization_endpoint than the manually + configured one, so combining its endpoints with the pinned authorize URL would be the + config-time mix-up the discovery gate exists to prevent.""" manager = MCPServerManager() discovered_metadata = MCPOAuthMetadata( @@ -2343,8 +2479,8 @@ class TestMCPServerManager: server = next(iter(manager.config_mcp_servers.values())) assert server.scopes == ["config"] # config overrides discovery assert server.authorization_url == "https://config.example.com/auth" - assert server.token_url == "https://discovered.example.com/token" - assert server.registration_url == "https://discovered.example.com/register" + assert server.token_url is None + assert server.registration_url is None @pytest.mark.asyncio async def test_load_servers_from_config_filters_blank_scopes(self): From 447d50fa4041c3d6dcbfdaa6fa4b6f89ea2eaaee Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 15 Jul 2026 10:39:04 -0700 Subject: [PATCH 03/12] fix(mcp): enforce the OAuth endpoint trust rule at carry-forward too, elide default port The corroboration check belongs to adopting a token_url from any non-manual source, not to discovery alone. Carry-forward is the other such source: it copied a prior registry entry's token_url/registration_url onto a rebuild whose authorization_url had been re-pointed to a different server, reviving an uncorroborated token endpoint the discovery gate would reject. Both sites now share one predicate, _endpoints_corroborate_authorization_url: previous endpoints carry forward only when the previous authorization_url corroborates the authorize endpoint the build will use (absent -> the previous one is adopted too, a consistent group; else it must match). Endpoint comparison now elides the default port so :443 and formatting-only differences still match. --- .../mcp_server/mcp_server_manager.py | 95 ++++++++++++------ .../mcp_server/test_mcp_server_manager.py | 96 +++++++++++++++++++ 2 files changed, 160 insertions(+), 31 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 284bf6a0926..b84c5601560 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -186,6 +186,46 @@ _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: tuple[MCPAuth, ...] = ( ) +def _normalized_authorize_endpoint(url: str) -> str: + """Compare authorize endpoints on scheme, host, and path only. The default port is elided and + the host is lowercased so ``https://IDP.example.com:443/authorize/`` and + ``https://idp.example.com/authorize`` are the same identity; query and trailing slash are not.""" + parsed = urlparse(url) + scheme = parsed.scheme.lower() + host = (parsed.hostname or "").lower() + default_port = {"https": 443, "http": 80}.get(scheme) + try: + port = parsed.port + except ValueError: + port = None + authority = host if port is None or port == default_port else f"{host}:{port}" + return f"{scheme}://{authority}{parsed.path.rstrip('/')}" + + +def _endpoints_corroborate_authorization_url( + source_authorization_url: str | None, + trusted_authorization_url: str | None, +) -> bool: + """Whether a source's ``token_url``/``registration_url`` may be paired with a trusted authorize + endpoint. This is the single trust rule for adopting OAuth endpoints from any non-manual source. + + Discovery is rooted at the MCP resource (RFC 9728), so a compromised upstream can advertise an + attacker-run authorization server. When ``authorization_url`` is admin-pinned, pairing it with a + ``token_url`` from a different source is the RFC 9700 authorization-server mix-up: the user signs + in at the trusted authorize endpoint while the gateway redeems the code, with the stored client + secret and PKCE verifier, at the attacker's token endpoint. Endpoints are trustworthy together + only when they share an authorization server, so a source's endpoints are adopted only when the + same source advertised an ``authorization_endpoint`` matching the pinned value. With no pinned + value (``trusted_authorization_url is None``) there is nothing to protect: the authorize endpoint + comes from the same source as the token endpoint, so they corroborate each other by construction. + """ + if trusted_authorization_url is None: + return True + return bool(source_authorization_url) and _normalized_authorize_endpoint( + source_authorization_url + ) == _normalized_authorize_endpoint(trusted_authorization_url) + + def _carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_server: MCPServer | None) -> None: """Keep the last known good OAuth endpoints when a rebuild's re-discovery comes back empty. @@ -193,57 +233,50 @@ def _carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_serv during re-discovery downgrades a working server (``authorization_url`` set) to a broken one (``None``, /authorize 400s) with no configuration change. Mirrors the ``short_prefix`` carry-forward. Skipped when the server's ``url`` or ``auth_type`` changed, since the previous - endpoints may then belong to a different upstream. ``registration_url`` IS carried here even - though ``_persist_discovered_oauth_endpoints`` refuses to write it to the row: carrying only - restores the same in-memory value the previous build already ran with, while persisting it - would flip ``_dcr_bridge_relays_client_registration`` (which keys off the stored column) for - dcr_bridge servers that never had one configured. + endpoints may then belong to a different upstream. ``registration_url`` IS carried even though + ``_persist_discovered_oauth_endpoints`` refuses to write it to the row: carrying only restores + the same in-memory value the previous build already ran with, while persisting it would flip + ``_dcr_bridge_relays_client_registration`` (which keys off the stored column) for dcr_bridge + servers that never had one configured. + + Carry-forward is a non-manual endpoint source, so the same trust rule as discovery applies: the + previous ``token_url``/``registration_url`` are carried only when the previous + ``authorization_url`` corroborates the authorize endpoint this build will use, i.e. when the + incoming build has no pinned authorize endpoint (``None`` -> we adopt the previous one too, a + consistent group) or pins the same one. An admin re-pointing ``authorization_url`` to a different + server must not keep serving the old token endpoint. """ if previous_server is None: return if previous_server.url != new_server.url or previous_server.auth_type != new_server.auth_type: return + may_carry_endpoints = _endpoints_corroborate_authorization_url( + previous_server.authorization_url, new_server.authorization_url + ) if new_server.authorization_url is None and previous_server.authorization_url: new_server.authorization_url = previous_server.authorization_url - if new_server.token_url is None and previous_server.token_url: + if may_carry_endpoints and new_server.token_url is None and previous_server.token_url: new_server.token_url = previous_server.token_url - if new_server.registration_url is None and previous_server.registration_url: + if may_carry_endpoints and new_server.registration_url is None and previous_server.registration_url: new_server.registration_url = previous_server.registration_url if not new_server.scopes and previous_server.scopes: new_server.scopes = previous_server.scopes -def _normalized_authorize_endpoint(url: str) -> str: - parsed = urlparse(url) - return f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{parsed.path.rstrip('/')}" - - def _gate_discovered_endpoints_against_manual_authorization_url( metadata: MCPOAuthMetadata | None, manual_authorization_url: str | None, server_identifier: str, is_dcr_bridge: bool, ) -> MCPOAuthMetadata | None: - """Refuse discovered token/registration endpoints the pinned authorize endpoint cannot vouch for. - - Discovery is rooted at the MCP resource (RFC 9728), so a compromised upstream can advertise an - attacker-run authorization server. When the admin manually configured ``authorization_url``, - filling a blank ``token_url`` from that advertisement recreates the RFC 9700 mix-up attack at - configuration time: users sign in at the trusted authorize endpoint while the gateway redeems - the code, with the stored client secret and PKCE verifier, at the attacker's token endpoint. - Endpoints from one metadata document are only trustworthy together, so the discovered - ``token_url`` and ``registration_url`` are accepted only when that same document's - ``authorization_endpoint`` matches the pinned value (scheme+host+path; query and trailing slash - are not identity). Scope discovery stays ungated: scopes steer the redirect to the trusted - authorize endpoint and carry no credentials. + """Apply :func:`_endpoints_corroborate_authorization_url` to freshly discovered metadata, the + other non-manual endpoint source. Drops the discovered ``token_url``/``registration_url`` (never + the scopes, which carry no credentials) when they cannot be vouched for by the pinned authorize + endpoint, logging why so an intentional mismatch can be resolved by setting Token URL by hand. """ - if metadata is None or not manual_authorization_url: + if metadata is None or (not metadata.token_url and not metadata.registration_url): return metadata - if not metadata.token_url and not metadata.registration_url: - return metadata - if metadata.authorization_url and _normalized_authorize_endpoint( - manual_authorization_url - ) == _normalized_authorize_endpoint(metadata.authorization_url): + if _endpoints_corroborate_authorization_url(metadata.authorization_url, manual_authorization_url): return metadata bridge_note = ( " The discovered registration_url is rejected with it, so this dcr_bridge server stays on the" @@ -258,7 +291,7 @@ def _gate_discovered_endpoints_against_manual_authorization_url( "authorization server. Configure Token URL manually if the mismatch is intentional.%s", server_identifier, _normalized_authorize_endpoint(metadata.authorization_url) if metadata.authorization_url else "", - _normalized_authorize_endpoint(manual_authorization_url), + _normalized_authorize_endpoint(manual_authorization_url) if manual_authorization_url else "", bridge_note, ) return metadata.model_copy(update={"token_url": None, "registration_url": None}) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 8091675090e..229b1bfb014 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -5287,6 +5287,102 @@ class TestMCPServerTimestamps: _carry_forward_resolved_oauth_endpoints(new_server=explicit, previous_server=previous) assert explicit.authorization_url == "https://configured.example.com/auth" + def test_carry_forward_does_not_revive_token_url_across_authorization_url_change(self): + """Carry-forward is a non-manual endpoint source, so it obeys the same trust rule as + discovery: a previous token_url/registration_url belongs to the previous authorization + server, so it must not be pinned to a NEW authorization_url the admin re-pointed to. Without + this, re-pointing authorize to server B while the same MCP url keeps serving A's token + endpoint recreates the RFC 9700 mix-up, durably, and the discovery gate alone cannot catch + it because the stale endpoint comes from the registry, not from discovery.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _carry_forward_resolved_oauth_endpoints, + ) + + previous = MCPServer( + server_id="s1", + name="s1", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://idp-a.example.com/authorize", + token_url="https://idp-a.example.com/token", + registration_url="https://idp-a.example.com/register", + ) + repointed = MCPServer( + server_id="s1", + name="s1", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://idp-b.example.com/authorize", + ) + + _carry_forward_resolved_oauth_endpoints(new_server=repointed, previous_server=previous) + + assert repointed.authorization_url == "https://idp-b.example.com/authorize" + assert repointed.token_url is None + assert repointed.registration_url is None + + def test_carry_forward_restores_endpoints_when_authorization_url_unchanged(self): + """The last-known-good path still works: a rebuild whose discovery blipped (no authorize + endpoint) adopts the previous authorize endpoint AND its token endpoint together as a + consistent group, and a rebuild that re-pins the same authorize endpoint (formatting aside) + keeps carrying the corroborated token endpoint.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _carry_forward_resolved_oauth_endpoints, + ) + + def previous() -> MCPServer: + return MCPServer( + server_id="s1", + name="s1", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + registration_url="https://idp.example.com/register", + ) + + blipped = MCPServer( + server_id="s1", + name="s1", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url=None, + ) + _carry_forward_resolved_oauth_endpoints(new_server=blipped, previous_server=previous()) + assert blipped.authorization_url == "https://idp.example.com/authorize" + assert blipped.token_url == "https://idp.example.com/token" + assert blipped.registration_url == "https://idp.example.com/register" + + same_authorize = MCPServer( + server_id="s1", + name="s1", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url="https://IDP.example.com:443/authorize/", + ) + _carry_forward_resolved_oauth_endpoints(new_server=same_authorize, previous_server=previous()) + assert same_authorize.token_url == "https://idp.example.com/token" + assert same_authorize.registration_url == "https://idp.example.com/register" + + def test_normalized_authorize_endpoint_treats_default_port_and_slash_as_identity(self): + """The corroboration check must not fail on formatting-only differences an IdP legitimately + emits: default port, trailing slash, host case, and query string are not identity, but a + non-default port is.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _normalized_authorize_endpoint, + ) + + canonical = _normalized_authorize_endpoint("https://idp.example.com/authorize") + assert _normalized_authorize_endpoint("https://idp.example.com:443/authorize") == canonical + assert _normalized_authorize_endpoint("https://IDP.example.com/authorize/") == canonical + assert _normalized_authorize_endpoint("https://idp.example.com/authorize?prompt=consent") == canonical + assert _normalized_authorize_endpoint("https://idp.example.com:8443/authorize") != canonical + def test_build_mcp_server_table_preserves_timestamps(self): """_build_mcp_server_table must use the MCPServer's stored timestamps, not datetime.now().""" manager = MCPServerManager() From 8650f6c7d352ca079fe82fba3a84f1f5adcf3c91 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 15 Jul 2026 10:57:40 -0700 Subject: [PATCH 04/12] fix(mcp): bound pinned-config discovery to the corroborated authorization server, scopes included Provenance is a property of the whole discovered metadata document, not per field. Waving scopes through while gating endpoints left a second inflation vector: a compromised upstream advertises broad scopes via the resource metadata (RFC 9728 / WWW-Authenticate), the gateway requests them from the trusted authorization server, and the resulting token flows back to the upstream. Both that and the token-endpoint mix-up are now one rule: when authorization_url is admin-pinned, discovered token_url/registration_url are kept only if the document corroborates the pin, and scopes come from the authorization server's own scopes_supported (a new authorization_server_scopes field, trusted tier) rather than the resource-advertised scopes. A document that does not corroborate backfills nothing. Blank (empty-string) authorization_url is treated as unpinned so the merge and the gate agree. Carry-forward, the other non-manual source, drops the same three across an authorization_url change. --- .../mcp_server/mcp_server_manager.py | 51 ++++--- .../types/mcp_server/mcp_server_manager.py | 9 ++ .../mcp_server/test_mcp_server_manager.py | 137 ++++++++++++++---- 3 files changed, 147 insertions(+), 50 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index b84c5601560..2ace0d9b16a 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -219,7 +219,7 @@ def _endpoints_corroborate_authorization_url( value (``trusted_authorization_url is None``) there is nothing to protect: the authorize endpoint comes from the same source as the token endpoint, so they corroborate each other by construction. """ - if trusted_authorization_url is None: + if not (trusted_authorization_url and trusted_authorization_url.strip()): return True return bool(source_authorization_url) and _normalized_authorize_endpoint( source_authorization_url @@ -240,43 +240,53 @@ def _carry_forward_resolved_oauth_endpoints(new_server: MCPServer, previous_serv servers that never had one configured. Carry-forward is a non-manual endpoint source, so the same trust rule as discovery applies: the - previous ``token_url``/``registration_url`` are carried only when the previous + previous ``token_url``/``registration_url``/``scopes`` are carried only when the previous ``authorization_url`` corroborates the authorize endpoint this build will use, i.e. when the incoming build has no pinned authorize endpoint (``None`` -> we adopt the previous one too, a consistent group) or pins the same one. An admin re-pointing ``authorization_url`` to a different - server must not keep serving the old token endpoint. + server must not keep serving the old server's token endpoint or granted scopes. """ if previous_server is None: return if previous_server.url != new_server.url or previous_server.auth_type != new_server.auth_type: return - may_carry_endpoints = _endpoints_corroborate_authorization_url( + may_carry = _endpoints_corroborate_authorization_url( previous_server.authorization_url, new_server.authorization_url ) if new_server.authorization_url is None and previous_server.authorization_url: new_server.authorization_url = previous_server.authorization_url - if may_carry_endpoints and new_server.token_url is None and previous_server.token_url: + if may_carry and new_server.token_url is None and previous_server.token_url: new_server.token_url = previous_server.token_url - if may_carry_endpoints and new_server.registration_url is None and previous_server.registration_url: + if may_carry and new_server.registration_url is None and previous_server.registration_url: new_server.registration_url = previous_server.registration_url - if not new_server.scopes and previous_server.scopes: + if may_carry and not new_server.scopes and previous_server.scopes: new_server.scopes = previous_server.scopes -def _gate_discovered_endpoints_against_manual_authorization_url( +def _restrict_discovery_to_corroborated_authorization_server( metadata: MCPOAuthMetadata | None, manual_authorization_url: str | None, server_identifier: str, is_dcr_bridge: bool, ) -> MCPOAuthMetadata | None: - """Apply :func:`_endpoints_corroborate_authorization_url` to freshly discovered metadata, the - other non-manual endpoint source. Drops the discovered ``token_url``/``registration_url`` (never - the scopes, which carry no credentials) when they cannot be vouched for by the pinned authorize - endpoint, logging why so an intentional mismatch can be resolved by setting Token URL by hand. + """Bound what freshly discovered metadata may backfill into a manually pinned config. + + Discovery is rooted at the MCP resource, so provenance is a property of the whole metadata + document, not per field: a compromised upstream can advertise both an attacker ``token_endpoint`` + (the RFC 9700 mix-up) and inflated ``scopes`` (tricking the user into granting a broader token + that then flows to the upstream). Both are closed by one rule. When ``authorization_url`` is + admin-pinned, the discovered ``token_url`` and ``registration_url`` are kept only if the document + corroborates the pin (its ``authorization_endpoint`` matches), and scopes are taken from the + authorization server's own ``scopes_supported`` (``authorization_server_scopes``, trusted tier) + rather than the resource-advertised ``scopes`` a compromised upstream controls. A document that + does not corroborate backfills nothing. With no pin there is no trust anchor to protect and the + authorize endpoint comes from the same chain as everything else, so discovery is returned as-is. """ - if metadata is None or (not metadata.token_url and not metadata.registration_url): + if metadata is None or not (manual_authorization_url and manual_authorization_url.strip()): return metadata if _endpoints_corroborate_authorization_url(metadata.authorization_url, manual_authorization_url): + return metadata.model_copy(update={"scopes": metadata.authorization_server_scopes}) + if not metadata.token_url and not metadata.registration_url and not metadata.scopes: return metadata bridge_note = ( " The discovered registration_url is rejected with it, so this dcr_bridge server stays on the" @@ -286,15 +296,15 @@ def _gate_discovered_endpoints_against_manual_authorization_url( ) verbose_logger.warning( "MCP OAuth discovery for server %s advertised authorization_endpoint %s, which does not match the " - "manually configured authorization_url %s; rejecting the discovered token_url/registration_url so " - "authorization codes and client credentials only go to endpoints vouched for by the configured " - "authorization server. Configure Token URL manually if the mismatch is intentional.%s", + "manually configured authorization_url %s; rejecting the discovered token_url/registration_url/scopes " + "so authorization codes, client credentials, and granted scopes only follow the configured " + "authorization server. Configure Token URL and Scopes manually if the mismatch is intentional.%s", server_identifier, _normalized_authorize_endpoint(metadata.authorization_url) if metadata.authorization_url else "", - _normalized_authorize_endpoint(manual_authorization_url) if manual_authorization_url else "", + _normalized_authorize_endpoint(manual_authorization_url), bridge_note, ) - return metadata.model_copy(update={"token_url": None, "registration_url": None}) + return metadata.model_copy(update={"token_url": None, "registration_url": None, "scopes": None}) def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None: @@ -1126,7 +1136,7 @@ class MCPServerManager: mcp_oauth_metadata = None gated_oauth_metadata = ( - _gate_discovered_endpoints_against_manual_authorization_url( + _restrict_discovery_to_corroborated_authorization_server( mcp_oauth_metadata, server_config.get("authorization_url"), server_name or server_id, @@ -1568,7 +1578,7 @@ class MCPServerManager: server_url, ) gated_oauth_metadata = ( - _gate_discovered_endpoints_against_manual_authorization_url( + _restrict_discovery_to_corroborated_authorization_server( mcp_oauth_metadata, mcp_server.authorization_url, mcp_server.server_id, @@ -3365,6 +3375,7 @@ class MCPServerManager: authorization_url=data.get("authorization_endpoint"), token_url=data.get("token_endpoint"), registration_url=data.get("registration_endpoint"), + authorization_server_scopes=scopes, ) if any( diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 82ff15303f3..79a523f7c8b 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -17,9 +17,18 @@ MCPInfo = Dict[str, Any] class MCPOAuthMetadata(BaseModel): scopes: Optional[List[str]] = None + """Effective scopes, resource-preferred: the RFC 9728 protected-resource advertisement or the + WWW-Authenticate challenge when the resource supplied one, else the authorization server's + ``scopes_supported``. A compromised resource server can influence this, so it must not expand a + manually pinned ``authorization_url`` (see ``authorization_server_scopes``).""" authorization_url: Optional[str] = None token_url: Optional[str] = None registration_url: Optional[str] = None + authorization_server_scopes: Optional[List[str]] = None + """The ``scopes_supported`` enumerated by the authorization-server metadata document itself + (RFC 8414), independent of anything the resource server advertised. This is the only scope + source trusted to backfill a manually pinned ``authorization_url``, because it shares provenance + with the ``authorization_endpoint`` used to corroborate that pin.""" from_origin_fallback: bool = False """True when the metadata came from guessing the resource origin as its authorization server rather than from an RFC 9728/8414-advertised document. Guessed endpoints are diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 229b1bfb014..d14ae561f71 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -357,17 +357,18 @@ class TestMCPServerManager: assert server.needs_user_oauth_token is True @pytest.mark.asyncio - async def test_load_servers_from_config_rejects_discovered_token_url_on_authorization_endpoint_mismatch(self): + async def test_load_servers_from_config_rejects_uncorroborated_discovery_including_scopes(self): """The config loader always runs discovery and or-merges per field, so a yaml server with a - manual authorization_url has the same config-time mix-up exposure as a DB row: a discovered - token_url from a document advertising a different authorize endpoint must not be combined - with the pinned one. Scopes still backfill.""" + manual authorization_url has the same config-time mix-up exposure as a DB row: a document + advertising a different authorize endpoint backfills nothing, neither its token_url nor its + scopes.""" manager = MCPServerManager() metadata = MCPOAuthMetadata( authorization_url="https://attacker.example.com/authorize", token_url="https://attacker.example.com/token", - scopes=["read"], + scopes=["read", "admin"], + authorization_server_scopes=["read", "admin"], ) config = self._oauth2_config( oauth2_flow="authorization_code", @@ -380,19 +381,20 @@ class TestMCPServerManager: server = next(iter(manager.config_mcp_servers.values())) assert server.authorization_url == "https://idp.example.com/authorize" assert server.token_url is None - assert server.scopes == ["read"] + assert server.scopes is None @pytest.mark.asyncio async def test_load_servers_from_config_fills_token_url_when_metadata_corroborates_manual_authorization_url(self): """Corroborated metadata keeps the self-heal on the config path: when the discovered document advertises the same authorize endpoint the admin pinned, its token_url fills the - blank field.""" + blank field and scopes come from the authorization server's own scopes_supported.""" manager = MCPServerManager() metadata = MCPOAuthMetadata( authorization_url="https://idp.example.com/authorize", token_url="https://idp.example.com/token", - scopes=["read"], + scopes=["read", "admin"], + authorization_server_scopes=["read"], ) config = self._oauth2_config( oauth2_flow="authorization_code", @@ -404,6 +406,34 @@ class TestMCPServerManager: server = next(iter(manager.config_mcp_servers.values())) assert server.token_url == "https://idp.example.com/token" + assert server.scopes == ["read"] + + @pytest.mark.asyncio + async def test_load_servers_from_config_blank_authorization_url_is_not_a_pin(self): + """A blank (empty-string) authorization_url is not a trust anchor, so discovery backfills + the whole set — authorize endpoint, token_url, and its resource-preferred scopes — from the + same chain, exactly as if the field had been omitted. The corroboration gate must treat + empty-string as unpinned so it does not strand the token_url the merge still fills.""" + manager = MCPServerManager() + + metadata = MCPOAuthMetadata( + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + scopes=["read"], + authorization_server_scopes=["read"], + ) + config = self._oauth2_config( + oauth2_flow="authorization_code", + authorization_url="", + token_url=None, + ) + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)): + await manager.load_servers_from_config(config) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.authorization_url == "https://idp.example.com/authorize" + assert server.token_url == "https://idp.example.com/token" + assert server.scopes == ["read"] @pytest.mark.asyncio async def test_load_servers_from_config_non_oauth2_needs_no_flow(self): @@ -1102,12 +1132,12 @@ class TestMCPServerManager: assert built.token_url == "https://idp.example.com/token" @pytest.mark.asyncio - async def test_build_from_table_discovers_scopes_when_authorization_url_is_manual(self): - """An admin-typed authorization_url must not switch off discovery for the fields left - blank: without the scopes_supported backfill the authorize redirect goes out scope-less - and IdPs like Google hard-fail it with 400 "Missing required parameter: scope". Scope - backfill works even when the advertised authorization_endpoint differs from the manual - value, because scopes only steer the redirect to the trusted authorize endpoint.""" + async def test_build_from_table_backfills_scopes_from_authorization_server_not_resource(self): + """When authorization_url is admin-pinned, scopes backfill from the authorization server's + own scopes_supported (trusted tier), never from the resource-advertised scopes a compromised + upstream controls. Here the corroborating document carries an inflated resource `scopes` + (`admin`) alongside the real authorization_server_scopes; only the latter may be requested, + otherwise a hostile resource could trick the user into granting a broader token.""" manager = MCPServerManager() row = LiteLLM_MCPServerTable( server_id="manual-auth-url-1", @@ -1116,24 +1146,24 @@ class TestMCPServerManager: url="https://up.example.com/mcp", transport=MCPTransport.http, auth_type=MCPAuth.oauth2, - authorization_url="https://idp.example.com/manual-authorize", + authorization_url="https://idp.example.com/authorize", created_at=datetime.now(), updated_at=datetime.now(), ) metadata = MCPOAuthMetadata( - authorization_url="https://idp.example.com/discovered-authorize", + authorization_url="https://idp.example.com/authorize", token_url="https://idp.example.com/token", - registration_url=None, - scopes=["calendar.read", "calendar.write"], + scopes=["read", "admin"], + authorization_server_scopes=["read", "write"], ) with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)) as mock_discovery: built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) mock_discovery.assert_awaited_once() - assert built.authorization_url == "https://idp.example.com/manual-authorize" - assert built.token_url is None - assert built.scopes == ["calendar.read", "calendar.write"] + assert built.authorization_url == "https://idp.example.com/authorize" + assert built.token_url == "https://idp.example.com/token" + assert built.scopes == ["read", "write"] @pytest.mark.asyncio async def test_build_from_table_fills_endpoints_when_metadata_corroborates_manual_authorization_url(self): @@ -1159,6 +1189,7 @@ class TestMCPServerManager: token_url="https://idp.example.com/token", registration_url="https://idp.example.com/register", scopes=["read"], + authorization_server_scopes=["read"], ) with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)): built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) @@ -1173,15 +1204,15 @@ class TestMCPServerManager: "advertised_authorization_url", ["https://attacker.example.com/authorize", None], ) - async def test_build_from_table_rejects_discovered_token_url_on_authorization_endpoint_mismatch( + async def test_build_from_table_rejects_uncorroborated_discovery_including_scopes( self, advertised_authorization_url ): """Resource-rooted discovery lets a compromised upstream advertise its own authorization - server. With a manual authorization_url pinned, accepting that document's token_url would - send the authorization code, stored client secret, and PKCE verifier to the attacker's - token endpoint (config-time RFC 9700 mix-up), and the persist hook would make the hostile - endpoint durable. Both the in-memory merge and the persisted metadata must drop the - uncorroborated token_url and registration_url.""" + server. With a manual authorization_url pinned, a document that does not corroborate it + backfills nothing: accepting its token_url would send the code, client secret, and PKCE + verifier to the attacker (config-time RFC 9700 mix-up), and accepting its scopes would let + the upstream inflate the granted token. Both the in-memory merge and the persisted metadata + must drop the uncorroborated token_url, registration_url, and scopes.""" manager = MCPServerManager() row = LiteLLM_MCPServerTable( server_id="manual-auth-url-3", @@ -1199,7 +1230,8 @@ class TestMCPServerManager: authorization_url=advertised_authorization_url, token_url="https://attacker.example.com/token", registration_url="https://attacker.example.com/register", - scopes=["read"], + scopes=["read", "admin"], + authorization_server_scopes=["read", "admin"], ) with ( patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)), @@ -1210,11 +1242,11 @@ class TestMCPServerManager: assert built.authorization_url == "https://idp.example.com/authorize" assert built.token_url is None assert built.registration_url is None - assert built.scopes == ["read"] + assert built.scopes is None persisted_metadata = mock_persist.await_args.kwargs["metadata"] assert persisted_metadata.token_url is None assert persisted_metadata.registration_url is None - assert persisted_metadata.scopes == ["read"] + assert persisted_metadata.scopes is None @pytest.mark.asyncio async def test_build_from_table_skips_discovery_when_all_upstream_oauth_fields_present(self): @@ -2317,6 +2349,48 @@ class TestMCPServerManager: assert result.scopes == ["api://some-scope/.default"] assert result.from_origin_fallback is False + @pytest.mark.asyncio + async def test_descovery_metadata_preserves_authorization_server_scopes_under_resource_override(self): + """The effective `scopes` field is resource-preferred (RFC 9728 / WWW-Authenticate), but the + authorization server's own scopes_supported must survive on `authorization_server_scopes` so + the pinned-config backfill can request the trusted-tier scopes instead of resource-advertised + ones. This is the provenance split the scope-inflation defense depends on.""" + manager = MCPServerManager() + + mock_response = MagicMock() + mock_response.raise_for_status = MagicMock() + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + + authorization_server_metadata = MCPOAuthMetadata( + scopes=["as.read", "as.write"], + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + authorization_server_scopes=["as.read", "as.write"], + ) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.get_async_httpx_client", + return_value=mock_client, + ), + patch.object( + manager, + "_attempt_well_known_discovery", + AsyncMock(return_value=(["https://idp.example.com"], ["resource.only"])), + ), + patch.object( + manager, + "_fetch_authorization_server_metadata", + AsyncMock(return_value=authorization_server_metadata), + ), + ): + result = await manager._descovery_metadata("https://up.example.com/mcp") + + assert result is not None + assert result.scopes == ["resource.only"] + assert result.authorization_server_scopes == ["as.read", "as.write"] + @pytest.mark.asyncio async def test_fetch_single_authorization_server_metadata_supports_azure_issuer_path( self, @@ -2357,6 +2431,9 @@ class TestMCPServerManager: assert result.authorization_url == "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/authorize" assert result.token_url == "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/token" assert result.scopes == ["api://some-scope/.default"] + # The authorization server's own scopes_supported is retained under a dedicated field so a + # later resource-scope override cannot erase the trusted-tier value used to backfill a pin. + assert result.authorization_server_scopes == ["api://some-scope/.default"] @pytest.mark.asyncio async def test_fetch_single_authorization_server_metadata_derives_azure_metadata( From feedab214ee4cdbb19b0a24200004f9e5db1f080 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 15 Jul 2026 11:12:54 -0700 Subject: [PATCH 05/12] fix(mcp): normalize blank OAuth endpoint fields to None at build entry points A whitespace-only authorization_url was truthy to the row/config merges and has_all check but blank to the corroboration gate, so discovery and carry-forward adopted token_url/registration_url/scopes as if unpinned while the broken whitespace value was still used for redirects. Rather than add another strip() at each site, the pinned authorization_url/token_url/ registration_url are normalized once per build path (DB and config) via _blank_to_none, so the merge, has_all gate, discovery gate, persist hook, and carry-forward all see a single notion of blank. Empty and whitespace pins now behave identically to an omitted field. --- .../mcp_server/mcp_server_manager.py | 51 +++++++++++++------ .../mcp_server/test_mcp_server_manager.py | 46 ++++++++++++++--- 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 2ace0d9b16a..e011c8f8e88 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -186,6 +186,21 @@ _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: tuple[MCPAuth, ...] = ( ) +def _blank_to_none(value: str | None) -> str | None: + """Collapse an absent, empty, or whitespace-only string to ``None``. + + OAuth endpoint fields are consumed by truthiness-based merges (``row or discovered``) and by the + corroboration gate. A whitespace-only value is truthy to ``or`` but is not a usable endpoint, so + without this the merge would keep the blank value for redirects while the gate treats it as + unpinned and backfills the other fields, yielding a broken half-discovered config. Normalizing + the pinned fields once, at each build entry point, gives every downstream consumer a single + notion of "blank" so those code paths cannot disagree. + """ + if not isinstance(value, str): + return None + return value.strip() or None + + def _normalized_authorize_endpoint(url: str) -> str: """Compare authorize endpoints on scheme, host, and path only. The default port is elided and the host is lowercased so ``https://IDP.example.com:443/authorize/`` and @@ -1120,12 +1135,15 @@ class MCPServerManager: ) auth_type = server_config.get("auth_type", None) + manual_authorization_url = _blank_to_none(server_config.get("authorization_url")) + manual_token_url = _blank_to_none(server_config.get("token_url")) + manual_registration_url = _blank_to_none(server_config.get("registration_url")) if server_url and ( auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES or self._obo_needs_endpoint_discovery( auth_type, server_config.get("token_exchange_endpoint"), - server_config.get("token_url"), + manual_token_url, ) ): mcp_oauth_metadata = await self._descovery_metadata( @@ -1138,7 +1156,7 @@ class MCPServerManager: gated_oauth_metadata = ( _restrict_discovery_to_corroborated_authorization_server( mcp_oauth_metadata, - server_config.get("authorization_url"), + manual_authorization_url, server_name or server_id, bool(server_config.get("dcr_bridge")), ) @@ -1152,13 +1170,11 @@ class MCPServerManager: resolved_scopes = self._extract_scopes(server_config.get("scopes")) or ( gated_oauth_metadata.scopes if gated_oauth_metadata else None ) - resolved_authorization_url = server_config.get("authorization_url") or ( + resolved_authorization_url = manual_authorization_url or ( gated_oauth_metadata.authorization_url if gated_oauth_metadata else None ) - resolved_token_url = server_config.get("token_url") or ( - gated_oauth_metadata.token_url if gated_oauth_metadata else None - ) - resolved_registration_url = server_config.get("registration_url") or ( + resolved_token_url = manual_token_url or (gated_oauth_metadata.token_url if gated_oauth_metadata else None) + resolved_registration_url = manual_registration_url or ( gated_oauth_metadata.registration_url if gated_oauth_metadata else None ) @@ -1552,14 +1568,17 @@ class MCPServerManager: auth_type = cast(MCPAuthType, mcp_server.auth_type) server_url = mcp_server.url - has_all_upstream_oauth_fields = bool(mcp_server.authorization_url and mcp_server.token_url and scopes) + manual_authorization_url = _blank_to_none(mcp_server.authorization_url) + manual_token_url = _blank_to_none(mcp_server.token_url) + manual_registration_url = _blank_to_none(mcp_server.registration_url) + has_all_upstream_oauth_fields = bool(manual_authorization_url and manual_token_url and scopes) needs_discovery = bool(server_url) and ( (auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and not has_all_upstream_oauth_fields) or self._obo_needs_endpoint_discovery( auth_type, mcp_server.token_exchange_endpoint or (credentials_dict.get("token_exchange_endpoint") if credentials_dict else None), - mcp_server.token_url, + manual_token_url, ) ) mcp_oauth_metadata = ( @@ -1580,7 +1599,7 @@ class MCPServerManager: gated_oauth_metadata = ( _restrict_discovery_to_corroborated_authorization_server( mcp_oauth_metadata, - mcp_server.authorization_url, + manual_authorization_url, mcp_server.server_id, bool(getattr(mcp_server, "dcr_bridge", None)), ) @@ -1608,9 +1627,9 @@ class MCPServerManager: client_secret=client_secret_value or getattr(mcp_server, "client_secret", None), oauth2_flow=self._explicit_oauth2_flow(getattr(mcp_server, "oauth2_flow", None)), scopes=resolved_scopes, - authorization_url=mcp_server.authorization_url or getattr(gated_oauth_metadata, "authorization_url", None), - token_url=mcp_server.token_url or getattr(gated_oauth_metadata, "token_url", None), - registration_url=mcp_server.registration_url or getattr(gated_oauth_metadata, "registration_url", None), + authorization_url=manual_authorization_url or getattr(gated_oauth_metadata, "authorization_url", None), + token_url=manual_token_url or getattr(gated_oauth_metadata, "token_url", None), + registration_url=manual_registration_url or getattr(gated_oauth_metadata, "registration_url", None), token_endpoint_auth_method=( credentials_dict.get("token_endpoint_auth_method") if credentials_dict else None ), @@ -1661,14 +1680,14 @@ class MCPServerManager: await self._persist_discovered_obo_token_url( server_id=mcp_server.server_id, auth_type=auth_type, - existing_token_url=mcp_server.token_url, + existing_token_url=manual_token_url, discovered_token_url=new_server.token_url, ) await self._persist_discovered_oauth_endpoints( server_id=mcp_server.server_id, auth_type=auth_type, - existing_authorization_url=mcp_server.authorization_url, - existing_token_url=mcp_server.token_url, + existing_authorization_url=manual_authorization_url, + existing_token_url=manual_token_url, existing_scopes=scopes, metadata=gated_oauth_metadata, ) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index d14ae561f71..989281c27e9 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -409,11 +409,13 @@ class TestMCPServerManager: assert server.scopes == ["read"] @pytest.mark.asyncio - async def test_load_servers_from_config_blank_authorization_url_is_not_a_pin(self): - """A blank (empty-string) authorization_url is not a trust anchor, so discovery backfills - the whole set — authorize endpoint, token_url, and its resource-preferred scopes — from the - same chain, exactly as if the field had been omitted. The corroboration gate must treat - empty-string as unpinned so it does not strand the token_url the merge still fills.""" + @pytest.mark.parametrize("blank_authorization_url", ["", " "]) + async def test_load_servers_from_config_blank_authorization_url_is_not_a_pin(self, blank_authorization_url): + """A blank authorization_url — empty or whitespace-only — is not a trust anchor, so discovery + backfills the whole set (authorize endpoint, token_url, and its resource-preferred scopes) + from the same chain, exactly as if the field had been omitted. The merge and the corroboration + gate must agree that blank means unpinned; a whitespace value that the merge kept for redirects + while the gate treated as unpinned would strand a broken half-discovered config.""" manager = MCPServerManager() metadata = MCPOAuthMetadata( @@ -424,7 +426,7 @@ class TestMCPServerManager: ) config = self._oauth2_config( oauth2_flow="authorization_code", - authorization_url="", + authorization_url=blank_authorization_url, token_url=None, ) with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)): @@ -1165,6 +1167,38 @@ class TestMCPServerManager: assert built.token_url == "https://idp.example.com/token" assert built.scopes == ["read", "write"] + @pytest.mark.asyncio + async def test_build_from_table_whitespace_authorization_url_is_not_a_pin(self): + """A whitespace-only authorization_url on the row must not be kept for redirects while the + gate treats it as unpinned. It is normalized to unpinned everywhere, so the built server + takes the discovered authorize endpoint, token_url, and scopes as one consistent group + rather than serving the whitespace value with half-discovered fields.""" + manager = MCPServerManager() + row = LiteLLM_MCPServerTable( + server_id="whitespace-auth-url", + alias="whitespace_auth_url", + description="whitespace authorization_url is not a pin", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + authorization_url=" ", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + metadata = MCPOAuthMetadata( + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + scopes=["read"], + authorization_server_scopes=["read"], + ) + with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)): + built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) + + assert built.authorization_url == "https://idp.example.com/authorize" + assert built.token_url == "https://idp.example.com/token" + assert built.scopes == ["read"] + @pytest.mark.asyncio async def test_build_from_table_fills_endpoints_when_metadata_corroborates_manual_authorization_url(self): """A discovered token_url is only trusted next to a manual authorization_url when the same From 2b9b681e124c27dd5af1064f8c8100a74ccdc2f5 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 15 Jul 2026 13:48:27 -0700 Subject: [PATCH 06/12] fix(ui/chat): resolve chat routes at render time so navigation works under server_root_path CHAT_ROUTES was built once at module load via migratedHref, capturing an empty server root path before the UI-config bootstrap sets it. Under SERVER_ROOT_PATH every chat route came out unprefixed, so router.push hard-navigated to a 404; during a send that page unload also aborted the streaming request, so the first message of a new conversation never rendered and only a second send appeared to work. Compute the routes at render time via getChatRoutes(), update the send URL with a shallow history.pushState instead of a router navigation, and source the active conversation id from the hook's local state so it propagates without a router round-trip --- ui/litellm-dashboard/src/app/chat/page.tsx | 9 ++-- .../chat/ChatShell.serverRootPath.test.ts | 43 ++++++++++++++++++ .../src/components/chat/ChatShell.tsx | 45 ++++++++++--------- .../src/components/chat/useChatHistory.ts | 2 + .../src/contexts/ChatShellContext.tsx | 7 +-- 5 files changed, 77 insertions(+), 29 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/chat/ChatShell.serverRootPath.test.ts diff --git a/ui/litellm-dashboard/src/app/chat/page.tsx b/ui/litellm-dashboard/src/app/chat/page.tsx index ae86ca6e926..b6dccef47c6 100644 --- a/ui/litellm-dashboard/src/app/chat/page.tsx +++ b/ui/litellm-dashboard/src/app/chat/page.tsx @@ -10,7 +10,7 @@ import { Button } from "@/components/ui/button"; import MessageManager from "@/components/molecules/message_manager"; import { useRouter } from "next/navigation"; import { useChatShell } from "@/contexts/ChatShellContext"; -import { CHAT_ROUTES } from "@/components/chat/ChatShell"; +import { getChatRoutes } from "@/components/chat/ChatShell"; import ChatMessages from "@/components/chat/ChatMessages"; import MCPConnectPicker from "@/components/chat/MCPConnectPicker"; import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; @@ -87,7 +87,7 @@ export default function ChatConversationPage() { const streamScrollLock = useRef(null); useEffect(() => { - if (staleId) router.replace(CHAT_ROUTES.chats); + if (staleId) router.replace(getChatRoutes().chats); }, [staleId, router]); // Load models @@ -140,7 +140,7 @@ export default function ChatConversationPage() { if (!convId) { convId = createConversation(model); setResponsesSessionId(null); // new conversation starts a fresh session - router.push(`${CHAT_ROUTES.chats}?id=${convId}`); + window.history.pushState(null, "", `${window.location.pathname}?id=${convId}`); } appendMessage(convId, { role: "user", content: trimmed }); @@ -248,7 +248,6 @@ export default function ChatConversationPage() { createConversation, appendMessage, updateLastAssistantMessage, - router, isStreaming, responsesSessionId, ], @@ -529,7 +528,7 @@ export default function ChatConversationPage() { Chat with 100+ LLMs + MCP tools; authenticate once, use them here.{" "} @@ -85,32 +88,32 @@ const ChatShell: React.FC = ({ children }) => { } label="Chats" - onClick={() => router.push(CHAT_ROUTES.chats)} + onClick={() => router.push(routes.chats)} active={isChatsRoute} /> } label="Integrations" - onClick={() => router.push(CHAT_ROUTES.integrations)} - active={pathname === CHAT_ROUTES.integrations} + onClick={() => router.push(routes.integrations)} + active={pathname === routes.integrations} /> } label="Credentials" - onClick={() => router.push(CHAT_ROUTES.credentials)} - active={pathname === CHAT_ROUTES.credentials} + onClick={() => router.push(routes.credentials)} + active={pathname === routes.credentials} /> } label="API Keys" - onClick={() => router.push(CHAT_ROUTES.apiKeys)} - active={pathname === CHAT_ROUTES.apiKeys} + onClick={() => router.push(routes.apiKeys)} + active={pathname === routes.apiKeys} /> } label="Usage" - onClick={() => router.push(CHAT_ROUTES.usage)} - active={pathname === CHAT_ROUTES.usage} + onClick={() => router.push(routes.usage)} + active={pathname === routes.usage} /> @@ -120,10 +123,10 @@ const ChatShell: React.FC = ({ children }) => { router.push(`${CHAT_ROUTES.chats}?id=${id}`)} + onSelect={(id) => router.push(`${routes.chats}?id=${id}`)} onDelete={(id) => { deleteConversation(id); - if (id === activeConversationId) router.push(CHAT_ROUTES.chats); + if (id === activeConversationId) router.push(routes.chats); }} onRename={renameConversation} /> diff --git a/ui/litellm-dashboard/src/components/chat/useChatHistory.ts b/ui/litellm-dashboard/src/components/chat/useChatHistory.ts index 3b6d350520b..8e6c3ce5877 100644 --- a/ui/litellm-dashboard/src/components/chat/useChatHistory.ts +++ b/ui/litellm-dashboard/src/components/chat/useChatHistory.ts @@ -52,6 +52,7 @@ export function useChatHistory( ): { conversations: Conversation[]; activeConversation: Conversation | null; + currentActiveId: string | null; storageUnavailable: boolean; staleId: boolean; createConversation: (model: string) => string; @@ -208,6 +209,7 @@ export function useChatHistory( return { conversations, activeConversation, + currentActiveId, storageUnavailable, staleId, createConversation, diff --git a/ui/litellm-dashboard/src/contexts/ChatShellContext.tsx b/ui/litellm-dashboard/src/contexts/ChatShellContext.tsx index 96d352ade6f..b0f590d2394 100644 --- a/ui/litellm-dashboard/src/contexts/ChatShellContext.tsx +++ b/ui/litellm-dashboard/src/contexts/ChatShellContext.tsx @@ -57,12 +57,13 @@ export function ChatShellProvider({ children, }: ChatShellProviderProps) { const searchParams = useSearchParams(); - const activeConversationId = searchParams.get("id"); + const urlConversationId = searchParams.get("id"); const [selectedMCPServers, setSelectedMCPServers] = useState([]); const { conversations, activeConversation, + currentActiveId, storageUnavailable, staleId, createConversation, @@ -71,7 +72,7 @@ export function ChatShellProvider({ truncateFromMessage, deleteConversation, renameConversation, - } = useChatHistory(activeConversationId, userId); + } = useChatHistory(urlConversationId, userId); return ( Date: Wed, 15 Jul 2026 16:04:29 -0700 Subject: [PATCH 07/12] feat(ui): add reusable BetaBadge and use it for Projects sidebar item (#33449) * fix(ui): remove New badge from Projects sidebar item Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(ui): add reusable BetaBadge and use it for Projects sidebar item Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * feat(ui): rename disableShowNewBadge flag to disableShowBadges and make BetaBadge respect it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * style(ui): use blue for BetaBadge to match existing New badge Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * revert(ui): keep disableShowNewBadge localStorage key to preserve existing user opt-outs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore: remove non-functional md artifacts from PR Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: ryan Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/BetaBadge.test.tsx | 70 +++++++++++++++++++ .../src/components/BetaBadge.tsx | 18 +++++ .../src/components/leftnav.tsx | 3 +- .../src/components/navbar.test.tsx | 2 +- 4 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/BetaBadge.test.tsx create mode 100644 ui/litellm-dashboard/src/components/BetaBadge.tsx diff --git a/ui/litellm-dashboard/src/components/BetaBadge.test.tsx b/ui/litellm-dashboard/src/components/BetaBadge.test.tsx new file mode 100644 index 00000000000..7eaf414087d --- /dev/null +++ b/ui/litellm-dashboard/src/components/BetaBadge.test.tsx @@ -0,0 +1,70 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import BetaBadge from "./BetaBadge"; + +// Mock the hook directly +vi.mock("@/app/(dashboard)/hooks/useDisableShowNewBadge", () => ({ + useDisableShowNewBadge: vi.fn(), +})); + +import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge"; + +const mockUseDisableShowNewBadge = vi.mocked(useDisableShowNewBadge); + +describe("BetaBadge", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should render the badge when disableShowNewBadge is false", () => { + mockUseDisableShowNewBadge.mockReturnValue(false); + + render(Test Content); + + expect(screen.getByText("Beta")).toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + it("should render the badge when disableShowNewBadge is not set", () => { + mockUseDisableShowNewBadge.mockReturnValue(false); + + render(); + + expect(screen.getByText("Beta")).toBeInTheDocument(); + }); + + it("should render only children when disableShowNewBadge is true", () => { + mockUseDisableShowNewBadge.mockReturnValue(true); + + render(Test Content); + + expect(screen.queryByText("Beta")).not.toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + it("should render nothing when disableShowNewBadge is true and no children", () => { + mockUseDisableShowNewBadge.mockReturnValue(true); + + const { container } = render(); + + expect(container.firstChild).toBeNull(); + }); + + it("should render badge with dot instead of text when dot prop is true", () => { + mockUseDisableShowNewBadge.mockReturnValue(false); + + render(Test Content); + + expect(screen.queryByText("Beta")).not.toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); + + it("should render badge with 'Beta' text when dot prop is not provided (defaults to false)", () => { + mockUseDisableShowNewBadge.mockReturnValue(false); + + render(Test Content); + + expect(screen.getByText("Beta")).toBeInTheDocument(); + expect(screen.getByText("Test Content")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/BetaBadge.tsx b/ui/litellm-dashboard/src/components/BetaBadge.tsx new file mode 100644 index 00000000000..7c4ef04417e --- /dev/null +++ b/ui/litellm-dashboard/src/components/BetaBadge.tsx @@ -0,0 +1,18 @@ +import { Badge } from "antd"; +import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge"; + +export default function BetaBadge({ children, dot = false }: { children?: React.ReactNode; dot?: boolean }) { + const disableShowNewBadge = useDisableShowNewBadge(); + + if (disableShowNewBadge) { + return children ? <>{children} : null; + } + + return children ? ( + + {children} + + ) : ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index bf3440e7ab9..a2e2baf374e 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -72,6 +72,7 @@ import { rolesAllowedToViewWriteScopedPages, rolesWithWriteAccess, } from "../utils/roles"; +import BetaBadge from "./BetaBadge"; import NewBadge from "./common_components/NewBadge"; import type { Organization } from "./networking"; import SidebarAccountMenu from "./SidebarAccountMenu/SidebarAccountMenu"; @@ -211,7 +212,7 @@ const menuGroups: MenuGroup[] = [ page: "projects", label: ( - Projects + Projects ), icon: , diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx index bdd0681dfa8..ba0b4bf55f5 100644 --- a/ui/litellm-dashboard/src/components/navbar.test.tsx +++ b/ui/litellm-dashboard/src/components/navbar.test.tsx @@ -252,7 +252,7 @@ describe("Navbar", () => { expect(screen.queryByRole("button", { name: /^notifications$/i })).not.toBeInTheDocument(); }); - it("should handle hide new features toggle", async () => { + it("should handle hide new feature indicators toggle", async () => { const user = userEvent.setup(); // Initially disabled From ec4639fc0ea63c56dfa5f748b5435c25d609b138 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:23:26 -0700 Subject: [PATCH 08/12] test(claude_code): rename misleading REPO_ROOT to SUITE_ROOT in test_v0_layout --- .../_builder_unit_tests/test_v0_layout.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/tests/e2e/claude_code/_builder_unit_tests/test_v0_layout.py b/tests/e2e/claude_code/_builder_unit_tests/test_v0_layout.py index b1745008fac..a3569ebdb49 100644 --- a/tests/e2e/claude_code/_builder_unit_tests/test_v0_layout.py +++ b/tests/e2e/claude_code/_builder_unit_tests/test_v0_layout.py @@ -16,8 +16,8 @@ from pathlib import Path import pytest import yaml -REPO_ROOT = Path(__file__).resolve().parents[1] -MANIFEST_PATH = REPO_ROOT / "manifest.yaml" +SUITE_ROOT = Path(__file__).resolve().parents[1] +MANIFEST_PATH = SUITE_ROOT / "manifest.yaml" # The PRD's "Features in v0" section, in row order. EXPECTED_FEATURE_IDS = [ @@ -90,14 +90,14 @@ def test_manifest_every_feature_has_human_readable_name(manifest): @pytest.mark.parametrize("feature_id", EXPECTED_FEATURE_IDS) def test_feature_directory_exists(feature_id): - feature_dir = REPO_ROOT / feature_id + feature_dir = SUITE_ROOT / feature_id assert feature_dir.is_dir(), f"missing feature directory: {feature_dir}" @pytest.mark.parametrize("feature_id", EXPECTED_FEATURE_IDS) @pytest.mark.parametrize("provider", EXPECTED_PROVIDERS) def test_per_provider_test_file_exists(feature_id, provider): - test_file = REPO_ROOT / feature_id / f"test_{provider}.py" + test_file = SUITE_ROOT / feature_id / f"test_{provider}.py" assert test_file.is_file(), f"missing per-provider test file: {test_file}" @@ -106,7 +106,7 @@ def test_feature_directory_has_init_file(feature_id): """Each feature directory needs an __init__.py so pytest collects the per-provider test files as a package — matches the layout established by `basic_messaging_non_streaming/`.""" - init_file = REPO_ROOT / feature_id / "__init__.py" + init_file = SUITE_ROOT / feature_id / "__init__.py" assert init_file.is_file(), f"missing __init__.py: {init_file}" @@ -117,7 +117,7 @@ def test_feature_directory_has_init_file(feature_id): # a broken post-v0 directory still fails CI. @pytest.mark.parametrize("feature_id", ALL_FEATURE_IDS) def test_every_manifest_feature_has_directory(feature_id): - feature_dir = REPO_ROOT / feature_id + feature_dir = SUITE_ROOT / feature_id assert feature_dir.is_dir(), ( f"manifest declares {feature_id!r} but {feature_dir} is missing — " "feature_id MUST match its on-disk directory (see manifest.yaml header)." @@ -126,7 +126,7 @@ def test_every_manifest_feature_has_directory(feature_id): @pytest.mark.parametrize("feature_id", ALL_FEATURE_IDS) def test_every_manifest_feature_has_init_file(feature_id): - init_file = REPO_ROOT / feature_id / "__init__.py" + init_file = SUITE_ROOT / feature_id / "__init__.py" assert init_file.is_file(), f"missing __init__.py: {init_file}" @@ -137,7 +137,7 @@ def test_every_manifest_feature_has_per_provider_test_file(feature_id, provider) backed by a per-provider test file. Without this check, a missing file silently becomes a `not_tested` cell in the published matrix rather than a CI failure surfacing the layout drift.""" - test_file = REPO_ROOT / feature_id / f"test_{provider}.py" + test_file = SUITE_ROOT / feature_id / f"test_{provider}.py" assert test_file.is_file(), f"missing per-provider test file: {test_file}" @@ -151,7 +151,7 @@ def test_per_provider_test_file_imports_and_parametrizes_three_models( use plain aliases or per-provider-suffixed aliases (e.g. `claude-opus-4-7-bedrock-invoke`), so we check for the tier substrings rather than exact alias names.""" - text = (REPO_ROOT / feature_id / f"test_{provider}.py").read_text() + text = (SUITE_ROOT / feature_id / f"test_{provider}.py").read_text() for tier in ("haiku-4-5", "sonnet-4-6", "opus-4-7"): assert ( tier in text @@ -171,7 +171,7 @@ def test_azure_test_file_drives_the_proxy(feature_id): that wraps them — both shapes drive the proxy, and we don't want this layout pin to block legitimate de-duplication of test bodies. """ - text = (REPO_ROOT / feature_id / "test_azure.py").read_text() + text = (SUITE_ROOT / feature_id / "test_azure.py").read_text() assert "run_claude" in text or "run_basic_messaging_cell" in text, ( f"{feature_id}/test_azure.py must drive the claude CLI via run_claude() " "or a shared helper that wraps it; the not_applicable stub was removed " From e4a6516b4916eaae239ba9b147ae71b28b7a0022 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Wed, 15 Jul 2026 16:27:15 -0700 Subject: [PATCH 09/12] fix(mcp): keep scope selection resource-driven, not authorization-server-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the over-correction that restricted a pinned-authorization_url server's discovered scopes to the authorization server's own scopes_supported. Per the MCP authorization spec Scope Selection Strategy and RFC 9700 §2.3, the scopes a client requests are resource-driven: the WWW-Authenticate 401 challenge scope, else the RFC 9728 protected-resource scopes_supported. The authorization server's RFC 8414 scopes_supported is a non-exhaustive capability list (the server MAY omit supported scopes) and is never the selection source; scope inflation by a compromised resource is bounded by the authorization server and user consent (RFC 6749 §3.3), not by the client restricting the request. The corroboration gate now rejects only the uncorroborated token_url/registration_url (the RFC 9700 endpoint mix-up) and leaves scopes untouched. Removes the now-unused authorization_server_scopes field. --- .../mcp_server/mcp_server_manager.py | 37 ++++----- .../types/mcp_server/mcp_server_manager.py | 15 ++-- .../mcp_server/test_mcp_server_manager.py | 77 +++++++++---------- 3 files changed, 60 insertions(+), 69 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index e011c8f8e88..8b1d00c2855 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -284,24 +284,26 @@ def _restrict_discovery_to_corroborated_authorization_server( server_identifier: str, is_dcr_bridge: bool, ) -> MCPOAuthMetadata | None: - """Bound what freshly discovered metadata may backfill into a manually pinned config. + """Reject discovered token/registration endpoints a manually pinned authorize endpoint cannot + vouch for (the RFC 9700 authorization-server mix-up). - Discovery is rooted at the MCP resource, so provenance is a property of the whole metadata - document, not per field: a compromised upstream can advertise both an attacker ``token_endpoint`` - (the RFC 9700 mix-up) and inflated ``scopes`` (tricking the user into granting a broader token - that then flows to the upstream). Both are closed by one rule. When ``authorization_url`` is - admin-pinned, the discovered ``token_url`` and ``registration_url`` are kept only if the document - corroborates the pin (its ``authorization_endpoint`` matches), and scopes are taken from the - authorization server's own ``scopes_supported`` (``authorization_server_scopes``, trusted tier) - rather than the resource-advertised ``scopes`` a compromised upstream controls. A document that - does not corroborate backfills nothing. With no pin there is no trust anchor to protect and the - authorize endpoint comes from the same chain as everything else, so discovery is returned as-is. + Discovery is rooted at the MCP resource, so a compromised upstream can advertise an attacker + ``token_endpoint``: with ``authorization_url`` admin-pinned but ``token_url`` blank, the merge + would pair the trusted authorize endpoint with that attacker token endpoint, and the gateway would + post the authorization code and client secret there. So the discovered ``token_url`` and + ``registration_url`` are kept only if the document corroborates the pin (its + ``authorization_endpoint`` matches). ``scopes`` are deliberately NOT gated here: per the MCP + authorization spec Scope Selection Strategy and RFC 9700 §2.3, the scopes a client requests are + resource-driven (the WWW-Authenticate challenge or the RFC 9728 protected-resource + ``scopes_supported``), and scope inflation by a compromised resource is bounded by the + authorization server and user consent (RFC 6749 §3.3), not by the client second-guessing the + request. With no pin there is no trust anchor to protect, so discovery is returned as-is. """ if metadata is None or not (manual_authorization_url and manual_authorization_url.strip()): return metadata if _endpoints_corroborate_authorization_url(metadata.authorization_url, manual_authorization_url): - return metadata.model_copy(update={"scopes": metadata.authorization_server_scopes}) - if not metadata.token_url and not metadata.registration_url and not metadata.scopes: + return metadata + if not metadata.token_url and not metadata.registration_url: return metadata bridge_note = ( " The discovered registration_url is rejected with it, so this dcr_bridge server stays on the" @@ -311,15 +313,15 @@ def _restrict_discovery_to_corroborated_authorization_server( ) verbose_logger.warning( "MCP OAuth discovery for server %s advertised authorization_endpoint %s, which does not match the " - "manually configured authorization_url %s; rejecting the discovered token_url/registration_url/scopes " - "so authorization codes, client credentials, and granted scopes only follow the configured " - "authorization server. Configure Token URL and Scopes manually if the mismatch is intentional.%s", + "manually configured authorization_url %s; rejecting the discovered token_url/registration_url so " + "authorization codes and client credentials only follow the configured authorization server. " + "Configure Token URL manually if the mismatch is intentional.%s", server_identifier, _normalized_authorize_endpoint(metadata.authorization_url) if metadata.authorization_url else "", _normalized_authorize_endpoint(manual_authorization_url), bridge_note, ) - return metadata.model_copy(update={"token_url": None, "registration_url": None, "scopes": None}) + return metadata.model_copy(update={"token_url": None, "registration_url": None}) def invalidate_user_env_vars_cache(user_id: str, server_id: str) -> None: @@ -3394,7 +3396,6 @@ class MCPServerManager: authorization_url=data.get("authorization_endpoint"), token_url=data.get("token_endpoint"), registration_url=data.get("registration_endpoint"), - authorization_server_scopes=scopes, ) if any( diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 79a523f7c8b..e5c726296b2 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -17,18 +17,15 @@ MCPInfo = Dict[str, Any] class MCPOAuthMetadata(BaseModel): scopes: Optional[List[str]] = None - """Effective scopes, resource-preferred: the RFC 9728 protected-resource advertisement or the - WWW-Authenticate challenge when the resource supplied one, else the authorization server's - ``scopes_supported``. A compromised resource server can influence this, so it must not expand a - manually pinned ``authorization_url`` (see ``authorization_server_scopes``).""" + """Resource-driven scopes for the authorization request: the RFC 9728 protected-resource + ``scopes_supported``, or the ``scope`` from the WWW-Authenticate 401 challenge when the resource + supplied one, else the authorization server's ``scopes_supported``. This is the scope value a + client requests per the MCP authorization spec Scope Selection Strategy; scope minimization and + inflation control are the authorization server's and user's job at consent (RFC 6749 §3.3), not + the client's.""" authorization_url: Optional[str] = None token_url: Optional[str] = None registration_url: Optional[str] = None - authorization_server_scopes: Optional[List[str]] = None - """The ``scopes_supported`` enumerated by the authorization-server metadata document itself - (RFC 8414), independent of anything the resource server advertised. This is the only scope - source trusted to backfill a manually pinned ``authorization_url``, because it shares provenance - with the ``authorization_endpoint`` used to corroborate that pin.""" from_origin_fallback: bool = False """True when the metadata came from guessing the resource origin as its authorization server rather than from an RFC 9728/8414-advertised document. Guessed endpoints are diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py index 989281c27e9..bba00ed1819 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server_manager.py @@ -357,18 +357,18 @@ class TestMCPServerManager: assert server.needs_user_oauth_token is True @pytest.mark.asyncio - async def test_load_servers_from_config_rejects_uncorroborated_discovery_including_scopes(self): - """The config loader always runs discovery and or-merges per field, so a yaml server with a - manual authorization_url has the same config-time mix-up exposure as a DB row: a document - advertising a different authorize endpoint backfills nothing, neither its token_url nor its - scopes.""" + async def test_load_servers_from_config_rejects_uncorroborated_endpoints_but_keeps_resource_scopes(self): + """A yaml server with a manual authorization_url has the same config-time mix-up exposure as a + DB row: a document advertising a different authorize endpoint has its token_url rejected. The + resource-driven scopes are kept, because scope selection is resource-driven (MCP Scope + Selection Strategy) and scope inflation is bounded by the authorization server at consent, not + by dropping scopes when an endpoint mismatches.""" manager = MCPServerManager() metadata = MCPOAuthMetadata( authorization_url="https://attacker.example.com/authorize", token_url="https://attacker.example.com/token", scopes=["read", "admin"], - authorization_server_scopes=["read", "admin"], ) config = self._oauth2_config( oauth2_flow="authorization_code", @@ -381,20 +381,20 @@ class TestMCPServerManager: server = next(iter(manager.config_mcp_servers.values())) assert server.authorization_url == "https://idp.example.com/authorize" assert server.token_url is None - assert server.scopes is None + assert server.scopes == ["read", "admin"] @pytest.mark.asyncio async def test_load_servers_from_config_fills_token_url_when_metadata_corroborates_manual_authorization_url(self): - """Corroborated metadata keeps the self-heal on the config path: when the discovered - document advertises the same authorize endpoint the admin pinned, its token_url fills the - blank field and scopes come from the authorization server's own scopes_supported.""" + """Corroborated metadata keeps the self-heal on the config path: when the discovered document + advertises the same authorize endpoint the admin pinned, its token_url fills the blank field + and scopes come through resource-driven (the discovered document's resource-preferred scopes), + not the authorization server's own capability list.""" manager = MCPServerManager() metadata = MCPOAuthMetadata( authorization_url="https://idp.example.com/authorize", token_url="https://idp.example.com/token", scopes=["read", "admin"], - authorization_server_scopes=["read"], ) config = self._oauth2_config( oauth2_flow="authorization_code", @@ -406,7 +406,7 @@ class TestMCPServerManager: server = next(iter(manager.config_mcp_servers.values())) assert server.token_url == "https://idp.example.com/token" - assert server.scopes == ["read"] + assert server.scopes == ["read", "admin"] @pytest.mark.asyncio @pytest.mark.parametrize("blank_authorization_url", ["", " "]) @@ -422,7 +422,6 @@ class TestMCPServerManager: authorization_url="https://idp.example.com/authorize", token_url="https://idp.example.com/token", scopes=["read"], - authorization_server_scopes=["read"], ) config = self._oauth2_config( oauth2_flow="authorization_code", @@ -1134,12 +1133,13 @@ class TestMCPServerManager: assert built.token_url == "https://idp.example.com/token" @pytest.mark.asyncio - async def test_build_from_table_backfills_scopes_from_authorization_server_not_resource(self): - """When authorization_url is admin-pinned, scopes backfill from the authorization server's - own scopes_supported (trusted tier), never from the resource-advertised scopes a compromised - upstream controls. Here the corroborating document carries an inflated resource `scopes` - (`admin`) alongside the real authorization_server_scopes; only the latter may be requested, - otherwise a hostile resource could trick the user into granting a broader token.""" + async def test_build_from_table_backfills_resource_driven_scopes_for_pinned_authorization_url(self): + """When authorization_url is admin-pinned and corroborated, scopes backfill as the + resource-driven value (the WWW-Authenticate challenge scope, else the RFC 9728 + protected-resource scopes_supported), per the MCP authorization spec Scope Selection Strategy. + The client does not restrict scopes to the authorization server's own scopes_supported; scope + minimization and inflation control are the authorization server's and user's job at consent + (RFC 6749 §3.3).""" manager = MCPServerManager() row = LiteLLM_MCPServerTable( server_id="manual-auth-url-1", @@ -1157,7 +1157,6 @@ class TestMCPServerManager: authorization_url="https://idp.example.com/authorize", token_url="https://idp.example.com/token", scopes=["read", "admin"], - authorization_server_scopes=["read", "write"], ) with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)) as mock_discovery: built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) @@ -1165,7 +1164,7 @@ class TestMCPServerManager: mock_discovery.assert_awaited_once() assert built.authorization_url == "https://idp.example.com/authorize" assert built.token_url == "https://idp.example.com/token" - assert built.scopes == ["read", "write"] + assert built.scopes == ["read", "admin"] @pytest.mark.asyncio async def test_build_from_table_whitespace_authorization_url_is_not_a_pin(self): @@ -1190,7 +1189,6 @@ class TestMCPServerManager: authorization_url="https://idp.example.com/authorize", token_url="https://idp.example.com/token", scopes=["read"], - authorization_server_scopes=["read"], ) with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)): built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) @@ -1223,7 +1221,6 @@ class TestMCPServerManager: token_url="https://idp.example.com/token", registration_url="https://idp.example.com/register", scopes=["read"], - authorization_server_scopes=["read"], ) with patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)): built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) @@ -1238,15 +1235,17 @@ class TestMCPServerManager: "advertised_authorization_url", ["https://attacker.example.com/authorize", None], ) - async def test_build_from_table_rejects_uncorroborated_discovery_including_scopes( + async def test_build_from_table_rejects_uncorroborated_endpoints_but_keeps_resource_scopes( self, advertised_authorization_url ): """Resource-rooted discovery lets a compromised upstream advertise its own authorization - server. With a manual authorization_url pinned, a document that does not corroborate it - backfills nothing: accepting its token_url would send the code, client secret, and PKCE - verifier to the attacker (config-time RFC 9700 mix-up), and accepting its scopes would let - the upstream inflate the granted token. Both the in-memory merge and the persisted metadata - must drop the uncorroborated token_url, registration_url, and scopes.""" + server. With a manual authorization_url pinned, a document that does not corroborate it has + its token_url and registration_url dropped: accepting them would send the code, client secret, + and PKCE verifier to the attacker (config-time RFC 9700 mix-up). The resource-driven scopes + are kept, because scope selection is resource-driven (MCP Scope Selection Strategy) and scope + inflation is bounded by the authorization server at consent (RFC 6749 §3.3), not by dropping + scopes on an endpoint mismatch. Both the in-memory merge and the persisted metadata drop only + the uncorroborated endpoints.""" manager = MCPServerManager() row = LiteLLM_MCPServerTable( server_id="manual-auth-url-3", @@ -1265,7 +1264,6 @@ class TestMCPServerManager: token_url="https://attacker.example.com/token", registration_url="https://attacker.example.com/register", scopes=["read", "admin"], - authorization_server_scopes=["read", "admin"], ) with ( patch.object(manager, "_descovery_metadata", new=AsyncMock(return_value=metadata)), @@ -1276,11 +1274,11 @@ class TestMCPServerManager: assert built.authorization_url == "https://idp.example.com/authorize" assert built.token_url is None assert built.registration_url is None - assert built.scopes is None + assert built.scopes == ["read", "admin"] persisted_metadata = mock_persist.await_args.kwargs["metadata"] assert persisted_metadata.token_url is None assert persisted_metadata.registration_url is None - assert persisted_metadata.scopes is None + assert persisted_metadata.scopes == ["read", "admin"] @pytest.mark.asyncio async def test_build_from_table_skips_discovery_when_all_upstream_oauth_fields_present(self): @@ -2384,11 +2382,11 @@ class TestMCPServerManager: assert result.from_origin_fallback is False @pytest.mark.asyncio - async def test_descovery_metadata_preserves_authorization_server_scopes_under_resource_override(self): - """The effective `scopes` field is resource-preferred (RFC 9728 / WWW-Authenticate), but the - authorization server's own scopes_supported must survive on `authorization_server_scopes` so - the pinned-config backfill can request the trusted-tier scopes instead of resource-advertised - ones. This is the provenance split the scope-inflation defense depends on.""" + async def test_descovery_metadata_scopes_are_resource_driven(self): + """The effective `scopes` are resource-driven: the RFC 9728 protected-resource advertisement + (or WWW-Authenticate challenge) overrides the authorization server's own scopes_supported. This + is the MCP Scope Selection Strategy: the client requests what the resource needs, not the AS's + full capability list.""" manager = MCPServerManager() mock_response = MagicMock() @@ -2400,7 +2398,6 @@ class TestMCPServerManager: scopes=["as.read", "as.write"], authorization_url="https://idp.example.com/authorize", token_url="https://idp.example.com/token", - authorization_server_scopes=["as.read", "as.write"], ) with ( @@ -2423,7 +2420,6 @@ class TestMCPServerManager: assert result is not None assert result.scopes == ["resource.only"] - assert result.authorization_server_scopes == ["as.read", "as.write"] @pytest.mark.asyncio async def test_fetch_single_authorization_server_metadata_supports_azure_issuer_path( @@ -2465,9 +2461,6 @@ class TestMCPServerManager: assert result.authorization_url == "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/authorize" assert result.token_url == "https://login.microsoftonline.com/test-tenant-id/oauth2/v2.0/token" assert result.scopes == ["api://some-scope/.default"] - # The authorization server's own scopes_supported is retained under a dedicated field so a - # later resource-scope override cannot erase the trusted-tier value used to backfill a pin. - assert result.authorization_server_scopes == ["api://some-scope/.default"] @pytest.mark.asyncio async def test_fetch_single_authorization_server_metadata_derives_azure_metadata( From 056f85a71eab22b470ec62df0778e12d805cdf65 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 15 Jul 2026 16:52:21 -0700 Subject: [PATCH 10/12] feat(ui): show exact license expiration date in usage cards --- .../src/components/SidebarUsageCard.test.tsx | 23 ++++++++++++++ .../src/components/SidebarUsageCard.tsx | 15 ++-------- .../src/components/UsageIndicator.test.tsx | 30 ++++++++++++++++++- .../src/components/UsageIndicator.tsx | 18 ++--------- .../src/utils/licenseUtils.test.ts | 30 ++++++++++++++++++- .../src/utils/licenseUtils.ts | 8 +++++ 6 files changed, 94 insertions(+), 30 deletions(-) diff --git a/ui/litellm-dashboard/src/components/SidebarUsageCard.test.tsx b/ui/litellm-dashboard/src/components/SidebarUsageCard.test.tsx index 3244906ed8e..71ed3094a20 100644 --- a/ui/litellm-dashboard/src/components/SidebarUsageCard.test.tsx +++ b/ui/litellm-dashboard/src/components/SidebarUsageCard.test.tsx @@ -128,6 +128,29 @@ describe("SidebarUsageCard", () => { expect(container.querySelector('[data-slot="meter"]')).toBeNull(); }); + it("shows the exact license expiration date as the subtitle instead of time remaining", async () => { + mockUseLicenseInfo.mockReturnValue(licenseResult({ ...ACTIVE_LICENSE, expiration_date: "2099-12-31" })); + + renderWithClient( {}} />); + + expect(await screen.findByText("Expires Dec 31, 2099")).toBeInTheDocument(); + expect(screen.queryByText(/(day|days|month|months) remaining/)).not.toBeInTheDocument(); + }); + + it("shows the exact date as the subtitle when the license is expired", async () => { + mockUseLicenseInfo.mockReturnValue(licenseResult({ ...ACTIVE_LICENSE, expiration_date: "2020-01-01" })); + + renderWithClient( {}} />); + + expect(await screen.findByText("Expired Jan 1, 2020")).toBeInTheDocument(); + }); + + it("falls back to Active plan when the license has no expiration date", async () => { + renderWithClient( {}} />); + + expect(await screen.findByText("Active plan")).toBeInTheDocument(); + }); + it("shows a collapsed rail button that expands the sidebar", async () => { const onExpandRail = vi.fn(); const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx index 4fb0e1dac21..2a6d6b43f38 100644 --- a/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx +++ b/ui/litellm-dashboard/src/components/SidebarUsageCard.tsx @@ -1,6 +1,6 @@ import { useDisableUsageIndicator } from "@/app/(dashboard)/hooks/useDisableUsageIndicator"; import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; -import { getDaysUntilExpiration } from "@/utils/licenseUtils"; +import { formatExpirationStatus } from "@/utils/licenseUtils"; import { Button } from "@/components/ui/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { Meter, MeterIndicator, MeterLabel, MeterTrack } from "@/components/ui/meter"; @@ -20,16 +20,6 @@ interface MeterData { total: number; } -const formatExpiration = (daysRemaining: number | null): string => { - if (daysRemaining === null) return "No expiration"; - if (daysRemaining < 0) return "Expired"; - if (daysRemaining === 0) return "Expires today"; - if (daysRemaining === 1) return "1 day remaining"; - if (daysRemaining < 30) return `${daysRemaining} days remaining`; - if (daysRemaining < 60) return "1 month remaining"; - return `${Math.floor(daysRemaining / 30)} months remaining`; -}; - const meterTone = (pct: number): "default" | "warning" | "over" => { if (pct > 100) return "over"; if (pct >= 80) return "warning"; @@ -104,8 +94,7 @@ export default function SidebarUsageCard({ accessToken, collapsed, onExpandRail ); } - const daysUntilExpiration = licenseInfo?.expiration_date ? getDaysUntilExpiration(licenseInfo.expiration_date) : null; - const subtitle = licenseInfo?.expiration_date ? formatExpiration(daysUntilExpiration) : "Active plan"; + const subtitle = licenseInfo?.expiration_date ? formatExpirationStatus(licenseInfo.expiration_date) : "Active plan"; const meters = buildMeters(data); return ( diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx index ad27fbcd74f..c587ebde57f 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx @@ -14,9 +14,19 @@ vi.mock("@/app/(dashboard)/hooks/useDisableUsageIndicator", () => ({ useDisableUsageIndicator: vi.fn(() => false), })); -import { getRemainingUsers } from "./networking"; +import { getLicenseInfo, getRemainingUsers } from "./networking"; +import type { LicenseInfo } from "./networking"; const mockGetRemainingUsers = vi.mocked(getRemainingUsers); +const mockGetLicenseInfo = vi.mocked(getLicenseInfo); + +const licenseWithExpiry = (expiration_date: string): LicenseInfo => ({ + has_license: true, + license_type: "enterprise", + expiration_date, + allowed_features: [], + limits: { max_users: null, max_teams: null }, +}); const renderWithClient = (ui: React.ReactElement) => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); @@ -36,6 +46,7 @@ describe("UsageIndicator", () => { beforeEach(() => { vi.clearAllMocks(); mockGetRemainingUsers.mockResolvedValue(DEFAULT_USAGE_DATA); + mockGetLicenseInfo.mockResolvedValue(null); }); it("should render when given access token and usage data loads", async () => { @@ -126,6 +137,23 @@ describe("UsageIndicator", () => { expect(screen.getByText("Over limit")).toBeInTheDocument(); }); + it("should show the exact license expiration date instead of time remaining", async () => { + mockGetLicenseInfo.mockResolvedValue(licenseWithExpiry("2099-12-31")); + + renderWithClient(); + + expect(await screen.findByText("Expires Dec 31, 2099")).toBeInTheDocument(); + expect(screen.queryByText(/(day|days|month|months) remaining/)).not.toBeInTheDocument(); + }); + + it("should show the exact date when the license is expired", async () => { + mockGetLicenseInfo.mockResolvedValue(licenseWithExpiry("2020-01-01")); + + renderWithClient(); + + expect(await screen.findByText("Expired Jan 1, 2020")).toBeInTheDocument(); + }); + it("should render nothing when accessToken is null", () => { renderWithClient(); diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.tsx index 6e7b5e9ec60..6ff4008c2cf 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.tsx @@ -15,7 +15,7 @@ import { useEffect, useState } from "react"; import { getRemainingUsers } from "./networking"; import { cn } from "@/lib/cva.config"; -import { getDaysUntilExpiration } from "@/utils/licenseUtils"; +import { formatExpirationStatus, getDaysUntilExpiration } from "@/utils/licenseUtils"; import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; interface UsageIndicatorProps { @@ -32,18 +32,6 @@ interface UsageData { total_teams_remaining: number | null; } -// Format expiration for display -const formatExpirationDisplay = (daysRemaining: number | null): string => { - if (daysRemaining === null) return "No expiration"; - if (daysRemaining < 0) return "Expired"; - if (daysRemaining === 0) return "Expires today"; - if (daysRemaining === 1) return "1 day remaining"; - if (daysRemaining < 30) return `${daysRemaining} days remaining`; - if (daysRemaining < 60) return "1 month remaining"; - const months = Math.floor(daysRemaining / 30); - return `${months} months remaining`; -}; - export default function UsageIndicator({ accessToken, width = 220 }: UsageIndicatorProps) { const disableUsageIndicator = useDisableUsageIndicator(); const [isExpanded, setIsExpanded] = useState(false); @@ -292,7 +280,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica ) : isLicenseExpiringSoon ? ( ) : null} - {formatExpirationDisplay(daysUntilExpiration)} + {formatExpirationStatus(licenseInfo.expiration_date)} )} @@ -529,7 +517,7 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica isLicenseExpiringSoon && "text-yellow-600", )} > - {formatExpirationDisplay(daysUntilExpiration)} + {formatExpirationStatus(licenseInfo.expiration_date)} {licenseInfo.license_type && ( diff --git a/ui/litellm-dashboard/src/utils/licenseUtils.test.ts b/ui/litellm-dashboard/src/utils/licenseUtils.test.ts index 717b8f0d90d..8a489c30c2d 100644 --- a/ui/litellm-dashboard/src/utils/licenseUtils.test.ts +++ b/ui/litellm-dashboard/src/utils/licenseUtils.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect } from "vitest"; -import { type LicenseExpiryTier, formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "./licenseUtils"; +import { + type LicenseExpiryTier, + formatExpirationStatus, + formatExpiryDate, + getDaysUntilExpiration, + getLicenseExpiryTier, +} from "./licenseUtils"; const NOW = new Date("2026-07-08T00:00:00Z"); @@ -59,3 +65,25 @@ describe("formatExpiryDate", () => { expect(formatExpiryDate("bogus")).toBe("bogus"); }); }); + +describe("formatExpirationStatus", () => { + it("shows the exact date for a future expiration", () => { + expect(formatExpirationStatus("2026-08-07", NOW)).toBe("Expires Aug 7, 2026"); + }); + + it("still reads as upcoming on the expiration day itself", () => { + expect(formatExpirationStatus("2026-07-08", NOW)).toBe("Expires Jul 8, 2026"); + }); + + it("shows the exact date for a past expiration", () => { + expect(formatExpirationStatus("2026-07-07", NOW)).toBe("Expired Jul 7, 2026"); + }); + + it("returns No expiration for a null date", () => { + expect(formatExpirationStatus(null, NOW)).toBe("No expiration"); + }); + + it("returns No expiration for an unparseable date", () => { + expect(formatExpirationStatus("not-a-date", NOW)).toBe("No expiration"); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/licenseUtils.ts b/ui/litellm-dashboard/src/utils/licenseUtils.ts index 57acad85508..4ba39dabe0b 100644 --- a/ui/litellm-dashboard/src/utils/licenseUtils.ts +++ b/ui/litellm-dashboard/src/utils/licenseUtils.ts @@ -49,3 +49,11 @@ export const formatExpiryDate = (expirationDate: string): string => { } return date.toLocaleDateString("en-US", EXPIRY_DATE_FORMAT); }; + +export const formatExpirationStatus = (expirationDate: string | null, now: Date = new Date()): string => { + const days = getDaysUntilExpiration(expirationDate, now); + if (expirationDate === null || days === null) { + return "No expiration"; + } + return days < 0 ? `Expired ${formatExpiryDate(expirationDate)}` : `Expires ${formatExpiryDate(expirationDate)}`; +}; From edc38eab348d33d01a8eeb2347e985494c69c5eb Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 15 Jul 2026 17:23:17 -0700 Subject: [PATCH 11/12] build(deps): update ddtrace to the 4.x line A single ddtrace constraint now covers every supported Python version, so this collapses the version split introduced in #33438. Also aligns the build_from_pip image pin and updates the type-only Tracer import to its current module path --- .../build_from_pip/Dockerfile.build_from_pip | 2 +- litellm/litellm_core_utils/dd_tracing.py | 2 +- pyproject.toml | 3 +- uv.lock | 129 +++--------------- 4 files changed, 25 insertions(+), 111 deletions(-) diff --git a/docker/build_from_pip/Dockerfile.build_from_pip b/docker/build_from_pip/Dockerfile.build_from_pip index bda742c71a9..372606a5b0f 100644 --- a/docker/build_from_pip/Dockerfile.build_from_pip +++ b/docker/build_from_pip/Dockerfile.build_from_pip @@ -36,7 +36,7 @@ RUN uv venv --python python && \ "opentelemetry-api==1.28.0" \ "opentelemetry-sdk==1.28.0" \ "opentelemetry-exporter-otlp==1.28.0" \ - "ddtrace==2.19.0" \ + "ddtrace==4.11.0" \ "sentry-sdk==2.21.0" \ "mangum==0.17.0" \ "azure-ai-contentsafety==1.0.0" \ diff --git a/litellm/litellm_core_utils/dd_tracing.py b/litellm/litellm_core_utils/dd_tracing.py index ae4f46c38bd..3a1bd72e1a5 100644 --- a/litellm/litellm_core_utils/dd_tracing.py +++ b/litellm/litellm_core_utils/dd_tracing.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Optional, Union from litellm.secret_managers.main import get_secret_bool if TYPE_CHECKING: - from ddtrace.tracer import Tracer as DD_TRACER + from ddtrace.trace import Tracer as DD_TRACER else: DD_TRACER = Any diff --git a/pyproject.toml b/pyproject.toml index 8d88344a3f7..d70e99c5775 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -129,8 +129,7 @@ proxy-runtime = [ "opentelemetry-sdk==1.28.0", "opentelemetry-exporter-otlp==1.28.0", "opentelemetry-instrumentation-fastapi==0.49b0", - "ddtrace>=2.19.0,<3.0; python_version < '3.14'", - "ddtrace>=4.0.0,<5.0; python_version >= '3.14'", + "ddtrace>=4.8.2,<5.0", "sentry-sdk>=2.21.0,<3.0", "mangum>=0.17.0,<1.0", "azure-ai-contentsafety>=1.0.0,<2.0", diff --git a/uv.lock b/uv.lock index 80ab2f42f67..2439d1488cd 100644 --- a/uv.lock +++ b/uv.lock @@ -10,7 +10,7 @@ resolution-markers = [ ] [options] -exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values. +exclude-newer = "2026-07-13T00:19:39.570486Z" exclude-newer-span = "P3D" [manifest] @@ -222,9 +222,9 @@ name = "aiologic" version = "0.17.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio", marker = "python_full_version < '3.14'" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, - { name = "wrapt", marker = "python_full_version < '3.14'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/53/a7/809482759f40079f4c4328c7318bf569ae25d457f5017aad30a1b9aafedc/aiologic-0.17.0.tar.gz", hash = "sha256:65aa058e858c94cd208badb188e7f00b54dcabb3ba85b34f794db98074d108b9", size = 251625, upload-time = "2026-06-14T12:24:35.367Z" } wheels = [ @@ -1047,7 +1047,7 @@ name = "coloredlogs" version = "15.0.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "humanfriendly" }, + { name = "humanfriendly", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" } wheels = [ @@ -1420,7 +1420,7 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic", marker = "python_full_version < '3.14'" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } @@ -1464,80 +1464,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" }, ] -[[package]] -name = "ddtrace" -version = "2.19.0" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version == '3.13.*'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version < '3.11'", -] -dependencies = [ - { name = "bytecode", marker = "python_full_version < '3.14'" }, - { name = "envier", marker = "python_full_version < '3.14'" }, - { name = "legacy-cgi", marker = "python_full_version == '3.13.*'" }, - { name = "opentelemetry-api", marker = "python_full_version < '3.14'" }, - { name = "protobuf", marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.14'" }, - { name = "wrapt", marker = "python_full_version < '3.14'" }, - { name = "xmltodict", marker = "python_full_version < '3.14'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c3/06/417a8a9a8c89dc2fdb94c3acdb3f6f9da835e109c2a217fb5863d0d97df9/ddtrace-2.19.0.tar.gz", hash = "sha256:90d217b1906074881afd3e656a3cd1a630dd798bd25077254588c382a4075345", size = 8708460, upload-time = "2025-01-16T17:19:46.303Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/be/b3fc069ff2a20cc1d053b030268ba6999926232ce2195b4958486a9035ea/ddtrace-2.19.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:50d79ff042868b4d1d80b424285d755d9e0d466119399c2166a3894b178b85fd", size = 4412309, upload-time = "2025-01-16T17:16:10.884Z" }, - { url = "https://files.pythonhosted.org/packages/14/69/2d42669829c09eefbf4cbabb94dfe7615ee4610019ded28c9634411a574c/ddtrace-2.19.0-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:e17b3b8e1cadf23ed8e4466679a0cb1262aa00190bddcd0fc0f5f6f9a9c25480", size = 3051126, upload-time = "2025-01-16T17:16:15.377Z" }, - { url = "https://files.pythonhosted.org/packages/57/e0/82d3b5d474ea66e777c38e584053ee7f6ac923218642fdf4967857f48daa/ddtrace-2.19.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7804977b388fed1b1cbb0ef138100be923bf7afbe7d25357fcee07315a66cc8b", size = 6087687, upload-time = "2025-01-16T17:16:17.314Z" }, - { url = "https://files.pythonhosted.org/packages/54/ba/051ea8720695a8c0ecf7a5d9dcbc7000da18b2cd4efe27af69c09999832d/ddtrace-2.19.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c96ae2f074e422202f2b98a021b9fb864fc08bd5864eff27b0ec9da5919c0b1e", size = 2852443, upload-time = "2025-01-16T17:16:20.242Z" }, - { url = "https://files.pythonhosted.org/packages/d8/94/38f7706bbc1b3c010aab457a94edc07ac7145ebfd8ab01797634b60746cb/ddtrace-2.19.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a14662366c5c1d8898c057ef6820de85d71b3ada6fd89638c5ec4ba9d45c21b7", size = 6420509, upload-time = "2025-01-16T17:16:22.521Z" }, - { url = "https://files.pythonhosted.org/packages/37/f8/b900ffbdf85a06220ca04905caa066dfc1f60643c3d501e92fee08d32951/ddtrace-2.19.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1638fff37abf61d16f3dbef009c45d4c33b962324b10f642fb7966d0055c28e9", size = 7073719, upload-time = "2025-01-16T17:16:24.518Z" }, - { url = "https://files.pythonhosted.org/packages/e9/a4/12ffed1870c6ecc638283163d11cb675c2840a46dbd741acb2568bf94a6a/ddtrace-2.19.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3f72627f1887d628b025d227a642e5ae30884eedd6f6ef1afee02461bc19c95f", size = 3918050, upload-time = "2025-01-16T17:16:27.279Z" }, - { url = "https://files.pythonhosted.org/packages/29/35/d4c6a99df2a7ea6219c9b6390ad66ae88f63c86bc7ef84c2a3784f5b5798/ddtrace-2.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d5b8ac83af10e567564d1f016d72549a8b83e06f4c6a3440f7f610ede0b51954", size = 7463892, upload-time = "2025-01-16T17:16:29.299Z" }, - { url = "https://files.pythonhosted.org/packages/49/7e/881b58c69d7e2316ccc99e8c3b1d4b4d382d5c704a9a0aebc571353b1413/ddtrace-2.19.0-cp310-cp310-win32.whl", hash = "sha256:17971717ad481c2273336957a8c2f328f2e7776f2065821c00332f33cdaa2053", size = 3120778, upload-time = "2025-01-16T17:16:31.278Z" }, - { url = "https://files.pythonhosted.org/packages/27/39/d5d92f7d0f6d3f98c708c514498562965bd8bfbea8234d1cf3a2ab9f245e/ddtrace-2.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:41629eaa0e16367a45e5fe64b0bd969dd31eb2067e0224e48f149ea976ed5848", size = 3348128, upload-time = "2025-01-16T17:16:33.199Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ec/ac70516f825aba5a5bea78cea568fef6a6c34b80c621bea70d3f9128d3f2/ddtrace-2.19.0-cp311-cp311-macosx_12_0_universal2.whl", hash = "sha256:e58123e8bce549aa159cc3748248987dd2ac63ba4c69c7f0b0d49c2d2c05d20a", size = 4414054, upload-time = "2025-01-16T17:16:36.181Z" }, - { url = "https://files.pythonhosted.org/packages/e7/73/4f0cb04aef8450f23fbe6fc0ba66868bc9e415830fecbe88b65c658866e0/ddtrace-2.19.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:1cc7b2b7e9396c17b0356f550b278d1adc5112a0da57e9052169a98b75cbdb66", size = 3052135, upload-time = "2025-01-16T17:16:38.102Z" }, - { url = "https://files.pythonhosted.org/packages/f7/20/0e8d2ef1b1d2c7b4f55b4d2e978bb143fafac36cdc456e8e521b9559c484/ddtrace-2.19.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb3a4941c7604f0ee56713c207c1baf8984cb25e0acd9872512d2cd1cd9ef40e", size = 6093484, upload-time = "2025-01-16T17:16:41.192Z" }, - { url = "https://files.pythonhosted.org/packages/28/f8/af03509c93d91fc35b71c89b02e86635ef2c0d5c56379048c93b4b1d338b/ddtrace-2.19.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f906e6b05a66c85c7049076c69186b6208a9868086cb114e5cd784e5705d11ed", size = 2858353, upload-time = "2025-01-16T17:16:43.276Z" }, - { url = "https://files.pythonhosted.org/packages/dc/de/9062ccdd6b0bc00b15dc58bd7bb7ad1e27ab0c78cb4c9ad7218f7dd58106/ddtrace-2.19.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:544a41bba75c547c52595cec535d55ed3a8b109068121682ebe04e48a3af73d9", size = 6426319, upload-time = "2025-01-16T17:16:46.095Z" }, - { url = "https://files.pythonhosted.org/packages/c6/bc/2c8b9afa39c5b8370cb8587a8325bcf01ff6c5d87b07690ae764c3e02a9f/ddtrace-2.19.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4c9c4dc015e285368ac29ca263d41ff9480b1df42c5599860c139007a11dd54", size = 7076423, upload-time = "2025-01-16T17:16:49.439Z" }, - { url = "https://files.pythonhosted.org/packages/18/9c/caa119adf66d4a6b0e7f7d0de8ed6ecfb18ff2249eb55aeed03f412233ce/ddtrace-2.19.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7965330c03a4793d8bc71c702d49832b9900cf0e3b5f36e9d4c9037285f5fc73", size = 3919949, upload-time = "2025-01-16T17:16:51.98Z" }, - { url = "https://files.pythonhosted.org/packages/66/53/0d6b96db5c9ee6fdaf010adc968c7dadadbc5031d05e382dbb3088a20ea7/ddtrace-2.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b203be50ca19182120063a34ccffc34e3016555c8ceb5b1439ae61d7ef88ad0", size = 7470201, upload-time = "2025-01-16T17:16:54.498Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d4/81b8df76e10dbcd85ad1f0bb17075d6334c1abcb4136ad9a03835880fabb/ddtrace-2.19.0-cp311-cp311-win32.whl", hash = "sha256:bed9aa688e7f0185f96407fe9bd20192e767aa812fdaac5552bf3edc4fa5182c", size = 3120927, upload-time = "2025-01-16T17:16:57.998Z" }, - { url = "https://files.pythonhosted.org/packages/60/73/3ea8f4ddcf3b451ca2523767262fd8d9df76aa1e0403932c5cf49ff73eab/ddtrace-2.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:a40e64a96dbdb5b2124b54051c2de371b895175b62a97273b7f527be9721d8c2", size = 3352777, upload-time = "2025-01-16T17:16:59.998Z" }, - { url = "https://files.pythonhosted.org/packages/62/64/8c696adb83f2a1a5310d8f64094d8d76417928c136f1b2fc55bb912977ad/ddtrace-2.19.0-cp312-cp312-macosx_12_0_universal2.whl", hash = "sha256:8f5e6e0086717cc7c8fd1ad3da2ee7d5cb30ba3eb0d75ee79b070b310443d884", size = 4852896, upload-time = "2025-01-16T17:17:02.584Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b9/2cd4347db133128429f60044e40600c9016a98e147b110d7020e8767ee60/ddtrace-2.19.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:aa38304a6b5c937154acd33dafdc8d1cbdc4c4879e135578515dd9be44241b2c", size = 3280741, upload-time = "2025-01-16T17:17:04.77Z" }, - { url = "https://files.pythonhosted.org/packages/85/a2/a94bd0e39657b45008cce9c33931f824f27a3db2da655b0b599c44d51617/ddtrace-2.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e77b42bd5a269f2bc1ad0ba8141987634c288ee96366ea9505aec8871ee5662f", size = 6062585, upload-time = "2025-01-16T17:17:07.095Z" }, - { url = "https://files.pythonhosted.org/packages/66/07/f655ede9fbf1c7de2a0a271687d0a31c39e4afc46102b1e73eac342298d8/ddtrace-2.19.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:56816cdda82b18e8e99ee60d0f70a5cdbd57fb54bdc9038ba01d779139db1fcc", size = 2827198, upload-time = "2025-01-16T17:17:11.978Z" }, - { url = "https://files.pythonhosted.org/packages/0f/9d/a193623a7d9a5226cd63ddbdc42250ef3e6d4b37bc77725ff06b2a9838c4/ddtrace-2.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fd3237342fa7753c47161904bbb3bb691625a99a4b396069fd1db927d20a74c", size = 6398024, upload-time = "2025-01-16T17:17:14.303Z" }, - { url = "https://files.pythonhosted.org/packages/56/76/43c132d259d1fd710a5ece3abac4e6d7789626ce1ae66536ed0e22fc5361/ddtrace-2.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:713beebab7398310f0753e33234cb70b91b3eb387525a7cae5897d19471917f4", size = 7041348, upload-time = "2025-01-16T17:17:16.996Z" }, - { url = "https://files.pythonhosted.org/packages/0a/9e/f59030213600c58f87b4d5d814ded8b9453cbbfdc7c3a02a313f07c62db1/ddtrace-2.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:adaf1ef268c5bb3599f3a1e34b1089d77b6e323cd1ec31da482f794f64213aa6", size = 3886975, upload-time = "2025-01-16T17:17:20.77Z" }, - { url = "https://files.pythonhosted.org/packages/84/c6/626560e37f0024572456d7cc2cafb0ab61da22deb2f9b218231d43053325/ddtrace-2.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0c38952a4f4d1ed61d53bdc55acb9d931c355e1e5a90001b95576e99dcacfc11", size = 7434753, upload-time = "2025-01-16T17:17:24.304Z" }, - { url = "https://files.pythonhosted.org/packages/1f/2a/3c181fc7f2021ec05e95586ca8fa8236f1429adfabb6152bb950b79247a4/ddtrace-2.19.0-cp312-cp312-win32.whl", hash = "sha256:045773c382aada18feeb5584fdba9aa47ff660ac93a94b43b24434760c77802a", size = 3108737, upload-time = "2025-01-16T17:17:27.696Z" }, - { url = "https://files.pythonhosted.org/packages/cd/81/b000c6919d9cc204fead0069b3523d6a65d0da21a45a686a872b4201013e/ddtrace-2.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:e96980c8e81831c7cb367b1ab066ba4cfaf389be1099c0f15985484a8de6d80d", size = 3343024, upload-time = "2025-01-16T17:17:29.98Z" }, - { url = "https://files.pythonhosted.org/packages/d9/55/32f7142cc96410a534868eb553ef9d238cf44d2cb10c2107cf880d9d42b9/ddtrace-2.19.0-cp313-cp313-macosx_12_0_universal2.whl", hash = "sha256:c3cccef7e15a561ad5e5699ca2f6045d7d1e655c487fd2d617b9541152c3f217", size = 4832649, upload-time = "2025-01-16T17:17:32.785Z" }, - { url = "https://files.pythonhosted.org/packages/1a/60/5d1e99cfa6bc29d13eae55fbe6b395138ce17b96d6e95c9c7b57c071d410/ddtrace-2.19.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:a13acabcf0fad276e55e9d35ade74aae91b9629367e06692788ee5ed484491d6", size = 3269614, upload-time = "2025-01-16T17:17:35.273Z" }, - { url = "https://files.pythonhosted.org/packages/5d/75/b3b00c1325d64ab1445a7965554b7a311842ae26f6995a0f64348c597848/ddtrace-2.19.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:724f3954e16bf66f0f45479ba3fffb4fa39fe4e43667c3085d9090b17ea9242d", size = 6016732, upload-time = "2025-01-16T17:17:37.783Z" }, - { url = "https://files.pythonhosted.org/packages/84/a7/ec1fa6f8ad7254f9baed33c37cb88abf0f324e2740792f8e5f24bd1fbfea/ddtrace-2.19.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47bb8980fb8d711d96f66d8727196476bbdafe2cccc501c93b5352c5797a4422", size = 2815983, upload-time = "2025-01-16T17:17:40.305Z" }, - { url = "https://files.pythonhosted.org/packages/4a/1a/2b8102e738bc4ed335dd3a5bfeba21b554b73abc9ea8d1c47cb7f3ccfbe0/ddtrace-2.19.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:661341280a69d8ceb91e48f67cfa33adb3af901b09d329b8375b1a3ba04a68b7", size = 6351654, upload-time = "2025-01-16T17:17:44.094Z" }, - { url = "https://files.pythonhosted.org/packages/0e/30/56095f289ae7689cb789f2c85e0e227b02bd8de548c25ab0c952cc823051/ddtrace-2.19.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:facae23052586b171c47faecb622a0c8a15beeab0fb3af4d53367a749b1cbded", size = 6996885, upload-time = "2025-01-16T17:17:47.304Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/c315500dbde69a4193665c964dd56e9be523b7e05979718140ad2c9a6821/ddtrace-2.19.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4cb13fdb6587ff1c460b7e673d83979280efa0a5a5a4104fc92ebcaf0c6ca36e", size = 3881860, upload-time = "2025-01-16T17:17:52.081Z" }, - { url = "https://files.pythonhosted.org/packages/26/8c/e1a7043e562b5b29fb5d0930630a18078fecb1c30ca6776221ce0dab6f95/ddtrace-2.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d36a16e8746cb38a143faa6e1cd10927bf4a482c29f4010afde4bd0f4bb89db4", size = 7390107, upload-time = "2025-01-16T17:17:54.826Z" }, -] - [[package]] name = "ddtrace" version = "4.11.0" source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version >= '3.14'", -] dependencies = [ - { name = "bytecode", marker = "python_full_version >= '3.14'" }, - { name = "envier", marker = "python_full_version >= '3.14'" }, - { name = "opentelemetry-api", marker = "python_full_version >= '3.14'" }, - { name = "wrapt", marker = "python_full_version >= '3.14'" }, + { name = "bytecode" }, + { name = "envier" }, + { name = "opentelemetry-api" }, + { name = "wrapt" }, ] sdist = { url = "https://files.pythonhosted.org/packages/81/51/a628f0177274bab5b67c93a1558fd222babb15286a427e4e8c1d65a265b2/ddtrace-4.11.0.tar.gz", hash = "sha256:260c5b46e80565f4fd08cec2650f707627fb57cd6f8951a2d8c9e6a02b490074", size = 2422158, upload-time = "2026-07-10T08:57:53.396Z" } wheels = [ @@ -3031,7 +2966,7 @@ name = "humanfriendly" version = "10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pyreadline3", marker = "sys_platform == 'win32'" }, + { name = "pyreadline3", marker = "python_full_version < '3.14' and sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" } wheels = [ @@ -3795,15 +3730,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" }, ] -[[package]] -name = "legacy-cgi" -version = "2.6.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/9c/91c7d2c5ebbdf0a1a510bfa0ddeaa2fbb5b78677df5ac0a0aa51cf7125b0/legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577", size = 24603, upload-time = "2025-10-27T05:20:05.395Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" }, -] - [[package]] name = "litellm" version = "1.94.0" @@ -3887,8 +3813,7 @@ proxy-runtime = [ { name = "anthropic", extra = ["vertex"] }, { name = "azure-ai-contentsafety" }, { name = "azure-storage-file-datalake" }, - { name = "ddtrace", version = "2.19.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" }, - { name = "ddtrace", version = "4.11.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" }, + { name = "ddtrace" }, { name = "detect-secrets" }, { name = "google-cloud-aiplatform" }, { name = "google-genai" }, @@ -4023,8 +3948,7 @@ requires-dist = [ { name = "boto3", marker = "extra == 'proxy'", specifier = ">=1.43.1,<2.0" }, { name = "click", specifier = ">=8.0.0,<9.0" }, { name = "cryptography", marker = "extra == 'proxy'", specifier = ">=48.0.1,<49.0" }, - { name = "ddtrace", marker = "python_full_version >= '3.14' and extra == 'proxy-runtime'", specifier = ">=4.0.0,<5.0" }, - { name = "ddtrace", marker = "python_full_version < '3.14' and extra == 'proxy-runtime'", specifier = ">=2.19.0,<3.0" }, + { name = "ddtrace", marker = "extra == 'proxy-runtime'", specifier = ">=4.8.2,<5.0" }, { name = "detect-secrets", marker = "extra == 'proxy-runtime'", specifier = ">=1.5.0,<2.0" }, { name = "diskcache", marker = "extra == 'caching'", specifier = ">=5.6.3,<6.0" }, { name = "expression", marker = "extra == 'proxy'", specifier = ">=5.6.0,<6.0" }, @@ -4540,7 +4464,7 @@ version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/fd/15/76f86faa0902836cc133939732f7611ace68cf54148487a99c539c272dc8/ml_dtypes-0.4.1.tar.gz", hash = "sha256:fad5f2de464fd09127e49b7fd1252b9006fb43d2edc1ff112d390c324af5ca7a", size = 692594, upload-time = "2024-09-13T19:07:11.624Z" } wheels = [ @@ -7201,16 +7125,16 @@ name = "redisvl" version = "0.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "coloredlogs" }, - { name = "ml-dtypes" }, + { name = "coloredlogs", marker = "python_full_version < '3.14'" }, + { name = "ml-dtypes", marker = "python_full_version < '3.14'" }, { name = "numpy", version = "1.26.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, - { name = "pydantic" }, - { name = "python-ulid" }, - { name = "pyyaml" }, - { name = "redis" }, - { name = "tabulate" }, - { name = "tenacity" }, + { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12' and python_full_version < '3.14'" }, + { name = "pydantic", marker = "python_full_version < '3.14'" }, + { name = "python-ulid", marker = "python_full_version < '3.14'" }, + { name = "pyyaml", marker = "python_full_version < '3.14'" }, + { name = "redis", marker = "python_full_version < '3.14'" }, + { name = "tabulate", marker = "python_full_version < '3.14'" }, + { name = "tenacity", marker = "python_full_version < '3.14'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/21/33/ab14865a0b2a31b1d003c29e7e8ea3a7a2f2c8ecb24e58e58d606e1f031b/redisvl-0.4.1.tar.gz", hash = "sha256:fd6a36426ba94792c0efca20915c31232d4ee3cc58eb23794a62c142696401e6", size = 77688, upload-time = "2025-02-21T22:51:41.389Z" } wheels = [ @@ -9223,15 +9147,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405, upload-time = "2025-11-20T18:18:00.454Z" }, ] -[[package]] -name = "xmltodict" -version = "1.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/70/80f3b7c10d2630aa66414bf23d210386700aa390547278c789afa994fd7e/xmltodict-1.0.4.tar.gz", hash = "sha256:6d94c9f834dd9e44514162799d344d815a3a4faec913717a9ecbfa5be1bb8e61", size = 26124, upload-time = "2026-02-22T02:21:22.074Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/38/34/98a2f52245f4d47be93b580dae5f9861ef58977d73a79eb47c58f1ad1f3a/xmltodict-1.0.4-py3-none-any.whl", hash = "sha256:a4a00d300b0e1c59fc2bfccb53d7b2e88c32f200df138a0dd2229f842497026a", size = 13580, upload-time = "2026-02-22T02:21:21.039Z" }, -] - [[package]] name = "xxhash" version = "3.7.0" From fac43df9b95376cd7159e105415d3b0316bbbf07 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:46:00 -0700 Subject: [PATCH 12/12] fix(complexity_router): return empty dict from _classifier_call_metadata when metadata is absent (#33452) * fix(complexity_router): return empty dict from _classifier_call_metadata when metadata is absent The LLM classifier reads request_kwargs.get("litellm_metadata"), but the proxy stores request metadata under "metadata", so this returned None. _classifier_call_metadata then passed None straight through to the classifier acompletion call, which assumes a dict and blows up with 'NoneType' object has no attribute 'update'; the router swallowed it and silently fell back to heuristic scoring, so the configured LLM classifier never ran. Returning an empty dict keeps the classifier call well-formed. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(e2e): cover complexity-router LLM classifier routes over the proxy Add a live e2e regression for the complexity auto-router: a lexically simple but hard prompt ("Is P equal to NP?") is routed by the LLM classifier to the higher-tier anthropic backend, read back from the spend log's model. Before the metadata fix the classifier silently crashed and the router fell back to heuristic SIMPLE scoring on the openai backend, so this test fails pre-fix and passes post-fix. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Krrish Dholakia Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../complexity_router/complexity_router.py | 8 +-- tests/e2e/coverage_registry/reliability.yaml | 1 + tests/e2e/docker-compose.yml | 17 +++++ tests/e2e/router/complexity_router_client.py | 20 ++++++ tests/e2e/router/conftest.py | 15 +++++ .../e2e/router/test_complexity_router_e2e.py | 62 +++++++++++++++++++ .../router_strategy/test_complexity_router.py | 10 +++ 7 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/router/complexity_router_client.py create mode 100644 tests/e2e/router/conftest.py create mode 100644 tests/e2e/router/test_complexity_router_e2e.py diff --git a/litellm/router_strategy/complexity_router/complexity_router.py b/litellm/router_strategy/complexity_router/complexity_router.py index eb0f74a58e7..e85987870e1 100644 --- a/litellm/router_strategy/complexity_router/complexity_router.py +++ b/litellm/router_strategy/complexity_router/complexity_router.py @@ -98,9 +98,9 @@ def _sanitize_user_api_key_auth(auth: Any) -> Any: return auth -def _classifier_call_metadata(metadata: dict[str, Any] | None) -> dict[str, Any] | None: +def _classifier_call_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]: if not metadata: - return metadata + return {} return { k: _sanitize_user_api_key_auth(v) if k == "user_api_key_auth" else v for k, v in metadata.items() @@ -763,8 +763,8 @@ class ComplexityRouter(CustomLogger): # embedding call. Forwarding it would let the embedding's cost callback finalize the # reservation, so the routed completion's own callback then skips incrementing the # key/team budget. Key/team attribution fields are preserved for spend logging. - metadata = _classifier_call_metadata(request_kwargs.get("metadata")) or {} - litellm_metadata = _classifier_call_metadata(request_kwargs.get("litellm_metadata")) or {} + metadata = _classifier_call_metadata(request_kwargs.get("metadata")) + litellm_metadata = _classifier_call_metadata(request_kwargs.get("litellm_metadata")) query_vector = ( await encoder.aencode_queries([user_message], metadata=metadata, litellm_metadata=litellm_metadata) )[0] diff --git a/tests/e2e/coverage_registry/reliability.yaml b/tests/e2e/coverage_registry/reliability.yaml index 3746b029331..ba192c2912e 100644 --- a/tests/e2e/coverage_registry/reliability.yaml +++ b/tests/e2e/coverage_registry/reliability.yaml @@ -17,6 +17,7 @@ - {id: reliability.routing.cost_based.picks_lowest_cost, module: reliability, tier: P1, behavior: routing, variant: cost_based, assertions: [picks_lowest_cost], exercised_on: [chat_completions, messages], source: "router_strategy/lowest_cost.py", rationale: "Spend-aware routing"} - {id: reliability.routing.usage_based.picks_under_tpm, module: reliability, tier: P0, behavior: routing, variant: usage_based, assertions: [picks_under_tpm], exercised_on: [chat_completions, messages], source: "router_strategy/lowest_tpm_rpm_v2.py", rationale: "Routes to lowest-TPM deployment; prevents over-allocation"} - {id: reliability.routing.least_busy.picks_lowest_traffic, module: reliability, tier: P1, behavior: routing, variant: least_busy, assertions: [picks_lowest_traffic], exercised_on: [chat_completions, messages], source: "router_strategy/least_busy.py", rationale: "Fewest in-flight requests"} +- {id: reliability.routing.complexity_llm_classifier.routes_by_llm_tier, module: reliability, tier: P1, behavior: routing, variant: complexity_llm_classifier, assertions: [routes_by_llm_tier], exercised_on: [chat_completions], source: "router_strategy/complexity_router/complexity_router.py", fail_before_fix: proven, rationale: "v2 auto-router LLM complexity classifier runs over the proxy and routes by semantic tier instead of silently crashing on absent litellm_metadata and falling back to heuristic scoring"} - {id: reliability.cache.exact.returns_cached, module: reliability, tier: P1, behavior: cache, variant: exact, assertions: [returns_cached], exercised_on: [chat_completions, messages, embeddings], source: "litellm/caching/caching.py", rationale: "Response cache returns cached on exact match"} - {id: reliability.cache.prompt_caching_model_select.returns_cached, module: reliability, tier: P1, behavior: cache, variant: prompt_caching_model_select, assertions: [returns_cached], exercised_on: [chat_completions], source: "router_utils/prompt_caching_cache.py", rationale: "Selects model supporting prompt caching for cacheable prefix"} - {id: reliability.circuit_breaker.redis.trips_then_recovers, module: reliability, tier: P0, behavior: circuit_breaker, variant: redis, assertions: [trips_then_recovers], exercised_on: [chat_completions, messages, embeddings], source: "litellm/caching/redis_cache.py:99", rationale: "Redis breaker CLOSED->OPEN->HALF_OPEN; guards all cache/rate-limit ops"} diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index e2bb6ca8933..b64f3d8dbfd 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -66,6 +66,23 @@ configs: model: openai/text-embedding-3-small api_key: os.environ/OPENAI_API_KEY + # v2 auto-router with the LLM complexity classifier. SIMPLE stays on the + # openai backend; every higher tier routes to the anthropic backend, so the + # served deployment (read back from the spend log's model) reveals whether + # the LLM classifier actually ran or silently fell back to heuristic scoring. + - model_name: complexity-smart-router + litellm_params: + model: auto_router/complexity_router + complexity_router_config: + classifier_type: llm + classifier_llm_config: + model: gpt-5.5 + tiers: + SIMPLE: gpt-5.5 + MEDIUM: claude-haiku-4-5 + COMPLEX: claude-haiku-4-5 + REASONING: claude-haiku-4-5 + services: litellm: image: ghcr.io/berriai/litellm:main-latest diff --git a/tests/e2e/router/complexity_router_client.py b/tests/e2e/router/complexity_router_client.py new file mode 100644 index 00000000000..929acbb3461 --- /dev/null +++ b/tests/e2e/router/complexity_router_client.py @@ -0,0 +1,20 @@ +"""Client for the complexity auto-router e2e tests. + +The suite drives the shared /chat/completions and spend-log reads on the Gateway, +so this client only carries the Gateway the shared lifecycle needs for cleanup. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from e2e_gateway import Gateway, build_gateway + + +@dataclass(frozen=True, slots=True) +class ComplexityRouterClient: + gateway: Gateway + + +def build_client() -> ComplexityRouterClient: + return ComplexityRouterClient(gateway=build_gateway()) diff --git a/tests/e2e/router/conftest.py b/tests/e2e/router/conftest.py new file mode 100644 index 00000000000..e8c05520b10 --- /dev/null +++ b/tests/e2e/router/conftest.py @@ -0,0 +1,15 @@ +"""Router suite's `client` fixture. + +The shared lifecycle (resources/scoped_key), proxy liveness skip, and e2e marker +live in the parent tests/e2e/conftest.py. ComplexityRouterClient holds the shared +Gateway, so the `resources` fixture cleans up keys this suite creates. +""" + +import pytest + +from complexity_router_client import ComplexityRouterClient, build_client + + +@pytest.fixture(scope="session") +def client() -> ComplexityRouterClient: + return build_client() diff --git a/tests/e2e/router/test_complexity_router_e2e.py b/tests/e2e/router/test_complexity_router_e2e.py new file mode 100644 index 00000000000..88d79a9cac0 --- /dev/null +++ b/tests/e2e/router/test_complexity_router_e2e.py @@ -0,0 +1,62 @@ +"""Live e2e: the v2 auto-router's LLM complexity classifier actually runs over the +proxy and drives routing, instead of silently crashing and falling back to the +local heuristic scorer. + +The regression this guards (complexity_router.py `_classifier_call_metadata` +returning None when the request carries no `litellm_metadata`, which the classifier +sub-call then fed into a `.update`, raising `'NoneType' object has no attribute +'update'`) was invisible from the outside: the router caught the error and answered +from heuristic scoring, so every request still returned 200. The only tell is which +tier, and therefore which backend, served the request. + +`complexity-smart-router` (see the inline config in docker-compose.yml) pins SIMPLE +to the openai backend and every higher tier to the anthropic backend. "Is P equal +to NP?" is lexically trivial, so the heuristic scorer lands it in SIMPLE (openai), +but any competent LLM classifier reads it as a hard reasoning question and lands it +above SIMPLE (anthropic). The served deployment is read back from the spend log's +`model`, so anthropic proves the classifier ran and openai proves it silently fell +back - the exact failure before the fix. +""" + +import pytest + +from complexity_router_client import ComplexityRouterClient +from e2e_http import unwrap +from models import ChatBody, ChatMessage + +pytestmark = pytest.mark.e2e + +ROUTER_MODEL = "complexity-smart-router" +# Lexically simple (heuristic -> SIMPLE) but a hard reasoning question (LLM -> above SIMPLE). +LEXICALLY_SIMPLE_HARD_PROMPT = "Is P equal to NP?" +# SIMPLE tier backend; served only when the classifier silently falls back to heuristic. +HEURISTIC_TIER_MODEL = "openai/gpt-5.5" +# MEDIUM/COMPLEX/REASONING tier backend; served only when the LLM classifier runs. +LLM_TIER_MODEL = "anthropic/claude-haiku-4-5" + + +class TestComplexityRouterLlmClassifier: + @pytest.mark.covers("reliability.routing.complexity_llm_classifier.routes_by_llm_tier") + def test_llm_classifier_runs_and_routes_by_semantic_tier( + self, client: ComplexityRouterClient, scoped_key: str + ) -> None: + chat = unwrap( + client.gateway.chat( + scoped_key, + ChatBody( + model=ROUTER_MODEL, + messages=[ChatMessage(role="user", content=LEXICALLY_SIMPLE_HARD_PROMPT)], + max_tokens=16, + ), + ) + ) + assert chat.choices, f"router returned no choices: {chat}" + + rows = client.gateway.poll_logs_for_key(scoped_key, min_rows=1) + served = [row.model for row in rows] + assert served == [LLM_TIER_MODEL], ( + f"expected the request to be served by {LLM_TIER_MODEL!r} (the higher-tier " + f"backend the LLM classifier picks for a hard prompt), but the spend log shows " + f"{served!r}. {HEURISTIC_TIER_MODEL!r} means the LLM classifier silently failed " + f"and the router fell back to heuristic scoring (SIMPLE) - the pre-fix regression" + ) diff --git a/tests/test_litellm/router_strategy/test_complexity_router.py b/tests/test_litellm/router_strategy/test_complexity_router.py index 41dc7269372..3404b55f0db 100644 --- a/tests/test_litellm/router_strategy/test_complexity_router.py +++ b/tests/test_litellm/router_strategy/test_complexity_router.py @@ -2387,6 +2387,16 @@ class TestSubCallMetadataSanitization: assert sanitized["user_api_key_auth"] is not None assert _get_budget_reservation_from_metadata(sanitized) is None + def test_returns_empty_dict_for_missing_metadata(self): + from litellm.router_strategy.complexity_router.complexity_router import ( + _classifier_call_metadata, + ) + + for absent in (None, {}): + result = _classifier_call_metadata(absent) + assert result == {} + assert isinstance(result, dict) + def test_sanitized_auth_keeps_access_group_fields_and_leaves_original_untouched(self): from litellm.proxy._types import UserAPIKeyAuth from litellm.router_strategy.complexity_router.complexity_router import (