From 50c90281f4fda1b080c2c4be91d8065f14f6f973 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Thu, 2 Jul 2026 13:24:22 -0700 Subject: [PATCH 001/365] feat(mcp): add true_passthrough and oauth_delegate auth modes Introduce two first-class MCP server auth_type values that make LiteLLM's role in upstream authentication explicit, added alongside the existing delegate_auth_to_upstream / oauth_passthrough flags without changing their behavior. true_passthrough is a transparent proxy: LiteLLM performs no admission auth, requires no x-litellm-api-key, mints/stores/refreshes nothing, and forwards the client's Authorization to the upstream exactly as received. oauth_delegate keeps normal LiteLLM admission (x-litellm-api-key / SSO / JWT) and then forwards the client's separate upstream Authorization unchanged; the admission credential is never forwarded upstream. Both modes forward the caller's token via the existing extra_headers path and defer egress credential resolution to v1 (the v2 to_server_spec returns None for them). Upstream 401/403 responses are surfaced rather than swallowed so upstream OAuth challenges are preserved. Servers in either mode require per-user auth, so userless health checks are skipped. --- .../mcp_server/auth/user_api_key_auth_mcp.py | 33 + .../mcp_server/mcp_server_manager.py | 95 ++- .../outbound_credentials/adapter.py | 10 +- .../outbound_credentials/resolver.py | 23 +- .../proxy/_experimental/mcp_server/server.py | 8 + litellm/types/mcp.py | 4 + .../types/mcp_server/mcp_server_manager.py | 15 + .../auth/test_user_api_key_auth_mcp.py | 677 +++++++----------- .../outbound_credentials/test_adapter.py | 7 + .../outbound_credentials/test_resolver.py | 25 +- .../mcp_server/test_mcp_server_manager.py | 223 ++++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 +- 12 files changed, 676 insertions(+), 446 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index d2986a3cd82..9f28da19292 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -220,6 +220,12 @@ class MCPRequestHandler: # when EVERY target is auth_type=oauth2 with delegate_auth_to_upstream # set; fails closed otherwise. validated_user_api_key_auth = UserAPIKeyAuth() + elif MCPRequestHandler._target_servers_are_true_passthrough( + path=request_route, + mcp_servers=mcp_servers, + client_ip=IPAddressUtils.get_mcp_client_ip(request), + ): + validated_user_api_key_auth = UserAPIKeyAuth() elif oauth2_headers: # Authorization on a non-delegated server: the bearer must be a real # LiteLLM credential, so a failed validation is a genuine 401/403 and @@ -399,6 +405,33 @@ class MCPRequestHandler: return False return True + @staticmethod + def _target_servers_are_true_passthrough( + path: str, mcp_servers: Optional[list[str]], client_ip: Optional[str] + ) -> bool: + """ + True only when EVERY MCP server the request targets is ``auth_type == true_passthrough``. + Fails closed when any target does not opt in or cannot be resolved. + + Used by :meth:`process_mcp_request` to skip LiteLLM admission auth entirely: the gateway is a + transparent proxy and the caller's ``Authorization`` is an upstream token, never a LiteLLM key. + Mirrors :meth:`_target_servers_delegate_auth_to_upstream`; a mixed-target request keeps normal auth. + """ + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.types.mcp import MCPAuth + + target_names = MCPRequestHandler._resolve_target_server_names(path=path, mcp_servers_header=mcp_servers) + if not target_names: + return False + + for name in target_names: + server = global_mcp_server_manager.get_mcp_server_by_name(name, client_ip=client_ip) + if server is None or server.auth_type != MCPAuth.true_passthrough: + return False + return True + @staticmethod def _resolve_target_server_names(path: str, mcp_servers_header: Optional[List[str]]) -> List[str]: """ diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 7c88c903324..fd978e5bfc9 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -78,6 +78,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.token_exchange_ ) from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AuthorizationCodeConfig, + PassthroughConfig, ServerSpec, TokenExchangeConfig, ) @@ -213,6 +214,13 @@ def _should_strip_caller_authorization( pass-through cold-start case (RFC 9728) the bearer in ``Authorization`` is the upstream OAuth token and must be forwarded, so we keep it. + - **oauth_delegate servers**: admission always runs and there is no + anonymous path, so the caller's separate ``Authorization`` is + forwarded only when a distinct ``x-litellm-api-key`` carried + admission. Without that header the ``Authorization`` *was* the + admission credential — a virtual key, an IdP JWT, or an SSO / OIDC / + session token whose ``api_key`` is ``None`` — and must never reach + the upstream, so it is stripped regardless of the ``api_key`` value. """ if mcp_server.auth_type == MCPAuth.oauth2_token_exchange: # OBO: the inbound Authorization is the subject token. It is exchanged at the IdP and only the @@ -226,11 +234,13 @@ def _should_strip_caller_authorization( # upstream — it would override another user's stored credential. Delegate and # pass-through return None from to_server_spec and keep forwarding the bearer. return True - if not mcp_server.is_oauth_passthrough: + if not (mcp_server.is_oauth_passthrough or mcp_server.is_oauth_delegate): return False normalized_raw_headers = {str(k).lower(): v for k, v in (raw_headers or {}).items() if isinstance(k, str)} has_explicit_litellm_admission_header = normalized_raw_headers.get("x-litellm-api-key") is not None + if mcp_server.is_oauth_delegate: + return not has_explicit_litellm_admission_header admission_consumed_authorization_as_litellm_key = ( user_api_key_auth is not None and bool(getattr(user_api_key_auth, "api_key", None)) @@ -323,6 +333,41 @@ async def _resolve_byok_mcp_auth_header( return mcp_auth_header +def _client_forwarded_authorization_headers( + mcp_server: MCPServer, + oauth2_headers: Optional[dict[str, str]], + raw_headers: Optional[dict[str, str]], + user_api_key_auth: Optional[UserAPIKeyAuth], +) -> Optional[dict[str, str]]: + """Egress headers for the client-forwarded-token modes (``true_passthrough`` / ``oauth_delegate``). + + Forwards the caller's ``Authorization`` to the upstream, stripped when + ``_should_strip_caller_authorization`` says it was consumed as the LiteLLM admission key. Shared by + ``_call_regular_mcp_tool`` and ``server.py``'s ``_prepare_mcp_server_headers`` so the two egress + paths cannot drift, mirroring the ``_should_strip_caller_authorization`` split. + """ + extra_headers = oauth2_headers.copy() if oauth2_headers else None + if extra_headers and _should_strip_caller_authorization( + mcp_server=mcp_server, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ): + return _without_authorization(extra_headers) + return extra_headers + + +def _take_forwarded_authorization( + headers: Optional[dict[str, str]], +) -> tuple[Optional[str], Optional[dict[str, str]]]: + """Pop the ``Authorization`` value out of ``headers`` (case-insensitive), returning it with the + remaining headers, so the passthrough resolver arm is the single Authorization source rather than + the header also riding in ``extra_headers`` (which the resolved auth would then defer to).""" + if not headers: + return None, headers + value = next((v for k, v in headers.items() if k.lower() == "authorization"), None) + return value, _without_authorization(headers) + + def _extract_upstream_auth_failure( exc: BaseException, ) -> Optional[tuple[int, Optional[str]]]: @@ -1623,15 +1668,18 @@ class MCPServerManager: delegate_server_ids = [ server.server_id for server in self.get_registry().values() - if getattr(server, "auth_type", None) == MCPAuth.oauth2 - and getattr(server, "delegate_auth_to_upstream", False) is True - # M2M servers must not be exposed anonymously: an - # unauthenticated caller would get LiteLLM to proxy tool - # calls using its stored client_credentials. Resolve the flow - # rather than reading has_client_credentials so an unstamped - # M2M-shape row (null column, verbatim-read as non-M2M) still - # fails closed here, matching the anonymous-delegate auth gate. - and MCPServerManager.effective_oauth2_flow(server) != "client_credentials" + if ( + getattr(server, "auth_type", None) == MCPAuth.oauth2 + and getattr(server, "delegate_auth_to_upstream", False) is True + # M2M servers must not be exposed anonymously: an + # unauthenticated caller would get LiteLLM to proxy tool + # calls using its stored client_credentials. Resolve the flow + # rather than reading has_client_credentials so an unstamped + # M2M-shape row (null column, verbatim-read as non-M2M) still + # fails closed here, matching the anonymous-delegate auth gate. + and MCPServerManager.effective_oauth2_flow(server) != "client_credentials" + ) + or getattr(server, "auth_type", None) == MCPAuth.true_passthrough ] combined_servers.update(delegate_server_ids) @@ -2229,16 +2277,17 @@ class MCPServerManager: spec = None if transport == MCPTransport.stdio else to_server_spec(server) provider = cred_provider or self._cred_provider # A caller-supplied per-request override (mcp_auth_header / x-mcp-*) defers to the v1 path - # so it wins - except for the per-user modes the v2 resolver owns (authorization_code's - # stored token and token_exchange's RFC 8693 minted token). A caller must not be able to - # substitute another user's stored credential, nor silently disable the OBO exchange and - # forward an arbitrary bearer upstream, so we keep the v2 spec and ignore the override for - # both; the REST tools preview supplies its not-yet-persisted token through the resolver - # (cred_provider), never this path. + # so it wins - except for the modes the v2 resolver owns per-caller (authorization_code's + # stored token, token_exchange's RFC 8693 minted token, and the passthrough modes' + # forwarded caller token). A caller must not be able to substitute another user's stored + # credential, nor silently disable the OBO exchange and forward an arbitrary bearer + # upstream, so we keep the v2 spec and ignore the override for these; the REST tools + # preview supplies its not-yet-persisted token through the resolver (cred_provider), + # never this path. if ( spec is not None and mcp_auth_header - and not isinstance(spec.config, (AuthorizationCodeConfig, TokenExchangeConfig)) + and not isinstance(spec.config, (AuthorizationCodeConfig, PassthroughConfig, TokenExchangeConfig)) ): spec = None auth_value = ( @@ -2305,11 +2354,14 @@ class MCPServerManager: server_url = server.url or "" if spec is not None: + inbound_token = subject_token + if isinstance(spec.config, PassthroughConfig): + inbound_token, extra_headers = _take_forwarded_authorization(extra_headers) resolved_auth, extra_headers = await self._resolve_v2_auth( server=server, spec=spec, provider=provider, - subject_token=subject_token, + subject_token=inbound_token, user_api_key_auth=user_api_key_auth, extra_headers=extra_headers, ) @@ -3730,6 +3782,13 @@ class MCPServerManager: user_api_key_auth=user_api_key_auth, ): extra_headers = _without_authorization(extra_headers) + elif mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate: + extra_headers = _client_forwarded_authorization_headers( + mcp_server=mcp_server, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) if mcp_server.extra_headers and raw_headers: if extra_headers is None: diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py index 05896bfff74..e87e8081ced 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/adapter.py @@ -23,6 +23,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AuthorizationCodeConfig, CredError, NoneConfig, + PassthroughConfig, ServerSpec, SharedKey, Subject, @@ -62,9 +63,10 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]: an ``assert_never`` tail, so a newly added auth mode fails the type gate here until it is explicitly mapped or explicitly deferred, rather than silently falling through to v1. Live modes: ``none``, the static-header family (``api_key`` plus the Authorization schemes, - all shared-key), ``oauth2`` per-user tokens (``authorization_code``), and - ``oauth2_token_exchange`` (OBO); client_credentials (M2M), delegated/passthrough - oauth2, and SigV4 return None and stay on v1. + all shared-key), ``oauth2`` per-user tokens (``authorization_code``), ``oauth2_token_exchange`` + (OBO), and the client-forwarded token modes ``true_passthrough`` / ``oauth_delegate`` + (``PassthroughConfig``); client_credentials (M2M), delegated/passthrough oauth2, and SigV4 + return None and stay on v1. """ if server.is_byok: return None # per-user BYOK source not migrated yet -> defer to v1 (any auth_type) @@ -94,6 +96,8 @@ def to_server_spec(server: MCPServer) -> Optional[ServerSpec]: ) # client_credentials (M2M) and delegate/passthrough oauth2 stay on v1 return None + case MCPAuth.true_passthrough | MCPAuth.oauth_delegate: + return ServerSpec(server_id=server.server_id, resource=resource, config=PassthroughConfig()) case MCPAuth.oauth2_token_exchange: return _token_exchange_spec(server, resource) case MCPAuth.aws_sigv4: diff --git a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py index c82ce1037d6..ecfd471190c 100644 --- a/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py +++ b/litellm/proxy/_experimental/mcp_server/outbound_credentials/resolver.py @@ -7,10 +7,11 @@ no precedence cascade. It is wildcard-free with an `assert_never` tail, so addin an arm fails the type gate (basedpyright `reportMatchNotExhaustive`); a bypassed gate fails loudly at runtime instead of returning `None`. -`none` and `api_key` (shared-key source) are live, as is `authorization_code`, which reads the -user's token from the injected `OAuthTokenStore`, and `token_exchange`, which swaps the caller's -inbound token through the injected `TokenExchanger`. The remaining arms are `not_implemented` stubs -that each land in a follow-up PR with their seam. Pure v2: no imports from v1. +`none`, `api_key` (shared-key source), and `passthrough` (forwards the caller's own inbound token) +are live, as is `authorization_code`, which reads the user's token from the injected +`OAuthTokenStore`, and `token_exchange`, which swaps the caller's inbound token through the +injected `TokenExchanger`. The remaining arms are `not_implemented` stubs that each land in a +follow-up PR with their seam. Pure v2: no imports from v1. """ from __future__ import annotations @@ -97,7 +98,7 @@ class UpstreamCredentialProvider: case ApiKeyConfig() as config: return self._api_key(config) case PassthroughConfig(): - return _not_implemented(AuthSpecKind.passthrough) + return self._passthrough(subject) case ClientCredentialsConfig(): return _not_implemented(AuthSpecKind.client_credentials) case TokenExchangeConfig() as config: @@ -118,6 +119,18 @@ class UpstreamCredentialProvider: """ return await self._authz_token(subject, server) is not None + def _passthrough(self, subject: Subject) -> Result[httpx.Auth, CredError]: + """Forward the caller's own upstream credential verbatim; the gateway mints nothing. + + The inbound token is the caller's already-disambiguated ``Authorization`` (never the LiteLLM + admission credential; the edge adapter drops that before building the ``Subject``). When it is + absent the request is sent unauthenticated so the upstream's own 401 surfaces, rather than the + gateway challenging on the upstream's behalf. + """ + if subject.inbound_token is None: + return Ok(NoOpAuth()) + return Ok(StaticHeaderAuth(subject.inbound_token.get_secret_value(), header_name="Authorization")) + def _api_key(self, config: ApiKeyConfig) -> Result[httpx.Auth, CredError]: match config.key_source: case SharedKey() as source: diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index e3812522ded..96fd014fbe1 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -331,6 +331,7 @@ if MCP_AVAILABLE: ) from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( MCPServerManager, + _client_forwarded_authorization_headers, _should_strip_caller_authorization, _without_authorization, global_mcp_server_manager, @@ -1561,6 +1562,13 @@ if MCP_AVAILABLE: user_api_key_auth=user_api_key_auth, ): extra_headers = _without_authorization(extra_headers) + elif server.is_true_passthrough or server.is_oauth_delegate: + extra_headers = _client_forwarded_authorization_headers( + mcp_server=server, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) if server.extra_headers and raw_headers: if extra_headers is None: diff --git a/litellm/types/mcp.py b/litellm/types/mcp.py index 9c564a3c7a6..d273e8ec4db 100644 --- a/litellm/types/mcp.py +++ b/litellm/types/mcp.py @@ -38,6 +38,8 @@ class MCPAuth(str, enum.Enum): aws_sigv4 = "aws_sigv4" token = "token" oauth2_token_exchange = "oauth2_token_exchange" + true_passthrough = "true_passthrough" + oauth_delegate = "oauth_delegate" # RFC 8693 default subject_token_type. A NULL column / omitted config key means @@ -60,6 +62,8 @@ MCPAuthType = Optional[ MCPAuth.aws_sigv4, MCPAuth.token, MCPAuth.oauth2_token_exchange, + MCPAuth.true_passthrough, + MCPAuth.oauth_delegate, ] ] diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 522a6f09165..f102ab5b7b9 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -152,6 +152,18 @@ class MCPServer(BaseModel): """True if this is an OAuth2 server that relies on per-user tokens (no client_credentials).""" return self.auth_type == MCPAuth.oauth2 and not self.has_client_credentials + @property + def is_true_passthrough(self) -> bool: + """True for the transparent-proxy mode: LiteLLM performs no admission auth and forwards the + client's ``Authorization`` to the upstream unchanged.""" + return self.auth_type == MCPAuth.true_passthrough + + @property + def is_oauth_delegate(self) -> bool: + """True for the delegated-upstream-OAuth mode: LiteLLM still admits the caller (API key / SSO / + JWT) but forwards the caller's separate upstream ``Authorization`` unchanged, minting nothing.""" + return self.auth_type == MCPAuth.oauth_delegate + @property def requires_per_user_auth(self) -> bool: """ @@ -167,6 +179,9 @@ class MCPServer(BaseModel): if self.needs_user_oauth_token: return True + if self.is_true_passthrough or self.is_oauth_delegate: + return True + # PAT passthrough: auth_type is none but extra_headers includes auth headers if self.auth_type == MCPAuth.none and self.extra_headers: auth_header_names = {"authorization", "x-api-key", "api-key", "apikey"} diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index 3db0f8540f9..c984ccb783e 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -1,14 +1,12 @@ import json import os import sys -from unittest.mock import AsyncMock, MagicMock, call as mock_call, patch +from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi.testclient import TestClient -sys.path.insert( - 0, os.path.abspath("../../../..") -) # Adds the parent directory to the system path +sys.path.insert(0, os.path.abspath("../../../..")) # Adds the parent directory to the system path from starlette.datastructures import Headers @@ -78,20 +76,14 @@ class TestMCPRequestHandler: ) # Mock the helper methods instead of database calls - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key_servers: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team_servers: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key_servers: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team_servers: # Set up return values mock_key_servers.return_value = key_servers mock_team_servers.return_value = team_servers # Call the method - result = await MCPRequestHandler.get_allowed_mcp_servers( - user_api_key_auth=mock_user_auth - ) + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth=mock_user_auth) # Assert the result (order-independent comparison) assert sorted(result) == sorted(expected_result) @@ -148,20 +140,14 @@ class TestMCPRequestHandler: ) # Mock the helper functions - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key_servers: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team_servers: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key_servers: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team_servers: # Configure mocks to return the test data mock_key_servers.return_value = key_servers mock_team_servers.return_value = team_servers # Call the method - result = await MCPRequestHandler.get_allowed_mcp_servers( - user_api_key_auth - ) + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth) # Assert the result (order-independent comparison) assert sorted(result) == sorted(expected_servers) @@ -186,9 +172,7 @@ class TestMCPRequestHandler: ): """The require_key_mcp_access_defined general setting flips an empty key from inheriting its team's MCP servers (default) to inheriting none.""" - auth = UserAPIKeyAuth( - api_key="test-key", user_id="test-user", team_id="test-team" - ) + auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user", team_id="test-team") with ( patch.object( MCPRequestHandler, @@ -272,23 +256,22 @@ class TestMCPRequestHandler: async def test_no_mcp_servers_sentinel_returns_empty(self, team_servers): """A key scoped to the no-mcp-servers sentinel resolves to zero servers, overriding team inheritance and never leaking the sentinel marker.""" - user_api_key_auth = UserAPIKeyAuth( - api_key="test-key", user_id="test-user", team_id="test-team" - ) + user_api_key_auth = UserAPIKeyAuth(api_key="test-key", user_id="test-user", team_id="test-team") key_object_permission = MagicMock() - key_object_permission.mcp_servers = [ - SpecialMCPServerNames.no_mcp_servers.value - ] + key_object_permission.mcp_servers = [SpecialMCPServerNames.no_mcp_servers.value] - with patch.object( - MCPRequestHandler, - "_get_key_object_permission", - return_value=key_object_permission, - ), patch.object( - MCPRequestHandler, - "_get_allowed_mcp_servers_for_team", - new_callable=AsyncMock, - return_value=team_servers, + with ( + patch.object( + MCPRequestHandler, + "_get_key_object_permission", + return_value=key_object_permission, + ), + patch.object( + MCPRequestHandler, + "_get_allowed_mcp_servers_for_team", + new_callable=AsyncMock, + return_value=team_servers, + ), ): result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth) @@ -309,9 +292,7 @@ class TestMCPRequestHandler: "_get_key_object_permission", return_value=key_object_permission, ): - result = await MCPRequestHandler._get_allowed_mcp_servers_for_key( - user_api_key_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(user_api_key_auth) assert result == [SpecialMCPServerNames.no_mcp_servers.value] @@ -320,9 +301,7 @@ class TestMCPRequestHandler: # Test case: None values in database mock_prisma_client = MagicMock() - mock_prisma_client.db.litellm_objectpermissiontable.find_unique.return_value = ( - None - ) + mock_prisma_client.db.litellm_objectpermissiontable.find_unique.return_value = None mock_prisma_client.db.litellm_teamtable.find_unique.return_value = None user_api_key_auth = UserAPIKeyAuth( @@ -337,9 +316,7 @@ class TestMCPRequestHandler: assert result == [] # Test case: Exception handling - mock_prisma_client.db.litellm_objectpermissiontable.find_unique.side_effect = ( - Exception("DB Error") - ) + mock_prisma_client.db.litellm_objectpermissiontable.find_unique.side_effect = Exception("DB Error") with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client): result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth) @@ -384,15 +361,9 @@ class TestMCPRequestHandler: access_group_ids=["grp-mcp"], ) with ( - patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key, - patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team, - patch.object( - MCPRequestHandler, "_get_key_access_group_mcp_server_extras" - ) as mock_grants, + patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key, + patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team, + patch.object(MCPRequestHandler, "_get_key_access_group_mcp_server_extras") as mock_grants, ): mock_key.return_value = key_servers mock_team.return_value = team_servers @@ -414,13 +385,9 @@ class TestMCPRequestHandler: "litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups", new=AsyncMock(return_value=[]), ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - auth - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(auth) assert result == [] # expand_permission_list must not be reached when there are no raw ids. mock_mgr.expand_permission_list.assert_not_called() @@ -433,14 +400,10 @@ class TestMCPRequestHandler: "litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups", new=AsyncMock(return_value=["alias-a", "srv-b"]), ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.expand_permission_list.return_value = ["srv-a", "srv-b"] - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - auth - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(auth) assert sorted(result) == ["srv-a", "srv-b"] mock_mgr.expand_permission_list.assert_called_once_with(["alias-a", "srv-b"]) @@ -451,9 +414,7 @@ class TestMCPRequestHandler: "litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups", new=AsyncMock(side_effect=Exception("db down")), ): - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - auth - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(auth) assert result == [] @pytest.mark.parametrize( @@ -743,9 +704,7 @@ class TestMCPRequestHandler: # Verify MCP servers mcp_servers_header = extracted_headers.get(SpecialHeaders.mcp_servers.value) mcp_servers = None - if ( - mcp_servers_header is not None - ): # Changed from 'if mcp_servers_header:' to handle empty strings + if mcp_servers_header is not None: # Changed from 'if mcp_servers_header:' to handle empty strings try: # First try to parse as JSON array for backward compatibility try: @@ -754,16 +713,12 @@ class TestMCPRequestHandler: mcp_servers = None except (json.JSONDecodeError, TypeError, ValueError): # If JSON parsing fails, treat as comma-separated list - mcp_servers = [ - s.strip() for s in mcp_servers_header.split(",") if s.strip() - ] + mcp_servers = [s.strip() for s in mcp_servers_header.split(",") if s.strip()] except Exception: mcp_servers = None # If we got an empty string or parsing resulted in no servers, return empty list - if mcp_servers_header == "" or ( - mcp_servers is not None and len(mcp_servers) == 0 - ): + if mcp_servers_header == "" or (mcp_servers is not None and len(mcp_servers) == 0): mcp_servers = [] assert mcp_servers == expected_result["mcp_servers"] @@ -833,9 +788,7 @@ class TestMCPOAuth2AuthFlow: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = oauth2_server ( @@ -851,10 +804,7 @@ class TestMCPOAuth2AuthFlow: # The upstream token is never validated as a LiteLLM key ... mock_auth.assert_not_called() # ... and is preserved for upstream forwarding. - assert ( - oauth2_headers.get("Authorization") - == "Bearer atlassian-oauth2-access-token-xyz" - ) + assert oauth2_headers.get("Authorization") == "Bearer atlassian-oauth2-access-token-xyz" async def test_explicit_litellm_key_with_oauth2_authorization(self): """ @@ -893,9 +843,7 @@ class TestMCPOAuth2AuthFlow: assert call_args.kwargs["api_key"] == "sk-litellm-valid-key" # OAuth2 headers should still contain the Authorization token - assert ( - oauth2_headers.get("Authorization") == "Bearer atlassian-oauth2-token" - ) + assert oauth2_headers.get("Authorization") == "Bearer atlassian-oauth2-token" async def test_litellm_key_in_authorization_backward_compat(self): """ @@ -997,9 +945,7 @@ class TestMCPOAuth2AuthFlow: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_proxy_exception, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = oauth2_server with pytest.raises(ProxyException) as exc_info: @@ -1068,9 +1014,7 @@ class TestMCPPublicRouteGuard: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): # Explicit unresolvable target — proves auth still fails even # when the registry has no info to fall back to. @@ -1101,9 +1045,7 @@ class TestMCPPublicRouteGuard: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = None with pytest.raises(HTTPException) as exc_info: @@ -1157,9 +1099,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, patch( "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp._is_mcp_passthrough_cold_start" ) as mock_cold_start, @@ -1199,9 +1139,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1229,9 +1167,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1263,18 +1199,14 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.IPAddressUtils.get_mcp_client_ip", return_value="203.0.113.10", ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = None with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) assert exc_info.value.status_code == 401 - mock_mgr.get_mcp_server_by_name.assert_any_call( - "passthrough_server", client_ip="203.0.113.10" - ) + mock_mgr.get_mcp_server_by_name.assert_any_call("passthrough_server", client_ip="203.0.113.10") async def test_cold_start_propagates_non_401_http_error(self): from fastapi import HTTPException @@ -1294,9 +1226,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_forbidden, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1329,9 +1259,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_server_error, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1357,9 +1285,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1367,9 +1293,7 @@ class TestMCPPassthroughColdStartAdmission: auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) - mock_mgr.get_mcp_server_by_name.assert_any_call( - "passthrough_server", client_ip="" - ) + mock_mgr.get_mcp_server_by_name.assert_any_call("passthrough_server", client_ip="") async def test_cold_start_allows_proxy_exception_401_for_path_target(self): from litellm.proxy._types import ProxyException @@ -1394,9 +1318,7 @@ class TestMCPPassthroughColdStartAdmission: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = ( TestMCPPassthroughColdStartAdmission._make_passthrough_server() @@ -1404,9 +1326,7 @@ class TestMCPPassthroughColdStartAdmission: auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) - mock_mgr.get_mcp_server_by_name.assert_any_call( - "passthrough_server", client_ip="" - ) + mock_mgr.get_mcp_server_by_name.assert_any_call("passthrough_server", client_ip="") @pytest.mark.asyncio @@ -1450,12 +1370,10 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPOAuth2FallbackTargetGating._make_server(MCPAuth.api_key) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPOAuth2FallbackTargetGating._make_server( + MCPAuth.api_key ) with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) @@ -1483,9 +1401,7 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = None with pytest.raises(HTTPException) as exc_info: @@ -1523,12 +1439,10 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPOAuth2FallbackTargetGating._make_server(MCPAuth.oauth2) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPOAuth2FallbackTargetGating._make_server( + MCPAuth.oauth2 ) with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) @@ -1562,15 +1476,11 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPOAuth2FallbackTargetGating._make_server( - auth_type=MCPAuth.none, - is_oauth_passthrough=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPOAuth2FallbackTargetGating._make_server( + auth_type=MCPAuth.none, + is_oauth_passthrough=True, ) auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) @@ -1598,9 +1508,7 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.IPAddressUtils.get_mcp_client_ip", return_value="203.0.113.10", ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = None with pytest.raises(HTTPException) as exc_info: @@ -1612,9 +1520,7 @@ class TestMCPOAuth2FallbackTargetGating: # resolve to ``None`` (hidden by client IP) so neither bypass # opens. Use ``assert_any_call`` to assert the IP-scoped lookup # happened without locking the count. - mock_mgr.get_mcp_server_by_name.assert_any_call( - "hidden_oauth2_server", client_ip="203.0.113.10" - ) + mock_mgr.get_mcp_server_by_name.assert_any_call("hidden_oauth2_server", client_ip="203.0.113.10") async def test_fallback_blocked_when_any_target_in_header_is_not_oauth2(self): """ @@ -1649,9 +1555,7 @@ class TestMCPOAuth2FallbackTargetGating: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.side_effect = mock_lookup with pytest.raises(HTTPException) as exc_info: @@ -1732,17 +1636,10 @@ class TestMCPDelegateAuthToUpstream: delegate_auth_to_upstream=True, available_on_public_internet=True, ) - assert ( - manager._build_mcp_server_table(delegated).delegate_auth_to_upstream is True - ) + assert manager._build_mcp_server_table(delegated).delegate_auth_to_upstream is True - not_delegated = delegated.model_copy( - update={"delegate_auth_to_upstream": False} - ) - assert ( - manager._build_mcp_server_table(not_delegated).delegate_auth_to_upstream - is False - ) + not_delegated = delegated.model_copy(update={"delegate_auth_to_upstream": False}) + assert manager._build_mcp_server_table(not_delegated).delegate_auth_to_upstream is False def test_build_mcp_server_table_preserves_oauth_passthrough(self): """Registry → API list rows must expose oauth_passthrough for the UI. @@ -1773,9 +1670,7 @@ class TestMCPDelegateAuthToUpstream: assert row.delegate_auth_to_upstream is False not_passthrough = passthrough.model_copy(update={"oauth_passthrough": False}) - assert ( - manager._build_mcp_server_table(not_passthrough).oauth_passthrough is False - ) + assert manager._build_mcp_server_table(not_passthrough).oauth_passthrough is False async def test_delegate_skips_litellm_auth_with_no_authorization(self): """ @@ -1796,15 +1691,11 @@ class TestMCPDelegateAuthToUpstream: patch( "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.oauth2, - delegate_auth_to_upstream=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, ) auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) @@ -1834,15 +1725,11 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", new_callable=AsyncMock, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.oauth2, - delegate_auth_to_upstream=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, ) ( auth_result, @@ -1880,15 +1767,11 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.oauth2, - delegate_auth_to_upstream=False, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=False, ) with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) @@ -1919,15 +1802,11 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.api_key, - delegate_auth_to_upstream=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.api_key, + delegate_auth_to_upstream=True, ) with pytest.raises(HTTPException) as exc_info: await MCPRequestHandler.process_mcp_request(scope) @@ -1971,9 +1850,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.side_effect = mock_lookup with pytest.raises(HTTPException) as exc_info: @@ -2003,9 +1880,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_user_api_key_auth_fails, ), - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = None with pytest.raises(HTTPException) as exc_info: @@ -2034,15 +1909,11 @@ class TestMCPDelegateAuthToUpstream: new_callable=AsyncMock, return_value=UserAPIKeyAuth(user_id="real-user"), ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.oauth2, - delegate_auth_to_upstream=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, ) auth_result, *_rest = await MCPRequestHandler.process_mcp_request(scope) assert isinstance(auth_result, UserAPIKeyAuth) @@ -2074,15 +1945,11 @@ class TestMCPDelegateAuthToUpstream: new_callable=AsyncMock, return_value=UserAPIKeyAuth(user_id="real-user"), ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): - mock_mgr.get_mcp_server_by_name.return_value = ( - TestMCPDelegateAuthToUpstream._make_server( - auth_type=MCPAuth.oauth2, - delegate_auth_to_upstream=True, - ) + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, ) ( auth_result, @@ -2135,9 +2002,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = m2m_server # No delegate bypass → normal auth is attempted → 401 raised @@ -2188,9 +2053,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = legacy_m2m_server with pytest.raises(HTTPException) as exc_info: @@ -2235,9 +2098,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = pkce_server auth, *_rest = await MCPRequestHandler.process_mcp_request(scope) @@ -2278,9 +2139,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.return_value = internal_server auth, *_rest = await MCPRequestHandler.process_mcp_request(scope) @@ -2426,6 +2285,104 @@ class TestMCPDelegateAuthToUpstream: assert "public-server" in result assert "internal-server" in result + async def test_true_passthrough_skips_litellm_auth_anonymously(self): + """auth_type=true_passthrough performs no admission auth: the caller's Authorization is an + upstream token forwarded unchanged and user_api_key_auth is never called.""" + from litellm.types.mcp import MCPAuth + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/true_passthrough_server", + "headers": [(b"authorization", b"Bearer upstream-token")], + } + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + new_callable=AsyncMock, + ) as mock_auth, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.return_value = TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.true_passthrough, + ) + ( + auth_result, + _, + _, + _, + oauth2_headers, + _, + ) = await MCPRequestHandler.process_mcp_request(scope) + assert isinstance(auth_result, UserAPIKeyAuth) + assert auth_result.api_key is None + assert oauth2_headers.get("Authorization") == "Bearer upstream-token" + mock_auth.assert_not_called() + + async def test_true_passthrough_mixed_targets_fail_closed(self): + """One true_passthrough target mixed with a non-passthrough target must NOT skip admission.""" + from fastapi import HTTPException + + from litellm.types.mcp import MCPAuth + + scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [(b"x-mcp-servers", b"tp_server,plain_server")], + } + + async def mock_user_api_key_auth_fails(api_key, request): + raise HTTPException(status_code=401, detail="Invalid API key") + + def mock_lookup(name, client_ip=None): + if name == "tp_server": + return TestMCPDelegateAuthToUpstream._make_server( + auth_type=MCPAuth.true_passthrough, + ) + return TestMCPDelegateAuthToUpstream._make_server(auth_type=MCPAuth.api_key) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", + side_effect=mock_user_api_key_auth_fails, + ), + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, + ): + mock_mgr.get_mcp_server_by_name.side_effect = mock_lookup + with pytest.raises(HTTPException) as exc_info: + await MCPRequestHandler.process_mcp_request(scope) + assert exc_info.value.status_code == 401 + + async def test_get_allowed_servers_includes_true_passthrough(self): + """Anonymous callers can reach true_passthrough servers; admission is delegated upstream.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + MCPServerManager, + ) + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + manager = MCPServerManager() + tp_server = MCPServer( + server_id="tp-server", + name="tp_server", + transport="http", + auth_type=MCPAuth.true_passthrough, + available_on_public_internet=True, + ) + manager.registry = {tp_server.server_id: tp_server} + + with patch.object( + MCPRequestHandler, + "get_allowed_mcp_servers", + new_callable=AsyncMock, + return_value=[], + ): + result = await manager.get_allowed_mcp_servers(None) + + assert "tp-server" in result + def test_extract_target_server_names_matches_routing_parser(self): """ Regression: _extract_target_server_names_from_path must match the @@ -2464,13 +2421,12 @@ class TestMCPDelegateAuthToUpstream: ("/", []), ] for path_input, expected in cases: - assert ( - MCPRequestHandler._extract_target_server_names_from_path(path_input) - == expected - ), f"path={path_input!r} → expected {expected!r}" - assert ( - _get_mcp_servers_in_path(path_input) or [] - ) == expected, f"path={path_input!r} → routing expected {expected!r}" + assert MCPRequestHandler._extract_target_server_names_from_path(path_input) == expected, ( + f"path={path_input!r} → expected {expected!r}" + ) + assert (_get_mcp_servers_in_path(path_input) or []) == expected, ( + f"path={path_input!r} → routing expected {expected!r}" + ) async def test_delegate_does_not_bypass_on_extra_path_segment(self): """ @@ -2514,9 +2470,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.side_effect = lookup_by_name with pytest.raises(HTTPException) as exc_info: @@ -2579,9 +2533,7 @@ class TestMCPDelegateAuthToUpstream: "litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp.user_api_key_auth", side_effect=mock_auth_raises, ) as mock_auth, - patch( - "litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager" - ) as mock_mgr, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager") as mock_mgr, ): mock_mgr.get_mcp_server_by_name.side_effect = lookup_by_name # Bypass MUST NOT fire — path-derived target is the non-delegate @@ -2601,15 +2553,12 @@ class TestMCPDelegateAuthToUpstream: empty-list case, which fails closed). """ # Path matches /mcp/... — header is ignored. - assert MCPRequestHandler._resolve_target_server_names( - path="/mcp/foo", mcp_servers_header=["evil"] - ) == ["foo"] - assert MCPRequestHandler._resolve_target_server_names( - path="/mcp/foo,bar", mcp_servers_header=["evil"] - ) == ["foo", "bar"] - assert MCPRequestHandler._resolve_target_server_names( - path="/foo/mcp", mcp_servers_header=["evil"] - ) == ["foo"] + assert MCPRequestHandler._resolve_target_server_names(path="/mcp/foo", mcp_servers_header=["evil"]) == ["foo"] + assert MCPRequestHandler._resolve_target_server_names(path="/mcp/foo,bar", mcp_servers_header=["evil"]) == [ + "foo", + "bar", + ] + assert MCPRequestHandler._resolve_target_server_names(path="/foo/mcp", mcp_servers_header=["evil"]) == ["foo"] # Path does not match — header is trusted. assert MCPRequestHandler._resolve_target_server_names( path="/.well-known/oauth-authorization-server", @@ -2653,16 +2602,12 @@ class TestMCPCustomHeaderName: (None, "", "x-mcp-auth"), ], ) - def test_get_mcp_client_side_auth_header_name( - self, env_var, general_setting, expected_header_name - ): + def test_get_mcp_client_side_auth_header_name(self, env_var, general_setting, expected_header_name): """Test that custom header name configuration works correctly""" # Mock the secret manager and general settings with patch("litellm.secret_managers.main.get_secret_str") as mock_get_secret: - with patch( - "litellm.proxy.proxy_server.general_settings" - ) as mock_general_settings: + with patch("litellm.proxy.proxy_server.general_settings") as mock_general_settings: # Configure mocks mock_get_secret.return_value = env_var mock_general_settings.get.return_value = general_setting @@ -2685,9 +2630,7 @@ class TestMCPCustomHeaderName: if env_var is None: # When env var is None, general settings should be checked (twice if not None) expected_general_calls = 2 if general_setting is not None else 1 - assert ( - mock_general_settings.get.call_count == expected_general_calls - ) + assert mock_general_settings.get.call_count == expected_general_calls for call in mock_general_settings.get.call_args_list: assert call.args == ("mcp_client_side_auth_header_name",) else: @@ -2728,9 +2671,7 @@ class TestMCPCustomHeaderName: ), ], ) - def test_get_mcp_auth_header_from_headers_with_custom_name( - self, custom_header_name, headers, expected_auth_header - ): + def test_get_mcp_auth_header_from_headers_with_custom_name(self, custom_header_name, headers, expected_auth_header): """Test that MCP auth header extraction uses custom header name""" # Mock the header name method @@ -2749,9 +2690,7 @@ class TestMCPCustomHeaderName: extracted_headers = MCPRequestHandler._safe_get_headers_from_scope(scope) # Call the method - result = MCPRequestHandler._get_mcp_auth_header_from_headers( - extracted_headers - ) + result = MCPRequestHandler._get_mcp_auth_header_from_headers(extracted_headers) # Assert the result assert result == expected_auth_header @@ -2818,9 +2757,7 @@ class TestMCPCustomHeaderName: from starlette.datastructures import Headers # Test case 1: No server-specific headers - headers = Headers( - {"x-litellm-api-key": "test-key", "content-type": "application/json"} - ) + headers = Headers({"x-litellm-api-key": "test-key", "content-type": "application/json"}) result = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) assert result == {} @@ -2904,17 +2841,13 @@ class TestMCPCustomHeaderName: assert result == {"github_mcp": {"Authorization": "Bearer github-mcp-token"}} # Test case 8: Edge case - empty header value - headers = Headers( - {"x-litellm-api-key": "test-key", "x-mcp-github-authorization": ""} - ) + headers = Headers({"x-litellm-api-key": "test-key", "x-mcp-github-authorization": ""}) result = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) assert result == {"github": {"Authorization": ""}} # Test case 9: Edge case - very long header value long_token = "Bearer " + "x" * 1000 - headers = Headers( - {"x-litellm-api-key": "test-key", "x-mcp-github-authorization": long_token} - ) + headers = Headers({"x-litellm-api-key": "test-key", "x-mcp-github-authorization": long_token}) result = MCPRequestHandler._get_mcp_server_auth_headers_from_headers(headers) assert result == {"github": {"Authorization": long_token}} @@ -2980,9 +2913,7 @@ class TestMCPAccessGroupsE2E: # Assert the results assert auth_result.api_key == "test-api-key" assert mcp_auth_header is None - assert ( - mcp_servers is None - ) # x-mcp-access-groups is not parsed as mcp_servers + assert mcp_servers is None # x-mcp-access-groups is not parsed as mcp_servers assert mcp_server_auth_headers == {} # Verify the mock was called @@ -3097,9 +3028,7 @@ def test_mcp_path_based_server_segregation(monkeypatch): # Use TestClient to make a request to /mcp/zapier,group1/tools client = TestClient(app) - response = client.get( - "/mcp/zapier,group1/tools", headers={"x-litellm-api-key": "test"} - ) + response = client.get("/mcp/zapier,group1/tools", headers={"x-litellm-api-key": "test"}) assert response.status_code == 200 assert response.json() == {"status": "ok"} @@ -3177,15 +3106,11 @@ async def test_get_team_object_permission_with_already_loaded_permission(): mock_prisma, ): with patch("litellm.proxy.auth.auth_checks.get_team_object") as mock_get_team: - with patch( - "litellm.proxy.auth.auth_checks.get_object_permission" - ) as mock_get_perm: + with patch("litellm.proxy.auth.auth_checks.get_object_permission") as mock_get_perm: mock_get_team.return_value = mock_team_obj # Call the method - result = await MCPRequestHandler._get_team_object_permission( - mock_user_auth - ) + result = await MCPRequestHandler._get_team_object_permission(mock_user_auth) # Assert we got the object permission assert result == mock_object_permission @@ -3272,9 +3197,7 @@ async def test_get_team_object_permission_ui_session_team_skips_db_lookup(): mock_prisma = MagicMock() with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): with patch("litellm.proxy.auth.auth_checks.get_team_object") as mock_get_team: - result = await MCPRequestHandler._get_team_object_permission( - mock_user_auth - ) + result = await MCPRequestHandler._get_team_object_permission(mock_user_auth) assert result is None mock_get_team.assert_not_called() @@ -3342,9 +3265,7 @@ async def test_get_allowed_tools_for_server_ui_session_team_keeps_key_restrictio detail={"error": "Team doesn't exist in db. Team=litellm-dashboard."}, ), ): - with patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=key_perm - ): + with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm): result = await MCPRequestHandler.get_allowed_tools_for_server( server_id="server_1", user_api_key_auth=user_api_key_auth, @@ -3410,9 +3331,7 @@ async def test_get_allowed_mcp_servers_for_team_uses_helper(): return_value=["group-server1", "group-server2"], ) as mock_get_access_group_servers, ): - result = await MCPRequestHandler._get_allowed_mcp_servers_for_team( - mock_user_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_team(mock_user_auth) assert set(result) == { "direct-server1", @@ -3455,9 +3374,7 @@ async def test_get_allowed_mcp_servers_for_team_with_no_object_permission(): return_value=mock_team, ), ): - result = await MCPRequestHandler._get_allowed_mcp_servers_for_team( - mock_user_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_team(mock_user_auth) assert result == [] @@ -3507,9 +3424,7 @@ async def test_get_allowed_mcp_servers_for_team_without_team_id_returns_empty(): ), ], ) -async def test_get_allowed_mcp_servers_for_key_guard_conditions( - user_api_key_auth, prisma_client_value, scenario -): +async def test_get_allowed_mcp_servers_for_key_guard_conditions(user_api_key_auth, prisma_client_value, scenario): """Ensure guard clauses return [] before hitting get_object_permission.""" with patch( @@ -3517,9 +3432,7 @@ async def test_get_allowed_mcp_servers_for_key_guard_conditions( new_callable=AsyncMock, ) as mock_get_perm: with patch("litellm.proxy.proxy_server.prisma_client", prisma_client_value): - result = await MCPRequestHandler._get_allowed_mcp_servers_for_key( - user_api_key_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(user_api_key_auth) assert result == [] mock_get_perm.assert_not_called() @@ -3546,9 +3459,7 @@ async def test_get_allowed_mcp_servers_for_key_returns_empty_when_db_returns_non ): mock_get_perm.return_value = None - result = await MCPRequestHandler._get_allowed_mcp_servers_for_key( - user_api_key_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(user_api_key_auth) assert result == [] mock_get_perm.assert_awaited_once() @@ -3591,14 +3502,10 @@ async def test_get_allowed_mcp_servers_for_key_prefers_in_memory_permission(): "litellm.proxy.auth.auth_checks.get_object_permission", new_callable=AsyncMock, ) as mock_get_perm: - with patch.object( - MCPRequestHandler, "_get_mcp_servers_from_access_groups" - ) as mock_access_groups: + with patch.object(MCPRequestHandler, "_get_mcp_servers_from_access_groups") as mock_access_groups: mock_access_groups.return_value = ["group-server"] - result = await MCPRequestHandler._get_allowed_mcp_servers_for_key( - user_api_key_auth - ) + result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(user_api_key_auth) assert set(result) == {"direct-server", "group-server"} mock_get_perm.assert_not_called() @@ -3619,21 +3526,13 @@ class TestAgentMCPPermissions: team_id="test-team", agent_id="agent-123", ) - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_agent" - ) as mock_agent: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_agent") as mock_agent: mock_key.return_value = ["server_1", "server_2"] mock_team.return_value = [] mock_agent.return_value = ["server_1"] - result = await MCPRequestHandler.get_allowed_mcp_servers( - user_api_key_auth=user_api_key_auth - ) + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth=user_api_key_auth) assert sorted(result) == ["server_1"] mock_agent.assert_called_once_with(user_api_key_auth) @@ -3644,21 +3543,13 @@ class TestAgentMCPPermissions: user_id="test-user", agent_id="agent-456", ) - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_agent" - ) as mock_agent: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_agent") as mock_agent: mock_key.return_value = ["server_1", "server_2"] mock_team.return_value = [] mock_agent.return_value = [] # no agent-level restriction - result = await MCPRequestHandler.get_allowed_mcp_servers( - user_api_key_auth=user_api_key_auth - ) + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth=user_api_key_auth) assert sorted(result) == ["server_1", "server_2"] mock_agent.assert_called_once_with(user_api_key_auth) @@ -3669,21 +3560,13 @@ class TestAgentMCPPermissions: user_id="test-user", agent_id="agent-789", ) - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_key" - ) as mock_key: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_team" - ) as mock_team: - with patch.object( - MCPRequestHandler, "_get_allowed_mcp_servers_for_agent" - ) as mock_agent: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_key") as mock_key: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_team") as mock_team: + with patch.object(MCPRequestHandler, "_get_allowed_mcp_servers_for_agent") as mock_agent: mock_key.return_value = ["server_1", "server_2"] mock_team.return_value = [] mock_agent.return_value = ["server_2", "server_3"] - result = await MCPRequestHandler.get_allowed_mcp_servers( - user_api_key_auth=user_api_key_auth - ) + result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth=user_api_key_auth) assert sorted(result) == ["server_2"] async def test_get_allowed_tools_for_server_agent_intersection(self): @@ -3696,9 +3579,7 @@ class TestAgentMCPPermissions: key_perm = MagicMock() key_perm.mcp_tool_permissions = {"server_1": ["tool_a", "tool_b"]} team_perm = None - with patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=key_perm - ): + with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm): with patch.object( MCPRequestHandler, "_get_team_object_permission", @@ -3730,9 +3611,7 @@ class TestAgentMCPPermissions: ) key_perm = MagicMock() key_perm.mcp_tool_permissions = {"server_1": ["tool_a", "tool_b"]} - with patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=key_perm - ): + with patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm): with patch.object( MCPRequestHandler, "_get_team_object_permission", @@ -3762,9 +3641,7 @@ class TestAgentMCPPermissions: agent_row = MagicMock() agent_row.object_permission_id = "perm-xyz" prisma_client = MagicMock() - prisma_client.db.litellm_agentstable.find_unique = AsyncMock( - return_value=agent_row - ) + prisma_client.db.litellm_agentstable.find_unique = AsyncMock(return_value=agent_row) user_api_key_auth = UserAPIKeyAuth( api_key="test-key", user_id="test-user", @@ -3782,9 +3659,7 @@ class TestAgentMCPPermissions: return_value=expected_perm, ) as mock_get_perm, ): - result = await MCPRequestHandler._get_agent_object_permission( - user_api_key_auth - ) + result = await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) assert result is expected_perm mock_get_perm.assert_awaited_once() assert mock_get_perm.await_args.kwargs["object_permission_id"] == "perm-xyz" @@ -3804,9 +3679,7 @@ class TestAgentMCPPermissions: agent_row = MagicMock() agent_row.object_permission_id = None prisma_client = MagicMock() - prisma_client.db.litellm_agentstable.find_unique = AsyncMock( - return_value=agent_row - ) + prisma_client.db.litellm_agentstable.find_unique = AsyncMock(return_value=agent_row) user_api_key_auth = UserAPIKeyAuth( api_key="test-key", user_id="test-user", @@ -3822,14 +3695,8 @@ class TestAgentMCPPermissions: new_callable=AsyncMock, ) as mock_get_perm, ): - assert ( - await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) - is None - ) - assert ( - await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) - is None - ) + assert await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) is None + assert await MCPRequestHandler._get_agent_object_permission(user_api_key_auth) is None mock_get_perm.assert_not_awaited() prisma_client.db.litellm_agentstable.find_unique.assert_awaited_once() @@ -3870,9 +3737,7 @@ async def test_tool_permission_servers_included_in_allowed_servers(): ) with ( - patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=perm - ), + patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=perm), patch.object( MCPRequestHandler, "_get_mcp_servers_from_access_groups", @@ -4105,9 +3970,7 @@ class TestOrgMCPPermissions: org_perm.mcp_tool_permissions = {"server_1": ["tool_a", "tool_b"]} with ( - patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=key_perm - ), + patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm), patch.object( MCPRequestHandler, "_get_team_object_permission", @@ -4137,9 +4000,7 @@ class TestOrgMCPPermissions: org_perm.mcp_tool_permissions = {} with ( - patch.object( - MCPRequestHandler, "_get_key_object_permission", return_value=key_perm - ), + patch.object(MCPRequestHandler, "_get_key_object_permission", return_value=key_perm), patch.object( MCPRequestHandler, "_get_team_object_permission", @@ -4231,9 +4092,7 @@ async def test_mcp_key_access_group_extras_when_team_authorized(): ] _start_patches(patches) try: - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == ["srv-stripe"] finally: _stop_patches(patches) @@ -4270,9 +4129,7 @@ async def test_mcp_key_access_group_extras_when_key_directly_authorized(): ] _start_patches(patches) try: - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == ["srv-stripe"] finally: _stop_patches(patches) @@ -4286,9 +4143,7 @@ async def test_mcp_key_access_group_extras_when_key_has_no_groups(): access_group_ids=[], team_id="team-a", ) - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == [] @@ -4315,9 +4170,7 @@ async def test_mcp_key_access_group_extras_when_group_has_no_servers(): ] _start_patches(patches) try: - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == [] finally: _stop_patches(patches) @@ -4351,9 +4204,7 @@ async def test_mcp_key_access_group_extras_granted_even_when_group_authorizes_ne ] _start_patches(patches) try: - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == ["srv-finance-only"] finally: _stop_patches(patches) @@ -4376,9 +4227,7 @@ async def test_mcp_key_access_group_extras_when_get_access_object_raises(): ] _start_patches(patches) try: - result = await MCPRequestHandler._get_key_access_group_mcp_server_extras( - valid_token - ) + result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(valid_token) assert result == [] finally: _stop_patches(patches) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py index fbe768e07c6..17960e917a4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_adapter.py @@ -23,6 +23,7 @@ from litellm.proxy._experimental.mcp_server.outbound_credentials.types import ( AuthorizationCodeConfig, CredError, NoneConfig, + PassthroughConfig, SharedKey, TokenExchangeConfig, ) @@ -234,6 +235,12 @@ def test_token_exchange_empty_subject_token_type_normalizes_to_default(): assert spec.config.subject_token_type == "urn:ietf:params:oauth:token-type:access_token" +@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) +def test_client_forwarded_modes_map_to_passthrough_config(auth_type): + spec = to_server_spec(_server(auth_type=auth_type)) + assert spec is not None and isinstance(spec.config, PassthroughConfig) + + @pytest.mark.parametrize( "server", [ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py index f8e45b38b49..64226eea821 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/outbound_credentials/test_resolver.py @@ -1,9 +1,9 @@ """Tests for the resolver dispatch: live arms produce auth, stubbed arms fail closed. -`none`, `api_key` (shared-key source), `authorization_code`, and `token_exchange` are implemented; -every other arm, plus the `api_key` BYOK source, returns a typed `not_implemented` error until its -mode lands. Parametrizing the stubs over one config each also guards reachability: a dropped `case` -would hit `assert_never` and raise instead of returning the stub. +`none`, `api_key` (shared-key source), `passthrough`, `authorization_code`, and `token_exchange` are +implemented; every other arm, plus the `api_key` BYOK source, returns a typed `not_implemented` error +until its mode lands. Parametrizing the stubs over one config each also guards reachability: a dropped +`case` would hit `assert_never` and raise instead of returning the stub. """ import httpx @@ -253,9 +253,24 @@ async def test_token_exchange_without_an_exchanger_fails_closed(): assert result.error.tag == "misconfigured" +@pytest.mark.asyncio +async def test_passthrough_forwards_the_inbound_token_verbatim(): + subject = Subject(tenant_id="", subject_id="", inbound_token=SecretStr("Bearer upstream-xyz")) + result = await UpstreamCredentialProvider().resolve_credentials(subject, _spec(PassthroughConfig())) + assert isinstance(result, Ok) + assert isinstance(result.ok, StaticHeaderAuth) + assert _emitted(result.ok)["Authorization"] == "Bearer upstream-xyz" + + +@pytest.mark.asyncio +async def test_passthrough_without_inbound_token_is_a_no_op(): + result = await UpstreamCredentialProvider().resolve_credentials(_SUBJECT, _spec(PassthroughConfig())) + assert isinstance(result, Ok) + assert isinstance(result.ok, NoOpAuth) + + _STUBBED = [ ("api_key_byok", ApiKeyConfig(key_source=Byok())), - ("passthrough", PassthroughConfig()), ("client_credentials", ClientCredentialsConfig()), ("aws_sigv4", AwsSigV4Config(region="us-east-1")), ] 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 6fa69b2f96c..a7a0379c7ad 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 @@ -1267,6 +1267,229 @@ class TestMCPServerManager: assert captured_extra_headers == {"Authorization": "Bearer upstream-oauth-bearer"} + async def _capture_call_extra_headers(self, server, oauth2_headers, raw_headers, user_api_key_auth): + manager = MCPServerManager() + mock_client = AsyncMock() + mock_client.call_tool = AsyncMock(return_value=CallToolResult(content=[], isError=False)) + captured = {"extra_headers": "unset"} + + async def capture_create_mcp_client( + server, mcp_auth_header, extra_headers, stdio_env, subject_token=None, **kwargs + ): # pragma: no cover - helper + captured["extra_headers"] = extra_headers + return mock_client + + manager._create_mcp_client = AsyncMock(side_effect=capture_create_mcp_client) + await manager._call_regular_mcp_tool( + mcp_server=server, + original_tool_name="tool", + arguments={}, + tasks=[], + mcp_auth_header=None, + mcp_server_auth_headers=None, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + proxy_logging_obj=None, + user_api_key_auth=user_api_key_auth, + ) + return captured["extra_headers"] + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_true_passthrough_forwards_authorization(self): + from litellm.proxy._types import UserAPIKeyAuth + + server = MCPServer( + server_id="server-true-passthrough", + name="tp-server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + extra_headers = await self._capture_call_extra_headers( + server, + oauth2_headers={"Authorization": "Bearer upstream-token"}, + raw_headers={"authorization": "Bearer upstream-token"}, + user_api_key_auth=UserAPIKeyAuth(api_key=None), + ) + assert extra_headers == {"Authorization": "Bearer upstream-token"} + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_oauth_delegate_forwards_separate_authorization(self): + from litellm.proxy._types import UserAPIKeyAuth + + server = MCPServer( + server_id="server-oauth-delegate", + name="od-server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + extra_headers = await self._capture_call_extra_headers( + server, + oauth2_headers={"Authorization": "Bearer upstream-token"}, + raw_headers={ + "x-litellm-api-key": "Bearer sk-litellm-key", + "authorization": "Bearer upstream-token", + }, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + assert extra_headers == {"Authorization": "Bearer upstream-token"} + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_oauth_delegate_never_forwards_admission_key(self): + from litellm.proxy._types import UserAPIKeyAuth + + server = MCPServer( + server_id="server-oauth-delegate-leak", + name="od-server", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + extra_headers = await self._capture_call_extra_headers( + server, + oauth2_headers={"Authorization": "Bearer sk-litellm-key"}, + raw_headers={"authorization": "Bearer sk-litellm-key"}, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} + + def test_should_strip_caller_authorization_new_modes(self): + from litellm.proxy._types import UserAPIKeyAuth + + true_passthrough = MCPServer( + server_id="tp", + name="tp", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + assert ( + _should_strip_caller_authorization( + mcp_server=true_passthrough, + raw_headers={"authorization": "Bearer upstream"}, + user_api_key_auth=UserAPIKeyAuth(api_key=None), + ) + is False + ) + + oauth_delegate = MCPServer( + server_id="od", + name="od", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + assert ( + _should_strip_caller_authorization( + mcp_server=oauth_delegate, + raw_headers={ + "x-litellm-api-key": "Bearer sk-litellm-key", + "authorization": "Bearer upstream", + }, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + is False + ) + assert ( + _should_strip_caller_authorization( + mcp_server=oauth_delegate, + raw_headers={"authorization": "Bearer sk-litellm-key"}, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + ) + is True + ) + + def test_should_strip_authorization_for_oauth_delegate_admitted_via_jwt_without_api_key(self): + """JWT / SSO / OIDC / session admission yields a UserAPIKeyAuth with a user_id but + api_key=None; the caller's Authorization was that credential and must be stripped for + oauth_delegate when no separate x-litellm-api-key carried admission (LIT-3794-class leak).""" + from litellm.proxy._types import UserAPIKeyAuth + + oauth_delegate = MCPServer( + server_id="od-jwt", + name="od", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + assert ( + _should_strip_caller_authorization( + mcp_server=oauth_delegate, + raw_headers={"authorization": "Bearer eyJ-idp-jwt"}, + user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key=None), + ) + is True + ) + assert ( + _should_strip_caller_authorization( + mcp_server=oauth_delegate, + raw_headers={ + "x-litellm-api-key": "Bearer sk-1234", + "authorization": "Bearer upstream", + }, + user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key=None), + ) + is False + ) + + @pytest.mark.asyncio + async def test_call_regular_mcp_tool_oauth_delegate_never_forwards_jwt_admission(self): + from litellm.proxy._types import UserAPIKeyAuth + + server = MCPServer( + server_id="od-jwt-e2e", + name="od", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + extra_headers = await self._capture_call_extra_headers( + server, + oauth2_headers={"Authorization": "Bearer eyJ-idp-jwt"}, + raw_headers={"authorization": "Bearer eyJ-idp-jwt"}, + user_api_key_auth=UserAPIKeyAuth(user_id="alice", api_key=None), + ) + assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} + + def test_new_passthrough_modes_require_per_user_auth(self): + for auth_type in (MCPAuth.true_passthrough, MCPAuth.oauth_delegate): + server = MCPServer( + server_id="s", + name="s", + url="https://example.com", + transport=MCPTransport.http, + auth_type=auth_type, + ) + assert server.requires_per_user_auth is True + + @pytest.mark.asyncio + async def test_create_mcp_client_forwarded_modes_use_the_passthrough_arm(self): + manager = MCPServerManager() + server = MCPServer( + server_id="tp-egress", + name="tp", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + with ( + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.resolve_mcp_auth", + new_callable=AsyncMock, + ) as mock_resolve, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPClient") as mock_client_cls, + ): + await manager._create_mcp_client(server=server, extra_headers={"Authorization": "Bearer upstream-token"}) + mock_resolve.assert_not_awaited() + kwargs = mock_client_cls.call_args.kwargs + emitted = httpx.Request("GET", "https://example.com/mcp") + flow = kwargs["resolved_auth"].auth_flow(emitted) + next(flow) + flow.close() + assert emitted.headers["Authorization"] == "Bearer upstream-token" + assert not kwargs["extra_headers"] or "authorization" not in {k.lower() for k in kwargs["extra_headers"]} + @pytest.mark.asyncio async def test_get_prompts_from_server_success(self): """Ensure prompts are fetched and prefixed when requested.""" diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index d9bba85bf4b..9765775af8f 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -27008,7 +27008,7 @@ export interface components { /** Alias */ alias?: string | null; /** Auth Type */ - auth_type?: ("none" | "api_key" | "bearer_token" | "basic" | "authorization" | "oauth2" | "aws_sigv4" | "token" | "oauth2_token_exchange") | null; + auth_type?: ("none" | "api_key" | "bearer_token" | "basic" | "authorization" | "oauth2" | "aws_sigv4" | "token" | "oauth2_token_exchange" | "true_passthrough" | "oauth_delegate") | null; /** Mcp Info */ mcp_info?: { [key: string]: unknown; From 446a6e8cddc883f058023ec3a173115d87437b27 Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Mon, 6 Jul 2026 13:23:28 -0700 Subject: [PATCH 002/365] fix(mcp): route true_passthrough and oauth_delegate through upstream OAuth discovery Both modes advertised LiteLLM as the authorization server and answered initialize locally, so a client with no token connected empty and was never driven into the upstream OAuth flow. The protected-resource discovery now proxies the upstream metadata for both modes (verbatim for true_passthrough, resource rewritten to the gateway for oauth_delegate), and the preemptive 401 emits the matching challenge: oauth_delegate uses the gateway-proxied resource_metadata once admission passes, true_passthrough probes the upstream anonymously and surfaces its WWW-Authenticate verbatim so the client authorizes directly against the upstream --- .../mcp_server/discoverable_endpoints.py | 9 +- .../proxy/_experimental/mcp_server/server.py | 27 +- .../mcp_server/test_mcp_oauth_passthrough.py | 99 ++++++ .../mcp_server/test_mcp_stale_session.py | 310 ++++++++++++++++++ 4 files changed, 440 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index 89d645b6f8a..f383c99c563 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1337,7 +1337,9 @@ async def _build_oauth_protected_resource_response( # Pass-through branch: proxy the upstream's own metadata so discovery # directs the client at the real IdP (Okta, Keycloak, …) instead of us. - if mcp_server is not None and mcp_server.is_oauth_passthrough: + if mcp_server is not None and ( + mcp_server.is_oauth_passthrough or mcp_server.is_oauth_delegate or mcp_server.is_true_passthrough + ): try: upstream_metadata = await fetch_upstream_oauth_protected_resource(mcp_server) except Exception as exc: @@ -1353,8 +1355,9 @@ async def _build_oauth_protected_resource_response( ) if upstream_metadata is not None: - response = {**upstream_metadata, "resource": resource_url} - return response + if mcp_server.is_true_passthrough: + return upstream_metadata + return {**upstream_metadata, "resource": resource_url} # Upstream responded but with non-200 or non-dict payload. For # pass-through servers the gateway is NOT the authorization server, diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 96fd014fbe1..7a854f698c9 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -3526,6 +3526,29 @@ if MCP_AVAILABLE: headers={"www-authenticate": www_authenticate}, ) + if server and server.is_oauth_delegate and _get_forwarded_auth_from_scope(scope) is None: + www_authenticate = _get_passthrough_www_authenticate( + scope=scope, + server_name=server_name, + ) + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"www-authenticate": www_authenticate}, + ) + + if server and server.is_true_passthrough and not _scope_has_authorization_header(scope): + upstream_status, upstream_www_authenticate = await _probe_upstream_auth(server.url or "", "") + if upstream_status == 401 and upstream_www_authenticate: + raise HTTPException( + status_code=401, + detail="Unauthorized", + headers={"www-authenticate": upstream_www_authenticate}, + ) + + def _scope_has_authorization_header(scope: Scope) -> bool: + return any(key.lower() == b"authorization" for key, _ in scope.get("headers", [])) + def _get_forwarded_auth_from_scope(scope: Scope) -> Optional[str]: """Return the upstream-bound ``Authorization`` header value, or None. @@ -3553,7 +3576,7 @@ if MCP_AVAILABLE: url: str, auth_header: str, timeout: float = 5.0, - ) -> tuple: + ) -> tuple[int, Optional[str]]: """JSON-RPC initialize-probe the upstream URL to check whether the token is accepted. Uses POST so StreamableHTTP MCP servers run the same auth path as a @@ -3583,8 +3606,8 @@ if MCP_AVAILABLE: }, } probe_headers = { - "Authorization": auth_header, "Accept": "application/json, text/event-stream", + **({"Authorization": auth_header} if auth_header else {}), } try: resp = await client.post( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py index ad78609ee18..002e98034c1 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py @@ -472,3 +472,102 @@ async def test_oauth_protected_resource_gateway_managed_unchanged(): "https://gateway.example.com/keycloak_whoami" ] assert result["scopes_supported"] == ["read"] + + +def _make_upstream_metadata_client() -> tuple[dict, MagicMock]: + upstream_payload = { + "resource": "https://upstream.example.com/mcp", + "authorization_servers": ["https://okta.example.com/oauth2/default"], + "scopes_supported": ["openid", "profile"], + "bearer_methods_supported": ["header"], + } + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = upstream_payload + mock_client = MagicMock() + mock_client.get = AsyncMock(return_value=mock_response) + return upstream_payload, mock_client + + +@pytest.mark.asyncio +async def test_oauth_protected_resource_oauth_delegate_proxies_upstream_with_gateway_resource(): + """oauth_delegate discovery must proxy the upstream's authorization_servers + (so the client authorizes against the upstream IdP) while rewriting resource + to the gateway (so bearers are presented back to LiteLLM). A regression that + dropped oauth_delegate from the pass-through predicate would fall through to + the gateway-AS branch and advertise LiteLLM as the authorization server.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + global_mcp_server_manager.registry.clear() + delegate_server = MCPServer( + server_id="delegate-1", + name="sample_docs", + server_name="sample_docs", + alias="sample_docs", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + global_mcp_server_manager.registry[delegate_server.server_id] = delegate_server + + _, mock_client = _make_upstream_metadata_client() + try: + with patch.object( + discoverable_endpoints, "get_async_httpx_client", return_value=mock_client + ): + result = await _build_oauth_protected_resource_response( + request=_make_request(), + mcp_server_name="sample_docs", + use_standard_pattern=True, + ) + + assert result["authorization_servers"] == [ + "https://okta.example.com/oauth2/default" + ] + assert result["resource"].endswith("/mcp/sample_docs") + assert result["resource"] != "https://upstream.example.com/mcp" + finally: + global_mcp_server_manager.registry.clear() + + +@pytest.mark.asyncio +async def test_oauth_protected_resource_true_passthrough_returns_upstream_metadata_verbatim(): + """true_passthrough discovery must return the upstream metadata verbatim, + resource included, so the client treats the upstream as the resource and + authorizes directly against it. A regression that rewrote resource (the + gateway-proxied behavior) would break the transparent-proxy contract.""" + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + + global_mcp_server_manager.registry.clear() + true_passthrough_server = MCPServer( + server_id="tp-1", + name="sample_docs", + server_name="sample_docs", + alias="sample_docs", + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + global_mcp_server_manager.registry[true_passthrough_server.server_id] = ( + true_passthrough_server + ) + + upstream_payload, mock_client = _make_upstream_metadata_client() + try: + with patch.object( + discoverable_endpoints, "get_async_httpx_client", return_value=mock_client + ): + result = await _build_oauth_protected_resource_response( + request=_make_request(), + mcp_server_name="sample_docs", + use_standard_pattern=True, + ) + + assert result == upstream_payload + assert result["resource"] == "https://upstream.example.com/mcp" + finally: + global_mcp_server_manager.registry.clear() diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py index 76614a35b53..44ea5f43a70 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py @@ -713,6 +713,9 @@ async def test_handle_streamable_http_mcp_delegated_server_surfaces_upstream_cha delegated_server.auth_type = MCPAuth.oauth2 delegated_server.delegate_auth_to_upstream = True delegated_server.needs_user_oauth_token = True + delegated_server.is_oauth_passthrough = False + delegated_server.is_oauth_delegate = False + delegated_server.is_true_passthrough = False delegated_server.server_id = "delegated-oauth-server" upstream_challenge = 'Bearer resource_metadata="https://upstream.example.com/.well-known/oauth-protected-resource"' @@ -1048,3 +1051,310 @@ async def test_handle_streamable_http_mcp_token_exchange_without_subject_returns assert "/.well-known/oauth-protected-resource" in challenge assert challenge.split('resource_metadata="', 1)[1].split('"', 1)[0].endswith("/mcp/obo_server") assert 'error="invalid_token"' in challenge + + +def _passthrough_mode_scope(server_name: str, extra_headers=None): + headers = [ + (b"content-type", b"application/json"), + (b"host", b"litellm.example.com"), + ] + list(extra_headers or []) + return { + "type": "http", + "method": "POST", + "path": f"/mcp/{server_name}", + "_original_path": f"/{server_name}/mcp", + "scheme": "https", + "query_string": b"", + "root_path": "", + "server": ("litellm.example.com", 443), + "headers": headers, + } + + +def _build_passthrough_mode_server(server_name: str, auth_type): + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + return MCPServer( + server_id=f"{server_name}-id", + name=server_name, + server_name=server_name, + alias=server_name, + url="https://upstream.example.com/mcp", + transport=MCPTransport.http, + auth_type=auth_type, + ) + + +@pytest.mark.asyncio +async def test_handle_streamable_http_mcp_oauth_delegate_without_token_returns_gateway_proxied_401(): + """oauth_delegate is admitted with the LiteLLM key but still owns upstream + OAuth. With no forwarded upstream token the gateway must challenge with the + proxied resource_metadata (which advertises the upstream IdP), never the + gateway authorization_uri and never a silent 200.""" + from fastapi import HTTPException + + try: + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateful, + ) + except ImportError: + pytest.skip("MCP server not available") + + scope = _passthrough_mode_scope("od_server") + receive = AsyncMock( + return_value={ + "type": "http.request", + "body": b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', + "more_body": False, + } + ) + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = "u1" + od_server = _build_passthrough_mode_server("od_server", MCPAuth.oauth_delegate) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, ["od_server"], None, None, None), + ), + patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"), + patch( + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=od_server, + ), + patch.object( + session_manager_stateful, + "handle_request", + new_callable=AsyncMock, + ) as mock_handle_request, + ): + with pytest.raises(HTTPException) as exc_info: + await handle_streamable_http_mcp(scope, receive, send) + + assert mock_handle_request.await_count == 0 + assert exc_info.value.status_code == 401 + challenge = exc_info.value.headers["www-authenticate"] + assert "resource_metadata=" in challenge + assert "authorization_uri=" not in challenge + assert "/.well-known/oauth-protected-resource/od_server/mcp" in challenge + + +@pytest.mark.asyncio +async def test_handle_streamable_http_mcp_oauth_delegate_with_forwarded_token_skips_challenge(): + """When the oauth_delegate caller carries both the LiteLLM key and a separate + upstream Authorization, the gateway must forward to the session manager, not + re-challenge. Guards the ``_get_forwarded_auth_from_scope(...) is None`` + condition: dropping it would 401 even a fully-authenticated request.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateless, + ) + except ImportError: + pytest.skip("MCP server not available") + + scope = _passthrough_mode_scope( + "od_server", + extra_headers=[ + (b"x-litellm-api-key", b"Bearer sk-1234"), + (b"authorization", b"Bearer upstream-token"), + ], + ) + receive = AsyncMock( + return_value={ + "type": "http.request", + "body": b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}', + "more_body": False, + } + ) + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = "u1" + od_server = _build_passthrough_mode_server("od_server", MCPAuth.oauth_delegate) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, ["od_server"], None, None, None), + ), + patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"), + patch( + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._handle_stale_mcp_session", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=od_server, + ), + patch.object( + session_manager_stateless, + "handle_request", + new_callable=AsyncMock, + ) as mock_handle_request, + patch.object(session_manager_stateless, "_server_instances", {}), + ): + await handle_streamable_http_mcp(scope, receive, send) + + assert mock_handle_request.await_count == 1 + + +@pytest.mark.asyncio +async def test_handle_streamable_http_mcp_true_passthrough_without_token_surfaces_verbatim_upstream_challenge(): + """true_passthrough is a transparent proxy: with no client Authorization the + gateway probes the upstream and surfaces its own WWW-Authenticate verbatim, + so the client discovers and authorizes against the upstream directly. Guards + against answering initialize locally with a silent 200.""" + from fastapi import HTTPException + + try: + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateful, + ) + except ImportError: + pytest.skip("MCP server not available") + + upstream_challenge = 'Bearer resource_metadata="https://upstream.example.com/.well-known/oauth-protected-resource"' + probe_response = MagicMock() + probe_response.status_code = 401 + probe_response.headers = {"www-authenticate": upstream_challenge} + probe_client = MagicMock() + probe_client.post = AsyncMock(return_value=probe_response) + + scope = _passthrough_mode_scope("tp_server") + receive = AsyncMock( + return_value={ + "type": "http.request", + "body": b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}', + "more_body": False, + } + ) + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = None + tp_server = _build_passthrough_mode_server("tp_server", MCPAuth.true_passthrough) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, ["tp_server"], None, None, None), + ), + patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"), + patch( + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", + return_value=probe_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=tp_server, + ), + patch.object( + session_manager_stateful, + "handle_request", + new_callable=AsyncMock, + ) as mock_handle_request, + ): + with pytest.raises(HTTPException) as exc_info: + await handle_streamable_http_mcp(scope, receive, send) + + assert mock_handle_request.await_count == 0 + assert exc_info.value.status_code == 401 + assert exc_info.value.headers["www-authenticate"] == upstream_challenge + probe_client.post.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_handle_streamable_http_mcp_true_passthrough_with_token_skips_probe_and_challenge(): + """When the true_passthrough caller already carries an Authorization the + gateway must forward without probing or challenging. Guards the + ``not _scope_has_authorization_header(scope)`` condition and the no-probe + fast path.""" + try: + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateless, + ) + except ImportError: + pytest.skip("MCP server not available") + + probe_client = MagicMock() + probe_client.post = AsyncMock() + + scope = _passthrough_mode_scope( + "tp_server", + extra_headers=[(b"authorization", b"Bearer upstream-token")], + ) + receive = AsyncMock( + return_value={ + "type": "http.request", + "body": b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}', + "more_body": False, + } + ) + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = None + tp_server = _build_passthrough_mode_server("tp_server", MCPAuth.true_passthrough) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, ["tp_server"], None, None, None), + ), + patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"), + patch( + "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", + True, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._handle_stale_mcp_session", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", + return_value=probe_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=tp_server, + ), + patch.object( + session_manager_stateless, + "handle_request", + new_callable=AsyncMock, + ) as mock_handle_request, + patch.object(session_manager_stateless, "_server_instances", {}), + ): + await handle_streamable_http_mcp(scope, receive, send) + + assert mock_handle_request.await_count == 1 + probe_client.post.assert_not_awaited() From 9bf3907de546beb34bbfddad048c8295e746e50a Mon Sep 17 00:00:00 2001 From: Tin Chi Lo Date: Tue, 7 Jul 2026 16:13:43 -0700 Subject: [PATCH 003/365] fix(mcp): bind oauth_delegate discovery resource to the upstream oauth_delegate forwards the caller's token to the upstream, which validates its audience, so the protected-resource metadata must keep resource pointing at the upstream (returned verbatim, like true_passthrough) rather than rewriting it to the gateway. Rewriting to the gateway asks the client to mint a token bound to the gateway audience, which a strict IdP (Entra) refuses to issue for an unregistered resource and a spec-compliant upstream rejects on receipt. The legacy is_oauth_passthrough opt-in keeps the gateway rewrite unchanged. --- .../mcp_server/discoverable_endpoints.py | 16 ++-- .../mcp_server/test_mcp_oauth_passthrough.py | 94 ++++++------------- 2 files changed, 39 insertions(+), 71 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index f383c99c563..415336b1a9e 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -1292,11 +1292,15 @@ async def _build_oauth_protected_resource_response( """ Build OAuth protected resource response with the appropriate URL pattern. - For pass-through MCP servers (``MCPServer.is_oauth_passthrough``), the - gateway proxies the upstream's own ``oauth-protected-resource`` metadata - so that standards-compliant MCP clients discover the **upstream** IdP - instead of the gateway. The ``resource`` field is rewritten to the - gateway's own URL so clients present the bearer token back to the gateway. + For pass-through MCP servers, the gateway proxies the upstream's own + ``oauth-protected-resource`` metadata so standards-compliant MCP clients + discover the **upstream** IdP instead of the gateway. For ``true_passthrough`` + and ``oauth_delegate`` the metadata is returned verbatim (``resource`` stays + the upstream): the caller's token is forwarded to and validated by the + upstream, so its audience must be the upstream — rewriting it to the gateway + would make a strict IdP (e.g. Entra) refuse to mint it or the upstream reject + it. Only the legacy ``is_oauth_passthrough`` opt-in rewrites ``resource`` to + the gateway's own URL so clients present the bearer token back to the gateway. Args: request: FastAPI Request object @@ -1355,7 +1359,7 @@ async def _build_oauth_protected_resource_response( ) if upstream_metadata is not None: - if mcp_server.is_true_passthrough: + if mcp_server.is_true_passthrough or mcp_server.is_oauth_delegate: return upstream_metadata return {**upstream_metadata, "resource": resource_url} diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py index 002e98034c1..ef2a8318d7a 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_oauth_passthrough.py @@ -34,8 +34,7 @@ from litellm.types.mcp_server.mcp_server_manager import MCPServer def _mock_mcp_client_ip(): """Bypass IP-based access control in tests.""" with patch( - "litellm.proxy._experimental.mcp_server.discoverable_endpoints" - ".IPAddressUtils.get_mcp_client_ip", + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.IPAddressUtils.get_mcp_client_ip", return_value=None, ): yield @@ -191,9 +190,7 @@ async def test_oauth_protected_resource_passthrough_proxies_upstream_metadata(): extra_headers=["Authorization"], oauth_passthrough=True, ) - global_mcp_server_manager.registry[passthrough_server.server_id] = ( - passthrough_server - ) + global_mcp_server_manager.registry[passthrough_server.server_id] = passthrough_server upstream_payload = { "resource": "https://upstream.example.com/mcp", @@ -207,18 +204,14 @@ async def test_oauth_protected_resource_passthrough_proxies_upstream_metadata(): mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_response) - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): result = await _build_oauth_protected_resource_response( request=_make_request(), mcp_server_name="sample_docs", use_standard_pattern=True, ) - assert result["authorization_servers"] == [ - "https://okta.example.com/oauth2/default" - ] + assert result["authorization_servers"] == ["https://okta.example.com/oauth2/default"] # resource is normalized to the gateway URL so bearers are sent back to us assert result["resource"].endswith("/mcp/sample_docs") assert result["scopes_supported"] == ["openid", "profile"] @@ -242,9 +235,7 @@ async def test_oauth_protected_resource_passthrough_cache_hit(): extra_headers=["Authorization"], oauth_passthrough=True, ) - global_mcp_server_manager.registry[passthrough_server.server_id] = ( - passthrough_server - ) + global_mcp_server_manager.registry[passthrough_server.server_id] = passthrough_server mock_response = MagicMock() mock_response.status_code = 200 @@ -254,9 +245,7 @@ async def test_oauth_protected_resource_passthrough_cache_hit(): mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_response) - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): await _build_oauth_protected_resource_response( request=_make_request(), mcp_server_name="sample_docs", @@ -348,12 +337,8 @@ async def test_oauth_metadata_cache_expired_entry_is_refetched(): mock_client = MagicMock() mock_client.get = AsyncMock(return_value=mock_response) - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): - result = await discoverable_endpoints.fetch_upstream_oauth_protected_resource( - passthrough_server - ) + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): + result = await discoverable_endpoints.fetch_upstream_oauth_protected_resource(passthrough_server) assert result == {"authorization_servers": ["https://fresh.example.com"]} assert mock_client.get.await_count == 1 @@ -377,16 +362,12 @@ async def test_oauth_protected_resource_passthrough_network_error_returns_502(): extra_headers=["Authorization"], oauth_passthrough=True, ) - global_mcp_server_manager.registry[passthrough_server.server_id] = ( - passthrough_server - ) + global_mcp_server_manager.registry[passthrough_server.server_id] = passthrough_server mock_client = MagicMock() mock_client.get = AsyncMock(side_effect=httpx.ConnectError("boom")) - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): with pytest.raises(HTTPException) as exc_info: await _build_oauth_protected_resource_response( request=_make_request(), @@ -414,16 +395,10 @@ async def test_fetch_upstream_metadata_returns_none_when_not_all_candidates_netw not_found_response = MagicMock() not_found_response.status_code = 404 mock_client = MagicMock() - mock_client.get = AsyncMock( - side_effect=[not_found_response, httpx.ConnectError("path fallback failed")] - ) + mock_client.get = AsyncMock(side_effect=[not_found_response, httpx.ConnectError("path fallback failed")]) - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): - result = await discoverable_endpoints.fetch_upstream_oauth_protected_resource( - passthrough_server - ) + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): + result = await discoverable_endpoints.fetch_upstream_oauth_protected_resource(passthrough_server) assert result is None assert mock_client.get.await_count == 2 @@ -458,9 +433,7 @@ async def test_oauth_protected_resource_gateway_managed_unchanged(): mock_client = MagicMock() mock_client.get = AsyncMock() - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): result = await _build_oauth_protected_resource_response( request=_make_request(), mcp_server_name="keycloak_whoami", @@ -468,9 +441,7 @@ async def test_oauth_protected_resource_gateway_managed_unchanged(): ) mock_client.get.assert_not_awaited() - assert result["authorization_servers"] == [ - "https://gateway.example.com/keycloak_whoami" - ] + assert result["authorization_servers"] == ["https://gateway.example.com/keycloak_whoami"] assert result["scopes_supported"] == ["read"] @@ -490,12 +461,13 @@ def _make_upstream_metadata_client() -> tuple[dict, MagicMock]: @pytest.mark.asyncio -async def test_oauth_protected_resource_oauth_delegate_proxies_upstream_with_gateway_resource(): - """oauth_delegate discovery must proxy the upstream's authorization_servers - (so the client authorizes against the upstream IdP) while rewriting resource - to the gateway (so bearers are presented back to LiteLLM). A regression that - dropped oauth_delegate from the pass-through predicate would fall through to - the gateway-AS branch and advertise LiteLLM as the authorization server.""" +async def test_oauth_protected_resource_oauth_delegate_returns_upstream_metadata_verbatim(): + """oauth_delegate discovery must return the upstream metadata verbatim, + resource included. The caller's token is forwarded to and validated by the + upstream, so its audience must be the upstream; rewriting resource to the + gateway would make a strict IdP refuse to mint it or the upstream reject it. + A regression that dropped oauth_delegate from the pass-through predicate would + fall through to the gateway-AS branch and advertise LiteLLM as the AS.""" from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( global_mcp_server_manager, ) @@ -512,22 +484,18 @@ async def test_oauth_protected_resource_oauth_delegate_proxies_upstream_with_gat ) global_mcp_server_manager.registry[delegate_server.server_id] = delegate_server - _, mock_client = _make_upstream_metadata_client() + upstream_payload, mock_client = _make_upstream_metadata_client() try: - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): result = await _build_oauth_protected_resource_response( request=_make_request(), mcp_server_name="sample_docs", use_standard_pattern=True, ) - assert result["authorization_servers"] == [ - "https://okta.example.com/oauth2/default" - ] - assert result["resource"].endswith("/mcp/sample_docs") - assert result["resource"] != "https://upstream.example.com/mcp" + assert result == upstream_payload + assert result["authorization_servers"] == ["https://okta.example.com/oauth2/default"] + assert result["resource"] == "https://upstream.example.com/mcp" finally: global_mcp_server_manager.registry.clear() @@ -552,15 +520,11 @@ async def test_oauth_protected_resource_true_passthrough_returns_upstream_metada transport=MCPTransport.http, auth_type=MCPAuth.true_passthrough, ) - global_mcp_server_manager.registry[true_passthrough_server.server_id] = ( - true_passthrough_server - ) + global_mcp_server_manager.registry[true_passthrough_server.server_id] = true_passthrough_server upstream_payload, mock_client = _make_upstream_metadata_client() try: - with patch.object( - discoverable_endpoints, "get_async_httpx_client", return_value=mock_client - ): + with patch.object(discoverable_endpoints, "get_async_httpx_client", return_value=mock_client): result = await _build_oauth_protected_resource_response( request=_make_request(), mcp_server_name="sample_docs", From 732832d3426a1a0c29363e83d62be0546d338d8f Mon Sep 17 00:00:00 2001 From: Tin Date: Tue, 7 Jul 2026 19:46:23 -0700 Subject: [PATCH 004/365] fix(mcp): bind client-forwarded Authorization to a single upstream In a listing fan-out over a scope containing more than one server that consumes the caller's Authorization (true_passthrough, oauth_delegate, or the legacy delegate/passthrough shapes), the request-wide bearer is now withheld from the new modes instead of being replayed against every upstream (RFC 9700 cross-resource replay). Explicitly-addressed operations (tool call, get_prompt, read_resource, single-server routes) keep forwarding it. Multi-server aggregates use the per-server x-mcp-{alias}-authorization header instead: its value now feeds the passthrough resolver arm as the inbound token and wins over the request-wide header, binding one token to one server. --- .../mcp_server/mcp_server_manager.py | 51 ++ .../proxy/_experimental/mcp_server/server.py | 28 +- .../mcp_server/test_mcp_server.py | 583 +++++++----------- .../mcp_server/test_mcp_server_manager.py | 112 ++++ 4 files changed, 400 insertions(+), 374 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index fd978e5bfc9..acb7c80c5b7 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -368,6 +368,54 @@ def _take_forwarded_authorization( return value, _without_authorization(headers) +def _passthrough_token_from_mcp_auth_header( + mcp_auth_header: Optional[Union[str, dict[str, str]]], +) -> Optional[str]: + """The caller's per-server upstream credential for a passthrough-mode server, or None. + + Sourced from ``x-mcp-{alias}-authorization`` (string or per-header dict form) or the deprecated + global ``x-mcp-auth`` fallback. Per-server headers are the multi-server shape: they bind one + token to one server, so an aggregate scope with several passthrough-mode servers never replays + a single credential across upstreams. The value is forwarded verbatim, so it must be the full + header value (e.g. ``Bearer ``).""" + if isinstance(mcp_auth_header, str): + return mcp_auth_header or None + if isinstance(mcp_auth_header, dict): + return next((v for k, v in mcp_auth_header.items() if k.lower() == "authorization"), None) + return None + + +def _consumes_caller_authorization(server: MCPServer) -> bool: + """True when this server's egress forwards the caller's request-wide ``Authorization`` upstream: + the client-forwarded token modes, legacy OAuth pass-through, and legacy upstream-delegated + interactive oauth2. An unstamped oauth2 row (flow column not yet backfilled) reads as a consumer, + which errs toward suppression — the fail-safe direction.""" + if server.is_true_passthrough or server.is_oauth_delegate or server.is_oauth_passthrough: + return True + return ( + server.auth_type == MCPAuth.oauth2 + and getattr(server, "delegate_auth_to_upstream", False) is True + and not server.has_client_credentials + ) + + +def _caller_authorization_fans_out( + server: MCPServer, + scope_servers: Optional[list[MCPServer]], +) -> bool: + """True when forwarding the caller's request-wide ``Authorization`` to ``server`` inside a + listing fan-out would replay one credential against multiple upstreams: another server in the + scope also consumes it (RFC 9700 cross-resource replay). ``scope_servers`` is None for + explicitly-addressed operations (tool call, get_prompt, read_resource, single-server routes), + where the client named the one target and the gateway is not choosing recipients.""" + if scope_servers is None: + return False + return any( + other is not None and other.server_id != server.server_id and _consumes_caller_authorization(other) + for other in scope_servers + ) + + def _extract_upstream_auth_failure( exc: BaseException, ) -> Optional[tuple[int, Optional[str]]]: @@ -2357,6 +2405,9 @@ class MCPServerManager: inbound_token = subject_token if isinstance(spec.config, PassthroughConfig): inbound_token, extra_headers = _take_forwarded_authorization(extra_headers) + per_server_token = _passthrough_token_from_mcp_auth_header(mcp_auth_header) + if per_server_token is not None: + inbound_token = per_server_token resolved_auth, extra_headers = await self._resolve_v2_auth( server=server, spec=spec, diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 7a854f698c9..4fda15f664e 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -331,6 +331,7 @@ if MCP_AVAILABLE: ) from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( MCPServerManager, + _caller_authorization_fans_out, _client_forwarded_authorization_headers, _should_strip_caller_authorization, _without_authorization, @@ -1529,8 +1530,16 @@ if MCP_AVAILABLE: oauth2_headers: Optional[Dict[str, str]], raw_headers: Optional[Dict[str, str]], user_api_key_auth: Optional[UserAPIKeyAuth] = None, + scope_servers: Optional[list[MCPServer]] = None, ) -> Tuple[Optional[Union[Dict[str, str], str]], Optional[Dict[str, str]]]: - """Build auth and extra headers for a server.""" + """Build auth and extra headers for a server. + + ``scope_servers`` is the full server list a fan-out handler iterates. Passing it lets the + client-forwarded token modes withhold the caller's request-wide ``Authorization`` when + another server in the scope would also receive it (``_caller_authorization_fans_out``); + explicitly-addressed operations leave it None. Per-server ``x-mcp-{alias}-authorization`` + headers are unaffected — they bind one token to one server and are the multi-server shape. + """ server_auth_header: Optional[Union[Dict[str, str], str]] = None if mcp_server_auth_headers: from litellm.proxy._experimental.mcp_server.utils import ( @@ -1563,12 +1572,13 @@ if MCP_AVAILABLE: ): extra_headers = _without_authorization(extra_headers) elif server.is_true_passthrough or server.is_oauth_delegate: - extra_headers = _client_forwarded_authorization_headers( - mcp_server=server, - oauth2_headers=oauth2_headers, - raw_headers=raw_headers, - user_api_key_auth=user_api_key_auth, - ) + if not _caller_authorization_fans_out(server, scope_servers): + extra_headers = _client_forwarded_authorization_headers( + mcp_server=server, + oauth2_headers=oauth2_headers, + raw_headers=raw_headers, + user_api_key_auth=user_api_key_auth, + ) if server.extra_headers and raw_headers: if extra_headers is None: @@ -1793,6 +1803,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) # Prefer server-stored per-user OAuth when configured, so a stale @@ -1979,6 +1990,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) try: @@ -2031,6 +2043,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) try: @@ -2081,6 +2094,7 @@ if MCP_AVAILABLE: oauth2_headers=oauth2_headers, raw_headers=raw_headers, user_api_key_auth=user_api_key_auth, + scope_servers=allowed_mcp_servers, ) try: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index b24457deabd..71ece75b2fb 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -69,9 +69,7 @@ async def test_mcp_server_tool_call_body_contains_request_data(): # Mock the add_litellm_data_to_request function to capture the data captured_data = {} - async def mock_add_litellm_data_to_request( - data, request, user_api_key_dict, proxy_config - ): + async def mock_add_litellm_data_to_request(data, request, user_api_key_dict, proxy_config): captured_data.update(data) # Simulate the proxy_server_request creation captured_data["proxy_server_request"] = { @@ -355,6 +353,79 @@ def test_prepare_mcp_server_headers_m2m_skips_authorization_from_raw_extra_heade assert extra_headers.get("X-Custom") == "trace" +def _client_forwarded_mode_server(server_id: str, auth_type) -> MCPServer: + return MCPServer( + server_id=server_id, + name=server_id, + transport=MCPTransport.http, + auth_type=auth_type, + ) + + +def _prepare_headers_in_scope(server: MCPServer, scope_servers): + from litellm.proxy._experimental.mcp_server.server import ( + _prepare_mcp_server_headers, + ) + + return _prepare_mcp_server_headers( + server=server, + mcp_server_auth_headers=None, + mcp_auth_header=None, + oauth2_headers={"Authorization": "Bearer upstream-token"}, + raw_headers={ + "x-litellm-api-key": "Bearer sk-litellm-key", + "authorization": "Bearer upstream-token", + }, + user_api_key_auth=UserAPIKeyAuth(api_key="sk-litellm-key"), + scope_servers=scope_servers, + ) + + +def test_prepare_mcp_server_headers_withholds_global_authorization_when_scope_fans_out(): + """One caller bearer must not be replayed against multiple upstreams (RFC 9700 + cross-resource replay): in a fan-out scope with a second Authorization-consuming + server, the client-forwarded modes get no global Authorization.""" + delegate = _client_forwarded_mode_server("od-fanout", MCPAuth.oauth_delegate) + second_consumer = _client_forwarded_mode_server("tp-fanout", MCPAuth.true_passthrough) + + _, extra_headers = _prepare_headers_in_scope(delegate, [delegate, second_consumer]) + + assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} + + +def test_prepare_mcp_server_headers_forwards_global_authorization_to_sole_consumer(): + """Non-consuming servers (static api_key) in scope do not make the forward ambiguous.""" + delegate = _client_forwarded_mode_server("od-sole", MCPAuth.oauth_delegate) + static_server = MCPServer( + server_id="static-api-key", + name="static-api-key", + transport=MCPTransport.http, + auth_type=MCPAuth.api_key, + authentication_token="static-key", + ) + + _, extra_headers = _prepare_headers_in_scope(delegate, [delegate, static_server]) + + assert extra_headers == {"Authorization": "Bearer upstream-token"} + + +def test_prepare_mcp_server_headers_scope_counts_legacy_delegate_as_consumer(): + """Legacy upstream-delegated oauth2 servers still receive the caller's Authorization on the + v1 path, so their presence in scope must suppress the new modes' forward too.""" + delegate = _client_forwarded_mode_server("od-vs-legacy", MCPAuth.oauth_delegate) + legacy_delegate = MCPServer( + server_id="legacy-delegate", + name="legacy-delegate", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + ) + + _, extra_headers = _prepare_headers_in_scope(delegate, [delegate, legacy_delegate]) + + assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} + + @pytest.mark.asyncio async def test_call_tool_m2m_skips_authorization_headers(): """M2M call_tool must not forward caller Authorization in oauth2/raw headers.""" @@ -382,9 +453,7 @@ async def test_call_tool_m2m_skips_authorization_headers(): mock_client = MagicMock() mock_client.call_tool = AsyncMock(return_value=MagicMock()) - with patch.object( - manager, "_create_mcp_client", new=AsyncMock(return_value=mock_client) - ) as create_client_mock: + with patch.object(manager, "_create_mcp_client", new=AsyncMock(return_value=mock_client)) as create_client_mock: await manager._call_regular_mcp_tool( mcp_server=server, original_tool_name="echo", @@ -879,9 +948,7 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails(): # Mock global_mcp_server_manager mock_manager = MagicMock() - mock_manager.get_allowed_mcp_servers = AsyncMock( - return_value=["working_server", "failing_server"] - ) + mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["working_server", "failing_server"]) mock_manager.get_mcp_server_by_id = lambda server_id: ( working_server if server_id == "working_server" else failing_server ) @@ -942,9 +1009,7 @@ async def test_get_tools_from_mcp_servers_continues_when_one_server_fails(): ) # Verify success logging - mock_logger.info.assert_any_call( - "Successfully fetched 1 tools total from all MCP servers" - ) + mock_logger.info.assert_any_call("Successfully fetched 1 tools total from all MCP servers") @pytest.mark.asyncio @@ -985,9 +1050,7 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing(): # Mock global_mcp_server_manager mock_manager = MagicMock() - mock_manager.get_allowed_mcp_servers = AsyncMock( - return_value=["failing_server1", "failing_server2"] - ) + mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["failing_server1", "failing_server2"]) mock_manager.get_mcp_server_by_id = lambda server_id: ( failing_server1 if server_id == "failing_server1" else failing_server2 ) @@ -1042,9 +1105,7 @@ async def test_get_tools_from_mcp_servers_handles_all_servers_failing(): ) # Verify total logging - mock_logger.info.assert_any_call( - "Successfully fetched 0 tools total from all MCP servers" - ) + mock_logger.info.assert_any_call("Successfully fetched 0 tools total from all MCP servers") @pytest.mark.asyncio @@ -1069,9 +1130,7 @@ async def test_mcp_server_tool_call_body_with_none_arguments(): # Mock the add_litellm_data_to_request function to capture the data captured_data = {} - async def mock_add_litellm_data_to_request( - data, request, user_api_key_dict, proxy_config - ): + async def mock_add_litellm_data_to_request(data, request, user_api_key_dict, proxy_config): captured_data.update(data) captured_data["proxy_server_request"] = { "url": str(request.url), @@ -1176,31 +1235,29 @@ async def test_concurrent_initialize_session_managers(): results = await asyncio.gather(*tasks, return_exceptions=True) # All tasks should complete successfully (no exceptions) - assert all( - result == "success" for result in results - ), f"Some tasks failed: {results}" + assert all(result == "success" for result in results), f"Some tasks failed: {results}" # Each session manager.run() should only be called once due to the lock - assert ( - mock_stateless_run.call_count == 1 - ), f"Expected 1 call to session_manager_stateless.run(), got {mock_stateless_run.call_count}" - assert ( - mock_stateful_run.call_count == 1 - ), f"Expected 1 call to session_manager_stateful.run(), got {mock_stateful_run.call_count}" - assert ( - mock_sse_run.call_count == 1 - ), f"Expected 1 call to sse_session_manager.run(), got {mock_sse_run.call_count}" + assert mock_stateless_run.call_count == 1, ( + f"Expected 1 call to session_manager_stateless.run(), got {mock_stateless_run.call_count}" + ) + assert mock_stateful_run.call_count == 1, ( + f"Expected 1 call to session_manager_stateful.run(), got {mock_stateful_run.call_count}" + ) + assert mock_sse_run.call_count == 1, ( + f"Expected 1 call to sse_session_manager.run(), got {mock_sse_run.call_count}" + ) # The context managers should only be entered once each - assert ( - mock_cm_stateless.__aenter__.call_count == 1 - ), f"Expected 1 call to stateless __aenter__, got {mock_cm_stateless.__aenter__.call_count}" - assert ( - mock_cm_stateful.__aenter__.call_count == 1 - ), f"Expected 1 call to stateful __aenter__, got {mock_cm_stateful.__aenter__.call_count}" - assert ( - mock_cm_sse.__aenter__.call_count == 1 - ), f"Expected 1 call to sse __aenter__, got {mock_cm_sse.__aenter__.call_count}" + assert mock_cm_stateless.__aenter__.call_count == 1, ( + f"Expected 1 call to stateless __aenter__, got {mock_cm_stateless.__aenter__.call_count}" + ) + assert mock_cm_stateful.__aenter__.call_count == 1, ( + f"Expected 1 call to stateful __aenter__, got {mock_cm_stateful.__aenter__.call_count}" + ) + assert mock_cm_sse.__aenter__.call_count == 1, ( + f"Expected 1 call to sse __aenter__, got {mock_cm_sse.__aenter__.call_count}" + ) # State should be properly set assert mcp_server._SESSION_MANAGERS_INITIALIZED is True @@ -1343,16 +1400,12 @@ async def test_mcp_routing_initialize_to_stateful_no_session_to_stateless(): # initialize → stateful init_body = b'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05"}}' stateless_called, stateful_called = await make_request(init_body) - assert ( - stateful_called and not stateless_called - ), "initialize (no session) should route to stateful, not stateless" + assert stateful_called and not stateless_called, "initialize (no session) should route to stateful, not stateless" # tools/list → stateless tools_body = b'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' stateless_called, stateful_called = await make_request(tools_body) - assert ( - stateless_called and not stateful_called - ), "tools/list (no session) should route to stateless, not stateful" + assert stateless_called and not stateful_called, "tools/list (no session) should route to stateless, not stateful" @pytest.mark.asyncio @@ -1437,9 +1490,9 @@ async def test_mcp_routing_chunked_initialize_to_stateful(): ): await handle_streamable_http_mcp(scope, receive, send) - assert ( - stateful_called and not stateless_called - ), "chunked initialize (no session) should route to stateful, not stateless" + assert stateful_called and not stateless_called, ( + "chunked initialize (no session) should route to stateful, not stateless" + ) @pytest.mark.asyncio @@ -1468,10 +1521,7 @@ async def test_mcp_routing_caps_body_peek_for_oversized_chunked_body(): messages = [ {"type": "http.request", "body": first_chunk, "more_body": True}, - *[ - {"type": "http.request", "body": chunk, "more_body": True} - for chunk in oversized_tail - ], + *[{"type": "http.request", "body": chunk, "more_body": True} for chunk in oversized_tail], {"type": "http.request", "body": b"", "more_body": False}, ] receive_calls = {"count": 0} @@ -1522,12 +1572,8 @@ async def test_mcp_routing_caps_body_peek_for_oversized_chunked_body(): "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), - patch.object( - session_manager_stateless, "handle_request", side_effect=stateless_handle - ), - patch.object( - session_manager_stateful, "handle_request", side_effect=stateful_handle - ), + patch.object(session_manager_stateless, "handle_request", side_effect=stateless_handle), + patch.object(session_manager_stateful, "handle_request", side_effect=stateful_handle), patch.object(session_manager_stateless, "_server_instances", {}), patch.object(session_manager_stateful, "_server_instances", {}), ): @@ -1578,9 +1624,7 @@ async def test_enforce_stateful_session_cap_evicts_oldest_idle_then_rejects(): patch.object(session_manager_stateful, "_server_instances", instances), patch.object(mcp_server, "_MAX_STATEFUL_SESSIONS_PER_OWNER", 3), patch.dict(mcp_server._stateful_session_owners, owners, clear=True), - patch.dict( - mcp_server._stateful_session_auth_context_last_seen, last_seen, clear=True - ), + patch.dict(mcp_server._stateful_session_auth_context_last_seen, last_seen, clear=True), patch.dict(mcp_server._stateful_session_auth_contexts, contexts, clear=True), patch.dict(mcp_server._stateful_session_active_request_counts, {}, clear=True), ): @@ -1593,9 +1637,7 @@ async def test_enforce_stateful_session_cap_evicts_oldest_idle_then_rejects(): # A different owner at the cap is unaffected by owner-A's sessions. terminated.clear() - allowed_other = await mcp_server._enforce_stateful_session_cap_for_owner( - "owner-B" - ) + allowed_other = await mcp_server._enforce_stateful_session_cap_for_owner("owner-B") assert allowed_other is True assert terminated == [] @@ -1614,9 +1656,7 @@ async def test_enforce_stateful_session_cap_evicts_oldest_idle_then_rejects(): {f"s{i}": float(i) for i in range(3)}, clear=True, ), - patch.dict( - mcp_server._stateful_session_active_request_counts, active, clear=True - ), + patch.dict(mcp_server._stateful_session_active_request_counts, active, clear=True), ): rejected = await mcp_server._enforce_stateful_session_cap_for_owner("owner-A") assert rejected is False @@ -1662,9 +1702,7 @@ async def test_mcp_routing_initialize_rejected_when_owner_at_session_cap(): (b"authorization", b"Bearer test-key"), ], } - receive = AsyncMock( - return_value={"type": "http.request", "body": init_body, "more_body": False} - ) + receive = AsyncMock(return_value={"type": "http.request", "body": init_body, "more_body": False}) send = AsyncMock() stateful_called = [] @@ -1685,9 +1723,7 @@ async def test_mcp_routing_initialize_rejected_when_owner_at_session_cap(): ), patch.object(mcp_server, "_owner_fingerprint_for", return_value="owner-X"), patch.object(mcp_server, "_MAX_STATEFUL_SESSIONS_PER_OWNER", cap), - patch.object( - session_manager_stateful, "handle_request", side_effect=stateful_handle - ), + patch.object(session_manager_stateful, "handle_request", side_effect=stateful_handle), patch.object(session_manager_stateful, "_server_instances", instances), patch.object(session_manager_stateless, "_server_instances", {}), patch.dict(mcp_server._stateful_session_owners, owners, clear=True), @@ -1696,18 +1732,14 @@ async def test_mcp_routing_initialize_rejected_when_owner_at_session_cap(): {f"s{i}": float(i) for i in range(cap)}, clear=True, ), - patch.dict( - mcp_server._stateful_session_active_request_counts, active, clear=True - ), + patch.dict(mcp_server._stateful_session_active_request_counts, active, clear=True), patch.dict(mcp_server._stateful_session_auth_contexts, contexts, clear=True), ): await handle_streamable_http_mcp(scope, receive, send) assert not stateful_called, "initialize at session cap must not reach the manager" start_messages = [ - call.args[0] - for call in send.call_args_list - if call.args and call.args[0].get("type") == "http.response.start" + call.args[0] for call in send.call_args_list if call.args and call.args[0].get("type") == "http.response.start" ] assert start_messages, "a response should have been sent" assert start_messages[0]["status"] == 429 @@ -1743,9 +1775,7 @@ async def test_stateful_mcp_requests_refresh_session_auth_context(): None, "1.1.1.1", ) - mcp_server._stateful_session_auth_contexts[session_id] = callback_context.run( - mcp_server.auth_context_var.get - ) + mcp_server._stateful_session_auth_contexts[session_id] = callback_context.run(mcp_server.auth_context_var.get) scope = { "type": "http", @@ -2006,24 +2036,14 @@ async def test_initialize_request_with_existing_session_tracks_new_session(): ) await mcp_server._purge_expired_stateful_session_auth_contexts(now=now) assert new_session_id in mcp_server._stateful_session_auth_contexts - assert ( - mcp_server._stateful_session_auth_contexts[new_session_id] - is not existing_auth_user - ) - assert ( - mcp_server._stateful_session_auth_contexts[new_session_id].mcp_auth_header - == "new-mcp-auth" - ) + assert mcp_server._stateful_session_auth_contexts[new_session_id] is not existing_auth_user + assert mcp_server._stateful_session_auth_contexts[new_session_id].mcp_auth_header == "new-mcp-auth" async def stateless_handle(s, r, se): - raise AssertionError( - "initialize request with session should use stateful manager" - ) + raise AssertionError("initialize request with session should use stateful manager") try: - mcp_server._stateful_session_auth_contexts[existing_session_id] = ( - existing_auth_user - ) + mcp_server._stateful_session_auth_contexts[existing_session_id] = existing_auth_user mcp_server._stateful_session_auth_context_last_seen[existing_session_id] = 1.0 mcp_server._stateful_session_owners[existing_session_id] = owner_fingerprint @@ -2065,10 +2085,7 @@ async def test_initialize_request_with_existing_session_tracks_new_session(): assert stateful_called assert new_session_id not in mcp_server._stateful_session_active_request_counts assert new_session_id in mcp_server._stateful_session_auth_contexts - assert ( - mcp_server._stateful_session_auth_contexts[existing_session_id] - is existing_auth_user - ) + assert mcp_server._stateful_session_auth_contexts[existing_session_id] is existing_auth_user assert existing_auth_user.mcp_auth_header == "old-mcp-auth" assert existing_auth_user.mcp_servers == ["old-server"] finally: @@ -2191,15 +2208,11 @@ async def test_stateful_mcp_cleanup_loop_survives_purge_errors(): except ImportError: pytest.skip("MCP server not available") - purge = AsyncMock( - side_effect=[RuntimeError("terminate failed"), asyncio.CancelledError()] - ) + purge = AsyncMock(side_effect=[RuntimeError("terminate failed"), asyncio.CancelledError()]) with ( patch.object(mcp_server.asyncio, "sleep", AsyncMock(return_value=None)), - patch.object( - mcp_server, "_purge_expired_stateful_session_auth_contexts", purge - ), + patch.object(mcp_server, "_purge_expired_stateful_session_auth_contexts", purge), ): with pytest.raises(asyncio.CancelledError): await mcp_server._cleanup_expired_stateful_session_auth_contexts() @@ -2289,9 +2302,7 @@ async def test_stateful_mcp_session_owner_mismatch_returns_403(): intruder_auth = UserAPIKeyAuth(api_key="intruder-key", user_id="intruder") mcp_server._stateful_session_auth_contexts[session_id] = MagicMock() - mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for( - owner_auth - ) + mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for(owner_auth) scope = { "type": "http", @@ -2341,9 +2352,7 @@ async def test_stateful_mcp_session_owner_mismatch_returns_403(): await handle_streamable_http_mcp(scope, receive, capture_send) handle_request_mock.assert_not_awaited() - statuses = [ - m["status"] for m in sent_messages if m.get("type") == "http.response.start" - ] + statuses = [m["status"] for m in sent_messages if m.get("type") == "http.response.start"] assert statuses == [403] mcp_server._stateful_session_auth_contexts.pop(session_id, None) @@ -2368,12 +2377,10 @@ async def test_stateful_mcp_session_serializes_concurrent_requests(): session_id = "serialized-session-1" owner_auth = UserAPIKeyAuth(api_key="owner-key", user_id="owner") - mcp_server._stateful_session_auth_contexts[session_id] = ( - mcp_server.MCPAuthenticatedUser(user_api_key_auth=owner_auth) - ) - mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for( - owner_auth + mcp_server._stateful_session_auth_contexts[session_id] = mcp_server.MCPAuthenticatedUser( + user_api_key_auth=owner_auth ) + mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for(owner_auth) inside = 0 max_inside = 0 @@ -2414,9 +2421,7 @@ async def test_stateful_mcp_session_serializes_concurrent_requests(): "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), - patch.object( - session_manager_stateful, "handle_request", side_effect=slow_handle - ), + patch.object(session_manager_stateful, "handle_request", side_effect=slow_handle), patch.object( session_manager_stateful, "_server_instances", @@ -2432,9 +2437,7 @@ async def test_stateful_mcp_session_serializes_concurrent_requests(): mcp_server._stateful_session_owners.pop(session_id, None) mcp_server._stateful_session_locks.pop(session_id, None) - assert ( - max_inside == 1 - ), "concurrent requests on same stateful session must be serialized" + assert max_inside == 1, "concurrent requests on same stateful session must be serialized" @pytest.mark.asyncio @@ -2486,9 +2489,7 @@ async def test_stateful_mcp_lock_does_not_leak_when_auth_context_missing(): "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), - patch.object( - session_manager_stateful, "handle_request", side_effect=handle - ), + patch.object(session_manager_stateful, "handle_request", side_effect=handle), patch.object( session_manager_stateful, "_server_instances", @@ -2498,9 +2499,9 @@ async def test_stateful_mcp_lock_does_not_leak_when_auth_context_missing(): assert session_id not in mcp_server._stateful_session_auth_contexts await handle_streamable_http_mcp(scope, receive, AsyncMock()) - assert ( - session_id not in mcp_server._stateful_session_locks - ), "lock entry must be cleaned up for untracked stateful session" + assert session_id not in mcp_server._stateful_session_locks, ( + "lock entry must be cleaned up for untracked stateful session" + ) finally: mcp_server._stateful_session_auth_contexts.pop(session_id, None) mcp_server._stateful_session_owners.pop(session_id, None) @@ -2526,12 +2527,10 @@ async def test_stateful_mcp_get_stream_does_not_block_post(): session_id = "stream-session-1" owner_auth = UserAPIKeyAuth(api_key="owner-key", user_id="owner") - mcp_server._stateful_session_auth_contexts[session_id] = ( - mcp_server.MCPAuthenticatedUser(user_api_key_auth=owner_auth) - ) - mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for( - owner_auth + mcp_server._stateful_session_auth_contexts[session_id] = mcp_server.MCPAuthenticatedUser( + user_api_key_auth=owner_auth ) + mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for(owner_auth) stream_release = asyncio.Event() post_finished = asyncio.Event() @@ -2569,9 +2568,7 @@ async def test_stateful_mcp_get_stream_does_not_block_post(): "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), - patch.object( - session_manager_stateful, "handle_request", side_effect=handle - ), + patch.object(session_manager_stateful, "handle_request", side_effect=handle), patch.object( session_manager_stateful, "_server_instances", @@ -2615,10 +2612,7 @@ def test_jsonrpc_text_has_top_level_method_ignores_nested_method(): assert _jsonrpc_text_has_top_level_method(reordered) is True # response whose result nests a "method" key (and arrays of them) - response = ( - '{"jsonrpc":"2.0","id":1,"result":{"toolResult":{"method":"GET"},' - '"steps":[{"method":"x"}]}}' - ) + response = '{"jsonrpc":"2.0","id":1,"result":{"toolResult":{"method":"GET"},"steps":[{"method":"x"}]}}' assert _jsonrpc_text_has_top_level_method(response) is False # truncated response: result value never closes, no top-level method seen @@ -2643,12 +2637,10 @@ async def test_truncated_jsonrpc_response_with_nested_method_skips_lock(): session_id = "nested-method-response-session" owner_auth = UserAPIKeyAuth(api_key="owner-key", user_id="owner") - mcp_server._stateful_session_auth_contexts[session_id] = ( - mcp_server.MCPAuthenticatedUser(user_api_key_auth=owner_auth) - ) - mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for( - owner_auth + mcp_server._stateful_session_auth_contexts[session_id] = mcp_server.MCPAuthenticatedUser( + user_api_key_auth=owner_auth ) + mcp_server._stateful_session_owners[session_id] = mcp_server._owner_fingerprint_for(owner_auth) gate = asyncio.Event() request_in_handle = asyncio.Event() @@ -2685,8 +2677,7 @@ async def test_truncated_jsonrpc_response_with_nested_method_skips_lock(): # parsed, with a nested "method" key in the first bytes to trip a flat # substring heuristic. response_body = ( - '{"jsonrpc":"2.0","id":99,"result":{"toolResult":' - '{"method":"GET","payload":"' + ("x" * 5000) + '"}}}' + '{"jsonrpc":"2.0","id":99,"result":{"toolResult":{"method":"GET","payload":"' + ("x" * 5000) + '"}}}' ).encode() try: @@ -2700,9 +2691,7 @@ async def test_truncated_jsonrpc_response_with_nested_method_skips_lock(): "litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True, ), - patch.object( - session_manager_stateful, "handle_request", side_effect=handle - ), + patch.object(session_manager_stateful, "handle_request", side_effect=handle), patch.object( session_manager_stateful, "_server_instances", @@ -2774,13 +2763,9 @@ async def test_mcp_routing_with_conflicting_alias_and_group_name(): mock_get_tools_spy = AsyncMock(return_value=[]) # Mock the function that checks DB for an access group named "custom_solutions" - mock_db_lookup = AsyncMock( - return_value=[specific_server.server_id, other_server.server_id] - ) + mock_db_lookup = AsyncMock(return_value=[specific_server.server_id, other_server.server_id]) - mock_get_allowed = AsyncMock( - return_value=[specific_server.server_id, other_server.server_id] - ) + mock_get_allowed = AsyncMock(return_value=[specific_server.server_id, other_server.server_id]) with ( patch( @@ -2805,14 +2790,12 @@ async def test_mcp_routing_with_conflicting_alias_and_group_name(): ) # Get the list of actual server objects that the orchestrator tried to contact - called_servers = [ - call.kwargs["server"] for call in mock_get_tools_spy.call_args_list - ] + called_servers = [call.kwargs["server"] for call in mock_get_tools_spy.call_args_list] assert len(called_servers) == 1, "Should have resolved to exactly one server." - assert ( - called_servers[0].server_id == specific_server.server_id - ), "Should have contacted the specific server alias, not the group." + assert called_servers[0].server_id == specific_server.server_id, ( + "Should have contacted the specific server alias, not the group." + ) @pytest.mark.asyncio @@ -2926,9 +2909,7 @@ async def test_oauth2_caller_headers_not_forwarded_for_migrated_server(): ) # Verify that _create_mcp_client was called - assert ( - mock_create_client.call_count == 1 - ), "Expected _create_mcp_client to be called once" + assert mock_create_client.call_count == 1, "Expected _create_mcp_client to be called once" # Verify the server passed to _create_mcp_client is the OAuth2 server assert captured_client_args["server"].server_id == oauth2_server.server_id @@ -2938,9 +2919,9 @@ async def test_oauth2_caller_headers_not_forwarded_for_migrated_server(): # oauth2 Authorization upstream. The v2 resolver injects the stored per-user token, # so a caller-supplied bearer cannot override another user's stored credential. extra_headers = captured_client_args["extra_headers"] - assert extra_headers is None or "Authorization" not in { - k.lower() for k in extra_headers - }, f"Caller Authorization must not be forwarded, got {extra_headers}" + assert extra_headers is None or "Authorization" not in {k.lower() for k in extra_headers}, ( + f"Caller Authorization must not be forwarded, got {extra_headers}" + ) @pytest.mark.asyncio @@ -3049,12 +3030,8 @@ async def test_list_tools_multiple_servers_prefixed_names(): # Mock manager mock_manager = MagicMock() - mock_manager.get_allowed_mcp_servers = AsyncMock( - return_value=["server1", "server2"] - ) - mock_manager.get_mcp_server_by_id = lambda server_id: ( - server1 if server_id == "server1" else server2 - ) + mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["server1", "server2"]) + mock_manager.get_mcp_server_by_id = lambda server_id: server1 if server_id == "server1" else server2 # Mock filter_server_ids_by_ip to return server_ids unchanged (no IP filtering) mock_manager.filter_server_ids_by_ip_with_info = lambda server_ids, client_ip: ( server_ids, @@ -3653,9 +3630,7 @@ async def test_list_tools_strips_prefix_when_matching_permissions(): tool1.inputSchema = {} tool2 = MagicMock() - tool2.name = ( - "GITMCP-search_litellm_documentation" # Prefixed, not in allowed list - ) + tool2.name = "GITMCP-search_litellm_documentation" # Prefixed, not in allowed list tool2.description = "Search docs" tool2.inputSchema = {} @@ -3876,17 +3851,13 @@ class TestMCPServerManagerReload: db_row = _make_db_mcp_server("server-1", timestamp) mock_prisma = MagicMock() - mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock( - return_value=[db_row] - ) + mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[db_row]) with ( patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", return_value=mock_prisma, ), - patch.object( - manager, "build_mcp_server_from_table", AsyncMock() - ) as mock_build, + patch.object(manager, "build_mcp_server_from_table", AsyncMock()) as mock_build, ): await manager.reload_servers_from_database() @@ -3922,9 +3893,7 @@ class TestMCPServerManagerReload: ) mock_prisma = MagicMock() - mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock( - return_value=[db_row] - ) + mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[db_row]) with ( patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", @@ -4049,9 +4018,7 @@ class TestMCPServerManagerReload: raise RuntimeError("blocked address") mock_prisma = MagicMock() - mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock( - return_value=[healthy_row, bad_openapi_row] - ) + mock_prisma.db.litellm_mcpservertable.find_many = AsyncMock(return_value=[healthy_row, bad_openapi_row]) with ( patch( "litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw", @@ -4147,10 +4114,7 @@ async def test_call_mcp_tool_logs_failure_via_post_call_failure_hook(): ) proxy_logging_mock.post_call_failure_hook.assert_awaited_once() - assert ( - proxy_logging_mock.post_call_failure_hook.await_args.kwargs.get("route") - == "/mcp/call_tool" - ) + assert proxy_logging_mock.post_call_failure_hook.await_args.kwargs.get("route") == "/mcp/call_tool" @pytest.mark.asyncio @@ -4232,9 +4196,7 @@ async def test_get_tools_from_mcp_servers_logs_list_tools_to_spendlogs_when_enab assert tools == [tool_1] dummy_logging_obj.async_success_handler.assert_awaited_once() - assert dummy_logging_obj.async_success_handler.await_args.kwargs["result"] == [ - tool_1.model_dump(mode="json") - ] + assert dummy_logging_obj.async_success_handler.await_args.kwargs["result"] == [tool_1.model_dump(mode="json")] assert function_setup_kwargs["metadata"]["tags"] == ["team-a"] spend_meta = dummy_logging_obj.model_call_details["metadata"]["spend_logs_metadata"] @@ -4580,9 +4542,7 @@ async def test_get_tools_from_mcp_servers_injects_stored_oauth2_token(): oauth2_server.extra_headers = None # Simulate the DB returning a valid credential for this user+server - prefetched_creds = { - SERVER_ID: {"access_token": STORED_TOKEN, "server_id": SERVER_ID} - } + prefetched_creds = {SERVER_ID: {"access_token": STORED_TOKEN, "server_id": SERVER_ID}} tool_1 = MagicMock() tool_1.name = "atlassian_test-search" @@ -4689,16 +4649,12 @@ class TestMergeGatewayInitializeInstructions: global_mcp_server_manager, ) - global_mcp_server_manager._upstream_initialize_instructions_by_server_id[ - "s1" - ] = "upstream" + global_mcp_server_manager._upstream_initialize_instructions_by_server_id["s1"] = "upstream" try: s = _make_instruction_server(instructions="yaml wins") assert self._merge([s]) == "yaml wins" finally: - global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop( - "s1", None - ) + global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop("s1", None) def test_upstream_cache_used_when_no_yaml(self): """Upstream cached instructions are used when no YAML override is set.""" @@ -4706,16 +4662,12 @@ class TestMergeGatewayInitializeInstructions: global_mcp_server_manager, ) - global_mcp_server_manager._upstream_initialize_instructions_by_server_id[ - "s1" - ] = "from upstream" + global_mcp_server_manager._upstream_initialize_instructions_by_server_id["s1"] = "from upstream" try: s = _make_instruction_server(instructions=None) assert self._merge([s]) == "from upstream" finally: - global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop( - "s1", None - ) + global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop("s1", None) def test_spec_path_servers_skipped(self): """OpenAPI (spec_path) servers do not contribute instructions.""" @@ -4729,12 +4681,8 @@ class TestMergeGatewayInitializeInstructions: def test_multiple_servers_merged_with_labels(self): """Multiple servers get label-prefixed and separator-joined.""" - s1 = _make_instruction_server( - server_id="a", name="a", alias="Alpha", instructions="instr A" - ) - s2 = _make_instruction_server( - server_id="b", name="b", alias="Beta", instructions="instr B" - ) + s1 = _make_instruction_server(server_id="a", name="a", alias="Alpha", instructions="instr A") + s2 = _make_instruction_server(server_id="b", name="b", alias="Beta", instructions="instr B") result = self._merge([s1, s2]) assert result is not None assert "[Alpha]" in result and "[Beta]" in result @@ -4754,25 +4702,17 @@ class TestMergeGatewayInitializeInstructions: global_mcp_server_manager, ) - global_mcp_server_manager._upstream_initialize_instructions_by_server_id[ - "c" - ] = "cached C" + global_mcp_server_manager._upstream_initialize_instructions_by_server_id["c"] = "cached C" try: - s_yaml = _make_instruction_server( - server_id="a", name="a", alias="A", instructions="yaml A" - ) - s_spec = _make_instruction_server( - server_id="b", name="b", alias="B", spec_path="/spec.json", url=None - ) + s_yaml = _make_instruction_server(server_id="a", name="a", alias="A", instructions="yaml A") + s_spec = _make_instruction_server(server_id="b", name="b", alias="B", spec_path="/spec.json", url=None) s_cached = _make_instruction_server(server_id="c", name="c", alias="C") result = self._merge([s_yaml, s_spec, s_cached]) assert "yaml A" in result assert "cached C" in result assert "[B]" not in result finally: - global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop( - "c", None - ) + global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop("c", None) class TestEnsureUpstreamInitializeInstructionsCached: @@ -4784,15 +4724,9 @@ class TestEnsureUpstreamInitializeInstructionsCached: global_mcp_server_manager, ) - server = _make_instruction_server( - server_id="yaml-only", instructions="from yaml" - ) - with patch.object( - global_mcp_server_manager, "_create_mcp_client", AsyncMock() - ) as mock_create: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) + server = _make_instruction_server(server_id="yaml-only", instructions="from yaml") + with patch.object(global_mcp_server_manager, "_create_mcp_client", AsyncMock()) as mock_create: + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) mock_create.assert_not_awaited() @pytest.mark.asyncio @@ -4804,21 +4738,13 @@ class TestEnsureUpstreamInitializeInstructionsCached: ) server = _make_instruction_server(server_id="cached-only", instructions=None) - global_mcp_server_manager._upstream_initialize_instructions_by_server_id[ - "cached-only" - ] = "warm" + global_mcp_server_manager._upstream_initialize_instructions_by_server_id["cached-only"] = "warm" try: - with patch.object( - global_mcp_server_manager, "_create_mcp_client", AsyncMock() - ) as mock_create: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) + with patch.object(global_mcp_server_manager, "_create_mcp_client", AsyncMock()) as mock_create: + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) mock_create.assert_not_awaited() finally: - global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop( - "cached-only", None - ) + global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop("cached-only", None) @pytest.mark.asyncio async def test_skips_when_spec_path_set(self): @@ -4828,15 +4754,9 @@ class TestEnsureUpstreamInitializeInstructionsCached: global_mcp_server_manager, ) - server = _make_instruction_server( - server_id="openapi-spec", spec_path="/openapi.json", url=None - ) - with patch.object( - global_mcp_server_manager, "_create_mcp_client", AsyncMock() - ) as mock_create: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) + server = _make_instruction_server(server_id="openapi-spec", spec_path="/openapi.json", url=None) + with patch.object(global_mcp_server_manager, "_create_mcp_client", AsyncMock()) as mock_create: + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) mock_create.assert_not_awaited() @pytest.mark.asyncio @@ -4858,22 +4778,14 @@ class TestEnsureUpstreamInitializeInstructionsCached: AsyncMock(return_value=fake_client), ): try: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) assert ( - global_mcp_server_manager._upstream_initialize_instructions_by_server_id[ - "cold-server" - ] + global_mcp_server_manager._upstream_initialize_instructions_by_server_id["cold-server"] == "upstream says hi" ) finally: - global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop( - "cold-server", None - ) - global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop( - "cold-server", None - ) + global_mcp_server_manager._upstream_initialize_instructions_by_server_id.pop("cold-server", None) + global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop("cold-server", None) @pytest.mark.asyncio async def test_cooldown_after_empty_upstream_response(self): @@ -4892,27 +4804,13 @@ class TestEnsureUpstreamInitializeInstructionsCached: create = AsyncMock(return_value=fake_client) with patch.object(global_mcp_server_manager, "_create_mcp_client", create): try: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) - assert ( - create.await_count == 1 - ), "Second probe within cooldown must not reconnect to upstream" - assert ( - "empty-server" - not in global_mcp_server_manager._upstream_initialize_instructions_by_server_id - ) - assert ( - "empty-server" - in global_mcp_server_manager._upstream_initialize_instructions_probed_at - ) + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) + assert create.await_count == 1, "Second probe within cooldown must not reconnect to upstream" + assert "empty-server" not in global_mcp_server_manager._upstream_initialize_instructions_by_server_id + assert "empty-server" in global_mcp_server_manager._upstream_initialize_instructions_probed_at finally: - global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop( - "empty-server", None - ) + global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop("empty-server", None) @pytest.mark.asyncio async def test_cooldown_after_upstream_failure(self): @@ -4925,35 +4823,19 @@ class TestEnsureUpstreamInitializeInstructionsCached: server = _make_instruction_server(server_id="boom-server", instructions=None) fake_client = MagicMock() - fake_client.run_with_session = AsyncMock( - side_effect=RuntimeError("upstream down") - ) + fake_client.run_with_session = AsyncMock(side_effect=RuntimeError("upstream down")) fake_client._last_initialize_instructions = None create = AsyncMock(return_value=fake_client) with patch.object(global_mcp_server_manager, "_create_mcp_client", create): try: - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) - await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached( - server - ) - assert ( - create.await_count == 1 - ), "Second probe within cooldown must not reconnect after failure" - assert ( - "boom-server" - not in global_mcp_server_manager._upstream_initialize_instructions_by_server_id - ) - assert ( - "boom-server" - in global_mcp_server_manager._upstream_initialize_instructions_probed_at - ) + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) + await global_mcp_server_manager._ensure_upstream_initialize_instructions_cached(server) + assert create.await_count == 1, "Second probe within cooldown must not reconnect after failure" + assert "boom-server" not in global_mcp_server_manager._upstream_initialize_instructions_by_server_id + assert "boom-server" in global_mcp_server_manager._upstream_initialize_instructions_probed_at finally: - global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop( - "boom-server", None - ) + global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop("boom-server", None) @pytest.mark.asyncio async def test_reload_resets_probe_cooldown(self): @@ -4962,19 +4844,12 @@ class TestEnsureUpstreamInitializeInstructionsCached: global_mcp_server_manager, ) - global_mcp_server_manager._upstream_initialize_instructions_probed_at[ - "reload-target" - ] = 1.0 + global_mcp_server_manager._upstream_initialize_instructions_probed_at["reload-target"] = 1.0 try: await global_mcp_server_manager.load_servers_from_config({}) - assert ( - "reload-target" - not in global_mcp_server_manager._upstream_initialize_instructions_probed_at - ) + assert "reload-target" not in global_mcp_server_manager._upstream_initialize_instructions_probed_at finally: - global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop( - "reload-target", None - ) + global_mcp_server_manager._upstream_initialize_instructions_probed_at.pop("reload-target", None) class TestGatewayCreateInitializationOptions: @@ -5040,9 +4915,7 @@ class TestGatewayCreateInitializationOptions: ): assert server.create_initialization_options().server_name == "grafana" - assert ( - server.create_initialization_options().server_name == "litellm-mcp-server" - ) + assert server.create_initialization_options().server_name == "litellm-mcp-server" @pytest.mark.asyncio async def test_sse_handler_scopes_server_name_from_single_server_path(self): @@ -5119,9 +4992,7 @@ class TestGatewayCreateInitializationOptions: await handle_sse_mcp(scope, AsyncMock(), AsyncMock()) assert captured["server_name"] == "grafana" - assert ( - server.create_initialization_options().server_name == "litellm-mcp-server" - ) + assert server.create_initialization_options().server_name == "litellm-mcp-server" def test_contextvar_set_injects_instructions(self): """When ContextVar has a value, it appears in InitializationOptions.""" @@ -5239,12 +5110,8 @@ async def test_list_tools_with_legacy_db_m2m_server_resolves_oauth2_flow(): ): mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=["legacy-m2m-id"]) mock_manager.get_mcp_server_by_id = MagicMock(return_value=legacy_server) - mock_manager.filter_server_ids_by_ip_with_info = MagicMock( - return_value=(["legacy-m2m-id"], 0) - ) - mock_manager._get_tools_from_server = AsyncMock( - side_effect=capture_extra_headers - ) + mock_manager.filter_server_ids_by_ip_with_info = MagicMock(return_value=(["legacy-m2m-id"], 0)) + mock_manager._get_tools_from_server = AsyncMock(side_effect=capture_extra_headers) tools = await _get_tools_from_mcp_servers( user_api_key_auth=user_auth, @@ -5336,10 +5203,8 @@ async def test_call_tool_empty_extra_headers_returns_none(): pass # We only care about the captured headers # With P2 fix: extra_headers should be None (not {}) when all headers filtered - assert ( - captured_extra_headers is None - ), "P2 API consistency issue: expected None for empty extra_headers, got: " + str( - captured_extra_headers + assert captured_extra_headers is None, ( + "P2 API consistency issue: expected None for empty extra_headers, got: " + str(captured_extra_headers) ) @@ -5364,9 +5229,7 @@ async def test_probe_upstream_auth_returns_upstream_status(): "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", return_value=mock_client, ): - status, www_auth = await _probe_upstream_auth( - "http://upstream/mcp", "Bearer some-token" - ) + status, www_auth = await _probe_upstream_auth("http://upstream/mcp", "Bearer some-token") assert status == 401 assert www_auth == 'Bearer realm="test"' @@ -5393,9 +5256,7 @@ async def test_probe_upstream_auth_surfaces_httpx_status_error(): mock_response.status_code = 401 mock_response.headers = {"www-authenticate": 'Bearer realm="test"'} request = httpx.Request("POST", "http://upstream/mcp") - error = httpx.HTTPStatusError( - message="401 Unauthorized", request=request, response=mock_response - ) + error = httpx.HTTPStatusError(message="401 Unauthorized", request=request, response=mock_response) mock_client = MagicMock() mock_client.post = AsyncMock(side_effect=error) @@ -5404,9 +5265,7 @@ async def test_probe_upstream_auth_surfaces_httpx_status_error(): "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", return_value=mock_client, ): - status, www_auth = await _probe_upstream_auth( - "http://upstream/mcp", "Bearer some-token" - ) + status, www_auth = await _probe_upstream_auth("http://upstream/mcp", "Bearer some-token") assert status == 401 assert www_auth == 'Bearer realm="test"' @@ -5424,9 +5283,7 @@ async def test_probe_upstream_auth_fails_open_on_network_error(): "litellm.proxy._experimental.mcp_server.server.get_async_httpx_client", return_value=mock_client, ): - status, www_auth = await _probe_upstream_auth( - "http://upstream/mcp", "Bearer some-token" - ) + status, www_auth = await _probe_upstream_auth("http://upstream/mcp", "Bearer some-token") assert status == 200 assert www_auth is None @@ -6349,9 +6206,7 @@ class TestProxyExceptionToHttpException: from litellm.proxy._types import ProxyException http_exc = _proxy_exception_to_http_exception( - ProxyException( - message="Forbidden", type="auth_error", param="key", code=403 - ) + ProxyException(message="Forbidden", type="auth_error", param="key", code=403) ) assert http_exc.status_code == 403 @@ -6415,10 +6270,7 @@ class TestStreamableHttpAuthErrorMapping: assert exc_info.value.status_code == 401 assert exc_info.value.headers["WWW-Authenticate"] == "Bearer" # Must not have emitted a 500 body via the generic catch-all. - assert not any( - m.get("type") == "http.response.start" and m.get("status") == 500 - for m in sent - ) + assert not any(m.get("type") == "http.response.start" and m.get("status") == 500 for m in sent) @pytest.mark.asyncio async def test_sse_propagates_proxy_exception_as_401(self): @@ -6458,10 +6310,7 @@ class TestStreamableHttpAuthErrorMapping: assert exc_info.value.status_code == 401 assert exc_info.value.headers["WWW-Authenticate"] == "Bearer" - assert not any( - m.get("type") == "http.response.start" and m.get("status") == 500 - for m in sent - ) + assert not any(m.get("type") == "http.response.start" and m.get("status") == 500 for m in sent) class TestMCPMetaTraceCarrier: 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 a7a0379c7ad..dc1a950f706 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 @@ -1490,6 +1490,118 @@ class TestMCPServerManager: assert emitted.headers["Authorization"] == "Bearer upstream-token" assert not kwargs["extra_headers"] or "authorization" not in {k.lower() for k in kwargs["extra_headers"]} + @staticmethod + def _emitted_authorization(mock_client_cls) -> str: + kwargs = mock_client_cls.call_args.kwargs + emitted = httpx.Request("GET", "https://example.com/mcp") + flow = kwargs["resolved_auth"].auth_flow(emitted) + next(flow) + flow.close() + return emitted.headers["Authorization"] + + @pytest.mark.asyncio + @pytest.mark.parametrize( + "per_server_header", + ["Bearer per-server-token", {"Authorization": "Bearer per-server-token"}], + ) + async def test_create_mcp_client_passthrough_prefers_per_server_token(self, per_server_header): + """A per-server x-mcp-{alias}-authorization value is the explicit one-token-one-server + binding, so it must win over the request-wide Authorization and reach the upstream + verbatim through the passthrough arm.""" + manager = MCPServerManager() + server = MCPServer( + server_id="tp-per-server", + name="tp", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + with ( + patch( + "litellm.proxy._experimental.mcp_server.mcp_server_manager.resolve_mcp_auth", + new_callable=AsyncMock, + ) as mock_resolve, + patch("litellm.proxy._experimental.mcp_server.mcp_server_manager.MCPClient") as mock_client_cls, + ): + await manager._create_mcp_client( + server=server, + mcp_auth_header=per_server_header, + extra_headers={"Authorization": "Bearer global-token"}, + ) + mock_resolve.assert_not_awaited() + assert self._emitted_authorization(mock_client_cls) == "Bearer per-server-token" + kwargs = mock_client_cls.call_args.kwargs + assert not kwargs["extra_headers"] or "authorization" not in {k.lower() for k in kwargs["extra_headers"]} + + def test_consumes_caller_authorization_per_mode(self): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _consumes_caller_authorization, + ) + + def build(**kwargs) -> MCPServer: + return MCPServer( + server_id="s", + name="s", + url="https://example.com", + transport=MCPTransport.http, + **kwargs, + ) + + assert _consumes_caller_authorization(build(auth_type=MCPAuth.true_passthrough)) is True + assert _consumes_caller_authorization(build(auth_type=MCPAuth.oauth_delegate)) is True + assert ( + _consumes_caller_authorization( + build(auth_type=MCPAuth.none, extra_headers=["Authorization"], oauth_passthrough=True) + ) + is True + ) + assert _consumes_caller_authorization(build(auth_type=MCPAuth.oauth2, delegate_auth_to_upstream=True)) is True + assert _consumes_caller_authorization(build(auth_type=MCPAuth.api_key, authentication_token="x")) is False + assert ( + _consumes_caller_authorization( + build( + auth_type=MCPAuth.oauth2, + delegate_auth_to_upstream=True, + oauth2_flow="client_credentials", + token_url="https://idp/token", + ) + ) + is False + ) + + def test_caller_authorization_fans_out_only_with_second_consumer(self): + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + _caller_authorization_fans_out, + ) + + delegate = MCPServer( + server_id="od", + name="od", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + ) + second = MCPServer( + server_id="tp", + name="tp", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.true_passthrough, + ) + static_server = MCPServer( + server_id="static", + name="static", + url="https://example.com", + transport=MCPTransport.http, + auth_type=MCPAuth.api_key, + authentication_token="x", + ) + + assert _caller_authorization_fans_out(delegate, None) is False + assert _caller_authorization_fans_out(delegate, [delegate]) is False + assert _caller_authorization_fans_out(delegate, [delegate, static_server]) is False + assert _caller_authorization_fans_out(delegate, [delegate, second]) is True + @pytest.mark.asyncio async def test_get_prompts_from_server_success(self): """Ensure prompts are fetched and prefixed when requested.""" From 33aaea363c13e8cc576f1732673308349945e7f9 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Wed, 8 Jul 2026 10:45:20 -0700 Subject: [PATCH 005/365] ci: add OSS daily branch workflow --- .github/workflows/create_daily_oss_branch.yml | 61 ++++++++++ .github/workflows/oss_daily_guardrails.yml | 109 ++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 .github/workflows/create_daily_oss_branch.yml create mode 100644 .github/workflows/oss_daily_guardrails.yml diff --git a/.github/workflows/create_daily_oss_branch.yml b/.github/workflows/create_daily_oss_branch.yml new file mode 100644 index 00000000000..43de4a0e75f --- /dev/null +++ b/.github/workflows/create_daily_oss_branch.yml @@ -0,0 +1,61 @@ +name: Create Daily OSS Branch + +on: + schedule: + - cron: "0 16 * * 1-5" # 9am PT during daylight saving time, weekdays. + workflow_dispatch: + inputs: + date: + description: "Branch date in YYYY_MM_DD format. Defaults to today's UTC date." + required: false + type: string + +permissions: + contents: write + +jobs: + create-oss-branch: + if: github.repository == 'BerriAI/litellm' + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Create dated OSS branch + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REQUESTED_DATE: ${{ inputs.date }} + run: | + set -euo pipefail + + if [ -n "${REQUESTED_DATE}" ]; then + if ! echo "${REQUESTED_DATE}" | grep -Eq '^[0-9]{4}_[0-9]{2}_[0-9]{2}$'; then + echo "::error::date must use YYYY_MM_DD format, got '${REQUESTED_DATE}'" + exit 1 + fi + BRANCH_DATE="${REQUESTED_DATE}" + else + BRANCH_DATE="$(date -u +'%Y_%m_%d')" + fi + + BRANCH_NAME="litellm_oss_daily_${BRANCH_DATE}" + echo "Creating branch: ${BRANCH_NAME}" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + git fetch origin main "${BRANCH_NAME}" || true + + if git show-ref --verify --quiet "refs/remotes/origin/${BRANCH_NAME}"; then + echo "Branch ${BRANCH_NAME} already exists. Skipping creation." + exit 0 + fi + + git checkout -b "${BRANCH_NAME}" origin/main + git push "https://x-access-token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "${BRANCH_NAME}" + echo "Successfully created and pushed branch: ${BRANCH_NAME}" diff --git a/.github/workflows/oss_daily_guardrails.yml b/.github/workflows/oss_daily_guardrails.yml new file mode 100644 index 00000000000..173b7cd4e41 --- /dev/null +++ b/.github/workflows/oss_daily_guardrails.yml @@ -0,0 +1,109 @@ +name: OSS Daily Guardrails + +on: + push: + branches: + - "litellm_oss_daily_20*" + pull_request: + branches: + - "litellm_oss_daily_20*" + - litellm_internal_staging + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + sensitive-file-guard: + name: Block sensitive OSS daily changes + if: startsWith(github.ref_name, 'litellm_oss_daily_20') || startsWith(github.head_ref, 'litellm_oss_daily_20') || startsWith(github.base_ref, 'litellm_oss_daily_20') + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Check for sensitive file changes + env: + EVENT_NAME: ${{ github.event_name }} + BASE_REF_NAME: ${{ github.base_ref }} + HEAD_REF_NAME: ${{ github.head_ref }} + run: | + set -euo pipefail + + if [ "${EVENT_NAME}" = "pull_request" ] && echo "${HEAD_REF_NAME}" | grep -Eq '^litellm_oss_daily_20[0-9]{2}_[0-9]{2}_[0-9]{2}$'; then + # Final daily OSS branch PR into staging: review only the OSS delta + # accumulated on top of main, not unrelated main/staging drift. + BASE_REF="origin/main" + git fetch origin main + elif [ "${EVENT_NAME}" = "pull_request" ]; then + # PR targeting the daily OSS branch: review the incoming PR delta. + BASE_REF="origin/${BASE_REF_NAME}" + git fetch origin "${BASE_REF_NAME}" + else + # Push to the daily OSS branch: review the accumulated OSS delta. + BASE_REF="origin/main" + git fetch origin main + fi + + CHANGED_FILES="$(git diff --name-only "${BASE_REF}...HEAD")" + + if [ -z "${CHANGED_FILES}" ]; then + echo "No changed files detected." + exit 0 + fi + + echo "Changed files:" + echo "${CHANGED_FILES}" + + BLOCKED_FILES="$( + echo "${CHANGED_FILES}" | grep -E '(^\.github/workflows/|^\.github/actions/|^\.circleci/|^\.cursor/|^Dockerfile$|(^|/)Dockerfile$|^Makefile$|(^|/)Makefile$|(^|/)pyproject\.toml$|(^|/)uv\.lock$|(^|/)poetry\.lock$|(^|/)requirements[^/]*\.txt$|(^|/)package\.json$|(^|/)package-lock\.json$|(^|/)pnpm-lock\.yaml$|(^|/)yarn\.lock$|(^|/)tsconfig[^/]*\.json$|(^|/)(ruff|mypy|pytest|eslint|prettier|vitest|next|vite|jest)\.config\.)' || true + )" + + if [ -n "${BLOCKED_FILES}" ]; then + echo "::error::OSS daily branch contains sensitive workflow, config, dependency, or lockfile changes. Split these into a separate internal/security-reviewed PR." + echo "${BLOCKED_FILES}" + exit 1 + fi + + echo "No sensitive OSS daily file changes detected." + + oss-safe-checks: + name: Run OSS daily safe checks + if: startsWith(github.ref_name, 'litellm_oss_daily_20') || startsWith(github.head_ref, 'litellm_oss_daily_20') || startsWith(github.base_ref, 'litellm_oss_daily_20') + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout repository + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - name: Set up uv + uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7 + with: + version: "0.10.9" + + - name: Run secret scan test + run: | + uv run --frozen --with 'pytest==9.0.2' pytest tests/litellm/test_no_hardcoded_secrets.py -v + + - name: Run Ruff + run: | + uv sync --frozen + cd litellm + uv run --no-sync ruff check . From 12d1873b44286f1bb9c1e07970958b5e134354b1 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Jul 2026 13:23:34 -0700 Subject: [PATCH 006/365] feat(ui): add enterprise license expiry banner to admin dashboard Surfaces a persistent, tiered banner under the dashboard navbar when an airgapped enterprise license is close to expiring: an amber, session-dismissible warning within 30 days, a non-dismissible red alert within 7 days, and a non-dismissible red banner once the date has passed. It reads the existing /health/license endpoint, so no backend change is needed, and is driven strictly by expiration_date; community and remote-validated instances that report no date show nothing. Shared day-count math is extracted to licenseUtils so the banner and the existing UsageIndicator widget stay in sync --- .../hooks/license/useLicenseInfo.ts | 15 +++ .../src/app/(dashboard)/layout.tsx | 2 + .../components/LicenseExpiryBanner.test.tsx | 90 ++++++++++++++++++ .../src/components/LicenseExpiryBanner.tsx | 92 +++++++++++++++++++ .../src/components/UsageIndicator.tsx | 12 +-- .../src/utils/licenseUtils.test.ts | 61 ++++++++++++ .../src/utils/licenseUtils.ts | 49 ++++++++++ 7 files changed, 310 insertions(+), 11 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts create mode 100644 ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx create mode 100644 ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx create mode 100644 ui/litellm-dashboard/src/utils/licenseUtils.test.ts create mode 100644 ui/litellm-dashboard/src/utils/licenseUtils.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts new file mode 100644 index 00000000000..f4574c36ef6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts @@ -0,0 +1,15 @@ +import { useQuery, UseQueryResult } from "@tanstack/react-query"; +import { getLicenseInfo, LicenseInfo } from "@/components/networking"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +const licenseInfoKeys = createQueryKeys("licenseInfo"); + +export const useLicenseInfo = (accessToken: string | null | undefined): UseQueryResult => { + return useQuery({ + queryKey: licenseInfoKeys.detail("license"), + queryFn: () => getLicenseInfo(accessToken!), + enabled: Boolean(accessToken), + staleTime: 5 * 60 * 1000, + retry: false, + }); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx index 09951dc1923..c84209b80cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.tsx @@ -8,6 +8,7 @@ import { useAuth } from "@/contexts/AuthContext"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import { useRouter, useSearchParams, usePathname } from "next/navigation"; import { DebugWarningBanner } from "@/components/DebugWarningBanner"; +import { LicenseExpiryBanner } from "@/components/LicenseExpiryBanner"; import { MIGRATED_PAGES, migratedHref, legacyPageHref, legacyKeyForPathname } from "@/utils/migratedPages"; import { PluginModeProvider, usePluginMode } from "@/contexts/PluginModeContext"; import { createApiClient } from "@/lib/http/client"; @@ -116,6 +117,7 @@ function DashboardShell({ children }: { children: React.ReactNode }) { onToggleSidebar={() => setSidebarCollapsed((v) => !v)} /> +
{mode !== "ai-gateway" ? (
diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx new file mode 100644 index 00000000000..d6b419ace7c --- /dev/null +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.test.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { LicenseExpiryBannerView } from "./LicenseExpiryBanner"; +import { LicenseInfo } from "./networking"; + +const daysFromNow = (n: number): string => { + const date = new Date(); + date.setUTCDate(date.getUTCDate() + n); + return date.toISOString().slice(0, 10); +}; + +const licenseWith = (expiration_date: string | null): LicenseInfo => ({ + has_license: expiration_date !== null, + license_type: expiration_date !== null ? "enterprise" : "community", + expiration_date, + allowed_features: [], + limits: { max_users: null, max_teams: null }, +}); + +describe("LicenseExpiryBannerView", () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + afterEach(() => { + sessionStorage.clear(); + }); + + it("renders nothing when there is no license info", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when expiration_date is null (community or remote-validated)", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when expiry is more than 30 days out", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("shows a dismissible amber warning within 30 days", () => { + const { container } = render(); + expect(screen.getByText(/expires in 20 days/)).toBeInTheDocument(); + expect(container.querySelector(".ant-alert-warning")).toBeInTheDocument(); + expect(screen.queryByRole("button")).toBeInTheDocument(); + expect(screen.getByRole("link", { name: "sales@berri.ai" })).toHaveAttribute("href", "mailto:sales@berri.ai"); + }); + + it("shows a non-dismissible red critical alert within 7 days", () => { + const { container } = render(); + expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument(); + expect(container.querySelector(".ant-alert-error")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("says 'expires today' on the expiration day", () => { + render(); + expect(screen.getByText(/expires today/)).toBeInTheDocument(); + }); + + it("shows a non-dismissible red expired alert stating features are disabled", () => { + const { container } = render(); + expect(screen.getByText(/expired on/)).toBeInTheDocument(); + expect(screen.getByText(/features are now disabled/i)).toBeInTheDocument(); + expect(container.querySelector(".ant-alert-error")).toBeInTheDocument(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("hides the warning after dismissal and stays hidden within the session", () => { + const expiration = daysFromNow(20); + const { unmount } = render(); + fireEvent.click(screen.getByRole("button")); + expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument(); + + unmount(); + render(); + expect(screen.queryByText(/expires in 20 days/)).not.toBeInTheDocument(); + }); + + it("still shows a critical alert even when its date was previously dismissed", () => { + const expiration = daysFromNow(5); + sessionStorage.setItem(`litellm:licenseExpiryBannerDismissed:${expiration}`, "true"); + render(); + expect(screen.getByText(/expires in 5 days/)).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx new file mode 100644 index 00000000000..e5b8a65168a --- /dev/null +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx @@ -0,0 +1,92 @@ +"use client"; + +import React, { useState } from "react"; +import { Alert } from "antd"; +import { LicenseInfo } from "@/components/networking"; +import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; +import { formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "@/utils/licenseUtils"; + +const DISMISS_KEY_PREFIX = "litellm:licenseExpiryBannerDismissed:"; +const SALES_EMAIL = "sales@berri.ai"; + +const salesLink = {SALES_EMAIL}; + +interface LicenseExpiryBannerProps { + accessToken: string | null; +} + +interface LicenseExpiryBannerViewProps { + licenseInfo: LicenseInfo | null; +} + +const describeCountdown = (days: number): string => { + if (days <= 0) { + return "expires today"; + } + if (days === 1) { + return "expires in 1 day"; + } + return `expires in ${days} days`; +}; + +export const LicenseExpiryBannerView: React.FC = ({ licenseInfo }) => { + const [locallyDismissed, setLocallyDismissed] = useState(false); + + const expirationDate = licenseInfo?.expiration_date ?? null; + const tier = getLicenseExpiryTier(expirationDate); + const days = getDaysUntilExpiration(expirationDate); + + if (expirationDate === null || tier === "none" || days === null) { + return null; + } + + const isDismissible = tier === "warning"; + const dismissKey = `${DISMISS_KEY_PREFIX}${expirationDate}`; + const previouslyDismissed = + isDismissible && typeof window !== "undefined" ? sessionStorage.getItem(dismissKey) === "true" : false; + + if (isDismissible && (locallyDismissed || previouslyDismissed)) { + return null; + } + + const formattedDate = formatExpiryDate(expirationDate); + + const message = + tier === "expired" + ? `Your LiteLLM Enterprise license expired on ${formattedDate}` + : `Your LiteLLM Enterprise license ${describeCountdown(days)} (${formattedDate})`; + + const description = + tier === "expired" ? ( + <>Enterprise features are now disabled. Reach out to {salesLink} to restore access + ) : tier === "critical" ? ( + <>Renew now to avoid losing enterprise features. Reach out to {salesLink} + ) : ( + <>Renew before it lapses to keep enterprise features. Reach out to {salesLink} + ); + + const handleClose = () => { + if (typeof window !== "undefined") { + sessionStorage.setItem(dismissKey, "true"); + } + setLocallyDismissed(true); + }; + + return ( + + ); +}; + +export const LicenseExpiryBanner: React.FC = ({ accessToken }) => { + const { data } = useLicenseInfo(accessToken); + return ; +}; diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.tsx index 030df228de0..8165f8eb6f6 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.tsx @@ -15,6 +15,7 @@ import { useEffect, useState } from "react"; import { getRemainingUsers, getLicenseInfo, LicenseInfo } from "./networking"; import { cn } from "@/lib/cva.config"; +import { getDaysUntilExpiration } from "@/utils/licenseUtils"; interface UsageIndicatorProps { accessToken: string | null; @@ -30,17 +31,6 @@ interface UsageData { total_teams_remaining: number | null; } -// Calculate days until expiration -const getDaysUntilExpiration = (expirationDate: string | null): number | null => { - if (!expirationDate) return null; - const expDate = new Date(expirationDate + "T00:00:00Z"); // Force UTC midnight - const now = new Date(); - now.setHours(0, 0, 0, 0); // Normalize to local midnight - const diffTime = expDate.getTime() - now.getTime(); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - return diffDays; -}; - // Format expiration for display const formatExpirationDisplay = (daysRemaining: number | null): string => { if (daysRemaining === null) return "No expiration"; diff --git a/ui/litellm-dashboard/src/utils/licenseUtils.test.ts b/ui/litellm-dashboard/src/utils/licenseUtils.test.ts new file mode 100644 index 00000000000..717b8f0d90d --- /dev/null +++ b/ui/litellm-dashboard/src/utils/licenseUtils.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { type LicenseExpiryTier, formatExpiryDate, getDaysUntilExpiration, getLicenseExpiryTier } from "./licenseUtils"; + +const NOW = new Date("2026-07-08T00:00:00Z"); + +describe("getDaysUntilExpiration", () => { + it("returns null for a null expiration", () => { + expect(getDaysUntilExpiration(null, NOW)).toBeNull(); + }); + + it("returns null for an unparseable date", () => { + expect(getDaysUntilExpiration("not-a-date", NOW)).toBeNull(); + }); + + it("returns 0 for an expiration on the current UTC day", () => { + expect(getDaysUntilExpiration("2026-07-08", NOW)).toBe(0); + }); + + it("returns a positive count for future dates", () => { + expect(getDaysUntilExpiration("2026-07-15", NOW)).toBe(7); + expect(getDaysUntilExpiration("2026-08-07", NOW)).toBe(30); + }); + + it("returns a negative count for a past date", () => { + expect(getDaysUntilExpiration("2026-07-07", NOW)).toBe(-1); + }); + + it("is timezone-independent within a UTC day", () => { + expect(getDaysUntilExpiration("2026-08-07", new Date("2026-07-08T00:00:01Z"))).toBe(30); + expect(getDaysUntilExpiration("2026-08-07", new Date("2026-07-08T23:59:59Z"))).toBe(30); + }); +}); + +describe("getLicenseExpiryTier", () => { + const cases: Array<[string | null, LicenseExpiryTier]> = [ + [null, "none"], + ["not-a-date", "none"], + ["2026-08-08", "none"], + ["2026-08-07", "warning"], + ["2026-07-16", "warning"], + ["2026-07-15", "critical"], + ["2026-07-09", "critical"], + ["2026-07-08", "critical"], + ["2026-07-07", "expired"], + ["2026-01-01", "expired"], + ]; + + it.each(cases)("classifies %s as %s", (date, expected) => { + expect(getLicenseExpiryTier(date, NOW)).toBe(expected); + }); +}); + +describe("formatExpiryDate", () => { + it("formats an ISO date as a human-readable UTC date", () => { + expect(formatExpiryDate("2026-07-31")).toBe("Jul 31, 2026"); + }); + + it("returns the input unchanged when unparseable", () => { + expect(formatExpiryDate("bogus")).toBe("bogus"); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/licenseUtils.ts b/ui/litellm-dashboard/src/utils/licenseUtils.ts new file mode 100644 index 00000000000..b2681664c56 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/licenseUtils.ts @@ -0,0 +1,49 @@ +export type LicenseExpiryTier = "none" | "warning" | "critical" | "expired"; + +export const LICENSE_EXPIRY_WARNING_DAYS = 30; +export const LICENSE_EXPIRY_CRITICAL_DAYS = 7; + +export const getDaysUntilExpiration = (expirationDate: string | null, now: Date = new Date()): number | null => { + if (!expirationDate) { + return null; + } + + const expiration = new Date(`${expirationDate}T00:00:00Z`); + if (Number.isNaN(expiration.getTime())) { + return null; + } + + const nowUtcMidnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()); + const diffMs = expiration.getTime() - nowUtcMidnight; + return Math.ceil(diffMs / (1000 * 60 * 60 * 24)); +}; + +export const getLicenseExpiryTier = (expirationDate: string | null, now: Date = new Date()): LicenseExpiryTier => { + const days = getDaysUntilExpiration(expirationDate, now); + if (days === null) { + return "none"; + } + if (days < 0) { + return "expired"; + } + if (days <= LICENSE_EXPIRY_CRITICAL_DAYS) { + return "critical"; + } + if (days <= LICENSE_EXPIRY_WARNING_DAYS) { + return "warning"; + } + return "none"; +}; + +export const formatExpiryDate = (expirationDate: string): string => { + const date = new Date(`${expirationDate}T00:00:00Z`); + if (Number.isNaN(date.getTime())) { + return expirationDate; + } + return date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", + }); +}; From 7b2742777d31d2c7af6eeb5e1d3a3770d3570c81 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Jul 2026 14:07:29 -0700 Subject: [PATCH 007/365] refactor(ui): dedupe /health/license fetch via shared useLicenseInfo hook UsageIndicator was fetching /health/license through its own useEffect while the new expiry banner fetches the same endpoint via useLicenseInfo, so an admin with the usage widget open made two identical calls per page load. Point UsageIndicator at useLicenseInfo too; both callers now share one React Query cache entry, collapsing it back to a single request. The null/error semantics are preserved (data ?? null matches the previous catch-to-null), and license errors never fed the widget's error state before either --- .../src/components/UsageIndicator.test.tsx | 30 +++++++++++-------- .../src/components/UsageIndicator.tsx | 12 ++++---- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx index 8c7c15bc5a5..ad27fbcd74f 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import UsageIndicator from "./UsageIndicator"; vi.mock("./networking", () => ({ @@ -17,6 +18,11 @@ import { getRemainingUsers } from "./networking"; const mockGetRemainingUsers = vi.mocked(getRemainingUsers); +const renderWithClient = (ui: React.ReactElement) => { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render({ui}); +}; + const DEFAULT_USAGE_DATA = { total_users: 100, total_users_used: 1, @@ -33,7 +39,7 @@ describe("UsageIndicator", () => { }); it("should render when given access token and usage data loads", async () => { - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -41,7 +47,7 @@ describe("UsageIndicator", () => { }); it("should not show Near limit when users usage is below 80% (1/100 -> 1%)", async () => { - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -58,7 +64,7 @@ describe("UsageIndicator", () => { total_users_remaining: null, }); - render(); + renderWithClient(); await waitFor(() => { expect(screen.queryByText("Usage")).not.toBeInTheDocument(); @@ -76,7 +82,7 @@ describe("UsageIndicator", () => { total_teams_remaining: 1, }); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -94,7 +100,7 @@ describe("UsageIndicator", () => { total_teams_remaining: null, }); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -112,7 +118,7 @@ describe("UsageIndicator", () => { total_teams_remaining: -2, }); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -121,7 +127,7 @@ describe("UsageIndicator", () => { }); it("should render nothing when accessToken is null", () => { - render(); + renderWithClient(); expect(mockGetRemainingUsers).not.toHaveBeenCalled(); expect(screen.queryByText("Usage")).not.toBeInTheDocument(); @@ -131,7 +137,7 @@ describe("UsageIndicator", () => { const { useDisableUsageIndicator } = await import("@/app/(dashboard)/hooks/useDisableUsageIndicator"); (useDisableUsageIndicator as ReturnType).mockReturnValue(true); - render(); + renderWithClient(); await waitFor(() => { expect(screen.queryByText("Usage")).not.toBeInTheDocument(); @@ -143,7 +149,7 @@ describe("UsageIndicator", () => { it("should show Loading while fetching", () => { mockGetRemainingUsers.mockImplementation(() => new Promise(() => {})); - render(); + renderWithClient(); expect(screen.getByText("Loading...")).toBeInTheDocument(); }); @@ -152,7 +158,7 @@ describe("UsageIndicator", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); mockGetRemainingUsers.mockRejectedValue(new Error("Network error")); - render(); + renderWithClient(); expect(await screen.findByText("Failed to load usage data")).toBeInTheDocument(); @@ -161,7 +167,7 @@ describe("UsageIndicator", () => { it("should minimize when user clicks minimize button", async () => { const user = userEvent.setup(); - render(); + renderWithClient(); await screen.findByText("Usage"); @@ -174,7 +180,7 @@ describe("UsageIndicator", () => { it("should restore from minimized when user clicks restore button", async () => { const user = userEvent.setup(); - render(); + renderWithClient(); await screen.findByText("Usage"); diff --git a/ui/litellm-dashboard/src/components/UsageIndicator.tsx b/ui/litellm-dashboard/src/components/UsageIndicator.tsx index 8165f8eb6f6..6e7b5e9ec60 100644 --- a/ui/litellm-dashboard/src/components/UsageIndicator.tsx +++ b/ui/litellm-dashboard/src/components/UsageIndicator.tsx @@ -12,10 +12,11 @@ import { Users, } from "lucide-react"; import { useEffect, useState } from "react"; -import { getRemainingUsers, getLicenseInfo, LicenseInfo } from "./networking"; +import { getRemainingUsers } from "./networking"; import { cn } from "@/lib/cva.config"; import { getDaysUntilExpiration } from "@/utils/licenseUtils"; +import { useLicenseInfo } from "@/app/(dashboard)/hooks/license/useLicenseInfo"; interface UsageIndicatorProps { accessToken: string | null; @@ -48,10 +49,11 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica const [isExpanded, setIsExpanded] = useState(false); const [isMinimized, setIsMinimized] = useState(false); const [data, setData] = useState(null); - const [licenseInfo, setLicenseInfo] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const licenseInfo = useLicenseInfo(accessToken).data ?? null; + useEffect(() => { const fetchData = async () => { if (!accessToken) return; @@ -60,12 +62,8 @@ export default function UsageIndicator({ accessToken, width = 220 }: UsageIndica setError(null); try { - const [usageResult, licenseResult] = await Promise.all([ - getRemainingUsers(accessToken), - getLicenseInfo(accessToken).catch(() => null), // Don't fail if license endpoint unavailable - ]); + const usageResult = await getRemainingUsers(accessToken); setData(usageResult); - setLicenseInfo(licenseResult); } catch (err) { console.error("Failed to fetch usage data:", err); setError("Failed to load usage data"); From 34aedc40c6467e8a81dbd18e8df71d20cb6bcd96 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Jul 2026 14:50:27 -0700 Subject: [PATCH 008/365] test(ui): mock LicenseExpiryBanner in the dashboard layout test The layout test renders DashboardShell without a QueryClientProvider and mocks DebugWarningBanner to null for exactly that reason. The new LicenseExpiryBanner also uses a React Query hook, so it needs the same treatment; without it the test threw "No QueryClient set". Runtime is unaffected: the app mounts a QueryClientProvider above the layout (DebugWarningBanner already relies on it) --- ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx index af68d9f87e9..7573ddb5a0f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/layout.test.tsx @@ -21,6 +21,10 @@ vi.mock("@/components/DebugWarningBanner", () => ({ DebugWarningBanner: () => null, })); +vi.mock("@/components/LicenseExpiryBanner", () => ({ + LicenseExpiryBanner: () => null, +})); + vi.mock("@/contexts/ThemeContext", () => ({ ThemeProvider: ({ children }: { children: React.ReactNode }) => <>{children}, })); From 641396762ac8e363325ae1e177e8079e4edec9e4 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Jul 2026 15:17:13 -0700 Subject: [PATCH 009/365] refactor(ui): conform license banner to new eslint rules Staging recently added the local eslint rules no-large-inline-object-arg and no-long-condition-chain and tightened no-nested-ternary to an error. After merging staging, the license-banner code tripped them: the banner's tiered description was a nested ternary (now an error), and two option objects were passed inline (adding budget debt). Extract the description into an early-return helper, and hoist the useQuery options and the date-format options into named constants. No behavior change; keeps the inline-object-arg count at the committed baseline rather than bumping it --- .../hooks/license/useLicenseInfo.ts | 5 +++-- .../src/components/LicenseExpiryBanner.tsx | 19 +++++++++++-------- .../src/utils/licenseUtils.ts | 14 ++++++++------ 3 files changed, 22 insertions(+), 16 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts index f4574c36ef6..3ea0bd20e40 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/license/useLicenseInfo.ts @@ -5,11 +5,12 @@ import { createQueryKeys } from "../common/queryKeysFactory"; const licenseInfoKeys = createQueryKeys("licenseInfo"); export const useLicenseInfo = (accessToken: string | null | undefined): UseQueryResult => { - return useQuery({ + const options = { queryKey: licenseInfoKeys.detail("license"), queryFn: () => getLicenseInfo(accessToken!), enabled: Boolean(accessToken), staleTime: 5 * 60 * 1000, retry: false, - }); + }; + return useQuery(options); }; diff --git a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx index e5b8a65168a..c3b20b5fac0 100644 --- a/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx +++ b/ui/litellm-dashboard/src/components/LicenseExpiryBanner.tsx @@ -29,6 +29,16 @@ const describeCountdown = (days: number): string => { return `expires in ${days} days`; }; +const expiryDescription = (tier: "warning" | "critical" | "expired"): React.ReactNode => { + if (tier === "expired") { + return <>Enterprise features are now disabled. Reach out to {salesLink} to restore access; + } + if (tier === "critical") { + return <>Renew now to avoid losing enterprise features. Reach out to {salesLink}; + } + return <>Renew before it lapses to keep enterprise features. Reach out to {salesLink}; +}; + export const LicenseExpiryBannerView: React.FC = ({ licenseInfo }) => { const [locallyDismissed, setLocallyDismissed] = useState(false); @@ -56,14 +66,7 @@ export const LicenseExpiryBannerView: React.FC = ( ? `Your LiteLLM Enterprise license expired on ${formattedDate}` : `Your LiteLLM Enterprise license ${describeCountdown(days)} (${formattedDate})`; - const description = - tier === "expired" ? ( - <>Enterprise features are now disabled. Reach out to {salesLink} to restore access - ) : tier === "critical" ? ( - <>Renew now to avoid losing enterprise features. Reach out to {salesLink} - ) : ( - <>Renew before it lapses to keep enterprise features. Reach out to {salesLink} - ); + const description = expiryDescription(tier); const handleClose = () => { if (typeof window !== "undefined") { diff --git a/ui/litellm-dashboard/src/utils/licenseUtils.ts b/ui/litellm-dashboard/src/utils/licenseUtils.ts index b2681664c56..57acad85508 100644 --- a/ui/litellm-dashboard/src/utils/licenseUtils.ts +++ b/ui/litellm-dashboard/src/utils/licenseUtils.ts @@ -35,15 +35,17 @@ export const getLicenseExpiryTier = (expirationDate: string | null, now: Date = return "none"; }; +const EXPIRY_DATE_FORMAT: Intl.DateTimeFormatOptions = { + year: "numeric", + month: "short", + day: "numeric", + timeZone: "UTC", +}; + export const formatExpiryDate = (expirationDate: string): string => { const date = new Date(`${expirationDate}T00:00:00Z`); if (Number.isNaN(date.getTime())) { return expirationDate; } - return date.toLocaleDateString("en-US", { - year: "numeric", - month: "short", - day: "numeric", - timeZone: "UTC", - }); + return date.toLocaleDateString("en-US", EXPIRY_DATE_FORMAT); }; From bd23c44cb197e71143c4bae838b8af0da1869971 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 8 Jul 2026 15:28:28 -0700 Subject: [PATCH 010/365] refactor(ui): consolidate table cells onto a shared table_cells kit (#32393) * feat(ui): add shared table_cells kit and convert logs columns DateCell, MoneyCell, IdCell and StatusBadge consolidate the duplicated per-table cell implementations behind one component each. The logs page columns are the reference conversion; the dead auditLogColumns export (superseded by audit_logs.tsx) is removed with it * refactor(ui): consolidate table cells onto the shared table_cells kit 106 cell sites across 44 table files converge onto DateCell, MoneyCell, IdCell and StatusBadge, replacing 8 date formats, 6 spend formats, 7 id truncation strategies and 6 status badge styles with one implementation each. Badge now forwards refs so Base UI tooltip triggers composed over it can attach (they previously never opened under React 18). TimeCell is deleted; its two consumers now render DateCell * fix(ui): suppress cost tooltip for zero spend and drop dead getStatusBadge param The logs Cost tooltip showed the raw $0 over a "-" cell for zero or null spend (pre-existing, surfaced by review); the tooltip now only renders when there is a real amount. healthCheckColumns no longer takes the unused getStatusBadge callback and its dead definition is removed * fix(ui): restyle StatusBadge as tinted pill matching the prior antd Tag look * fix(ui): keep StatusBadge fully rounded like the other kit pills --- ui/litellm-dashboard/eslint-metrics.json | 4 +- ui/litellm-dashboard/eslint-suppressions.json | 7 +- .../components/AccessGroupsPage.tsx | 21 +- .../budgets/components/budget_panel.tsx | 5 +- .../memory/components/MemoryView.tsx | 32 +-- .../projects/components/ProjectKeysTable.tsx | 5 +- .../projects/components/ProjectsPage.tsx | 18 +- .../prompts/components/prompt_table.tsx | 59 +---- .../_components/SearchToolColumn.tsx | 14 +- .../users/_components/BulkEditUsers.test.tsx | 2 +- .../users/_components/BulkEditUsers.tsx | 3 +- .../users/_components/view_users.test.tsx | 3 +- .../users/_components/view_users/columns.tsx | 42 +--- .../components/AIHub/AgentHubTableColumns.tsx | 13 +- .../DeletedKeysTable/DeletedKeysTable.tsx | 49 +---- .../DeletedTeamsTable/DeletedTeamsTable.tsx | 50 +---- .../src/components/OldTeams.tsx | 23 +- .../LoggingCallbacksTable.tsx | 14 +- .../src/components/ToolPolicies.tsx | 14 +- .../components/EndpointUsageTable.test.tsx | 6 - .../components/EndpointUsageTable.tsx | 4 +- .../components/EntityUsage/EntityUsage.tsx | 9 +- .../EntityUsage/SpendByProvider.test.tsx | 10 +- .../EntityUsage/SpendByProvider.tsx | 5 +- .../EntityUsage/TopKeyView.test.tsx | 27 +-- .../components/EntityUsage/TopKeyView.tsx | 23 +- .../EntityUsage/TopModelView.test.tsx | 2 +- .../components/EntityUsage/TopModelView.tsx | 6 +- .../components/KeyModelUsageView.test.tsx | 2 +- .../components/KeyModelUsageView.tsx | 3 +- .../VirtualKeysPage/VirtualKeysTable.test.tsx | 10 +- .../VirtualKeysPage/VirtualKeysTable.tsx | 79 ++----- .../src/components/agents.tsx | 21 +- .../claude_code_plugins/plugin_table.tsx | 34 +-- .../src/components/general_settings.tsx | 12 +- .../components/guardrails/guardrail_table.tsx | 52 +---- .../src/components/mcp_hub_table_columns.tsx | 21 +- .../components/mcp_tools/MCPToolsetsTab.tsx | 13 +- .../model_dashboard/HealthCheckComponent.tsx | 18 +- .../model_dashboard/health_check_columns.tsx | 25 ++- .../components/model_hub_table_columns.tsx | 9 +- .../molecules/models/columns.test.tsx | 58 +++++ .../components/molecules/models/columns.tsx | 54 ++--- .../organization/organization_view.tsx | 3 +- .../src/components/organizations.tsx | 32 ++- .../src/components/pass_through_settings.tsx | 16 +- .../policies/attachment_table.test.tsx | 7 +- .../components/policies/attachment_table.tsx | 25 +-- .../src/components/policies/policy_table.tsx | 16 +- .../shared/table_cells/cell_tooltip.tsx | 21 ++ .../shared/table_cells/date_cell.test.tsx | 57 +++++ .../shared/table_cells/date_cell.tsx | 41 ++++ .../shared/table_cells/id_cell.test.tsx | 78 +++++++ .../components/shared/table_cells/id_cell.tsx | 94 ++++++++ .../components/shared/table_cells/index.ts | 5 + .../shared/table_cells/money_cell.test.tsx | 44 ++++ .../shared/table_cells/money_cell.tsx | 23 ++ .../shared/table_cells/status_badge.test.tsx | 42 ++++ .../shared/table_cells/status_badge.tsx | 38 ++++ .../components/skill_hub_table_columns.tsx | 8 +- .../tag_management/TagTable.test.tsx | 18 +- .../components/tag_management/TagTable.tsx | 39 +--- .../components/team/TeamMemberTab.test.tsx | 27 ++- .../src/components/team/TeamMemberTab.tsx | 36 +--- .../components/team/TeamVirtualKeysTable.tsx | 76 ++----- .../src/components/ui/badge.tsx | 22 +- ui/litellm-dashboard/src/components/usage.tsx | 9 +- .../DocumentsTable.tsx | 17 +- .../VectorStoreTable.test.tsx | 9 +- .../VectorStoreTable.tsx | 25 +-- .../src/components/view_logs/audit_logs.tsx | 12 +- .../src/components/view_logs/columns.test.tsx | 56 +++++ .../src/components/view_logs/columns.tsx | 202 ++---------------- .../components/view_logs/time_cell.test.tsx | 36 ---- .../src/components/view_logs/time_cell.tsx | 42 ---- 75 files changed, 918 insertions(+), 1139 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/cell_tooltip.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/index.ts create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/money_cell.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/money_cell.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx create mode 100644 ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx create mode 100644 ui/litellm-dashboard/src/components/view_logs/columns.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/time_cell.test.tsx delete mode 100644 ui/litellm-dashboard/src/components/view_logs/time_cell.tsx diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index f4dc89c5b80..ded6ab97e1e 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { - "@typescript-eslint/no-explicit-any": 1988, + "@typescript-eslint/no-explicit-any": 1982, "complexity": 128, - "local/no-large-inline-object-arg": 513, + "local/no-large-inline-object-arg": 512, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index b077338c75b..fe8f182c106 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1650,7 +1650,7 @@ "count": 1 }, "no-restricted-imports": { - "count": 2 + "count": 1 } }, "src/components/guardrails/tool_permission/ToolPermissionRulesEditor.tsx": { @@ -2491,11 +2491,6 @@ "count": 4 } }, - "src/components/view_logs/columns.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/components/view_logs/index.tsx": { "no-restricted-imports": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx index c3dd1d54b32..dbbf4e35900 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/access-groups/components/AccessGroupsPage.tsx @@ -19,6 +19,7 @@ import { SortState, TableHeaderSortDropdown, } from "@/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import { AccessGroupDetail } from "./AccessGroupsDetailsPage"; import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal"; import { AccessGroup } from "./types"; @@ -143,21 +144,7 @@ export function AccessGroupsPage() { header: () => ID, enableSorting: false, size: 170, - cell: ({ row }) => { - const record = row.original; - return ( - - setSelectedGroupId(record.id)} - > - {record.id} - - - ); - }, + cell: ({ row }) => , }, { id: "name", @@ -211,7 +198,7 @@ export function AccessGroupsPage() { header: () => Created, enableSorting: true, sortingFn: "datetime", - cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(), + cell: ({ getValue }) => , meta: { responsive: ["lg"] }, }, { @@ -219,7 +206,7 @@ export function AccessGroupsPage() { accessorKey: "updatedAt", header: () => Updated, enableSorting: false, - cell: ({ getValue }) => new Date(getValue() as string).toLocaleDateString(), + cell: ({ getValue }) => , meta: { responsive: ["xl"] }, }, ...(canModify diff --git a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx index 0e601645c20..af15a99f0b4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/budgets/components/budget_panel.tsx @@ -25,6 +25,7 @@ import DeleteResourceModal from "@/components/common_components/DeleteResourceMo import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { useBudgets, useDeleteBudget, budgetItem } from "@/app/(dashboard)/hooks/budgets/useBudgets"; +import { MoneyCell } from "@/components/shared/table_cells"; import BudgetModal from "./budget_modal"; import EditBudgetModal from "./edit_budget_modal"; import { CREATE_END_USER_CURL_COMMAND, CHAT_COMPLETIONS_CURL_COMMAND, OPENAI_SDK_PYTHON_CODE } from "./constants"; @@ -127,7 +128,9 @@ const BudgetPanel: React.FC = ({ accessToken }) => { .map((value: budgetItem) => ( {value.budget_id} - {value.max_budget ? value.max_budget : "n/a"} + + + {value.tpm_limit ? value.tpm_limit : "n/a"} {value.rpm_limit ? value.rpm_limit : "n/a"} {canModify && ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx index 402de29e0c5..4ee784f4664 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/components/MemoryView.tsx @@ -2,7 +2,7 @@ import React, { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Button, Card, Drawer, Empty, Input, Space, Table, Tooltip, Typography, message } from "antd"; +import { Button, Card, Drawer, Empty, Input, Space, Table, Typography, message } from "antd"; import type { ColumnsType } from "antd/es/table"; import { DeleteOutlined, @@ -13,6 +13,7 @@ import { SearchOutlined, } from "@ant-design/icons"; import { MemoryRow, createMemory, deleteMemory, fetchMemoryList, updateMemory } from "@/components/networking"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import { MemoryEditModal } from "./MemoryEditModal"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; @@ -191,34 +192,13 @@ export const MemoryView: React.FC = ({ accessToken }) => { } }; - const renderIdPill = (id: string | null | undefined, onClick?: () => void) => { - if (!id) return -; - const short = id.length > 10 ? `${id.slice(0, 7)}...` : id; - const pillClass = - "font-mono text-blue-600 bg-blue-50 text-xs font-medium px-2 py-0.5 rounded-md border border-blue-200 inline-block max-w-[15ch] truncate whitespace-nowrap"; - return ( - - {onClick ? ( - - ) : ( - {short} - )} - - ); - }; - const columns: ColumnsType = [ { title: "ID", dataIndex: "memory_id", key: "memory_id", width: 140, - render: (_: unknown, r: MemoryRow) => renderIdPill(r.memory_id, () => setDetailRow(r)), + render: (_: unknown, r: MemoryRow) => setDetailRow(r)} />, }, { title: "Name", @@ -246,21 +226,21 @@ export const MemoryView: React.FC = ({ accessToken }) => { dataIndex: "user_id", key: "user_id", width: 160, - render: (uid?: string | null) => renderIdPill(uid), + render: (uid?: string | null) => , }, { title: "Team ID", dataIndex: "team_id", key: "team_id", width: 160, - render: (tid?: string | null) => renderIdPill(tid), + render: (tid?: string | null) => , }, { title: "Updated", dataIndex: "updated_at", key: "updated_at", width: 180, - render: (ts?: string) => {formatTimestamp(ts)}, + render: (ts?: string) => , // No sorter — backend already returns rows in `updated_at DESC` order, // and a client-side sorter on a paginated view would only affect the // current page. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.tsx index 7b891078d36..8269c843b98 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectKeysTable.tsx @@ -3,6 +3,7 @@ import { Empty, Table, Tooltip } from "antd"; import type { ColumnsType } from "antd/es/table"; import type { SpinProps } from "antd"; import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag"; +import { DateCell } from "@/components/shared/table_cells"; interface ProjectKeysTableProps { keys: KeyResponse[]; @@ -33,13 +34,13 @@ const columns: ColumnsType = [ title: "Created", dataIndex: "created_at", key: "created_at", - render: (date: string) => (date ? new Date(date).toLocaleDateString() : "—"), + render: (date: string) => , }, { title: "Last Active", dataIndex: "last_active", key: "last_active", - render: (date: string | null) => (date ? new Date(date).toLocaleDateString() : "Never"), + render: (date: string | null) => , }, ]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.tsx index 8a3fc178914..be989229022 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/components/ProjectsPage.tsx @@ -1,5 +1,6 @@ import { useProjects, ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import { LoadingOutlined, PlusOutlined } from "@ant-design/icons"; import { Button, @@ -72,18 +73,7 @@ export function ProjectsPage() { dataIndex: "project_id", key: "project_id", width: 170, - render: (id: string) => ( - - setSelectedProjectId(id)} - > - {id} - - - ), + render: (id: string) => , }, { title: "Name", @@ -137,14 +127,14 @@ export function ProjectsPage() { key: "created_at", sorter: (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(), responsive: ["lg"], - render: (date: string) => new Date(date).toLocaleDateString(), + render: (date: string) => , }, { title: "Updated", dataIndex: "updated_at", key: "updated_at", responsive: ["xl"], - render: (date: string) => new Date(date).toLocaleDateString(), + render: (date: string) => , }, ]; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/prompts/components/prompt_table.tsx b/ui/litellm-dashboard/src/app/(dashboard)/prompts/components/prompt_table.tsx index 94b242ca4ea..51a03d19e83 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/prompts/components/prompt_table.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/prompts/components/prompt_table.tsx @@ -2,8 +2,8 @@ import React, { useState, useEffect } from "react"; import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Button } from "@tremor/react"; import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, TrashIcon } from "@heroicons/react/outline"; import { Tooltip } from "antd"; -import { CopyOutlined } from "@ant-design/icons"; import { PromptSpec, modelHubCall } from "@/components/networking"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import { ColumnDef, flexRender, @@ -62,48 +62,11 @@ const PromptTable: React.FC = ({ fetchModelHubData(); }, [accessToken]); - // Format date helper function - const formatDate = (dateString?: string) => { - if (!dateString) return "-"; - const date = new Date(dateString); - return date.toLocaleString(); - }; - - const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text); - }; - const columns: ColumnDef[] = [ { header: "Prompt ID", accessorKey: "prompt_id", - cell: (info: any) => { - const fullId = String(info.getValue() || ""); - const displayId = fullId.length > 25 ? `${fullId.slice(0, 25)}...` : fullId; - return ( -
- - - - - { - e.stopPropagation(); - copyToClipboard(fullId); - }} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - -
- ); - }, + cell: (info: any) => , }, { header: "Model", @@ -162,26 +125,12 @@ const PromptTable: React.FC = ({ { header: "Created At", accessorKey: "created_at", - cell: ({ row }) => { - const prompt = row.original; - return ( - - {formatDate(prompt.created_at)} - - ); - }, + cell: ({ row }) => , }, { header: "Updated At", accessorKey: "updated_at", - cell: ({ row }) => { - const prompt = row.original; - return ( - - {formatDate(prompt.updated_at)} - - ); - }, + cell: ({ row }) => , }, { header: "Environment", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolColumn.tsx b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolColumn.tsx index 198b3ea095f..3d9fdb2866e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolColumn.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/search-tools/_components/SearchToolColumn.tsx @@ -1,6 +1,7 @@ import { Tag } from "antd"; import { ColumnsType } from "antd/es/table"; import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import { SearchTool } from "./types"; export const searchToolColumns = ( @@ -20,14 +21,7 @@ export const searchToolColumns = ( return -; } - return ( - - ); + return ; }, }, { @@ -52,7 +46,7 @@ export const searchToolColumns = ( dataIndex: "created_at", key: "created_at", render: (_, tool) => { - return {tool.created_at ? new Date(tool.created_at).toLocaleDateString() : "-"}; + return ; }, }, { @@ -60,7 +54,7 @@ export const searchToolColumns = ( dataIndex: "updated_at", key: "updated_at", render: (_, tool) => { - return {tool.updated_at ? new Date(tool.updated_at).toLocaleDateString() : "-"}; + return ; }, }, { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.test.tsx index f16f5325952..e49ac854e6d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.test.tsx @@ -91,7 +91,7 @@ describe("BulkEditUserModal", () => { it("should display budget information in table", () => { renderWithProviders(); - expect(screen.getByText("$50")).toBeInTheDocument(); + expect(screen.getByText("$50.00")).toBeInTheDocument(); expect(screen.getByText("Unlimited")).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx index 9bef2f3a937..7ea53352807 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/BulkEditUsers.tsx @@ -4,6 +4,7 @@ import { userBulkUpdateUserCall, teamBulkMemberAddCall, Member } from "@/compone import { UserEditView } from "./user_edit_view"; import NotificationsManager from "@/components/molecules/notifications_manager"; import MessageManager from "@/components/molecules/message_manager"; +import { MoneyCell } from "@/components/shared/table_cells"; const { Text, Title } = Typography; @@ -270,7 +271,7 @@ const BulkEditUserModal: React.FC = ({ key: "max_budget", width: "20%", render: (budget: number | null) => ( - {budget !== null ? `$${budget}` : "Unlimited"} + ), }, ]} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx index 241cda3464e..996fd58efc1 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users.test.tsx @@ -158,7 +158,8 @@ describe("ViewUserDashboard", () => { expect( screen.getByText("Are you sure you want to delete this user? This action cannot be undone."), ).toBeInTheDocument(); - expect(screen.getByText("user-1")).toBeInTheDocument(); + const userIdInstances = screen.getAllByText("user-1"); + expect(userIdInstances.length).toBeGreaterThan(0); const emailInstances = screen.getAllByText("test@example.com"); expect(emailInstances.length).toBeGreaterThan(0); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx index deeaeeb25f4..fc680cb5b1b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/users/_components/view_users/columns.tsx @@ -3,8 +3,7 @@ import { Badge, Grid, Icon } from "@tremor/react"; import { Tooltip, Checkbox, Tag } from "antd"; import { UserInfo } from "@/components/networking"; import { PencilAltIcon, TrashIcon, InformationCircleIcon, RefreshIcon } from "@heroicons/react/outline"; -import { CopyOutlined } from "@ant-design/icons"; -import { formatNumberWithCommas, copyToClipboard } from "@/utils/dataUtils"; +import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; interface SelectionOptions { selectedUsers: UserInfo[]; @@ -29,24 +28,7 @@ export const columns = ( header: "User ID", accessorKey: "user_id", enableSorting: true, - cell: ({ row }) => ( -
- - {row.original.user_id ? `${row.original.user_id.slice(0, 7)}...` : "-"} - - {row.original.user_id && ( - - { - e.stopPropagation(); - copyToClipboard(row.original.user_id, "User ID copied to clipboard"); - }} - className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs" - /> - - )} -
- ), + cell: ({ row }) => , }, { header: "Email", @@ -93,17 +75,13 @@ export const columns = ( header: "Spend (USD)", accessorKey: "spend", enableSorting: true, - cell: ({ row }) => ( - {row.original.spend ? formatNumberWithCommas(row.original.spend, 4) : "-"} - ), + cell: ({ row }) => , }, { header: "Budget (USD)", accessorKey: "max_budget", enableSorting: false, - cell: ({ row }) => ( - {row.original.max_budget !== null ? row.original.max_budget : "Unlimited"} - ), + cell: ({ row }) => , }, { header: () => ( @@ -142,21 +120,13 @@ export const columns = ( header: "Created At", accessorKey: "created_at", enableSorting: true, - cell: ({ row }) => ( - - {row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "-"} - - ), + cell: ({ row }) => , }, { header: "Updated At", accessorKey: "updated_at", enableSorting: false, - cell: ({ row }) => ( - - {row.original.updated_at ? new Date(row.original.updated_at).toLocaleDateString() : "-"} - - ), + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx index 09b1c147615..ae1a19ff95d 100644 --- a/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/AgentHubTableColumns.tsx @@ -2,6 +2,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { Button, Badge, Text } from "@tremor/react"; import { Tooltip, Tag } from "antd"; import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons"; +import { StatusBadge } from "@/components/shared/table_cells"; export interface AgentHubData { agent_id?: string; @@ -194,17 +195,9 @@ export const getAgentHubTableColumns = ( return publicA - publicB; }, cell: ({ row }) => { - const agent = row.original; + const isPublic = row.original.is_public === true; - return agent.is_public === true ? ( - - Yes - - ) : ( - - No - - ); + return ; }, meta: { className: "hidden md:table-cell", diff --git a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx index bc52bbbe062..d4a120d0589 100644 --- a/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/DeletedKeysPage/DeletedKeysTable/DeletedKeysTable.tsx @@ -1,5 +1,5 @@ "use client"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; import { ColumnDef, @@ -59,14 +59,7 @@ export function DeletedKeysTable({ header: "Key ID", size: 150, maxSize: 250, - cell: (info) => { - const value = info.getValue() as string; - return ( - - {value || "-"} - - ); - }, + cell: (info) => , }, { id: "key_alias", @@ -100,9 +93,7 @@ export function DeletedKeysTable({ header: "Spend (USD)", size: 100, maxSize: 140, - cell: (info) => ( - {formatNumberWithCommas(info.getValue() as number, 4)} - ), + cell: (info) => , }, { id: "max_budget", @@ -110,14 +101,9 @@ export function DeletedKeysTable({ header: "Budget (USD)", size: 110, maxSize: 150, - cell: (info) => { - const maxBudget = info.getValue() as number | null; - return ( - - {maxBudget === null ? "Unlimited" : `$${formatNumberWithCommas(maxBudget)}`} - - ); - }, + cell: (info) => ( + + ), }, { id: "user_email", @@ -140,14 +126,7 @@ export function DeletedKeysTable({ header: "User ID", size: 120, maxSize: 200, - cell: (info) => { - const userId = info.getValue() as string | null; - return ( - - {userId || "-"} - - ); - }, + cell: (info) => , }, { id: "created_at", @@ -155,12 +134,7 @@ export function DeletedKeysTable({ header: "Created At", size: 120, maxSize: 140, - cell: (info) => { - const value = info.getValue(); - return ( - {value ? new Date(value as string).toLocaleDateString() : "-"} - ); - }, + cell: (info) => , }, { id: "created_by", @@ -183,10 +157,9 @@ export function DeletedKeysTable({ header: "Deleted At", size: 120, maxSize: 140, - cell: (info) => { - const value = (info.row.original as any).deleted_at as string | null | undefined; - return {value ? new Date(value).toLocaleDateString() : "-"}; - }, + cell: (info) => ( + + ), }, { id: "deleted_by", diff --git a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx index 57260f6bb2d..ddfd5cf73b6 100644 --- a/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx +++ b/ui/litellm-dashboard/src/components/DeletedTeamsPage/DeletedTeamsTable/DeletedTeamsTable.tsx @@ -1,5 +1,5 @@ "use client"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; import { ChevronDownIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; import { ColumnDef, @@ -51,14 +51,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams header: "Team ID", size: 150, maxSize: 250, - cell: (info) => { - const value = info.getValue() as string; - return ( - - {value || "-"} - - ); - }, + cell: (info) => , }, { id: "created_at", @@ -66,12 +59,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams header: "Created", size: 120, maxSize: 140, - cell: (info) => { - const value = info.getValue(); - return ( - {value ? new Date(value as string).toLocaleDateString() : "-"} - ); - }, + cell: (info) => , }, { id: "spend", @@ -79,12 +67,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams header: "Spend (USD)", size: 100, maxSize: 140, - cell: (info) => { - const spend = (info.row.original as any).spend as number | undefined; - return ( - {spend !== undefined ? formatNumberWithCommas(spend, 4) : "-"} - ); - }, + cell: (info) => , }, { id: "max_budget", @@ -92,14 +75,9 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams header: "Budget (USD)", size: 110, maxSize: 150, - cell: (info) => { - const maxBudget = info.getValue() as number | null; - return ( - - {maxBudget === null || maxBudget === undefined ? "No limit" : `$${formatNumberWithCommas(maxBudget)}`} - - ); - }, + cell: (info) => ( + + ), }, { id: "models", @@ -148,14 +126,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams header: "Organization", size: 150, maxSize: 200, - cell: (info) => { - const value = info.getValue() as string; - return ( - - {value || "-"} - - ); - }, + cell: (info) => , }, { id: "deleted_at", @@ -163,10 +134,7 @@ export function DeletedTeamsTable({ teams, isLoading, isFetching }: DeletedTeams header: "Deleted At", size: 120, maxSize: 140, - cell: (info) => { - const value = (info.row.original as any).deleted_at as string | null | undefined; - return {value ? new Date(value).toLocaleDateString() : "-"}; - }, + cell: (info) => , }, { id: "deleted_by", diff --git a/ui/litellm-dashboard/src/components/OldTeams.tsx b/ui/litellm-dashboard/src/components/OldTeams.tsx index c2cb3d4948e..e83d1acf4e5 100644 --- a/ui/litellm-dashboard/src/components/OldTeams.tsx +++ b/ui/litellm-dashboard/src/components/OldTeams.tsx @@ -31,6 +31,7 @@ import type { SorterResult } from "antd/es/table/interface"; import { KeyIcon, LayersIcon, SearchIcon, UsersIcon } from "lucide-react"; import React, { useEffect, useMemo, useRef, useState } from "react"; import { AntDLoadingSpinner } from "@/components/ui/AntDLoadingSpinner"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import OrganizationDropdown from "./common_components/OrganizationDropdown"; import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; import { teamListCall as v2TeamListCall, type TeamsResponse } from "@/app/(dashboard)/hooks/teams/useTeams"; @@ -670,18 +671,8 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser key: "team_id", width: 170, ellipsis: true, - render: (id: string, record: Team) => ( - - setSelectedTeamId(record.team_id)} - data-testid="team-id-cell" - > - {id} - - + render: (id: string) => ( + setSelectedTeamId(teamId)} dataTestId="team-id-cell" /> ), }, { @@ -797,13 +788,7 @@ const Teams: React.FC = ({ accessToken, userID, userRole, premiumUser width: 130, ellipsis: true, sorter: true, - render: (date: string | undefined) => ( - - {date - ? new Date(date).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }) - : "—"} - - ), + render: (date: string | undefined) => , }, { title: "Actions", diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx index 70ec6599ca2..4f1889cbc99 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx @@ -3,6 +3,7 @@ import type { TableProps } from "antd"; import { Table } from "antd"; import Title from "antd/es/typography/Title"; import React from "react"; +import { StatusBadge, type StatusTone } from "@/components/shared/table_cells"; import TableIconActionButton from "../../../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; import { AlertingObject } from "./types"; @@ -61,17 +62,8 @@ export const LoggingCallbacksTable: React.FC = ({ // and server-fetched rows both render correctly. const mode = record.type || record.mode || "success"; const label = CALLBACK_MODES.find((m) => m.value === mode)?.label || mode; - const badgeClass = - mode === "success" - ? "bg-green-100 text-green-800" - : mode === "failure" - ? "bg-red-100 text-red-800" - : "bg-blue-100 text-blue-800"; - return ( - - {label} - - ); + const tone: StatusTone = mode === "success" ? "success" : mode === "failure" ? "error" : "info"; + return ; }, width: 240, }, diff --git a/ui/litellm-dashboard/src/components/ToolPolicies.tsx b/ui/litellm-dashboard/src/components/ToolPolicies.tsx index 4bd028f0c8f..4468334f813 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies.tsx @@ -3,7 +3,7 @@ import React, { useCallback, useDeferredValue, useEffect, useMemo, useState } from "react"; import { Button, Switch, Tooltip } from "antd"; import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell } from "@tremor/react"; -import { TimeCell } from "./view_logs/time_cell"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import type { SortState } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; import { TableHeaderSortDropdown } from "./common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; import FilterComponent, { FilterOption } from "./molecules/filter"; @@ -461,7 +461,7 @@ export const ToolPolicies: React.FC = ({ accessToken, onSelec paginated.map((tool) => ( - +
- - {tool.team_id ?? "-"} - + - - - {tool.key_hash ?? "-"} - - + diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx index 793e4c6e3cf..63c606eb00e 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.test.tsx @@ -31,12 +31,6 @@ vi.mock("antd", async () => { return { Table, Progress }; }); -vi.mock("@/utils/dataUtils", () => ({ - formatNumberWithCommas: (value: number, decimals?: number) => { - return value.toFixed(decimals || 0); - }, -})); - describe("EndpointUsageTable", () => { it("should render", () => { const mockEndpointData = { diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx index f5cfb553370..88f92360ab5 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EndpointUsage/components/EndpointUsageTable.tsx @@ -1,7 +1,7 @@ import React from "react"; import { Table, Progress } from "antd"; import type { ColumnsType } from "antd/es/table"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { MoneyCell } from "@/components/shared/table_cells"; import { MetricWithMetadata } from "../../../types"; interface EndpointUsageTableProps { @@ -112,7 +112,7 @@ const EndpointUsageTable: React.FC = ({ endpointData }) title: "Spend", dataIndex: "spend", key: "spend", - render: (value: number) => `$${formatNumberWithCommas(value, 2)}`, + render: (value: number) => , }, ]; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx index df5b14a57d6..9dd72f44f67 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/EntityUsage.tsx @@ -1,4 +1,5 @@ import useTeams from "@/app/(dashboard)/hooks/useTeams"; +import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { BarChart, @@ -665,7 +666,9 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti .map((entity) => ( {entity.metadata.alias} - ${formatNumberWithCommas(entity.metrics.spend, 4)} + + + {entity.metrics.successful_requests.toLocaleString()} @@ -777,7 +780,9 @@ const EntityUsage: React.FC = ({ accessToken, entityType, enti {provider.provider}
- ${formatNumberWithCommas(provider.spend, 2)} + + + {provider.successful_requests.toLocaleString()} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx index 0541a9c6925..7eea7653ef1 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi, beforeEach } from "vitest"; import SpendByProvider from "./SpendByProvider"; @@ -200,6 +200,14 @@ describe("SpendByProvider", () => { expect(screen.getByText("1,234,567")).toBeInTheDocument(); }); + it("should render zero spend as a dash when Show Zero Spend is on", () => { + render(); + fireEvent.click(screen.getAllByRole("switch")[0]); + expect(screen.getAllByText("google").length).toBeGreaterThan(0); + expect(screen.getByText("-")).toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); + }); + it("should filter data correctly when both toggles are off", () => { render(); expect(screen.getAllByText("openai").length).toBeGreaterThan(0); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx index 58d673bb7ad..5cea84affb9 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/SpendByProvider.tsx @@ -1,3 +1,4 @@ +import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { InfoCircleOutlined } from "@ant-design/icons"; import { @@ -109,7 +110,9 @@ const SpendByProvider: React.FC = ({ loading, isDateChangi {provider.provider} - ${formatNumberWithCommas(provider.spend, 2)} + + + {provider.successful_requests.toLocaleString()} {provider.failed_requests.toLocaleString()} {provider.tokens.toLocaleString()} diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx index 126bf51bd36..766f027434f 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.test.tsx @@ -171,7 +171,9 @@ describe("TopKeyView", () => { ]} />, ); - expect(screen.getByText(/sk-1234\.\.\./)).toBeInTheDocument(); + const keyId = screen.getByText("sk-1234567890abcdef"); + expect(keyId).toBeInTheDocument(); + expect(keyId).toHaveClass("truncate"); }); it("should display dash for missing key alias", () => { @@ -206,7 +208,7 @@ describe("TopKeyView", () => { expect(screen.getByText("$123.46")).toBeInTheDocument(); }); - it("should display less than 0.01 spend as <$0.01", () => { + it("should display sub-cent spend as < $0.01", () => { render( { { api_key: "key-123", key_alias: "Test Key", - spend: 0.005, + spend: 0.004, }, ]} />, ); - expect(screen.getByText("<$0.01")).toBeInTheDocument(); + expect(screen.getByText("< $0.01")).toBeInTheDocument(); }); - it("should display zero spend correctly", () => { + it("should display zero spend as a dash", () => { render( { ]} />, ); - expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.getByText("-")).toBeInTheDocument(); + expect(screen.queryByText("$0.00")).not.toBeInTheDocument(); }); it("should display dash for empty tags", () => { @@ -376,7 +379,7 @@ describe("TopKeyView", () => { />, ); - const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + const keyIdButton = screen.getByText("key-123").closest("button"); if (keyIdButton) { await user.click(keyIdButton); } @@ -410,7 +413,7 @@ describe("TopKeyView", () => { />, ); - const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + const keyIdButton = screen.getByText("key-123").closest("button"); if (keyIdButton) { await user.click(keyIdButton); } @@ -447,7 +450,7 @@ describe("TopKeyView", () => { />, ); - const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + const keyIdButton = screen.getByText("key-123").closest("button"); if (keyIdButton) { await user.click(keyIdButton); } @@ -483,7 +486,7 @@ describe("TopKeyView", () => { />, ); - const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + const keyIdButton = screen.getByText("key-123").closest("button"); if (keyIdButton) { await user.click(keyIdButton); } @@ -522,7 +525,7 @@ describe("TopKeyView", () => { />, ); - const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + const keyIdButton = screen.getByText("key-123").closest("button"); if (keyIdButton) { await user.click(keyIdButton); } @@ -552,7 +555,7 @@ describe("TopKeyView", () => { />, ); - const keyIdButton = screen.getByText(/key-123\.\.\./).closest("button"); + const keyIdButton = screen.getByText("key-123").closest("button"); if (keyIdButton) { await user.click(keyIdButton); } diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx index 40bc41b3e8c..2dcf98a1e5c 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopKeyView.tsx @@ -1,6 +1,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { IdCell, MoneyCell } from "@/components/shared/table_cells"; import { ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/outline"; -import { BarChart, Button } from "@tremor/react"; +import { BarChart } from "@tremor/react"; import { Segmented, Tooltip } from "antd"; import React, { useState } from "react"; import { formatNumberWithCommas } from "../../../../utils/dataUtils"; @@ -83,20 +84,7 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals { header: "Key ID", accessorKey: "api_key", - cell: (info: any) => ( -
- - - -
- ), + cell: (info: any) => handleKeyClick(info.row.original)} />, }, { header: "Key Alias", @@ -165,10 +153,7 @@ const TopKeyView: React.FC = ({ topKeys, teams, showTags = fals header: "Spend (USD)", accessorKey: "spend", meta: { numeric: true }, - cell: (info: any) => { - const value = info.getValue(); - return value > 0 && value < 0.01 ? "<$0.01" : `$${formatNumberWithCommas(value, 2)}`; - }, + cell: (info: any) => , }; const columns = showTags ? [...baseColumns, tagsColumn, spendColumn] : [...baseColumns, spendColumn]; diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx index f6014d025ae..bbf9379f5e1 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.test.tsx @@ -175,7 +175,7 @@ describe("TopModelView", () => { setTopModelsLimit={mockSetTopModelsLimit} />, ); - expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.getByText("-")).toBeInTheDocument(); expect(screen.getAllByText("0").length).toBeGreaterThan(0); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx index 7562ef06a03..8938767c02c 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/EntityUsage/TopModelView.tsx @@ -1,6 +1,7 @@ import { BarChart } from "@tremor/react"; import { Segmented } from "antd"; import { useState } from "react"; +import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "../../../../utils/dataUtils"; import { DataTable } from "../../../view_logs/table"; @@ -31,10 +32,7 @@ export default function TopModelView({ topModels, topModelsLimit, setTopModelsLi header: "Spend (USD)", accessorKey: "spend", meta: { numeric: true }, - cell: (info: any) => { - const value = info.getValue(); - return `$${formatNumberWithCommas(value, 2)}`; - }, + cell: (info: any) => , }, { header: "Successful", diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx index 61968294e18..322f00a501a 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.test.tsx @@ -158,7 +158,7 @@ describe("KeyModelUsageView", () => { }, ]; render(); - expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.getByText("-")).toBeInTheDocument(); expect(screen.getAllByText("0").length).toBeGreaterThan(0); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx index ee1a49051da..ceb00e8a19f 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyModelUsageView.tsx @@ -1,3 +1,4 @@ +import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { BarChart, Card, Title } from "@tremor/react"; import { Table } from "antd"; @@ -24,7 +25,7 @@ const columns: ColumnsType = [ title: "Spend (USD)", dataIndex: "spend", key: "spend", - render: (value) => `$${formatNumberWithCommas(value, 2)}`, + render: (value) => , }, { title: "Successful", diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index ba616a03fd9..02f5d588149 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -1,4 +1,5 @@ -import { act, screen, waitFor, within, fireEvent } from "@testing-library/react"; +import { screen, waitFor, within, fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { vi, it, expect, beforeEach, describe, MockedFunction } from "vitest"; import { renderWithProviders } from "../../../tests/test-utils"; import { VirtualKeysTable } from "./VirtualKeysTable"; @@ -175,7 +176,7 @@ it("should display key information correctly", async () => { await waitFor(() => { expect(screen.getByText("Test Key Alias")).toBeInTheDocument(); expect(screen.getByText("Test Team")).toBeInTheDocument(); - expect(screen.getByText("5.5000")).toBeInTheDocument(); + expect(screen.getByText("$5.5000")).toBeInTheDocument(); }); }); @@ -477,9 +478,8 @@ describe("Status column reflects key.blocked / scim_blocked metadata", () => { const tag = await screen.findByTestId(`key-status-${mockKey.token_id}`); expect(tag).toHaveTextContent("Blocked"); - act(() => { - fireEvent.mouseEnter(tag); - }); + const user = userEvent.setup(); + await user.hover(tag); await waitFor(() => { expect(screen.getByText(/Blocked by SCIM/i)).toBeInTheDocument(); }); diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index a803d78ed57..00f4304c8a9 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -13,20 +13,10 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; -import { - Badge, - Button, - Icon, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Text, -} from "@tremor/react"; +import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react"; import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons"; -import { Button as AntButton, Popover, Skeleton, Tag, Tooltip, Typography } from "antd"; +import { Button as AntButton, Popover, Skeleton, Typography } from "antd"; +import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells"; import React, { useDeferredValue, useMemo, useState } from "react"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect"; @@ -145,23 +135,7 @@ export function VirtualKeysTable() { header: "Key ID", size: 100, enableSorting: true, - cell: (info) => { - const value = info.getValue() as string; - const width = info.cell.column.getSize(); - return ( - - - - ); - }, + cell: (info) => setSelectedKey(info.row.original)} />, }, { id: "key_alias", @@ -187,22 +161,14 @@ export function VirtualKeysTable() { cell: ({ row }) => { const key = row.original; if (key.blocked !== true) { - return ( - - Active - - ); + return ; } const isScimBlocked = (key.metadata as Record | null | undefined)?.scim_blocked === true; const reason = isScimBlocked ? "Blocked by SCIM (external identity provider deactivated or deleted the owning user)." : "Blocked. Requests using this key will be rejected with 401."; return ( - - - Blocked - - + ); }, }, @@ -323,10 +289,7 @@ export function VirtualKeysTable() { header: "Created At", size: 120, enableSorting: true, - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleDateString() : "-"; - }, + cell: (info) => , }, { id: "created_by", @@ -394,10 +357,7 @@ export function VirtualKeysTable() { header: "Updated At", size: 120, enableSorting: true, - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleDateString() : "Never"; - }, + cell: (info) => , }, { id: "last_active", @@ -415,16 +375,7 @@ export function VirtualKeysTable() { ), size: 130, enableSorting: false, - cell: (info) => { - const value = info.getValue(); - if (!value) return "Unknown"; - const date = new Date(value as string); - return ( - - {date.toLocaleDateString()} - - ); - }, + cell: (info) => , }, { id: "expires", @@ -432,10 +383,7 @@ export function VirtualKeysTable() { header: "Expires", size: 120, enableSorting: false, - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleDateString() : "Never"; - }, + cell: (info) => , }, { id: "spend", @@ -443,7 +391,7 @@ export function VirtualKeysTable() { header: "Spend (USD)", size: 100, enableSorting: true, - cell: (info) => formatNumberWithCommas(info.getValue() as number, 4), + cell: (info) => , }, { id: "max_budget", @@ -470,10 +418,7 @@ export function VirtualKeysTable() { header: "Budget Reset", size: 130, enableSorting: false, - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleString() : "Never"; - }, + cell: (info) => , }, { id: "models", diff --git a/ui/litellm-dashboard/src/components/agents.tsx b/ui/litellm-dashboard/src/components/agents.tsx index 0d8916942da..e3703f6c588 100644 --- a/ui/litellm-dashboard/src/components/agents.tsx +++ b/ui/litellm-dashboard/src/components/agents.tsx @@ -20,7 +20,7 @@ import AgentInfoView from "./agents/agent_info"; import NotificationsManager from "./molecules/notifications_manager"; import { Agent } from "./agents/types"; import { Team } from "./key_team_helpers/key_list"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells"; import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; interface AgentsPanelProps { @@ -193,19 +193,10 @@ const AgentsPanel: React.FC = ({ accessToken, userRole, teams {agent.agent_name} - - - + setSelectedAgentId(id)} /> - {formatNumberWithCommas(agent.spend, 4)} + @@ -213,13 +204,13 @@ const AgentsPanel: React.FC = ({ accessToken, userRole, teams - {agent.created_at ? new Date(agent.created_at).toLocaleDateString() : "N/A"} + {(agent.keys?.length ?? 0) > 0 ? ( - Active + ) : ( - Needs Setup + )} {isAdmin && ( diff --git a/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_table.tsx b/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_table.tsx index 1646161891f..0932e658899 100644 --- a/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_table.tsx +++ b/ui/litellm-dashboard/src/components/claude_code_plugins/plugin_table.tsx @@ -11,6 +11,7 @@ import { import { Badge, Button, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react"; import { Tooltip } from "antd"; import React, { useState } from "react"; +import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells"; import NotificationsManager from "../molecules/notifications_manager"; import { getCategoryBadgeColor } from "./helpers"; import { Plugin } from "./types"; @@ -34,12 +35,6 @@ const PluginTable: React.FC = ({ }) => { const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]); - const formatDate = (dateString?: string) => { - if (!dateString) return "-"; - const date = new Date(dateString); - return date.toLocaleString(); - }; - const copyToClipboard = (text: string) => { navigator.clipboard.writeText(text); NotificationsManager.success("Copied to clipboard!"); @@ -51,19 +46,9 @@ const PluginTable: React.FC = ({ accessorKey: "name", cell: ({ row }) => { const plugin = row.original; - const name = plugin.name || ""; return (
- - - + onPluginClick(plugin.id)} /> { @@ -122,24 +107,13 @@ const PluginTable: React.FC = ({ accessorKey: "enabled", cell: ({ row }) => { const plugin = row.original; - return ( - - {plugin.enabled ? "Yes" : "No"} - - ); + return ; }, }, { header: "Created At", accessorKey: "created_at", - cell: ({ row }) => { - const plugin = row.original; - return ( - - {formatDate(plugin.created_at)} - - ); - }, + cell: ({ row }) => , }, ...(isAdmin ? [ diff --git a/ui/litellm-dashboard/src/components/general_settings.tsx b/ui/litellm-dashboard/src/components/general_settings.tsx index 5b8dec39505..038547c6e0e 100644 --- a/ui/litellm-dashboard/src/components/general_settings.tsx +++ b/ui/litellm-dashboard/src/components/general_settings.tsx @@ -4,7 +4,6 @@ import { Table, TableHead, TableRow, - Badge, TableHeaderCell, TableCell, TableBody, @@ -16,7 +15,8 @@ import { import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react"; import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "./networking"; import { InputNumber } from "antd"; -import { TrashIcon, CheckCircleIcon } from "@heroicons/react/outline"; +import { TrashIcon } from "@heroicons/react/outline"; +import { StatusBadge } from "@/components/shared/table_cells"; import RouterSettings from "./router_settings"; import Fallbacks from "./Settings/RouterSettings/Fallbacks/Fallbacks"; @@ -173,13 +173,11 @@ const GeneralSettings: React.FC = ({ accessToken, user {value.stored_in_db == true ? ( - - In DB - + ) : value.stored_in_db == false ? ( - In Config + ) : ( - Not Set + )} diff --git a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx b/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx index ecf6ce48fde..99f6b2793fd 100644 --- a/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx +++ b/ui/litellm-dashboard/src/components/guardrails/guardrail_table.tsx @@ -1,8 +1,8 @@ import React, { useState } from "react"; -import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon, Button } from "@tremor/react"; +import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Icon } from "@tremor/react"; import { TrashIcon, SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon } from "@heroicons/react/outline"; import { Tooltip } from "antd"; -import { Badge } from "@tremor/react"; +import { DateCell, IdCell, StatusBadge } from "@/components/shared/table_cells"; import { ColumnDef, flexRender, @@ -43,13 +43,6 @@ const GuardrailTable: React.FC = ({ const [editModalVisible, setEditModalVisible] = useState(false); const [selectedGuardrail, setSelectedGuardrail] = useState(null); - // Format date helper function - const formatDate = (dateString?: string) => { - if (!dateString) return "-"; - const date = new Date(dateString); - return date.toLocaleString(); - }; - const handleEditClick = (guardrail: Guardrail) => { setSelectedGuardrail(guardrail); setEditModalVisible(true); @@ -65,18 +58,7 @@ const GuardrailTable: React.FC = ({ { header: "Guardrail ID", accessorKey: "guardrail_id", - cell: (info: any) => ( - - - - ), + cell: (info: any) => , }, { header: "Name", @@ -126,41 +108,21 @@ const GuardrailTable: React.FC = ({ header: "Default On", accessorKey: "litellm_params.default_on", cell: ({ row }) => { - const guardrail = row.original; + const isDefaultOn = !!row.original.litellm_params?.default_on; return ( - - {guardrail.litellm_params?.default_on ? "Default On" : "Default Off"} - + ); }, }, { header: "Created At", accessorKey: "created_at", - cell: ({ row }) => { - const guardrail = row.original; - return ( - - {formatDate(guardrail.created_at)} - - ); - }, + cell: ({ row }) => , }, { header: "Updated At", accessorKey: "updated_at", - cell: ({ row }) => { - const guardrail = row.original; - return ( - - {formatDate(guardrail.updated_at)} - - ); - }, + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx index 7cf0d48a49f..1e25f87d262 100644 --- a/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_hub_table_columns.tsx @@ -2,6 +2,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { Button, Badge, Text } from "@tremor/react"; import { Tooltip, Tag } from "antd"; import { CopyOutlined, InfoCircleOutlined } from "@ant-design/icons"; +import { StatusBadge, type StatusTone } from "@/components/shared/table_cells"; export interface MCPServerData { server_id: string; @@ -124,21 +125,17 @@ export const mcpHubColumns = ( cell: ({ row }) => { const server = row.original; - const statusColors: Record = { - active: "green", - inactive: "red", - unknown: "gray", - healthy: "green", - unhealthy: "red", + const statusTones: Record = { + active: "success", + inactive: "error", + unknown: "neutral", + healthy: "success", + unhealthy: "error", }; - const color = statusColors[server.status] || "gray"; + const tone = statusTones[server.status] || "neutral"; - return ( - - {server.status || "unknown"} - - ); + return ; }, }, { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx index 546df9ebc4b..5e7e99ee8ec 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/MCPToolsetsTab.tsx @@ -6,6 +6,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { useMCPToolsets } from "@/app/(dashboard)/hooks/mcpServers/useMCPToolsets"; import { useMCPServers } from "@/app/(dashboard)/hooks/mcpServers/useMCPServers"; import { useQueryClient } from "@tanstack/react-query"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import { DataTable } from "../view_logs/table"; import { createMCPToolset, updateMCPToolset, deleteMCPToolset, listMCPTools, getProxyBaseUrl } from "../networking"; import { MCPToolset, MCPToolsetTool } from "./types"; @@ -302,11 +303,7 @@ function toolsetColumns( { header: "Toolset ID", accessorKey: "toolset_id", - cell: ({ row }) => ( - - {row.original.toolset_id.slice(0, 8)}… - - ), + cell: ({ row }) => , }, { header: "Name", @@ -359,11 +356,7 @@ function toolsetColumns( { header: "Created", accessorKey: "created_at", - cell: ({ row }) => ( - - {row.original.created_at ? new Date(row.original.created_at).toLocaleDateString() : "—"} - - ), + cell: ({ row }) => , }, ...(isAdmin ? [ diff --git a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx index a2194e23b6e..6497f2686cb 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/HealthCheckComponent.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from "react"; -import { Title, Text, Button, Badge } from "@tremor/react"; +import { Title, Text, Button } from "@tremor/react"; import { Modal } from "antd"; import { Button as AntdButton } from "antd"; import { ModelDataTable } from "./table"; @@ -468,21 +468,6 @@ const HealthCheckComponent: React.FC = ({ onPageChange?.(page); }; - const getStatusBadge = (status: string) => { - switch (status) { - case "healthy": - return healthy; - case "unhealthy": - return unhealthy; - case "checking": - return checking; - case "none": - return none; - default: - return unknown; - } - }; - const showErrorModal = (modelName: string, cleanedError: string, fullError: string) => { setSelectedErrorDetails({ modelName, @@ -612,7 +597,6 @@ const HealthCheckComponent: React.FC = ({ handleModelSelection, handleSelectAll, runIndividualHealthCheck, - getStatusBadge, getDisplayModelName, showErrorModal, showSuccessModal, diff --git a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx b/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx index 12cc984ef91..33c97236f97 100644 --- a/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx +++ b/ui/litellm-dashboard/src/components/model_dashboard/health_check_columns.tsx @@ -3,6 +3,7 @@ import { Tooltip, Checkbox } from "antd"; import { Text } from "@tremor/react"; import { InformationCircleIcon, PlayIcon, RefreshIcon } from "@heroicons/react/outline"; import { Team } from "@/components/key_team_helpers/key_list"; +import { IdCell, StatusBadge, type StatusTone } from "@/components/shared/table_cells"; interface HealthCheckData { model_name: string; @@ -21,6 +22,18 @@ interface HealthCheckData { health_full_error?: string; } +const HEALTH_STATUS_TONES: Record = { + healthy: "success", + unhealthy: "error", + checking: "info", + none: "neutral", +}; + +const healthStatusBadge = (status: string): JSX.Element => { + const tone = HEALTH_STATUS_TONES[status]; + return tone ? : ; +}; + interface HealthStatus { status: string; lastCheck: string; @@ -38,7 +51,6 @@ export const healthCheckColumns = ( handleModelSelection: (modelId: string, checked: boolean) => void, handleSelectAll: (checked: boolean) => void, runIndividualHealthCheck: (modelId: string) => void, - getStatusBadge: (status: string) => JSX.Element, getDisplayModelName: (model: any) => string, showErrorModal?: (modelName: string, cleanedError: string, fullError: string) => void, showSuccessModal?: (modelName: string, response: any) => void, @@ -72,14 +84,7 @@ export const healthCheckColumns = ( onChange={(e) => handleModelSelection(modelId, e.target.checked)} onClick={(e) => e.stopPropagation()} /> - -
setSelectedModelId && setSelectedModelId(model.model_info.id)} - > - {model.model_info.id} -
-
+
); }, @@ -175,7 +180,7 @@ export const healthCheckColumns = ( return (
- {getStatusBadge(healthStatus.status)} + {healthStatusBadge(healthStatus.status)} {hasSuccessResponse && showSuccessModal && ( - +
e.stopPropagation()}> +
) : ( "-" @@ -370,15 +345,10 @@ export const columns = ( minSize: 80, cell: ({ row }) => { const model = row.original; - return ( -
- {model.model_info.db_model ? "DB Model" : "Config Model"} -
+ return model.model_info.db_model ? ( + + ) : ( + ); }, }, diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index e1b8c6d5044..402cc33902f 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -1,6 +1,7 @@ import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { organizationKeys, useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useQueryClient } from "@tanstack/react-query"; +import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas, copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; import { createTeamAliasMap } from "@/utils/teamUtils"; import { ArrowLeftIcon } from "@heroicons/react/outline"; @@ -196,7 +197,7 @@ const OrganizationInfoView: React.FC = ({ render: (_: unknown, record: Member) => { const orgMember = record.user_id != null ? (orgData.members || []).find((m) => m.user_id === record.user_id) : undefined; - return ${formatNumberWithCommas(orgMember?.spend ?? 0, 4)}; + return ; }, }, { diff --git a/ui/litellm-dashboard/src/components/organizations.tsx b/ui/litellm-dashboard/src/components/organizations.tsx index 857a0a0c64c..edebc17087a 100644 --- a/ui/litellm-dashboard/src/components/organizations.tsx +++ b/ui/litellm-dashboard/src/components/organizations.tsx @@ -27,7 +27,7 @@ import { import { Form, Input, Modal, Select as Select2, Tooltip } from "antd"; import { useQueryClient } from "@tanstack/react-query"; import React, { useState } from "react"; -import { formatNumberWithCommas } from "../utils/dataUtils"; +import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; import DeleteResourceModal from "./common_components/DeleteResourceModal"; import TableIconActionButton from "./common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; @@ -263,30 +263,22 @@ const OrganizationsTable: React.FC = ({ .map((org: Organization) => ( -
- - - -
+
{org.organization_alias} - {org.created_at ? new Date(org.created_at).toLocaleDateString() : "N/A"} + - {formatNumberWithCommas(org.spend, 4)} - {org.litellm_budget_table?.max_budget !== null && - org.litellm_budget_table?.max_budget !== undefined - ? org.litellm_budget_table?.max_budget - : "No limit"} + + + + = ({ { header: "ID", accessorKey: "id", - cell: (info: any) => ( - -
info.row.original.id && setSelectedEndpointId(info.row.original.id)} - > - {info.row.original.id} -
-
- ), + cell: (info: any) => , }, { header: "Path", @@ -192,7 +184,9 @@ const PassThroughSettings: React.FC = ({
), accessorKey: "auth", - cell: (info: any) => {info.getValue() ? "Yes" : "No"}, + cell: (info: any) => ( + + ), }, { header: "Headers", diff --git a/ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx b/ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx index 099aa97a433..0cfd4e41e0a 100644 --- a/ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx +++ b/ui/litellm-dashboard/src/components/policies/attachment_table.test.tsx @@ -140,10 +140,13 @@ describe("AttachmentTable", () => { expect(screen.queryByRole("button", { name: /TrashIcon/i })).not.toBeInTheDocument(); }); - it("should show a truncated attachment ID in the table", () => { + it("should show the attachment ID as truncated plain mono text", () => { const attachment = makeAttachment({ attachment_id: "att-abcdef1234567" }); renderWithProviders(); - expect(screen.getByText("att-abc...")).toBeInTheDocument(); + const idElement = screen.getByText("att-abcdef1234567"); + expect(idElement.className).toContain("font-mono"); + expect(idElement.className).toContain("truncate"); + expect(idElement.className).not.toContain("bg-blue-50"); }); it("should render model tags when the attachment has models", () => { diff --git a/ui/litellm-dashboard/src/components/policies/attachment_table.tsx b/ui/litellm-dashboard/src/components/policies/attachment_table.tsx index d9de8378a8a..fa482552fd5 100644 --- a/ui/litellm-dashboard/src/components/policies/attachment_table.tsx +++ b/ui/litellm-dashboard/src/components/policies/attachment_table.tsx @@ -10,6 +10,7 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import { PolicyAttachment } from "./types"; import ImpactPopover from "./impact_popover"; @@ -30,24 +31,11 @@ const AttachmentTable: React.FC = ({ }) => { const [sorting, setSorting] = useState([{ id: "created_at", desc: true }]); - // Format date helper function - const formatDate = (dateString?: string) => { - if (!dateString) return "-"; - const date = new Date(dateString); - return date.toLocaleString(); - }; - const columns: ColumnDef[] = [ { header: "Attachment ID", accessorKey: "attachment_id", - cell: (info: any) => ( - - - {info.getValue() ? `${String(info.getValue()).slice(0, 7)}...` : ""} - - - ), + cell: (info: any) => , }, { header: "Policy", @@ -183,14 +171,7 @@ const AttachmentTable: React.FC = ({ { header: "Created At", accessorKey: "created_at", - cell: ({ row }) => { - const attachment = row.original; - return ( - - {formatDate(attachment.created_at)} - - ); - }, + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/policies/policy_table.tsx b/ui/litellm-dashboard/src/components/policies/policy_table.tsx index eafec442474..1716f5aea84 100644 --- a/ui/litellm-dashboard/src/components/policies/policy_table.tsx +++ b/ui/litellm-dashboard/src/components/policies/policy_table.tsx @@ -10,6 +10,7 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; +import { DateCell } from "@/components/shared/table_cells"; import { Policy } from "./types"; /** One row per policy name; primaryPolicy is used for display and for Edit (FlowBuilder loads all versions) */ @@ -59,12 +60,6 @@ const PolicyTable: React.FC = ({ const rows = useMemo(() => groupPoliciesByName(policies), [policies]); - const formatDate = (dateString?: string) => { - if (!dateString) return "-"; - const date = new Date(dateString); - return date.toLocaleString(); - }; - const columns: ColumnDef[] = [ { header: "Name", @@ -199,14 +194,7 @@ const PolicyTable: React.FC = ({ header: "Created At", id: "created_at", accessorFn: (row) => row.primaryPolicy.created_at ?? "", - cell: ({ row }) => { - const policy = row.original.primaryPolicy; - return ( - - {formatDate(policy.created_at)} - - ); - }, + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/cell_tooltip.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/cell_tooltip.tsx new file mode 100644 index 00000000000..c6e10590e8f --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/cell_tooltip.tsx @@ -0,0 +1,21 @@ +"use client"; + +import * as React from "react"; + +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; + +interface CellTooltipProps { + content: React.ReactNode; + trigger: React.ReactElement; +} + +export function CellTooltip({ content, trigger }: CellTooltipProps) { + return ( + + + + {content} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx new file mode 100644 index 00000000000..719047e0cfe --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { DateCell, formatCellDate, formatFullTimestamp } from "./date_cell"; + +const localIso = new Date(2026, 6, 7, 9, 50, 13).toISOString(); + +describe("formatCellDate", () => { + it("formats datetime precision as 'MMM D, HH:mm:ss' without a year", () => { + expect(formatCellDate(new Date(2026, 6, 7, 9, 50, 13), "datetime")).toBe("Jul 7, 09:50:13"); + }); + + it("zero-pads hours, minutes and seconds", () => { + expect(formatCellDate(new Date(2026, 0, 2, 1, 2, 3), "datetime")).toBe("Jan 2, 01:02:03"); + }); + + it("formats date precision as 'MMM D, YYYY' with no time", () => { + expect(formatCellDate(new Date(2026, 11, 31, 23, 59, 59), "date")).toBe("Dec 31, 2026"); + }); +}); + +describe("formatFullTimestamp", () => { + it("includes year, 24h time and the IANA timezone", () => { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + expect(formatFullTimestamp(new Date(2026, 6, 7, 9, 50, 13))).toBe(`Jul 7, 2026, 09:50:13 (${timeZone})`); + }); +}); + +describe("DateCell", () => { + it("renders the datetime format by default", () => { + render(); + expect(screen.getByText("Jul 7, 09:50:13")).toBeInTheDocument(); + }); + + it("renders date-only when precision is 'date'", () => { + render(); + expect(screen.getByText("Jul 7, 2026")).toBeInTheDocument(); + }); + + it("renders '-' for null and undefined", () => { + const { rerender } = render(); + expect(screen.getByText("-")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("renders the custom fallback for empty values", () => { + render(); + expect(screen.getByText("Never")).toBeInTheDocument(); + }); + + it("renders the fallback instead of 'Invalid Date' for unparseable input", () => { + render(); + expect(screen.getByText("Unknown")).toBeInTheDocument(); + expect(screen.queryByText(/Invalid/)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx new file mode 100644 index 00000000000..ee4c01bb237 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/date_cell.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { CellTooltip } from "./cell_tooltip"; + +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"] as const; + +export type DatePrecision = "datetime" | "date"; + +interface DateCellProps { + value: string | null | undefined; + precision?: DatePrecision; + fallback?: string; +} + +const pad = (n: number): string => String(n).padStart(2, "0"); + +export const formatCellDate = (date: Date, precision: DatePrecision): string => + precision === "date" + ? `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}` + : `${MONTHS[date.getMonth()]} ${date.getDate()}, ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + +export const formatFullTimestamp = (date: Date): string => { + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const day = `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`; + const time = `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; + return `${day}, ${time} (${timeZone})`; +}; + +export function DateCell({ value, precision = "datetime", fallback = "-" }: DateCellProps) { + const date = value ? new Date(value) : null; + if (!date || Number.isNaN(date.getTime())) { + return {fallback}; + } + + return ( + {formatCellDate(date, precision)}} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx new file mode 100644 index 00000000000..1a87f17d50b --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; + +import { IdCell } from "./id_cell"; + +const { copyToClipboardMock } = vi.hoisted(() => ({ copyToClipboardMock: vi.fn() })); + +vi.mock("@/utils/dataUtils", async (importOriginal) => ({ + ...(await importOriginal()), + copyToClipboard: copyToClipboardMock, +})); + +describe("IdCell", () => { + it("renders '-' for empty values", () => { + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("renders the custom fallback for empty values", () => { + render(); + expect(screen.getByText("—")).toBeInTheDocument(); + }); + + it("renders the full id as a non-interactive pill by default", () => { + render(); + const el = screen.getByText("sk-1234567890abcdef"); + expect(el.tagName).toBe("SPAN"); + expect(el.className).toContain("bg-blue-50"); + expect(el.className).toContain("font-mono"); + expect(el.className).toContain("max-w-[15ch]"); + expect(el.className).toContain("truncate"); + }); + + it("renders plain mono text without pill styling for the plain variant", () => { + render(); + const el = screen.getByText("req-123"); + expect(el.className).toContain("font-mono"); + expect(el.className).not.toContain("bg-blue-50"); + }); + + it("does not truncate when truncate is false", () => { + render(); + expect(screen.getByText("audit-object-id").className).not.toContain("truncate"); + }); + + it("becomes a button that fires onClick with the id value", async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + await user.click(screen.getByRole("button", { name: "team-42" })); + expect(onClick).toHaveBeenCalledWith("team-42"); + }); + + it("stays non-interactive when disabled, even with onClick", () => { + const onClick = vi.fn(); + render(); + expect(screen.queryByRole("button", { name: "tag-1" })).not.toBeInTheDocument(); + }); + + it("copies the id via the trailing copy button without triggering row clicks", async () => { + const user = userEvent.setup(); + const rowClick = vi.fn(); + render( +
+ +
, + ); + await user.click(screen.getByRole("button", { name: "Copy ID" })); + expect(copyToClipboardMock).toHaveBeenCalledWith("key-hash-9"); + expect(rowClick).not.toHaveBeenCalled(); + }); + + it("passes dataTestId through to the id element", () => { + render(); + expect(screen.getByTestId("key-id-cell")).toHaveTextContent("k-1"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx new file mode 100644 index 00000000000..6fbd2e2f9ed --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/id_cell.tsx @@ -0,0 +1,94 @@ +"use client"; + +import { Copy } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +import { CellTooltip } from "./cell_tooltip"; + +export type IdCellVariant = "pill" | "plain"; + +interface IdCellProps { + value: string | null | undefined; + variant?: IdCellVariant; + onClick?: (value: string) => void; + copyable?: boolean; + truncate?: boolean; + fallback?: string; + tooltip?: React.ReactNode; + disabled?: boolean; + dataTestId?: string; + className?: string; +} + +const VARIANT_CLASS: Record = { + pill: { + base: "font-mono text-xs font-normal px-2 py-0.5 rounded-md text-left bg-blue-50 text-blue-500", + clickable: "hover:bg-blue-100 cursor-pointer", + }, + plain: { + base: "font-mono text-xs text-left", + clickable: "hover:text-blue-600 cursor-pointer", + }, +}; + +export function IdCell({ + value, + variant = "pill", + onClick, + copyable = false, + truncate = true, + fallback = "-", + tooltip, + disabled = false, + dataTestId, + className, +}: IdCellProps) { + if (!value) { + return {fallback}; + } + + const clickable = !!onClick && !disabled; + const classes = cn( + VARIANT_CLASS[variant].base, + clickable && VARIANT_CLASS[variant].clickable, + truncate && "block max-w-[15ch] truncate", + disabled && "opacity-50", + className, + ); + + const idElement = clickable ? ( + + ) : ( + + {value} + + ); + + const withTooltip = ; + + if (!copyable) { + return withTooltip; + } + + return ( + + {withTooltip} + + + ); +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/index.ts b/ui/litellm-dashboard/src/components/shared/table_cells/index.ts new file mode 100644 index 00000000000..e189413d43d --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/index.ts @@ -0,0 +1,5 @@ +export { CellTooltip } from "./cell_tooltip"; +export { DateCell, formatCellDate, formatFullTimestamp, type DatePrecision } from "./date_cell"; +export { IdCell, type IdCellVariant } from "./id_cell"; +export { MoneyCell } from "./money_cell"; +export { StatusBadge, type StatusTone } from "./status_badge"; diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.test.tsx new file mode 100644 index 00000000000..473785e31c4 --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.test.tsx @@ -0,0 +1,44 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { MoneyCell } from "./money_cell"; + +describe("MoneyCell", () => { + it("renders '-' for null and undefined", () => { + const { rerender } = render(); + expect(screen.getByText("-")).toBeInTheDocument(); + rerender(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("renders the custom emptyText for null budgets", () => { + render(); + expect(screen.getByText("Unlimited")).toBeInTheDocument(); + }); + + it("renders '-' for zero by default", () => { + render(); + expect(screen.getByText("-")).toBeInTheDocument(); + }); + + it("renders a formatted zero when showZero is set, never the emptyText", () => { + render(); + expect(screen.getByText("$0.00")).toBeInTheDocument(); + expect(screen.queryByText("Unlimited")).not.toBeInTheDocument(); + }); + + it("formats amounts with commas, a dollar sign and the given decimals", () => { + render(); + expect(screen.getByText("$1,234.57")).toBeInTheDocument(); + }); + + it("defaults to 4 decimals", () => { + render(); + expect(screen.getByText("$42.0000")).toBeInTheDocument(); + }); + + it("renders the sub-threshold form for amounts that round to zero", () => { + render(); + expect(screen.getByText("< $0.000001")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.tsx new file mode 100644 index 00000000000..9d3c747b20e --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/money_cell.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { formatNumberWithCommas, getSpendString } from "@/utils/dataUtils"; + +interface MoneyCellProps { + value: number | null | undefined; + decimals?: number; + emptyText?: string; + showZero?: boolean; +} + +export function MoneyCell({ value, decimals = 4, emptyText = "-", showZero = false }: MoneyCellProps) { + if (value === null || value === undefined || Number.isNaN(value)) { + return {emptyText}; + } + if (value === 0) { + if (!showZero) { + return -; + } + return {`$${formatNumberWithCommas(0, decimals, false, true)}`}; + } + return {getSpendString(value, decimals)}; +} diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx new file mode 100644 index 00000000000..724a362c21f --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { StatusBadge, type StatusTone } from "./status_badge"; + +describe("StatusBadge", () => { + const toneClasses: Record = { + success: ["border-green-200", "bg-green-50", "text-green-600"], + error: ["border-red-200", "bg-red-50", "text-red-600"], + warning: ["border-amber-200", "bg-amber-50", "text-amber-600"], + neutral: ["border-gray-200", "bg-gray-50", "text-gray-600"], + info: ["border-blue-200", "bg-blue-50", "text-blue-600"], + }; + + (Object.entries(toneClasses) as [StatusTone, string[]][]).forEach(([tone, classes]) => { + it(`renders a tinted pill (${classes.join(" ")}) for the ${tone} tone`, () => { + render(); + const badge = screen.getByText(tone); + classes.forEach((cls) => expect(badge.className).toContain(cls)); + }); + }); + + it("renders the label text inside an outline badge with no status dot", () => { + render(); + const badge = screen.getByText("Active"); + expect(badge.dataset.variant).toBe("outline"); + expect(badge.querySelector("[aria-hidden]")).toBeNull(); + }); + + it("passes dataTestId through", () => { + render(); + expect(screen.getByTestId("key-status")).toHaveTextContent("Blocked"); + }); + + it("opens the tooltip on hover, which requires Badge to forward its ref to the trigger", async () => { + const user = userEvent.setup(); + render(); + await user.hover(screen.getByText("Blocked")); + expect(await screen.findByText("This key was blocked by SCIM")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx new file mode 100644 index 00000000000..f10f817650a --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/table_cells/status_badge.tsx @@ -0,0 +1,38 @@ +"use client"; + +import * as React from "react"; + +import { Badge } from "@/components/ui/badge"; +import { cn } from "@/lib/cva.config"; + +import { CellTooltip } from "./cell_tooltip"; + +export type StatusTone = "success" | "error" | "warning" | "neutral" | "info"; + +const TONE_CLASS: Record = { + success: "border-green-200 bg-green-50 text-green-600", + error: "border-red-200 bg-red-50 text-red-600", + warning: "border-amber-200 bg-amber-50 text-amber-600", + neutral: "border-gray-200 bg-gray-50 text-gray-600", + info: "border-blue-200 bg-blue-50 text-blue-600", +}; + +interface StatusBadgeProps { + tone: StatusTone; + label: string; + tooltip?: React.ReactNode; + dataTestId?: string; +} + +export function StatusBadge({ tone, label, tooltip, dataTestId }: StatusBadgeProps) { + const badge = ( + + {label} + + ); + + if (!tooltip) { + return badge; + } + return ; +} diff --git a/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx b/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx index 8a8d7ae7042..8fc9adc75a2 100644 --- a/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx +++ b/ui/litellm-dashboard/src/components/skill_hub_table_columns.tsx @@ -3,6 +3,7 @@ import { Badge, Text } from "@tremor/react"; import { Tooltip } from "antd"; import { CopyOutlined, LinkOutlined } from "@ant-design/icons"; import { Plugin } from "./claude_code_plugins/types"; +import { StatusBadge } from "@/components/shared/table_cells"; export const skillHubColumns = ( showModal: (skill: Plugin) => void, @@ -104,9 +105,10 @@ export const skillHubColumns = ( accessorKey: "enabled", enableSorting: true, cell: ({ row }) => ( - - {row.original.enabled ? "Public" : "Draft"} - + ), }, ]; diff --git a/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx b/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx index a56721787d5..057f30ccee5 100644 --- a/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/TagTable.test.tsx @@ -1,5 +1,6 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { formatCellDate } from "@/components/shared/table_cells"; import TagTable from "./TagTable"; import { Tag } from "./types"; @@ -75,14 +76,21 @@ describe("TagTable", () => { it("should display formatted created date", () => { render(); - const formattedDate = new Date(mockTag.created_at).toLocaleDateString(); + const formattedDate = formatCellDate(new Date(mockTag.created_at), "date"); expect(screen.getByText(formattedDate)).toBeInTheDocument(); }); - it("should disable tag name button for dynamic spend tags", () => { + it("should call onSelectTag when tag name is clicked", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: "test-tag" })); + expect(mockOnSelectTag).toHaveBeenCalledWith("test-tag"); + }); + + it("should render tag name as non-clickable for dynamic spend tags", () => { render(); - const tagButton = screen.getByRole("button", { name: "dynamic-spend-tag" }); - expect(tagButton).toBeDisabled(); + expect(screen.queryByRole("button", { name: "dynamic-spend-tag" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("dynamic-spend-tag")); + expect(mockOnSelectTag).not.toHaveBeenCalled(); }); it("should disable edit icon for dynamic spend tags", () => { diff --git a/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx b/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx index ce28ac6e6f2..e34653cc702 100644 --- a/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx +++ b/ui/litellm-dashboard/src/components/tag_management/TagTable.tsx @@ -7,20 +7,10 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; -import { - Badge, - Button, - Icon, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Text, -} from "@tremor/react"; +import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react"; import { Tooltip } from "antd"; import React from "react"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import { Tag } from "./types"; interface TagTableProps { @@ -45,21 +35,15 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag const isDynamicSpendTag = tag.description === DYNAMIC_SPEND_TAG_DESCRIPTION; return (
- - - + />
); }, @@ -104,10 +88,7 @@ const TagTable: React.FC = ({ data, onEdit, onDelete, onSelectTag header: "Created", accessorKey: "created_at", sortingFn: "datetime", - cell: ({ row }) => { - const tag = row.original; - return {new Date(tag.created_at).toLocaleDateString()}; - }, + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx index ac0ae16a44f..a07c57eaa30 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.test.tsx @@ -27,6 +27,8 @@ const mockSetSelectedEditMember = vi.fn(); const mockSetIsEditMemberModalVisible = vi.fn(); const mockSetIsAddMemberModalVisible = vi.fn(); +const budgetResetIso = new Date(2026, 6, 15, 12, 0, 0).toISOString(); + const createMockTeamData = (overrides: Partial = {}): TeamData => ({ team_id: "team-123", team_info: { @@ -78,6 +80,7 @@ const createMockTeamData = (overrides: Partial = {}): TeamData => ({ rpm_limit: 100, model_max_budget: null, budget_duration: null, + budget_reset_at: budgetResetIso, }, }, ], @@ -202,7 +205,7 @@ describe("TeamMembersComponent", () => { />, ); - expect(screen.getByText("-")).toBeInTheDocument(); + expect(screen.getAllByText("-").length).toBeGreaterThanOrEqual(1); }); it("should display Default Proxy Admin tag for default_user_id", () => { @@ -243,12 +246,12 @@ describe("TeamMembersComponent", () => { />, ); - expect(screen.getByText(/\$100\.5/)).toBeInTheDocument(); + expect(screen.getByText("$100.5000")).toBeInTheDocument(); expect(screen.getByText(/100 RPM/)).toBeInTheDocument(); expect(screen.getByText(/10000 TPM/)).toBeInTheDocument(); }); - it("should display No Limit for budget when member has no budget", () => { + it("should display the budget reset date for member with a budget reset", () => { renderWithProviders( { />, ); - expect(screen.getByText("No Limit")).toBeInTheDocument(); + expect(screen.getByText("Jul 15, 2026")).toBeInTheDocument(); + }); + + it("should display formatted budget and Unlimited for member with no budget", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("$1,000.0000")).toBeInTheDocument(); + expect(screen.getByText("Unlimited")).toBeInTheDocument(); }); it("should display No Limits for rate limits when member has no limits", () => { diff --git a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx index e2f108dcbf5..b884490efc0 100644 --- a/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamMemberTab.tsx @@ -1,7 +1,7 @@ import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { Member } from "@/components/networking"; -import { formatBudgetReset } from "@/utils/budgetUtils"; +import { DateCell, MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles"; import { InfoCircleOutlined } from "@ant-design/icons"; @@ -58,14 +58,10 @@ export default function TeamMemberTab({ return membership?.total_spend ?? 0; }; - const getUserBudget = (userId: string | null): string | null => { + const getUserBudget = (userId: string | null): number | null => { if (!userId) return null; const membership = teamData.team_memberships.find((tm) => tm.user_id === userId); - const maxBudget = membership?.litellm_budget_table?.max_budget; - if (maxBudget === null || maxBudget === undefined) { - return null; - } - return formatNumber(maxBudget); + return membership?.litellm_budget_table?.max_budget ?? null; }; // Helper function to get rate limits for a user @@ -98,7 +94,7 @@ export default function TeamMemberTab({ const getUserBudgetReset = (userId: string | null): string | null => { if (!userId) return null; const membership = teamData.team_memberships.find((tm) => tm.user_id === userId); - return formatBudgetReset(membership?.litellm_budget_table?.budget_reset_at); + return membership?.litellm_budget_table?.budget_reset_at ?? null; }; const extraColumns: ColumnsType = [ @@ -146,7 +142,7 @@ export default function TeamMemberTab({ ), key: "spend", render: (_: unknown, record: Member) => ( - ${formatNumberWithCommas(getUserCurrentCycleSpend(record.user_id), 4)} + ), }, { @@ -159,31 +155,19 @@ export default function TeamMemberTab({ ), key: "total_spend", - render: (_: unknown, record: Member) => ( - ${formatNumberWithCommas(getUserTotalSpend(record.user_id), 4)} - ), + render: (_: unknown, record: Member) => , }, { title: "Team Member Budget (USD)", key: "budget", - render: (_: unknown, record: Member) => { - const budget = getUserBudget(record.user_id); - return ( - {budget ? `$${formatNumberWithCommas(Number(budget), 4)}` : "No Limit"} - ); - }, + render: (_: unknown, record: Member) => ( + + ), }, { title: "Budget Reset", key: "budget_reset", - render: (_: unknown, record: Member) => { - const reset = getUserBudgetReset(record.user_id); - return reset ? ( - {reset} - ) : ( - - ); - }, + render: (_: unknown, record: Member) => , }, { title: ( diff --git a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx index e51c124d618..b7128e642a5 100644 --- a/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamVirtualKeysTable.tsx @@ -2,7 +2,7 @@ "use client"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; -import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { DateCell, IdCell, MoneyCell } from "@/components/shared/table_cells"; import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; import { ColumnDef, @@ -12,18 +12,7 @@ import { SortingState, useReactTable, } from "@tanstack/react-table"; -import { - Badge, - Button, - Icon, - Table, - TableBody, - TableCell, - TableHead, - TableHeaderCell, - TableRow, - Text, -} from "@tremor/react"; +import { Badge, Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow, Text } from "@tremor/react"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Popover, Skeleton, Tooltip, Typography } from "antd"; import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; @@ -214,23 +203,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: "Key ID", size: 100, enableSorting: true, - cell: (info) => { - const value = info.getValue() as string; - const width = info.cell.column.getSize(); - return ( - - - - ); - }, + cell: (info) => ( + setSelectedKey(info.row.original)} /> + ), }, { id: "key_alias", @@ -310,10 +285,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: "Created At", size: 120, enableSorting: true, - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleDateString() : "-"; - }, + cell: (info) => , }, { id: "created_by", @@ -380,10 +352,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: "Updated At", size: 120, enableSorting: true, - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleDateString() : "Never"; - }, + cell: (info) => , }, { id: "last_active", @@ -401,16 +370,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi ), size: 130, enableSorting: false, - cell: (info) => { - const value = info.getValue(); - if (!value) return "Unknown"; - const date = new Date(value as string); - return ( - - {date.toLocaleDateString()} - - ); - }, + cell: (info) => , }, { id: "expires", @@ -418,10 +378,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: "Expires", size: 120, enableSorting: false, - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleDateString() : "Never"; - }, + cell: (info) => , }, { id: "spend", @@ -429,7 +386,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: "Spend (USD)", size: 100, enableSorting: true, - cell: (info) => formatNumberWithCommas(info.getValue() as number, 4), + cell: (info) => , }, { id: "max_budget", @@ -437,11 +394,9 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: "Budget (USD)", size: 110, enableSorting: true, - cell: (info) => { - const maxBudget = info.getValue() as number | null; - if (maxBudget === null) return "Unlimited"; - return `$${formatNumberWithCommas(maxBudget)}`; - }, + cell: (info) => ( + + ), }, { id: "budget_reset_at", @@ -449,10 +404,7 @@ export function TeamVirtualKeysTable({ teamId, teamAlias, organization }: TeamVi header: "Budget Reset", size: 130, enableSorting: false, - cell: (info) => { - const value = info.getValue(); - return value ? new Date(value as string).toLocaleString() : "Never"; - }, + cell: (info) => , }, { id: "models", diff --git a/ui/litellm-dashboard/src/components/ui/badge.tsx b/ui/litellm-dashboard/src/components/ui/badge.tsx index 87b536cc37e..2e1ebffa109 100644 --- a/ui/litellm-dashboard/src/components/ui/badge.tsx +++ b/ui/litellm-dashboard/src/components/ui/badge.tsx @@ -21,14 +21,18 @@ const badgeVariants = cva({ }, }); -function Badge({ - className, - variant = "default", - ...props -}: React.ComponentProps<"span"> & VariantProps) { - return ( - - ); -} +const Badge = React.forwardRef< + HTMLSpanElement, + React.ComponentPropsWithoutRef<"span"> & VariantProps +>(({ className, variant = "default", ...props }, ref) => ( + +)); +Badge.displayName = "Badge"; export { Badge, badgeVariants }; diff --git a/ui/litellm-dashboard/src/components/usage.tsx b/ui/litellm-dashboard/src/components/usage.tsx index 04fcbddbd37..91c12fd1fa2 100644 --- a/ui/litellm-dashboard/src/components/usage.tsx +++ b/ui/litellm-dashboard/src/components/usage.tsx @@ -50,6 +50,7 @@ import { getProxyUISettings, } from "./networking"; import TopKeyView from "./UsagePage/components/EntityUsage/TopKeyView"; +import { MoneyCell } from "@/components/shared/table_cells"; import { formatNumberWithCommas } from "@/utils/dataUtils"; interface UsagePageProps { @@ -644,9 +645,7 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use {provider.provider} - {parseFloat(provider.spend.toFixed(2)) < 0.00001 - ? "less than 0.00" - : formatNumberWithCommas(provider.spend, 2)} + ))} @@ -819,7 +818,9 @@ const UsagePage: React.FC = ({ accessToken, token, userRole, use {topUsers?.map((user: any, index: number) => ( {user.end_user} - {formatNumberWithCommas(user.total_spend, 2)} + + + {user.total_count} ))} diff --git a/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx b/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx index c8288172d5b..eaa864ab3dc 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/DocumentsTable.tsx @@ -1,7 +1,8 @@ import React from "react"; -import { Table, Badge, Tooltip } from "antd"; +import { Table, Tooltip } from "antd"; import MessageManager from "@/components/molecules/message_manager"; import { EyeOutlined, CopyOutlined, DeleteOutlined } from "@ant-design/icons"; +import { StatusBadge, type StatusTone } from "@/components/shared/table_cells"; import { DocumentUpload } from "./types"; interface DocumentsTableProps { @@ -16,15 +17,15 @@ const DocumentsTable: React.FC = ({ documents, onRemove }) }; const getStatusBadge = (status: DocumentUpload["status"]) => { - const statusConfig = { - uploading: { color: "blue", text: "Uploading" }, - done: { color: "green", text: "Ready" }, - error: { color: "red", text: "Error" }, - removed: { color: "default", text: "Removed" }, + const statusConfig: Record = { + uploading: { tone: "info", label: "Uploading" }, + done: { tone: "success", label: "Ready" }, + error: { tone: "error", label: "Error" }, + removed: { tone: "neutral", label: "Removed" }, }; - const config = statusConfig[status]; - return ; + const config: { tone: StatusTone; label: string } = statusConfig[status] ?? { tone: "neutral", label: status }; + return ; }; const formatFileSize = (bytes?: number) => { diff --git a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx index ac790122507..54b05bc73b5 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.test.tsx @@ -154,9 +154,8 @@ describe("VectorStoreTable", () => { it("should truncate long vector store IDs", () => { renderComponent(); - // Check that the truncated text is rendered (first 15 chars + ...) - const truncatedText = "very-long-vecto..."; - expect(screen.getByText(truncatedText)).toBeInTheDocument(); + const idButton = screen.getByText("very-long-vector-store-id-that-should-be-truncated"); + expect(idButton).toHaveClass("truncate", "max-w-[15ch]"); }); it("should make vector store ID clickable", async () => { @@ -245,13 +244,13 @@ describe("VectorStoreTable", () => { describe("Date Columns", () => { it("should render created at dates", () => { renderComponent(); - const dateElements = screen.getAllByText(/1\/\d+\/2024/); + const dateElements = screen.getAllByText(/Jan \d+, 2024/); expect(dateElements.length).toBe(6); // 3 created_at + 3 updated_at dates }); it("should render updated at dates", () => { renderComponent(); - const dateElements = screen.getAllByText(/1\/\d+\/2024/); + const dateElements = screen.getAllByText(/Jan \d+, 2024/); expect(dateElements.length).toBe(6); // 3 created_at + 3 updated_at dates }); }); diff --git a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx index 180c2485ab5..c2e47cebe69 100644 --- a/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx +++ b/ui/litellm-dashboard/src/components/vector_store_management/VectorStoreTable.tsx @@ -10,6 +10,7 @@ import { import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react"; import { Tooltip } from "antd"; import React from "react"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton"; import { getProviderLogoAndName } from "../provider_info_helpers"; import { VectorStore } from "./types"; @@ -28,19 +29,7 @@ const VectorStoreTable: React.FC = ({ data, onView, onEdi { header: "Vector Store ID", accessorKey: "vector_store_id", - cell: ({ row }) => { - const vectorStore = row.original; - return ( - - ); - }, + cell: ({ row }) => , }, { header: "Name", @@ -109,19 +98,13 @@ const VectorStoreTable: React.FC = ({ data, onView, onEdi header: "Created At", accessorKey: "created_at", sortingFn: "datetime", - cell: ({ row }) => { - const vectorStore = row.original; - return {new Date(vectorStore.created_at).toLocaleDateString()}; - }, + cell: ({ row }) => , }, { header: "Updated At", accessorKey: "updated_at", sortingFn: "datetime", - cell: ({ row }) => { - const vectorStore = row.original; - return {new Date(vectorStore.updated_at).toLocaleDateString()}; - }, + cell: ({ row }) => , }, { id: "actions", diff --git a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx index 28b7afb6298..d811d3b9402 100644 --- a/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/audit_logs.tsx @@ -4,7 +4,7 @@ import { Table, Tag, Input, Select, Button, Pagination, Spin } from "antd"; import { ReloadOutlined, LoadingOutlined } from "@ant-design/icons"; import type { ColumnsType } from "antd/es/table"; import { resolveLogoSrc } from "@/lib/assetPaths"; -import moment from "moment"; +import { DateCell, IdCell } from "@/components/shared/table_cells"; import { uiAuditLogsCall } from "../networking"; import { AuditLogEntry } from "./columns"; import { AuditLogDrawer } from "./AuditLogDrawer/AuditLogDrawer"; @@ -95,11 +95,7 @@ export default function AuditLogs({ userID, userRole, token, accessToken, isActi dataIndex: "updated_at", key: "updated_at", width: 200, - render: (val: string) => ( - - {moment.utc(val).local().format("MMM D, YYYY HH:mm:ss")} - - ), + render: (val: string) => , }, { title: "Action", @@ -123,7 +119,7 @@ export default function AuditLogs({ userID, userRole, token, accessToken, isActi title: "Object ID", dataIndex: "object_id", key: "object_id", - render: (val: string) => {val}, + render: (val: string) => , }, { title: "Changed By", @@ -137,7 +133,7 @@ export default function AuditLogs({ userID, userRole, token, accessToken, isActi dataIndex: "changed_by_api_key", key: "changed_by_api_key", width: 140, - render: (val: string) => (val ? {val.slice(0, 12)}… : "—"), + render: (val: string) => , }, ]; diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx new file mode 100644 index 00000000000..afdd17813f3 --- /dev/null +++ b/ui/litellm-dashboard/src/components/view_logs/columns.test.tsx @@ -0,0 +1,56 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; + +import { createColumns, type LogEntry } from "./columns"; +import { DataTable } from "./table"; + +const logEntry = (overrides: Partial): LogEntry => ({ + request_id: "req-1", + api_key: "key-1", + team_id: "team-1", + model: "gpt-4o", + model_id: "model-1", + call_type: "acompletion", + spend: 0, + total_tokens: 10, + prompt_tokens: 5, + completion_tokens: 5, + startTime: "2026-07-07T09:50:13Z", + endTime: "2026-07-07T09:50:14Z", + cache_hit: "false", + messages: [], + response: {}, + ...overrides, +}); + +describe("Cost column", () => { + it("renders '-' for zero spend with no tooltip, so hovering never shows a contradictory $0", async () => { + const user = userEvent.setup(); + render( + r.request_id} + />, + ); + for (const dash of screen.getAllByText("-")) { + await user.hover(dash); + } + expect(screen.queryByText("$0")).not.toBeInTheDocument(); + }); + + it("shows the full-precision raw value in the tooltip for a real spend", async () => { + const user = userEvent.setup(); + render( + r.request_id} + />, + ); + const formatted = screen.getByText("$0.000123"); + await user.hover(formatted); + expect(await screen.findByText("$0.00012345678")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/view_logs/columns.tsx b/ui/litellm-dashboard/src/components/view_logs/columns.tsx index 7452992ed59..1d0f3f33d08 100644 --- a/ui/litellm-dashboard/src/components/view_logs/columns.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/columns.tsx @@ -1,11 +1,10 @@ +import { DateCell, IdCell, MoneyCell, StatusBadge } from "@/components/shared/table_cells"; import { getSpendString } from "@/utils/dataUtils"; import type { ColumnDef } from "@tanstack/react-table"; -import { Badge, Button } from "@tremor/react"; import { Tooltip } from "antd"; -import React, { useState } from "react"; +import React from "react"; import { getProviderLogoAndName } from "../provider_info_helpers"; import { TableHeaderSortDropdown } from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown"; -import { TimeCell } from "./time_cell"; import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; import { AgentBadge, AgentIcon, LlmBadge, McpBadge, SparkleIcon, WrenchIcon } from "./TypeBadges"; @@ -120,7 +119,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] : "Time", accessorKey: "startTime", size: 200, - cell: (info: any) => , + cell: (info: any) => , }, { header: "Type", @@ -174,48 +173,20 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] cell: (info: any) => { const status = info.getValue() || "Success"; const isSuccess = status.toLowerCase() !== "failure"; - - return ( - - {isSuccess ? "Success" : "Failure"} - - ); + return ; }, }, { header: "Session ID", accessorKey: "session_id", size: 120, - cell: (info: any) => { - const value = String(info.getValue() || ""); - const onSessionClick = info.row.original.onSessionClick; - return ( - - - - ); - }, + cell: (info: any) => , }, { header: "Request ID", accessorKey: "request_id", - cell: (info: any) => ( - - {String(info.getValue() || "")} - - ), + cell: (info: any) => , }, { header: sortProps @@ -236,11 +207,14 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] const row = info.row.original; const mcpCount = row.mcp_tool_call_count || 0; const mcpSpend = row.mcp_tool_call_spend || 0; + const spend = info.getValue(); return (
- - {getSpendString(info.getValue() || 0)} + + + + {mcpCount > 0 && mcpSpend > 0 && ( @@ -320,21 +294,7 @@ export const createColumns = (sortProps?: LogsSortProps): ColumnDef[] header: "Key Hash", accessorKey: "metadata.user_api_key", size: 110, - cell: (info: any) => { - const value = String(info.getValue() || "-"); - const onKeyHashClick = info.row.original.onKeyHashClick; - - return ( - - onKeyHashClick?.(value)} - > - {value} - - - ); - }, + cell: (info: any) => , }, { header: "Key Alias", @@ -589,141 +549,3 @@ export type AuditLogEntry = { before_value: Record; updated_values: Record; }; - -const getActionBadge = (action: string) => { - return ( - - {action} - - ); -}; - -export const auditLogColumns: ColumnDef[] = [ - { - id: "expander", - header: () => null, - cell: ({ row }) => { - const ExpanderCell = () => { - const [localExpanded, setLocalExpanded] = React.useState(row.getIsExpanded()); - - const toggleHandler = React.useCallback(() => { - setLocalExpanded((prev) => !prev); - row.getToggleExpandedHandler()(); - }, [row]); - - return row.getCanExpand() ? ( - - ) : ( - - ); - }; - return ; - }, - }, - { - header: "Timestamp", - accessorKey: "updated_at", - cell: (info: any) => , - }, - { - header: "Table Name", - accessorKey: "table_name", - cell: (info: any) => { - const tableName = info.getValue(); - let displayValue = tableName; - switch (tableName) { - case "LiteLLM_VerificationToken": - displayValue = "Keys"; - break; - case "LiteLLM_TeamTable": - displayValue = "Teams"; - break; - case "LiteLLM_OrganizationTable": - displayValue = "Organizations"; - break; - case "LiteLLM_UserTable": - displayValue = "Users"; - break; - case "LiteLLM_ProxyModelTable": - displayValue = "Models"; - break; - default: - displayValue = tableName; - } - return {displayValue}; - }, - }, - { - header: "Action", - accessorKey: "action", - cell: (info: any) => {getActionBadge(info.getValue())}, - }, - { - header: "Changed By", - accessorKey: "changed_by", - cell: (info: any) => { - const changedBy = info.row.original.changed_by; - const apiKey = info.row.original.changed_by_api_key; - return ( -
-
{changedBy}
- {apiKey && ( // Only show API key if it exists - -
- {" "} - {/* Apply max-width and truncate */} - {apiKey} -
-
- )} -
- ); - }, - }, - { - header: "Affected Item ID", - accessorKey: "object_id", - cell: (props) => { - const ObjectIdDisplay = () => { - const objectId = props.getValue(); - const [copied, setCopied] = useState(false); - - if (!objectId) return <>-; - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(String(objectId)); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - } catch (err) { - console.error("Failed to copy object ID: ", err); - } - }; - - return ( - - - {String(objectId)} - - - ); - }; - return ; - }, - }, -]; diff --git a/ui/litellm-dashboard/src/components/view_logs/time_cell.test.tsx b/ui/litellm-dashboard/src/components/view_logs/time_cell.test.tsx deleted file mode 100644 index 95a8b43b2c1..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/time_cell.test.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { TimeCell, getTimeZone } from "./time_cell"; - -describe("TimeCell", () => { - it("should render a formatted time string", () => { - render(); - // The global toLocaleString mock in setupTests returns "YYYY-MM-DD HH:MM:SS" - expect(screen.getByText(/2025/)).toBeInTheDocument(); - }); - - it("should render 'Error converting time' for invalid dates", () => { - // toLocaleString on an Invalid Date returns "Invalid Date", not throwing, - // but the component catches exceptions. Force an error by passing something - // that causes Date constructor to produce NaN. - render(); - // The mock returns "NaN-NaN-NaN NaN:NaN:NaN" for invalid dates - // The component has a try/catch that returns "Error converting time" on exception - const el = screen.getByText(/NaN|Error/); - expect(el).toBeInTheDocument(); - }); - - it("should render with monospace font", () => { - render(); - const span = screen.getByText(/2025/); - expect(span).toHaveStyle({ fontFamily: "monospace" }); - }); -}); - -describe("getTimeZone", () => { - it("should return a non-empty timezone string", () => { - const tz = getTimeZone(); - expect(typeof tz).toBe("string"); - expect(tz.length).toBeGreaterThan(0); - }); -}); diff --git a/ui/litellm-dashboard/src/components/view_logs/time_cell.tsx b/ui/litellm-dashboard/src/components/view_logs/time_cell.tsx deleted file mode 100644 index 8addc702dc6..00000000000 --- a/ui/litellm-dashboard/src/components/view_logs/time_cell.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import * as React from "react"; - -interface TimeCellProps { - utcTime: string; -} - -const getLocalTime = (utcTime: string): string => { - try { - const date = new Date(utcTime); - return date - .toLocaleString("en-US", { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: true, - }) - .replace(",", ""); - } catch (e) { - return "Error converting time"; - } -}; - -export const TimeCell: React.FC = ({ utcTime }) => { - return ( - - {getLocalTime(utcTime)} - - ); -}; - -export const getTimeZone = (): string => { - return Intl.DateTimeFormat().resolvedOptions().timeZone; -}; From 4a25cce114927f34287ec1c9444fc24199c1317c Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 14:57:21 -0700 Subject: [PATCH 011/365] fix(mcp): reject duplicate Authorization headers at MCP ingress For the client-forwarded token modes the gateway relays the caller's Authorization to the upstream, so a request carrying more than one Authorization header would make which token is forwarded ambiguous (the ASGI header list collapses to last-wins) and could diverge from what admission inspected. Multiple Authorization headers is malformed for bearer auth anyway (RFC 9110: not a comma-combinable field), so the ingress header converter now fails closed with a 400 instead of silently keeping one. Applies to every MCP request, not just passthrough. --- .../mcp_server/auth/user_api_key_auth_mcp.py | 31 +++++++++++++++- .../auth/test_user_api_key_auth_mcp.py | 35 +++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py index 9f28da19292..e7ffef0e4e3 100644 --- a/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py +++ b/litellm/proxy/_experimental/mcp_server/auth/user_api_key_auth_mcp.py @@ -591,10 +591,19 @@ class MCPRequestHandler: ASGI headers are in format: List[List[bytes, bytes]] We need to convert them to the format Headers expects. + + Collapsing the ASGI list into a dict keeps the last value for a duplicated + header name, so a request carrying more than one ``Authorization`` is + rejected first: for the client-forwarded token modes the gateway relays the + caller's ``Authorization`` upstream, so a duplicate would make which token is + forwarded ambiguous (and diverge from what admission inspected). Multiple + ``Authorization`` headers is malformed for bearer auth anyway (RFC 9110: not + a comma-combinable field), so fail closed with a 400. """ + raw_headers = scope.get("headers", []) + MCPRequestHandler._reject_duplicate_authorization(raw_headers) try: # ASGI headers are list of [name: bytes, value: bytes] pairs - raw_headers = scope.get("headers", []) # Convert bytes to strings and create dict for Headers constructor headers_dict = {name.decode("latin-1"): value.decode("latin-1") for name, value in raw_headers} return Headers(headers_dict) @@ -603,6 +612,26 @@ class MCPRequestHandler: # Return empty Headers object with empty dict return Headers({}) + @staticmethod + def _reject_duplicate_authorization(raw_headers: object) -> None: + """Raise 400 when the raw ASGI headers carry more than one ``Authorization`` header.""" + if not isinstance(raw_headers, (list, tuple)): + return + count = 0 + for entry in raw_headers: + if not isinstance(entry, (list, tuple)) or len(entry) < 1: + continue + name = entry[0] + if isinstance(name, (bytes, bytearray)) and bytes(name).lower() == b"authorization": + count += 1 + elif isinstance(name, str) and name.lower() == "authorization": + count += 1 + if count > 1: + raise HTTPException( + status_code=400, + detail="Multiple Authorization headers are not allowed", + ) + @staticmethod async def get_allowed_mcp_servers( user_api_key_auth: Optional[UserAPIKeyAuth] = None, diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py index c984ccb783e..ebaa6bc7cc0 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/auth/test_user_api_key_auth_mcp.py @@ -4,6 +4,7 @@ import sys from unittest.mock import AsyncMock, MagicMock, patch import pytest +from fastapi import HTTPException from fastapi.testclient import TestClient sys.path.insert(0, os.path.abspath("../../../..")) # Adds the parent directory to the system path @@ -750,6 +751,40 @@ class TestMCPRequestHandler: # For these tests, mcp_server_auth_headers should be empty assert mcp_server_auth_headers == {} + def test_duplicate_authorization_header_is_rejected(self): + """A request carrying more than one Authorization header is malformed for bearer auth and, + for the client-forwarded token modes, would make which upstream token is forwarded ambiguous. + The ingress header converter must reject it with a 400 rather than silently keeping one.""" + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/tp_server", + "headers": [ + (b"authorization", b"Bearer upstream-token-a"), + (b"authorization", b"Bearer upstream-token-b"), + (b"content-type", b"application/json"), + ], + } + with pytest.raises(HTTPException) as exc_info: + MCPRequestHandler._safe_get_headers_from_scope(scope) + assert exc_info.value.status_code == 400 + assert "Authorization" in str(exc_info.value.detail) + + def test_single_authorization_header_is_forwarded_verbatim(self): + """The rejection must not disturb the normal single-Authorization case: the value passes + through unchanged (guards against the duplicate check over-matching).""" + scope = { + "type": "http", + "method": "POST", + "path": "/mcp/tp_server", + "headers": [ + (b"authorization", b"Bearer upstream-token"), + (b"content-type", b"application/json"), + ], + } + headers = MCPRequestHandler._safe_get_headers_from_scope(scope) + assert headers.get("authorization") == "Bearer upstream-token" + @pytest.mark.asyncio class TestMCPOAuth2AuthFlow: From edf00bbe23c1c5be9363dc94f55ab837ca723005 Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 15:44:36 -0700 Subject: [PATCH 012/365] fix(mcp): recognize per-server auth header at the connect-time preemptive 401 The preemptive 401 for true_passthrough and oauth_delegate only inspected the request-wide Authorization, so a caller who bound the upstream token via the per-server x-mcp-{alias}-authorization header (the required shape in a multi-server aggregate, where the request-wide Authorization is withheld) was spuriously 401'd at initialize even though egress already honors that header. The gate now recognizes the per-server header for both modes via a shared helper, mode-correctly: true_passthrough treats any Authorization or the per-server header as the upstream token, oauth_delegate keeps requiring a distinct x-litellm-api-key so a lone Authorization consumed for admission is never mistaken for an upstream token. The preemptive raise is also gated to single-server scopes so a multi-server aggregate degrades gracefully instead of one missing token 401-ing the whole connect. --- .../proxy/_experimental/mcp_server/server.py | 64 +++++++++---- .../mcp_server/test_mcp_stale_session.py | 95 +++++++++++++++++++ 2 files changed, 139 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 4fda15f664e..d4597bf1adf 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -1441,6 +1441,35 @@ if MCP_AVAILABLE: return allowed_mcp_servers + def _client_has_per_server_auth_header( + server: MCPServer, + mcp_server_auth_headers: Optional[Dict[str, Dict[str, str]]], + ) -> bool: + """True if the request carries a per-server ``x-mcp-{alias}-authorization`` + header for this server. This is the multi-server binding: it names one + upstream, so it is unambiguously the caller's upstream token regardless of + auth mode (never the LiteLLM admission credential). + """ + if not mcp_server_auth_headers: + return False + for key in (server.alias, server.server_name, server.name): + if not key: + continue + server_headers = None + for k, v in mcp_server_auth_headers.items(): + if k.lower() == key.lower(): + server_headers = v + break + if server_headers is None: + continue + if isinstance(server_headers, str) and server_headers.strip(): + return True + if isinstance(server_headers, dict): + for hk in server_headers.keys(): + if hk.lower() == "authorization": + return True + return False + def _client_has_passthrough_authorization( server: MCPServer, oauth2_headers: Optional[Dict[str, str]], @@ -1458,24 +1487,7 @@ if MCP_AVAILABLE: for k in oauth2_headers.keys(): if k.lower() == "authorization": return True - if mcp_server_auth_headers: - for key in (server.alias, server.server_name, server.name): - if not key: - continue - server_headers = None - for k, v in mcp_server_auth_headers.items(): - if k.lower() == key.lower(): - server_headers = v - break - if server_headers is None: - continue - if isinstance(server_headers, str) and server_headers.strip(): - return True - if isinstance(server_headers, dict): - for hk in server_headers.keys(): - if hk.lower() == "authorization": - return True - return False + return _client_has_per_server_auth_header(server, mcp_server_auth_headers) async def _get_user_oauth_extra_headers_from_db( server: MCPServer, @@ -3540,7 +3552,13 @@ if MCP_AVAILABLE: headers={"www-authenticate": www_authenticate}, ) - if server and server.is_oauth_delegate and _get_forwarded_auth_from_scope(scope) is None: + if ( + server + and server.is_oauth_delegate + and len(mcp_servers or []) == 1 + and _get_forwarded_auth_from_scope(scope) is None + and not _client_has_per_server_auth_header(server, mcp_server_auth_headers) + ): www_authenticate = _get_passthrough_www_authenticate( scope=scope, server_name=server_name, @@ -3551,7 +3569,13 @@ if MCP_AVAILABLE: headers={"www-authenticate": www_authenticate}, ) - if server and server.is_true_passthrough and not _scope_has_authorization_header(scope): + if ( + server + and server.is_true_passthrough + and len(mcp_servers or []) == 1 + and not _scope_has_authorization_header(scope) + and not _client_has_per_server_auth_header(server, mcp_server_auth_headers) + ): upstream_status, upstream_www_authenticate = await _probe_upstream_auth(server.url or "", "") if upstream_status == 401 and upstream_www_authenticate: raise HTTPException( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py index 44ea5f43a70..04856b33f2a 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py @@ -1216,6 +1216,101 @@ async def test_handle_streamable_http_mcp_oauth_delegate_with_forwarded_token_sk assert mock_handle_request.await_count == 1 +async def _run_passthrough_connect( + *, + auth_type, + server_names, + mcp_server_auth_headers, + scope_extra_headers=None, +): + """Drive handle_streamable_http_mcp through the preemptive-401 gate and report whether it + challenged (raised) or forwarded to the session manager. Returns (challenged, www_authenticate).""" + from litellm.proxy._experimental.mcp_server.server import ( + handle_streamable_http_mcp, + session_manager_stateless, + ) + + scope = _passthrough_mode_scope(server_names[0], extra_headers=scope_extra_headers) + receive = AsyncMock( + return_value={ + "type": "http.request", + "body": b'{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}', + "more_body": False, + } + ) + send = AsyncMock() + user_auth = MagicMock() + user_auth.user_id = "u1" + server = _build_passthrough_mode_server(server_names[0], auth_type) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.server.extract_mcp_auth_context", + new_callable=AsyncMock, + return_value=(user_auth, None, server_names, mcp_server_auth_headers, None, None), + ), + patch("litellm.proxy._experimental.mcp_server.server.set_auth_context"), + patch("litellm.proxy._experimental.mcp_server.server._SESSION_MANAGERS_INITIALIZED", True), + patch( + "litellm.proxy._experimental.mcp_server.server._handle_stale_mcp_session", + new_callable=AsyncMock, + return_value=False, + ), + patch( + "litellm.proxy._experimental.mcp_server.server._check_passthrough_upstream_auth", + new_callable=AsyncMock, + ), + patch( + "litellm.proxy._experimental.mcp_server.server.global_mcp_server_manager.get_mcp_server_by_name", + return_value=server, + ), + patch.object(session_manager_stateless, "handle_request", new_callable=AsyncMock) as mock_handle_request, + patch.object(session_manager_stateless, "_server_instances", {}), + ): + try: + await handle_streamable_http_mcp(scope, receive, send) + except HTTPException as exc: + return True, (exc.headers or {}).get("www-authenticate") + return mock_handle_request.await_count == 0, None + + +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type", [MCPAuth.oauth_delegate, MCPAuth.true_passthrough]) +async def test_handle_streamable_http_mcp_per_server_header_skips_preemptive_challenge(auth_type): + """A per-server x-mcp-{alias}-authorization header binds the upstream token to one server; the + connect gate must recognize it and forward instead of spuriously 401-ing, since egress already + honors it. Without this, the mandatory multi-server binding is unusable at connect.""" + try: + from litellm.proxy._experimental.mcp_server.server import handle_streamable_http_mcp # noqa: F401 + except ImportError: + pytest.skip("MCP server not available") + + challenged, _ = await _run_passthrough_connect( + auth_type=auth_type, + server_names=["pt_server"], + mcp_server_auth_headers={"pt_server": {"Authorization": "Bearer upstream-token"}}, + ) + assert challenged is False + + +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type", [MCPAuth.oauth_delegate, MCPAuth.true_passthrough]) +async def test_handle_streamable_http_mcp_aggregate_does_not_preemptively_challenge(auth_type): + """A multi-server aggregate must degrade gracefully: the preemptive 401 is single-server only, so + one server missing a token cannot 401 the whole connect (the listing absorbs per-server failures).""" + try: + from litellm.proxy._experimental.mcp_server.server import handle_streamable_http_mcp # noqa: F401 + except ImportError: + pytest.skip("MCP server not available") + + challenged, _ = await _run_passthrough_connect( + auth_type=auth_type, + server_names=["pt_server", "pt_server_2"], + mcp_server_auth_headers=None, + ) + assert challenged is False + + @pytest.mark.asyncio async def test_handle_streamable_http_mcp_true_passthrough_without_token_surfaces_verbatim_upstream_challenge(): """true_passthrough is a transparent proxy: with no client Authorization the From ddec3b2b8b8846656611110c493307c3f5f70e53 Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 15:46:39 -0700 Subject: [PATCH 013/365] fix(mcp): plug fan-out Authorization bypass in the extra_headers loop The listing fan-out withholds the request-wide Authorization from a true_passthrough / oauth_delegate server when another server in scope also consumes it, so one bearer is not replayed across upstreams. The later server.extra_headers copy loop did not honor that decision: a server listing Authorization in extra_headers would re-copy the withheld bearer from raw_headers. The withhold decision is now computed once and applied to both the forwarding branch and the extra_headers loop. --- .../proxy/_experimental/mcp_server/server.py | 18 ++++++-- .../mcp_server/test_mcp_server.py | 43 +++++++++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index d4597bf1adf..25890d29368 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -1565,6 +1565,16 @@ if MCP_AVAILABLE: ) extra_headers: Optional[Dict[str, str]] = None + is_client_forwarded_mode = server.is_true_passthrough or server.is_oauth_delegate + # In a multi-server listing scope the request-wide Authorization can only carry one token, + # so it is withheld from a client-forwarded server when another server in scope also consumes + # it (RFC 9700 cross-resource replay); such scopes must bind per-server via + # x-mcp-{alias}-authorization. The decision is computed once so BOTH the forwarding branch and + # the extra_headers copy loop below honor it — otherwise a server that lists Authorization in + # extra_headers would re-copy the withheld bearer from raw_headers and replay it anyway. + withhold_forwarded_authorization = is_client_forwarded_mode and _caller_authorization_fans_out( + server, scope_servers + ) if server.auth_type == MCPAuth.oauth2: # For OAuth2 M2M servers, upstream Authorization must come from # client_credentials token fetch, never from caller headers. @@ -1583,8 +1593,8 @@ if MCP_AVAILABLE: user_api_key_auth=user_api_key_auth, ): extra_headers = _without_authorization(extra_headers) - elif server.is_true_passthrough or server.is_oauth_delegate: - if not _caller_authorization_fans_out(server, scope_servers): + elif is_client_forwarded_mode: + if not withhold_forwarded_authorization: extra_headers = _client_forwarded_authorization_headers( mcp_server=server, oauth2_headers=oauth2_headers, @@ -1611,7 +1621,9 @@ if MCP_AVAILABLE: for header in server.extra_headers: if not isinstance(header, str): continue - if header.lower() == "authorization" and strip_caller_authorization: + if header.lower() == "authorization" and ( + strip_caller_authorization or withhold_forwarded_authorization + ): continue header_value = normalized_raw_headers.get(header.lower()) if header_value is None: diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index 71ece75b2fb..12ebc31e33c 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -426,6 +426,49 @@ def test_prepare_mcp_server_headers_scope_counts_legacy_delegate_as_consumer(): assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} +def test_prepare_mcp_server_headers_fanout_withhold_survives_extra_headers_loop(): + """Regression: when fan-out withholds the request-wide Authorization from a client-forwarded + server, the later server.extra_headers copy loop must not re-add it from raw_headers even if + the server lists Authorization in extra_headers. Otherwise one bearer is replayed across every + consuming upstream in the scope (the exact cross-resource replay the withholding prevents).""" + delegate = MCPServer( + server_id="od-extra-hdr", + name="od-extra-hdr", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + extra_headers=["Authorization"], + ) + second_consumer = _client_forwarded_mode_server("tp-peer", MCPAuth.true_passthrough) + + _, extra_headers = _prepare_headers_in_scope(delegate, [delegate, second_consumer]) + + assert not extra_headers or "authorization" not in {k.lower() for k in extra_headers} + + +def test_prepare_mcp_server_headers_sole_consumer_still_forwards_via_extra_headers(): + """Guard the fix does not over-withhold: with no second consumer in scope, a client-forwarded + server that lists Authorization in extra_headers still forwards the caller's bearer.""" + delegate = MCPServer( + server_id="od-extra-sole", + name="od-extra-sole", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth_delegate, + extra_headers=["Authorization"], + ) + static_server = MCPServer( + server_id="static-peer", + name="static-peer", + transport=MCPTransport.http, + auth_type=MCPAuth.api_key, + authentication_token="static-key", + ) + + _, extra_headers = _prepare_headers_in_scope(delegate, [delegate, static_server]) + + assert extra_headers is not None + assert extra_headers.get("Authorization") == "Bearer upstream-token" + + @pytest.mark.asyncio async def test_call_tool_m2m_skips_authorization_headers(): """M2M call_tool must not forward caller Authorization in oauth2/raw headers.""" From b2ea36f4f11ddc31122027ffb6eea225d5a485fd Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 16:22:05 -0700 Subject: [PATCH 014/365] fix(mcp): match sanitized per-server alias at the connect-time preemptive 401 The connect gate resolved x-mcp-{alias}-authorization by matching the raw lowercased alias/server_name/name only, but dashboard clients send x-mcp-{sanitize_mcp_alias_for_header(alias)}-authorization, and egress resolves those through lookup_mcp_server_auth_in_headers, which also tries the sanitized alias. So a per-server token bound with a sanitized alias (e.g. alias 'pt-server' arriving as header key 'pt_server') was forwarded at egress but still triggered a preemptive 401 at connect. _client_has_per_server_auth_header now resolves through the same lookup_mcp_server_auth_in_headers egress uses, so connect and egress agree on which header names match. --- .../proxy/_experimental/mcp_server/server.py | 32 +++++++++---------- .../mcp_server/test_mcp_stale_session.py | 19 +++++++++++ 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 25890d29368..7e19c44052d 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -1449,25 +1449,25 @@ if MCP_AVAILABLE: header for this server. This is the multi-server binding: it names one upstream, so it is unambiguously the caller's upstream token regardless of auth mode (never the LiteLLM admission credential). + + Resolves through the same ``lookup_mcp_server_auth_in_headers`` egress uses, so + the connect gate and egress agree on which per-server header names match: a + dashboard client sends ``x-mcp-{sanitize_mcp_alias_for_header(alias)}-authorization``, + and matching only the raw alias here would 401 a token egress would forward. """ if not mcp_server_auth_headers: return False - for key in (server.alias, server.server_name, server.name): - if not key: - continue - server_headers = None - for k, v in mcp_server_auth_headers.items(): - if k.lower() == key.lower(): - server_headers = v - break - if server_headers is None: - continue - if isinstance(server_headers, str) and server_headers.strip(): - return True - if isinstance(server_headers, dict): - for hk in server_headers.keys(): - if hk.lower() == "authorization": - return True + from litellm.proxy._experimental.mcp_server.utils import ( + lookup_mcp_server_auth_in_headers, + ) + + server_headers = lookup_mcp_server_auth_in_headers( + mcp_server_auth_headers, alias=server.alias, server_name=server.server_name + ) + if isinstance(server_headers, str): + return bool(server_headers.strip()) + if isinstance(server_headers, dict): + return any(isinstance(hk, str) and hk.lower() == "authorization" for hk in server_headers) return False def _client_has_passthrough_authorization( diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py index 04856b33f2a..da183e8d02a 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_stale_session.py @@ -1293,6 +1293,25 @@ async def test_handle_streamable_http_mcp_per_server_header_skips_preemptive_cha assert challenged is False +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type", [MCPAuth.oauth_delegate, MCPAuth.true_passthrough]) +async def test_handle_streamable_http_mcp_sanitized_per_server_header_skips_preemptive_challenge(auth_type): + """A dashboard client sends x-mcp-{sanitize_mcp_alias_for_header(alias)}-authorization, so the + alias 'pt-server' arrives as the header key 'pt_server'. Egress resolves that via the sanitized + alias, so the connect gate must too, or it 401s a token egress would forward.""" + try: + from litellm.proxy._experimental.mcp_server.server import handle_streamable_http_mcp # noqa: F401 + except ImportError: + pytest.skip("MCP server not available") + + challenged, _ = await _run_passthrough_connect( + auth_type=auth_type, + server_names=["pt-server"], + mcp_server_auth_headers={"pt_server": {"Authorization": "Bearer upstream-token"}}, + ) + assert challenged is False + + @pytest.mark.asyncio @pytest.mark.parametrize("auth_type", [MCPAuth.oauth_delegate, MCPAuth.true_passthrough]) async def test_handle_streamable_http_mcp_aggregate_does_not_preemptively_challenge(auth_type): From 5aa5b8ef32776770e8f51dbe7485a5ebba76ac91 Mon Sep 17 00:00:00 2001 From: Mateo Wang <277851410+mateo-berri@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:30:46 -0700 Subject: [PATCH 015/365] fix(vertex): forward realtime health check params (#32550) * fix(vertex): forward realtime health check params * refactor(vertex): resolve realtime health check params via VertexBase helpers Address review feedback on the vertex param forwarding: pass model_params through to _realtime_health_check and resolve vertex credentials, project, and location inside the vertex_ai branch using the existing VertexBase.safe_get_vertex_ai_* helpers, so provider-specific key extraction no longer lives in litellm_core_utils and dict-typed vertex_credentials are supported * test(vertex): move realtime health check test to mapped unit test path codecov/patch reported the vertex branch of _realtime_health_check as uncovered because tests/litellm_utils_tests is not part of the unit test groups that upload coverage. Move the test into tests/test_litellm/litellm_core_utils/test_health_check_helpers.py, which the core-utils group runs, keeping the same end-to-end assertions through litellm.ahealth_check --------- Co-authored-by: Aleksandr Liadov <72351793+AleksandrLiadov@users.noreply.github.com> --- .../health_check_helpers.py | 1 + litellm/realtime_api/main.py | 12 ++-- .../test_health_check_helpers.py | 67 +++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/litellm/litellm_core_utils/health_check_helpers.py b/litellm/litellm_core_utils/health_check_helpers.py index 42ac82abf8b..9fc036e2a99 100644 --- a/litellm/litellm_core_utils/health_check_helpers.py +++ b/litellm/litellm_core_utils/health_check_helpers.py @@ -199,6 +199,7 @@ class HealthCheckHelpers: api_base=model_params.get("api_base", None), api_key=model_params.get("api_key", None), api_version=model_params.get("api_version", None), + model_params=model_params, ), "batch": lambda: HealthCheckHelpers._batch_health_check( custom_llm_provider=custom_llm_provider, diff --git a/litellm/realtime_api/main.py b/litellm/realtime_api/main.py index 0566ff73683..5ecf4d91ff6 100644 --- a/litellm/realtime_api/main.py +++ b/litellm/realtime_api/main.py @@ -515,6 +515,7 @@ async def _realtime_health_check( api_base: Optional[str] = None, api_version: Optional[str] = None, realtime_protocol: Optional[str] = None, + model_params: Optional[dict] = None, ): """ Health check for realtime API - tries connection to the realtime API websocket @@ -550,14 +551,17 @@ async def _realtime_health_check( elif custom_llm_provider == "xai": url = xai_realtime._construct_url(api_base=api_base or "https://api.x.ai/v1", query_params={"model": model}) elif custom_llm_provider == "vertex_ai": - vertex_location = litellm.vertex_location or get_secret_str("VERTEXAI_LOCATION") - resolved_location = vertex_llm_base.get_vertex_region(vertex_region=vertex_location, model=model) + vertex_model_params = model_params or {} + resolved_location = vertex_llm_base.get_vertex_region( + vertex_region=VertexBase.safe_get_vertex_ai_location(vertex_model_params), + model=model, + ) ( access_token, resolved_project, ) = await vertex_llm_base._ensure_access_token_async( - credentials=None, - project_id=litellm.vertex_project or get_secret_str("VERTEXAI_PROJECT"), + credentials=VertexBase.safe_get_vertex_ai_credentials(vertex_model_params), + project_id=VertexBase.safe_get_vertex_ai_project(vertex_model_params), custom_llm_provider="vertex_ai", ) vertex_realtime_config = VertexAIRealtimeConfig( diff --git a/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py b/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py index e8ef8f15142..f0d91224614 100644 --- a/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py +++ b/tests/test_litellm/litellm_core_utils/test_health_check_helpers.py @@ -277,3 +277,70 @@ async def test_batch_health_check_falls_back_to_acompletion_for_unsupported(): ) mock_alist.assert_not_called() mock_acompletion.assert_called_once_with(**model_params) + + +class _FakeWebsocketConnect: + def __init__(self, calls, url, **kwargs): + calls.append({"url": url, **kwargs}) + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, tb): + return False + + +@pytest.mark.asyncio +async def test_realtime_health_check_uses_model_level_vertex_params(): + """Regression test: realtime health checks must resolve vertex_credentials, + vertex_project, and vertex_location from the model row's params instead of + falling back to process-global VERTEXAI_* settings.""" + import litellm + from litellm.realtime_api import main as realtime_main + + fake_vertex_base = MagicMock() + fake_vertex_base.get_vertex_region = MagicMock(return_value="us-central1") + fake_vertex_base._ensure_access_token_async = AsyncMock( + return_value=("model-level-token", "model-level-project") + ) + connect_calls = [] + + with ( + patch.object(realtime_main, "vertex_llm_base", fake_vertex_base), + patch( + "websockets.connect", + lambda url, **kwargs: _FakeWebsocketConnect(connect_calls, url, **kwargs), + ), + patch.object( + HealthCheckHelpers, + "_update_model_params_with_health_check_tracking_information", + staticmethod(lambda model_params: model_params), + ), + ): + result = await litellm.ahealth_check( + model_params={ + "model": "vertex_ai/gemini-live-2.5-flash-native-audio", + "vertex_credentials": '{"type":"service_account"}', + "vertex_project": "model-level-project", + "vertex_location": "us-central1", + }, + mode="realtime", + ) + + assert result == {} + fake_vertex_base.get_vertex_region.assert_called_once_with( + vertex_region="us-central1", model="gemini-live-2.5-flash-native-audio" + ) + fake_vertex_base._ensure_access_token_async.assert_called_once_with( + credentials='{"type":"service_account"}', + project_id="model-level-project", + custom_llm_provider="vertex_ai", + ) + assert connect_calls[0]["url"] == ( + "wss://us-central1-aiplatform.googleapis.com/ws/" + "google.cloud.aiplatform.v1.LlmBidiService/BidiGenerateContent" + ) + assert connect_calls[0]["additional_headers"] == { + "Authorization": "Bearer model-level-token", + "x-goog-user-project": "model-level-project", + } From e1b9ec1cd62ce436b863db1fa07179693592b364 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 17:37:38 -0700 Subject: [PATCH 016/365] feat(pricing): add xai/grok-4.5 model pricing and metadata (#32549) Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- ...odel_prices_and_context_window_backup.json | 42 +++++++++++++++++++ model_prices_and_context_window.json | 42 +++++++++++++++++++ 2 files changed, 84 insertions(+) diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index 70b6b05e6ec..db534b52df9 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -38101,6 +38101,48 @@ "supports_vision": true, "supports_web_search": true }, + "xai/grok-4.5": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_200k_tokens": 1e-06, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "xai", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-4.5-latest": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_200k_tokens": 1e-06, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "xai", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "xai/grok-beta": { "input_cost_per_token": 5e-06, "litellm_provider": "xai", diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index b961c326625..363ba9842b0 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -38303,6 +38303,48 @@ "supports_vision": true, "supports_web_search": true }, + "xai/grok-4.5": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_200k_tokens": 1e-06, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "xai", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, + "xai/grok-4.5-latest": { + "cache_read_input_token_cost": 5e-07, + "cache_read_input_token_cost_above_200k_tokens": 1e-06, + "input_cost_per_token": 2e-06, + "input_cost_per_token_above_200k_tokens": 4e-06, + "litellm_provider": "xai", + "max_input_tokens": 500000, + "max_output_tokens": 500000, + "max_tokens": 500000, + "mode": "chat", + "output_cost_per_token": 6e-06, + "output_cost_per_token_above_200k_tokens": 1.2e-05, + "source": "https://docs.x.ai/docs/models", + "supports_function_calling": true, + "supports_prompt_caching": true, + "supports_reasoning": true, + "supports_response_schema": true, + "supports_tool_choice": true, + "supports_vision": true, + "supports_web_search": true + }, "xai/grok-beta": { "input_cost_per_token": 5e-06, "litellm_provider": "xai", From 9d745486d0b96d55e4a2809ea2cdea9465ddca2a Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:48:03 -0700 Subject: [PATCH 017/365] fix(rerank): log optional_rerank_params at debug to stop leaking request content (#32533) * fix(rerank): log optional_rerank_params at debug not info to avoid leaking request content * test(rerank): exercise sync rerank path so coverage counts the log line --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/rerank_api/main.py | 2 +- tests/test_litellm/rerank_api/__init__.py | 0 tests/test_litellm/rerank_api/test_main.py | 67 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/rerank_api/__init__.py create mode 100644 tests/test_litellm/rerank_api/test_main.py diff --git a/litellm/rerank_api/main.py b/litellm/rerank_api/main.py index 9320c7fae8a..b6ebf5589b7 100644 --- a/litellm/rerank_api/main.py +++ b/litellm/rerank_api/main.py @@ -158,7 +158,7 @@ def rerank( instruction=instruction, non_default_params=kwargs, ) - verbose_logger.info(f"optional_rerank_params: {optional_rerank_params}") + verbose_logger.debug(f"optional_rerank_params: {optional_rerank_params}") if isinstance(optional_params.timeout, str): optional_params.timeout = float(optional_params.timeout) diff --git a/tests/test_litellm/rerank_api/__init__.py b/tests/test_litellm/rerank_api/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/test_litellm/rerank_api/test_main.py b/tests/test_litellm/rerank_api/test_main.py new file mode 100644 index 00000000000..46d1461da50 --- /dev/null +++ b/tests/test_litellm/rerank_api/test_main.py @@ -0,0 +1,67 @@ +import logging +import os +import sys +from unittest.mock import MagicMock, patch + +sys.path.insert(0, os.path.abspath("../../..")) + +import litellm + +MARKER_QUERY = "MARKER_QUERY_do_not_log_at_info" +MARKER_DOC = "MARKER_DOC_sensitive_customer_text" + + +def _mock_cohere_response() -> MagicMock: + mock_response = MagicMock() + + def return_val(): + return { + "id": "cmpl-mockid", + "results": [{"index": 0, "relevance_score": 0.95}], + "meta": { + "api_version": {"version": "1.0"}, + "billed_units": {"search_units": 1}, + }, + } + + mock_response.json = return_val + mock_response.headers = {"key": "value"} + mock_response.status_code = 200 + return mock_response + + +def test_rerank_does_not_log_request_content_at_info(caplog): + """Regression for #32525: rerank must not emit query/documents to logs at INFO. + + The mapped ``optional_rerank_params`` (which always contains ``query`` and + ``documents``) bypasses ``turn_off_message_logging`` / ``redact_messages``, + so logging it at INFO leaks raw request content into stdout and any log sink. + """ + litellm.cohere_key = "test_api_key" + caplog.set_level(logging.DEBUG, logger="LiteLLM") + + with patch( + "litellm.llms.custom_httpx.http_handler.HTTPHandler.post", + return_value=_mock_cohere_response(), + ): + litellm.rerank( + model="cohere/rerank-english-v3.0", + query=MARKER_QUERY, + documents=[MARKER_DOC, "unrelated"], + top_n=2, + ) + + litellm_records = [r for r in caplog.records if r.name == "LiteLLM"] + + info_or_above = [ + r.getMessage() + for r in litellm_records + if r.levelno >= logging.INFO and (MARKER_QUERY in r.getMessage() or MARKER_DOC in r.getMessage()) + ] + assert not info_or_above, f"rerank leaked request content at INFO+: {info_or_above}" + + optional_params_logs = [r for r in litellm_records if "optional_rerank_params" in r.getMessage()] + assert optional_params_logs, "expected the optional_rerank_params line to be logged" + assert all( + r.levelno == logging.DEBUG for r in optional_params_logs + ), "optional_rerank_params must be logged at DEBUG, not INFO" From 637352735f2053e9326cf9c5c379548e2d00d7ce Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Jul 2026 18:35:05 -0700 Subject: [PATCH 018/365] fix(proxy): resolve team org from team_id so org admins can update team budgets An org admin updating a team budget from the Hub UI was rejected with 401, because the route gate only recognizes an org admin when the request body carries organization_id while the UI sends team_id. For /team/update, resolve the target team's organization_id from team_id before the gate runs, so an org admin of the team's own org clears the org-scoped branch without the client passing organization_id. Team admins and cross-org admins stay denied at the gate, and callers that already pass organization_id are unaffected, so the existing /team/update authorization matrix is unchanged --- litellm/proxy/auth/auth_checks.py | 25 +++- .../proxy/auth/auth_checks_organization.py | 32 ++++- .../management/test_team_update.py | 24 ++-- .../proxy/auth/test_route_checks.py | 129 ++++++++++++++++++ 4 files changed, 199 insertions(+), 11 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index e7fee8d6eb2..cd8103abf5e 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -98,7 +98,10 @@ from litellm.repositories.user_repository import UserRepository from litellm.router import Router from litellm.utils import get_utc_datetime -from .auth_checks_organization import organization_role_based_access_check +from .auth_checks_organization import ( + add_team_org_context_to_request_body, + organization_role_based_access_check, +) from .auth_utils import get_model_from_request if TYPE_CHECKING: @@ -707,10 +710,28 @@ async def common_checks( # 10 [OPTIONAL] Organization RBAC checks organization_role_based_access_check(user_object=user_object, route=route, request_body=request_body) + async def _fetch_team_org_id(team_id: str) -> Optional[str]: + try: + team = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + proxy_logging_obj=proxy_logging_obj, + ) + except HTTPException: + return None + return team.organization_id + + request_body_for_route_check = await add_team_org_context_to_request_body( + route=route, + request_body=request_body, + fetch_team_org_id=_fetch_team_org_id, + ) + _is_route_allowed = _is_api_route_allowed( route=route, request=request, - request_data=request_body, + request_data=request_body_for_route_check, valid_token=valid_token, user_obj=user_object, ) diff --git a/litellm/proxy/auth/auth_checks_organization.py b/litellm/proxy/auth/auth_checks_organization.py index 44c1d158cbe..b4caff9b8ee 100644 --- a/litellm/proxy/auth/auth_checks_organization.py +++ b/litellm/proxy/auth/auth_checks_organization.py @@ -2,7 +2,7 @@ Auth Checks for Organizations """ -from typing import Dict, List, Optional, Tuple +from typing import Awaitable, Callable, Dict, List, Optional, Tuple from fastapi import status @@ -170,3 +170,33 @@ def _user_is_org_admin( # User must be admin of ALL requested orgs, not just any one return all(org_id in admin_org_ids for org_id in candidate_org_ids) + + +TEAM_ORG_CONTEXT_ROUTES = frozenset({"/team/update"}) + + +async def add_team_org_context_to_request_body( + route: str, + request_body: dict, + fetch_team_org_id: Callable[[str], Awaitable[Optional[str]]], +) -> dict: + """ + Return a copy of request_body with organization_id resolved from the target + team when the route identifies the team by team_id and the caller did not + pass organization_id. This lets an org admin of the team's own org reach the + org-scoped branch of the route gate (which keys off organization_id) without + the client having to send it. Returns request_body unchanged when it does + not apply, so callers that already pass organization_id and non-team routes + are untouched. + """ + if route not in TEAM_ORG_CONTEXT_ROUTES: + return request_body + if request_body.get("organization_id"): + return request_body + team_id = request_body.get("team_id") + if not isinstance(team_id, str) or not team_id: + return request_body + org_id = await fetch_team_org_id(team_id) + if not org_id: + return request_body + return {**request_body, "organization_id": org_id} diff --git a/tests/proxy_behavior/management/test_team_update.py b/tests/proxy_behavior/management/test_team_update.py index 9cb2b0fecda..23ea89fa74d 100644 --- a/tests/proxy_behavior/management/test_team_update.py +++ b/tests/proxy_behavior/management/test_team_update.py @@ -105,27 +105,35 @@ async def test_team_update_authz_matrix( assert row.team_alias != MARKER_ALIAS, "denied but team mutated" -async def test_team_update_requires_proxy_admin_without_org_context( +async def test_team_update_org_admin_resolved_from_team_without_org_context( proxy_client, prisma, scratch, world ): - """With no organization_id in the body the route gate has no org context - and falls back to proxy-admin-only: an org admin of the team's own org - is 401, PROXY_ADMIN is 200.""" + """With no organization_id in the body the route gate resolves the target + team's org from team_id, so an org admin of the team's own org is allowed + (200), same as PROXY_ADMIN. A team admin of that same team stays denied + (401): the resolution grants org admins access, not team admins.""" await _seed_target(prisma, world, "alpha", scratch.prefix) - denied = await proxy_client.post( + allowed_org_admin = await proxy_client.post( "/team/update", headers={"Authorization": f"Bearer {world.keys[Actor.ORG_ADMIN].cleartext}"}, json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS}, ) - assert denied.status_code == 401, denied.text + assert allowed_org_admin.status_code == 200, allowed_org_admin.text - allowed = await proxy_client.post( + allowed_proxy_admin = await proxy_client.post( "/team/update", headers={"Authorization": f"Bearer {world.keys[Actor.PROXY_ADMIN].cleartext}"}, json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS}, ) - assert allowed.status_code == 200, allowed.text + assert allowed_proxy_admin.status_code == 200, allowed_proxy_admin.text + + denied_team_admin = await proxy_client.post( + "/team/update", + headers={"Authorization": f"Bearer {world.keys[Actor.TEAM_ADMIN].cleartext}"}, + json={"team_id": scratch.prefix, "team_alias": MARKER_ALIAS}, + ) + assert denied_team_admin.status_code == 401, denied_team_admin.text # Relocation gate — moving a team to a different org. The scratch team starts diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index d623149ff6a..204e6a671e3 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -2595,6 +2595,135 @@ def test_org_admin_of_multiple_orgs_can_operate_on_both(): assert _user_is_org_admin({"organizations": ["org-A", "org-B"]}, user_obj) is True +# ── LIT-4221: /team/update org-context resolution from team_id ──────────────── +from litellm.proxy.auth.auth_checks_organization import ( + add_team_org_context_to_request_body, +) + + +@pytest.mark.asyncio +async def test_add_team_org_context_resolves_org_from_team(): + """For /team/update with only team_id, the target team's org is resolved and + injected so the org-admin route gate can see it. This is what lets an org + admin update a team budget from the Hub UI, which sends team_id, not + organization_id (LIT-4221).""" + + async def fetch(team_id: str): + assert team_id == "team-1" + return "org-1" + + out = await add_team_org_context_to_request_body( + route="/team/update", + request_body={"team_id": "team-1", "max_budget": 42}, + fetch_team_org_id=fetch, + ) + assert out == {"team_id": "team-1", "max_budget": 42, "organization_id": "org-1"} + + +@pytest.mark.asyncio +async def test_add_team_org_context_noop_when_org_id_already_present(): + """If the caller already passed organization_id, no lookup happens and the + body is returned unchanged.""" + + async def fetch(team_id: str): + raise AssertionError("must not resolve when organization_id is present") + + body = {"team_id": "team-1", "organization_id": "org-explicit"} + out = await add_team_org_context_to_request_body( + route="/team/update", request_body=body, fetch_team_org_id=fetch + ) + assert out == body + + +@pytest.mark.asyncio +async def test_add_team_org_context_noop_for_other_routes(): + """Only /team/update opts into org resolution; other routes are untouched.""" + + async def fetch(team_id: str): + raise AssertionError("must not resolve for a non-opted-in route") + + body = {"team_id": "team-1"} + out = await add_team_org_context_to_request_body( + route="/team/delete", request_body=body, fetch_team_org_id=fetch + ) + assert out == body + + +@pytest.mark.asyncio +async def test_add_team_org_context_noop_when_team_has_no_org(): + """A standalone team (no org) resolves to None, so nothing is injected and + the org-admin branch stays unreachable (no blanket access).""" + + async def fetch(team_id: str): + return None + + body = {"team_id": "team-1"} + out = await add_team_org_context_to_request_body( + route="/team/update", request_body=body, fetch_team_org_id=fetch + ) + assert out == body + + +def test_team_update_gate_allows_org_admin_with_resolved_org(): + """Post-resolution (organization_id present), an org admin of that org clears + the gate for /team/update.""" + user_obj = _make_org_admin_user("org-1") + valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "POST" + request.query_params = {} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/team/update", + request=request, + valid_token=valid_token, + request_data={"team_id": "team-1", "organization_id": "org-1"}, + ) + + +def test_team_update_gate_rejects_without_org_context(): + """Without organization_id (i.e. resolution found no org, or a non-org-admin), + the gate still rejects /team/update — the fix adds no blanket allow. Guards + against re-widening the route (e.g. dropping it into self_managed_routes).""" + user_obj = _make_org_admin_user("org-1") + valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "POST" + request.query_params = {} + + with pytest.raises(Exception): + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/team/update", + request=request, + valid_token=valid_token, + request_data={"team_id": "team-1", "max_budget": 42}, + ) + + +def test_team_update_gate_rejects_cross_org_admin_with_resolved_org(): + """Even after the target team's org is resolved, an org admin of a DIFFERENT + org is rejected at the gate (no cross-org escalation).""" + user_obj = _make_org_admin_user("org-1") + valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "POST" + request.query_params = {} + + with pytest.raises(Exception): + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/team/update", + request=request, + valid_token=valid_token, + request_data={"team_id": "team-1", "organization_id": "org-2"}, + ) + + @pytest.mark.asyncio async def test_initialize_pass_through_registers_wildcard_for_auth_subpath(): """ From febb27695b72e1aea5e9cfa4d3173291863361dc Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Wed, 8 Jul 2026 19:47:05 -0700 Subject: [PATCH 019/365] refactor(ui): point invitation links at the dedicated /onboarding route (#30857) * refactor(ui): point invitation links at the dedicated /onboarding route Invitation and reset-password links were built as /ui?invitation_id=..., which lands on the dashboard index and renders the onboarding form inline. They now point at the standalone /ui/onboarding route, so the index no longer has to special-case invitations. Old links keep working unchanged; the index still renders onboarding inline for ?invitation_id until the migration closeout removes that branch. Updates the three generators (the enterprise email builder, bulk user create, and the invitation/reset-password modal) and extracts the modal's URL building into a pure, unit-tested buildOnboardingUrl Refs LIT-3687 * refactor(ui): guard buildOnboardingUrl against a missing invitation id Return "" instead of emitting an invitation_id=undefined link when the id is not yet available, matching the existing empty-baseUrl guard. Placed after the SSO branch so the SSO link, which does not use the id, is unaffected Refs LIT-3687 --- .../send_emails/base_email.py | 4 +- .../send_emails/test_base_email.py | 27 +++++-- .../components/bulk_create_users_button.tsx | 2 +- .../src/components/onboarding_link.test.tsx | 70 +++++++++++++++++++ .../src/components/onboarding_link.tsx | 51 +++++++++----- 5 files changed, 126 insertions(+), 28 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/onboarding_link.test.tsx diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py index be80a12c80a..e7898cac565 100644 --- a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py +++ b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/base_email.py @@ -919,9 +919,9 @@ class BaseEmailLogger(CustomLogger): """ Construct invitation link for the user - # http://localhost:4000/ui?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b + # http://localhost:4000/ui/onboarding?invitation_id=7a096b3a-37c6-440f-9dd1-ba22e8043f6b """ - return f"{base_url}/ui?invitation_id={invitation_id}" + return f"{base_url}/ui/onboarding?invitation_id={invitation_id}" async def send_email( self, diff --git a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py index c1ccc454305..61303340570 100644 --- a/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py +++ b/tests/test_litellm/enterprise/enterprise_callbacks/send_emails/test_base_email.py @@ -348,7 +348,9 @@ async def test_get_invitation_link(base_email_logger): result = await base_email_logger._get_invitation_link( user_id="test-user", base_url="http://test.com" ) - assert result == "http://test.com/ui?invitation_id=test-invitation-id" + assert ( + result == "http://test.com/ui/onboarding?invitation_id=test-invitation-id" + ) # Test with None user_id result = await base_email_logger._get_invitation_link( @@ -372,7 +374,7 @@ def test_construct_invitation_link(base_email_logger): result = base_email_logger._construct_invitation_link( invitation_id="test-id-123", base_url="http://test.com" ) - assert result == "http://test.com/ui?invitation_id=test-id-123" + assert result == "http://test.com/ui/onboarding?invitation_id=test-id-123" @pytest.mark.asyncio @@ -408,7 +410,10 @@ async def test_get_invitation_link_creates_new_when_none_exist(base_email_logger assert call_args["user_api_key_dict"].user_id == "test-user" # Verify the returned link uses the new invitation ID - assert result == "http://test.com/ui?invitation_id=new-invitation-id" + assert ( + result + == "http://test.com/ui/onboarding?invitation_id=new-invitation-id" + ) @pytest.mark.asyncio @@ -439,7 +444,10 @@ async def test_get_invitation_link_uses_existing_when_available(base_email_logge mock_create_invitation.assert_not_called() # Verify the returned link uses the existing invitation ID - assert result == "http://test.com/ui?invitation_id=existing-invitation-id" + assert ( + result + == "http://test.com/ui/onboarding?invitation_id=existing-invitation-id" + ) @pytest.mark.asyncio @@ -475,7 +483,10 @@ async def test_get_invitation_link_creates_new_when_list_is_none(base_email_logg assert call_args["user_api_key_dict"].user_id == "test-user" # Verify the returned link uses the new invitation ID - assert result == "http://test.com/ui?invitation_id=new-invitation-from-none" + assert ( + result + == "http://test.com/ui/onboarding?invitation_id=new-invitation-from-none" + ) @pytest.mark.asyncio @@ -495,7 +506,7 @@ async def test_get_email_params_user_invitation( with mock.patch.object( base_email_logger, "_get_invitation_link", - return_value="http://test.com/ui?invitation_id=test-id", + return_value="http://test.com/ui/onboarding?invitation_id=test-id", ): # Test with user invitation event result = await base_email_logger._get_email_params( @@ -509,7 +520,9 @@ async def test_get_email_params_user_invitation( == "https://litellm-listing.s3.amazonaws.com/litellm_logo.png" ) assert result.support_contact == "support@berri.ai" - assert result.base_url == "http://test.com/ui?invitation_id=test-id" + assert ( + result.base_url == "http://test.com/ui/onboarding?invitation_id=test-id" + ) assert result.recipient_email == "test@example.com" diff --git a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx index 17690c988dc..23b69722546 100644 --- a/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx +++ b/ui/litellm-dashboard/src/components/bulk_create_users_button.tsx @@ -361,7 +361,7 @@ const BulkCreateUsersButton: React.FC = ({ if (!uiSettings?.SSO_ENABLED) { // Regular invitation flow const invitationData = await invitationCreateCall(accessToken, user_id); - const invitationUrl = new URL(`/ui?invitation_id=${invitationData.id}`, baseUrl).toString(); + const invitationUrl = new URL(`/ui/onboarding?invitation_id=${invitationData.id}`, baseUrl).toString(); setParsedData((current) => current.map((u, i) => diff --git a/ui/litellm-dashboard/src/components/onboarding_link.test.tsx b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx new file mode 100644 index 00000000000..039d5e250da --- /dev/null +++ b/ui/litellm-dashboard/src/components/onboarding_link.test.tsx @@ -0,0 +1,70 @@ +import { describe, it, expect } from "vitest"; +import { buildOnboardingUrl } from "./onboarding_link"; + +describe("buildOnboardingUrl", () => { + it("points the invitation link at the dedicated /ui/onboarding route", () => { + expect( + buildOnboardingUrl({ + baseUrl: "http://localhost:4000/", + invitationId: "inv-123", + hasUserSetupSso: false, + resetPassword: false, + }), + ).toBe("http://localhost:4000/ui/onboarding?invitation_id=inv-123"); + }); + + it("preserves a server_root_path prefix before /ui/onboarding", () => { + expect( + buildOnboardingUrl({ + baseUrl: "https://proxy.example.com/litellm", + invitationId: "inv-123", + hasUserSetupSso: false, + resetPassword: false, + }), + ).toBe("https://proxy.example.com/litellm/ui/onboarding?invitation_id=inv-123"); + }); + + it("appends action=reset_password for the reset-password flow", () => { + expect( + buildOnboardingUrl({ + baseUrl: "http://localhost:4000/", + invitationId: "inv-123", + hasUserSetupSso: false, + resetPassword: true, + }), + ).toBe("http://localhost:4000/ui/onboarding?invitation_id=inv-123&action=reset_password"); + }); + + it("sends SSO users to the dashboard root, not the onboarding form", () => { + expect( + buildOnboardingUrl({ + baseUrl: "http://localhost:4000/", + invitationId: "inv-123", + hasUserSetupSso: true, + resetPassword: false, + }), + ).toBe("http://localhost:4000/ui"); + }); + + it("returns an empty string when no base URL is known yet", () => { + expect( + buildOnboardingUrl({ + baseUrl: "", + invitationId: "inv-123", + hasUserSetupSso: false, + resetPassword: false, + }), + ).toBe(""); + }); + + it("returns an empty string rather than an invitation_id=undefined link when the id is not ready", () => { + expect( + buildOnboardingUrl({ + baseUrl: "http://localhost:4000/", + invitationId: undefined, + hasUserSetupSso: false, + resetPassword: false, + }), + ).toBe(""); + }); +}); diff --git a/ui/litellm-dashboard/src/components/onboarding_link.tsx b/ui/litellm-dashboard/src/components/onboarding_link.tsx index 7eb337a970a..0c27287a9e4 100644 --- a/ui/litellm-dashboard/src/components/onboarding_link.tsx +++ b/ui/litellm-dashboard/src/components/onboarding_link.tsx @@ -25,6 +25,32 @@ interface OnboardingProps { modalType?: "invitation" | "resetPassword"; } +export function buildOnboardingUrl({ + baseUrl, + invitationId, + hasUserSetupSso, + resetPassword, +}: { + baseUrl: string; + invitationId: string | undefined; + hasUserSetupSso: boolean; + resetPassword: boolean; +}): string { + if (!baseUrl) { + return ""; + } + const basePath = new URL(baseUrl).pathname; + const uiPath = basePath && basePath !== "/" ? `${basePath}/ui` : "ui"; + if (hasUserSetupSso) { + return new URL(uiPath, baseUrl).toString(); + } + if (!invitationId) { + return ""; + } + const action = resetPassword ? "&action=reset_password" : ""; + return new URL(`${uiPath}/onboarding?invitation_id=${invitationId}${action}`, baseUrl).toString(); +} + export default function OnboardingModal({ isInvitationLinkModalVisible, setIsInvitationLinkModalVisible, @@ -41,24 +67,13 @@ export default function OnboardingModal({ setIsInvitationLinkModalVisible(false); }; - const getInvitationUrl = () => { - if (!baseUrl) { - return ""; - } - const baseUrlObj = new URL(baseUrl); - const basePath = baseUrlObj.pathname; // This will be "/litellm" or "" - const path = basePath && basePath !== "/" ? `${basePath}/ui` : "ui"; - // Get the path from the base URL - if (invitationLinkData?.has_user_setup_sso) { - return new URL(path, baseUrl).toString(); - } - let urlPath = `${path}?invitation_id=${invitationLinkData?.id}`; - if (modalType === "resetPassword") { - urlPath += "&action=reset_password"; - } - const url = new URL(urlPath, baseUrl).toString(); - return url; - }; + const getInvitationUrl = () => + buildOnboardingUrl({ + baseUrl, + invitationId: invitationLinkData?.id, + hasUserSetupSso: invitationLinkData?.has_user_setup_sso ?? false, + resetPassword: modalType === "resetPassword", + }); return ( Date: Wed, 8 Jul 2026 20:54:40 -0700 Subject: [PATCH 020/365] fix(bedrock): honor cache_control ttl on message-level cachePoint blocks (#32551) Bedrock Converse supports cachePoint ttl (1h GA for Claude 4.5+), and _get_cache_point_block maps cache_control.ttl -> cachePoint.ttl, but the model parameter its allow-list gate requires was only threaded through the system-message path. Every message-level path either called _get_cache_point_block without model= (8 call sites in _bedrock_converse_messages_pt / _pt_async) or hardcoded CachePointBlock(type="default") (tool-result blocks and _convert_to_bedrock_tool_call_invoke), so a requested 1h ttl silently degraded to the 5-minute default - exactly on the conversation-tail breakpoint that long-running agents need to survive tool calls longer than 5 minutes. - pass model= at the 8 _get_cache_point_block call sites - tool-result blocks: capture the cache_control dict (was a boolean) and route through _get_cache_point_block so ttl survives - _convert_to_bedrock_tool_call_invoke: accept optional model and route per-tool-call cache_control through _get_cache_point_block Completes the ttl support added for system messages (#19848, #20326): message-level cache_control now behaves identically. Note: message-level cache_control on a content-less assistant message emits no cachePoint at all today; that pre-existing gap is orthogonal to ttl and left out of scope (per-tool-call placement covers it). Co-authored-by: Arash --- .../prompt_templates/factory.py | 56 +++++++---- .../chat/test_converse_transformation.py | 96 +++++++++++++++++++ 2 files changed, 135 insertions(+), 17 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 06abb591717..8bb0e12905e 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -3626,6 +3626,7 @@ class BedrockImageProcessor: def _convert_to_bedrock_tool_call_invoke( tool_calls: list, + model: Optional[str] = None, ) -> List[BedrockContentBlock]: """ OpenAI tool invokes: @@ -3701,7 +3702,13 @@ def _convert_to_bedrock_tool_call_invoke( # cache_control applies to the whole original # tool call; attach after the last split block. if tool.get("cache_control", None) is not None: - _parts_list.append(BedrockContentBlock(cachePoint=CachePointBlock(type="default"))) + _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + {"cache_control": tool["cache_control"]}, + block_type="content_block", + model=model, + ) + if _cache_point_block is not None: + _parts_list.append(_cache_point_block) continue # Fallback: no objects extracted — use empty dict. arguments_dict = {} @@ -3712,8 +3719,13 @@ def _convert_to_bedrock_tool_call_invoke( # Check for cache_control and add a separate cachePoint block if tool.get("cache_control", None) is not None: - cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default")) - _parts_list.append(cache_point_block) + cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + {"cache_control": tool["cache_control"]}, + block_type="content_block", + model=model, + ) + if cache_point_block is not None: + _parts_list.append(cache_point_block) return _parts_list except Exception as e: raise Exception( @@ -4417,22 +4429,27 @@ class BedrockConverseMessagesProcessor: tool_content.append(tool_call_result) # Check if we need to add a separate cachePoint block - has_cache_control = False + tool_msg_cache_control = None # Check for message-level cache_control if current_message.get("cache_control", None) is not None: - has_cache_control = True + tool_msg_cache_control = current_message["cache_control"] # Check for content-level cache_control in list content elif isinstance(current_message.get("content"), list): for content_element in current_message["content"]: if isinstance(content_element, dict) and content_element.get("cache_control", None) is not None: - has_cache_control = True + tool_msg_cache_control = content_element["cache_control"] break # Add a separate cachePoint block if cache_control is present - if has_cache_control: - cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default")) - tool_content.append(cache_point_block) + if tool_msg_cache_control is not None: + cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + {"cache_control": tool_msg_cache_control}, + block_type="content_block", + model=model, + ) + if cache_point_block is not None: + tool_content.append(cache_point_block) msg_i += 1 # Deduplicate toolResult blocks with the same toolUseId @@ -4529,7 +4546,7 @@ class BedrockConverseMessagesProcessor: _tool_calls = assistant_message_block.get("tool_calls", []) if _tool_calls: - assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls)) + assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls, model=model)) msg_i += 1 @@ -4789,22 +4806,27 @@ def _bedrock_converse_messages_pt( tool_content.append(tool_call_result) # Check if we need to add a separate cachePoint block - has_cache_control = False + tool_msg_cache_control = None # Check for message-level cache_control if current_message.get("cache_control", None) is not None: - has_cache_control = True + tool_msg_cache_control = current_message["cache_control"] # Check for content-level cache_control in list content elif isinstance(current_message.get("content"), list): for content_element in current_message["content"]: if isinstance(content_element, dict) and content_element.get("cache_control", None) is not None: - has_cache_control = True + tool_msg_cache_control = content_element["cache_control"] break # Add a separate cachePoint block if cache_control is present - if has_cache_control: - cache_point_block = BedrockContentBlock(cachePoint=CachePointBlock(type="default")) - tool_content.append(cache_point_block) + if tool_msg_cache_control is not None: + cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + {"cache_control": tool_msg_cache_control}, + block_type="content_block", + model=model, + ) + if cache_point_block is not None: + tool_content.append(cache_point_block) msg_i += 1 # Deduplicate toolResult blocks with the same toolUseId @@ -4902,7 +4924,7 @@ def _bedrock_converse_messages_pt( assistant_content.append(_cache_point_block) _tool_calls = assistant_message_block.get("tool_calls", []) if _tool_calls: - assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls)) + assistant_content.extend(_convert_to_bedrock_tool_call_invoke(_tool_calls, model=model)) msg_i += 1 diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index fe060e2e40d..9f2a4168dec 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -5671,3 +5671,99 @@ async def test_grounding_source_and_query_rendered_as_text(): user_content = result[0]["content"] assert {"text": "Tokyo is the capital of Japan."} in user_content assert {"text": "What is the capital of Japan?"} in user_content + + +def _agentic_messages_with_ttl(ttl_target: str): + """A tool-loop conversation with `ttl: 1h` cache_control at `ttl_target`: + 'user', 'tool_call' (per-tool-call, on the assistant's tool call), or + 'tool' (message-level, on the tool result - where + `cache_control_injection_points` with `index: -1` lands mid-loop). + + Message-level cache_control on a content-less assistant message emits no + cachePoint at all today (a separate gap, orthogonal to ttl); per-tool-call + placement covers that message, so it's excluded from the params below.""" + user: dict = {"role": "user", "content": "optimize this kernel " * 60} + assistant: dict = { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call_1", + "type": "function", + "function": {"name": "evaluate", "arguments": "{}"}, + } + ], + } + tool: dict = {"role": "tool", "tool_call_id": "call_1", "content": "score: 42"} + ttl_cc = {"type": "ephemeral", "ttl": "1h"} + if ttl_target == "user": + user["cache_control"] = ttl_cc + elif ttl_target == "tool_call": + assistant["tool_calls"][0]["cache_control"] = ttl_cc + elif ttl_target == "tool": + tool["cache_control"] = ttl_cc + return [user, assistant, tool] + + +def _collect_cache_points(result): + return [ + block["cachePoint"] + for message in result + for block in message.get("content") or [] + if "cachePoint" in block + ] + + +@pytest.mark.parametrize("ttl_target", ["user", "tool_call", "tool"]) +@pytest.mark.asyncio +async def test_message_level_cache_control_honors_ttl_for_supported_model( + ttl_target, +): + """Message- and tool-call-level cache_control must carry `ttl` onto the + emitted cachePoint for models that support extended caching, mirroring the + system-message path. Regression test for the gap left by the system-only + fix: the message paths called `_get_cache_point_block` without `model` (or + hardcoded `{"type": "default"}`), silently downgrading 1h to 5m.""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + BedrockConverseMessagesProcessor, + _bedrock_converse_messages_pt, + ) + + messages = _agentic_messages_with_ttl(ttl_target) + + result = _bedrock_converse_messages_pt( + messages=messages, + model="global.anthropic.claude-opus-4-7", + llm_provider="bedrock_converse", + ) + async_result = ( + await BedrockConverseMessagesProcessor._bedrock_converse_messages_pt_async( + messages=messages, + model="global.anthropic.claude-opus-4-7", + llm_provider="bedrock_converse", + ) + ) + assert result == async_result + + cache_points = _collect_cache_points(result) + assert len(cache_points) == 1 + assert cache_points[0].get("ttl") == "1h" + + +@pytest.mark.parametrize("ttl_target", ["user", "tool_call", "tool"]) +def test_message_level_cache_control_drops_ttl_for_unsupported_model(ttl_target): + """Models outside the extended-caching allow-list must keep emitting the + plain `{"type": "default"}` cachePoint (Bedrock rejects `ttl` for them).""" + from litellm.litellm_core_utils.prompt_templates.factory import ( + _bedrock_converse_messages_pt, + ) + + result = _bedrock_converse_messages_pt( + messages=_agentic_messages_with_ttl(ttl_target), + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + llm_provider="bedrock_converse", + ) + + cache_points = _collect_cache_points(result) + assert len(cache_points) == 1 + assert "ttl" not in cache_points[0] From b4d63c1c9fd85eed13b1c7f47f311005c94f187b Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Wed, 8 Jul 2026 22:36:13 -0700 Subject: [PATCH 021/365] ci: drop regex file guard from OSS daily guardrails The in-workflow regex list was hard to maintain and, because it runs on pull_request, could be modified by the same PR it inspects. Path gating for the OSS daily branches now lives in repository branch protection settings, so this workflow keeps only the OSS-safe checks: the hardcoded-secret test and ruff --- .github/workflows/oss_daily_guardrails.yml | 59 ---------------------- 1 file changed, 59 deletions(-) diff --git a/.github/workflows/oss_daily_guardrails.yml b/.github/workflows/oss_daily_guardrails.yml index 173b7cd4e41..950c51c9b60 100644 --- a/.github/workflows/oss_daily_guardrails.yml +++ b/.github/workflows/oss_daily_guardrails.yml @@ -17,65 +17,6 @@ concurrency: cancel-in-progress: true jobs: - sensitive-file-guard: - name: Block sensitive OSS daily changes - if: startsWith(github.ref_name, 'litellm_oss_daily_20') || startsWith(github.head_ref, 'litellm_oss_daily_20') || startsWith(github.base_ref, 'litellm_oss_daily_20') - runs-on: ubuntu-latest - timeout-minutes: 5 - - steps: - - name: Checkout repository - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - with: - fetch-depth: 0 - ref: ${{ github.event.pull_request.head.sha || github.sha }} - persist-credentials: false - - - name: Check for sensitive file changes - env: - EVENT_NAME: ${{ github.event_name }} - BASE_REF_NAME: ${{ github.base_ref }} - HEAD_REF_NAME: ${{ github.head_ref }} - run: | - set -euo pipefail - - if [ "${EVENT_NAME}" = "pull_request" ] && echo "${HEAD_REF_NAME}" | grep -Eq '^litellm_oss_daily_20[0-9]{2}_[0-9]{2}_[0-9]{2}$'; then - # Final daily OSS branch PR into staging: review only the OSS delta - # accumulated on top of main, not unrelated main/staging drift. - BASE_REF="origin/main" - git fetch origin main - elif [ "${EVENT_NAME}" = "pull_request" ]; then - # PR targeting the daily OSS branch: review the incoming PR delta. - BASE_REF="origin/${BASE_REF_NAME}" - git fetch origin "${BASE_REF_NAME}" - else - # Push to the daily OSS branch: review the accumulated OSS delta. - BASE_REF="origin/main" - git fetch origin main - fi - - CHANGED_FILES="$(git diff --name-only "${BASE_REF}...HEAD")" - - if [ -z "${CHANGED_FILES}" ]; then - echo "No changed files detected." - exit 0 - fi - - echo "Changed files:" - echo "${CHANGED_FILES}" - - BLOCKED_FILES="$( - echo "${CHANGED_FILES}" | grep -E '(^\.github/workflows/|^\.github/actions/|^\.circleci/|^\.cursor/|^Dockerfile$|(^|/)Dockerfile$|^Makefile$|(^|/)Makefile$|(^|/)pyproject\.toml$|(^|/)uv\.lock$|(^|/)poetry\.lock$|(^|/)requirements[^/]*\.txt$|(^|/)package\.json$|(^|/)package-lock\.json$|(^|/)pnpm-lock\.yaml$|(^|/)yarn\.lock$|(^|/)tsconfig[^/]*\.json$|(^|/)(ruff|mypy|pytest|eslint|prettier|vitest|next|vite|jest)\.config\.)' || true - )" - - if [ -n "${BLOCKED_FILES}" ]; then - echo "::error::OSS daily branch contains sensitive workflow, config, dependency, or lockfile changes. Split these into a separate internal/security-reviewed PR." - echo "${BLOCKED_FILES}" - exit 1 - fi - - echo "No sensitive OSS daily file changes detected." - oss-safe-checks: name: Run OSS daily safe checks if: startsWith(github.ref_name, 'litellm_oss_daily_20') || startsWith(github.head_ref, 'litellm_oss_daily_20') || startsWith(github.base_ref, 'litellm_oss_daily_20') From 9813c4bf41b5b9b3af56c8f1dd4aa748bd98713b Mon Sep 17 00:00:00 2001 From: Thibault Serot Date: Thu, 9 Jul 2026 11:58:29 +1000 Subject: [PATCH 022/365] feat(ui): add session id filter to request logs --- .../spend_management_endpoints.py | 8 ++ .../test_spend_management_endpoints.py | 84 +++++++++++++++++++ .../src/components/networking.tsx | 1 + .../components/view_logs/filter_options.ts | 5 ++ .../view_logs/log_filter_logic.test.tsx | 1 + .../components/view_logs/log_filter_logic.tsx | 4 + ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 + 7 files changed, 107 insertions(+) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 8f530e3b8ce..cf7bedfdc71 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1622,6 +1622,10 @@ async def ui_view_spend_logs( default=None, description="request_id to get spend logs for specific request_id", ), + session_id: str | None = fastapi.Query( + default=None, + description="Filter spend logs by session_id", + ), team_id: str | None = fastapi.Query( default=None, description="Filter spend logs by team_id", @@ -1772,6 +1776,9 @@ async def ui_view_spend_logs( if request_id is not None: where_conditions["request_id"] = request_id + if session_id is not None: + where_conditions["session_id"] = session_id + if model is not None: where_conditions["model"] = model @@ -1887,6 +1894,7 @@ async def ui_view_spend_logs( ('"user"', "user"), ("api_key", "api_key"), ("request_id", "request_id"), + ("session_id", "session_id"), ("model", "model"), ("model_id", "model_id"), ("model_group", "model_group"), diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 1e9818534c9..69ffc2275ff 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -1,4 +1,5 @@ import asyncio +import collections import datetime import json import os @@ -85,6 +86,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params): '"user"': "user", "api_key": "api_key", "request_id": "request_id", + "session_id": "session_id", "model": "model", "model_id": "model_id", "model_group": "model_group", @@ -162,7 +164,21 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No async def count(self, *args, **kwargs): return len(filter_fn(kwargs.get("where", {}))) + async def group_by(self, by, where, count): + allowed = set(where["session_id"]["in"]) + tallied = collections.Counter( + log["session_id"] + for log in mock_spend_logs + if log.get("session_id") in allowed + ) + return [ + {"session_id": sid, "_count": {"session_id": n}} + for sid, n in tallied.items() + ] + async def query_raw(self, sql_query, *params): + if "mcp_tool_call_count" in sql_query: + return [] filtered = filter_fn(_reconstruct_ui_where_from_sql(sql_query, params)) total = len(filtered) if "COUNT(*)" in sql_query: @@ -597,6 +613,74 @@ async def test_ui_view_spend_logs_with_user_id(client, monkeypatch): assert data["data"][0]["user"] == "test_user_1" +@pytest.mark.asyncio +async def test_ui_view_spend_logs_with_session_id(client, monkeypatch): + mock_spend_logs = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "sk-test-key", + "user": "test_user_1", + "session_id": "session-abc", + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "sk-test-key", + "user": "test_user_1", + "session_id": "session-abc", + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + }, + { + "id": "log3", + "request_id": "req3", + "api_key": "sk-test-key", + "user": "test_user_1", + "session_id": "session-other", + "spend": 0.02, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + }, + ] + + def filter_by_session(where): + if "session_id" in where: + return [ + log + for log in mock_spend_logs + if log["session_id"] == where["session_id"] + ] + return mock_spend_logs + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_session), + ) + + start_date, end_date = _default_date_range() + + response = client.get( + "/spend/logs/ui", + params={ + "session_id": "session-abc", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["total"] == 2 + assert {log["request_id"] for log in data["data"]} == {"req1", "req2"} + assert all(log["session_id"] == "session-abc" for log in data["data"]) + + # Mock spend logs with distinct values for sorting tests. # req_a: spend=0.10, tokens=500, start/end earliest # req_b: spend=0.05, tokens=200, start/end 2nd diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 403647106d4..5ec2765c621 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1929,6 +1929,7 @@ interface UiSpendLogsParams { api_key?: string; team_id?: string; request_id?: string; + session_id?: string; user_id?: string; end_user?: string; status_filter?: string; diff --git a/ui/litellm-dashboard/src/components/view_logs/filter_options.ts b/ui/litellm-dashboard/src/components/view_logs/filter_options.ts index 52632ea5861..90e27b6144d 100644 --- a/ui/litellm-dashboard/src/components/view_logs/filter_options.ts +++ b/ui/litellm-dashboard/src/components/view_logs/filter_options.ts @@ -63,6 +63,11 @@ export function getLogFilterOptions(accessToken: string): FilterOption[] { label: "Key Hash", isSearchable: false, }, + { + name: FILTER_KEYS.SESSION_ID, + label: "Session ID", + isSearchable: false, + }, { name: "Model", label: "Model", diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index cbe37e0b70f..ef550baea91 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -218,6 +218,7 @@ describe("useLogFilterLogic", () => { { filterKey: "Team ID", paramName: "team_id", value: "team-a" }, { filterKey: "Key Hash", paramName: "api_key", value: "key-x" }, { filterKey: "Request ID", paramName: "request_id", value: "req-xyz" }, + { filterKey: "Session ID", paramName: "session_id", value: "sess-42" }, { filterKey: "User ID", paramName: "user_id", value: "user-123" }, { filterKey: "End User", paramName: "end_user", value: "user-a" }, { filterKey: "Status", paramName: "status_filter", value: "error" }, diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index c45e03905d0..1d699042f05 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -30,6 +30,7 @@ export const FILTER_KEYS = { TEAM_ID: "Team ID", KEY_HASH: "Key Hash", REQUEST_ID: "Request ID", + SESSION_ID: "Session ID", MODEL: "Model", /** Exact match on LiteLLM_SpendLogs.model — use for search tools and public model names. */ PUBLIC_MODEL_OR_SEARCH_TOOL: "Public model / search tool", @@ -49,6 +50,7 @@ const TEXT_FILTER_KEYS: readonly (keyof LogFilterState)[] = [ FILTER_KEYS.KEY_HASH, FILTER_KEYS.ERROR_MESSAGE, FILTER_KEYS.REQUEST_ID, + FILTER_KEYS.SESSION_ID, FILTER_KEYS.USER_ID, FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL, ]; @@ -62,6 +64,7 @@ export const defaultFilters: LogFilterState = { [FILTER_KEYS.TEAM_ID]: "", [FILTER_KEYS.KEY_HASH]: "", [FILTER_KEYS.REQUEST_ID]: "", + [FILTER_KEYS.SESSION_ID]: "", [FILTER_KEYS.MODEL]: "", [FILTER_KEYS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "", [FILTER_KEYS.USER_ID]: "", @@ -160,6 +163,7 @@ export function useLogFilterLogic({ api_key: effectiveFilters[FILTER_KEYS.KEY_HASH] || undefined, team_id: effectiveFilters[FILTER_KEYS.TEAM_ID] || undefined, request_id: effectiveFilters[FILTER_KEYS.REQUEST_ID] || undefined, + session_id: effectiveFilters[FILTER_KEYS.SESSION_ID] || undefined, user_id: effectiveFilters[FILTER_KEYS.USER_ID] || (filterByCurrentUser ? userID ?? undefined : undefined), end_user: effectiveFilters[FILTER_KEYS.END_USER] || undefined, status_filter: effectiveFilters[FILTER_KEYS.STATUS] || undefined, diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 852f8388ec2..8e26729aa18 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -48633,6 +48633,8 @@ export interface operations { user_id?: string | null; /** @description request_id to get spend logs for specific request_id */ request_id?: string | null; + /** @description Filter spend logs by session_id */ + session_id?: string | null; /** @description Filter spend logs by team_id */ team_id?: string | null; /** @description Filter logs with spend greater than or equal to this value */ @@ -48739,6 +48741,8 @@ export interface operations { user_id?: string | null; /** @description request_id to get spend logs for specific request_id */ request_id?: string | null; + /** @description Filter spend logs by session_id */ + session_id?: string | null; /** @description Filter spend logs by team_id */ team_id?: string | null; /** @description Filter logs with spend greater than or equal to this value */ From f33403cb4b3b4122eadb2bb628ff7b99d8f5be02 Mon Sep 17 00:00:00 2001 From: Thibault Serot Date: Thu, 9 Jul 2026 12:39:13 +1000 Subject: [PATCH 023/365] feat(ui): support partial match on session id filter --- .../spend_management_endpoints.py | 12 ++- .../test_spend_management_endpoints.py | 91 +++++++++---------- ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 +- 3 files changed, 54 insertions(+), 53 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index cf7bedfdc71..0bf35352d5f 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1624,7 +1624,7 @@ async def ui_view_spend_logs( ), session_id: str | None = fastapi.Query( default=None, - description="Filter spend logs by session_id", + description="Filter spend logs by session_id (partial string match)", ), team_id: str | None = fastapi.Query( default=None, @@ -1776,9 +1776,6 @@ async def ui_view_spend_logs( if request_id is not None: where_conditions["request_id"] = request_id - if session_id is not None: - where_conditions["session_id"] = session_id - if model is not None: where_conditions["model"] = model @@ -1894,7 +1891,6 @@ async def ui_view_spend_logs( ('"user"', "user"), ("api_key", "api_key"), ("request_id", "request_id"), - ("session_id", "session_id"), ("model", "model"), ("model_id", "model_id"), ("model_group", "model_group"), @@ -1914,6 +1910,12 @@ async def ui_view_spend_logs( p += 2 sql_conditions.append(or_clause) + if session_id is not None: + like_escaped_session_id = session_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + sql_conditions.append(f"session_id LIKE ${p}") + sql_params.append(f"%{like_escaped_session_id}%") + p += 1 + # Status filter if status_filter is not None: if status_filter == "success": diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 69ffc2275ff..c3899883d58 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -86,7 +86,6 @@ def _reconstruct_ui_where_from_sql(sql_query, params): '"user"': "user", "api_key": "api_key", "request_id": "request_id", - "session_id": "session_id", "model": "model", "model_id": "model_id", "model_group": "model_group", @@ -100,6 +99,7 @@ def _reconstruct_ui_where_from_sql(sql_query, params): alias = re.search(r"user_api_key_alias' LIKE \$(\d+)", cond) code = re.search(r"error_code' = \$(\d+)", cond) msg = re.search(r"error_message' LIKE \$(\d+)", cond) + sess = re.fullmatch(r"session_id LIKE \$(\d+)", cond) status = re.fullmatch(r"status = \$(\d+)", cond) if gte: date_bounds["gte"] = _iso(params[int(gte.group(1)) - 1]) @@ -109,6 +109,8 @@ def _reconstruct_ui_where_from_sql(sql_query, params): where["OR"] = where.get("OR", []) + [{"multi_team": True}] elif "status = 'success'" in cond: where["OR"] = where.get("OR", []) + [{"status": "success"}] + elif sess: + where["session_id"] = {"contains": str(params[int(sess.group(1)) - 1]).strip("%")} elif status: where["status"] = {"equals": params[int(status.group(1)) - 1]} elif alias: @@ -165,16 +167,14 @@ def make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_fn, team_lookup_fn=No return len(filter_fn(kwargs.get("where", {}))) async def group_by(self, by, where, count): - allowed = set(where["session_id"]["in"]) + col = by[0] + allowed = where.get(col, {}).get("in") tallied = collections.Counter( - log["session_id"] + log[col] for log in mock_spend_logs - if log.get("session_id") in allowed + if log.get(col) is not None and (allowed is None or log[col] in allowed) ) - return [ - {"session_id": sid, "_count": {"session_id": n}} - for sid, n in tallied.items() - ] + return [{col: value, "_count": {col: n}} for value, n in tallied.items()] async def query_raw(self, sql_query, *params): if "mcp_tool_call_count" in sql_query: @@ -614,48 +614,47 @@ async def test_ui_view_spend_logs_with_user_id(client, monkeypatch): @pytest.mark.asyncio -async def test_ui_view_spend_logs_with_session_id(client, monkeypatch): - mock_spend_logs = [ - { - "id": "log1", - "request_id": "req1", +@pytest.mark.parametrize( + "session_id_query,expected_request_ids", + [ + ("session-filter-demo-1", {"req1", "req2"}), + ("session-filter-demo-2", {"req3"}), + ("session-filter", {"req1", "req2", "req3"}), + ("demo", {"req1", "req2", "req3"}), + ("no-such-session", set()), + ], +) +async def test_ui_view_spend_logs_with_session_id( + client, monkeypatch, session_id_query, expected_request_ids +): + def make_log(request_id, session_id): + return { + "id": f"log-{request_id}", + "request_id": request_id, "api_key": "sk-test-key", "user": "test_user_1", - "session_id": "session-abc", + "session_id": session_id, "spend": 0.05, "startTime": datetime.datetime.now(timezone.utc).isoformat(), "model": "gpt-4", - }, - { - "id": "log2", - "request_id": "req2", - "api_key": "sk-test-key", - "user": "test_user_1", - "session_id": "session-abc", - "spend": 0.10, - "startTime": datetime.datetime.now(timezone.utc).isoformat(), - "model": "gpt-4", - }, - { - "id": "log3", - "request_id": "req3", - "api_key": "sk-test-key", - "user": "test_user_1", - "session_id": "session-other", - "spend": 0.02, - "startTime": datetime.datetime.now(timezone.utc).isoformat(), - "model": "gpt-4", - }, + } + + mock_spend_logs = [ + make_log("req1", "session-filter-demo-1"), + make_log("req2", "session-filter-demo-1"), + make_log("req3", "session-filter-demo-2"), + make_log("req4", "unrelated-abc"), ] def filter_by_session(where): - if "session_id" in where: - return [ - log - for log in mock_spend_logs - if log["session_id"] == where["session_id"] - ] - return mock_spend_logs + session_filter = where.get("session_id") + if session_filter is None: + return mock_spend_logs + return [ + log + for log in mock_spend_logs + if session_filter["contains"] in log["session_id"] + ] monkeypatch.setattr( "litellm.proxy.proxy_server.prisma_client", @@ -667,7 +666,7 @@ async def test_ui_view_spend_logs_with_session_id(client, monkeypatch): response = client.get( "/spend/logs/ui", params={ - "session_id": "session-abc", + "session_id": session_id_query, "start_date": start_date, "end_date": end_date, }, @@ -676,9 +675,9 @@ async def test_ui_view_spend_logs_with_session_id(client, monkeypatch): assert response.status_code == 200 data = response.json() - assert data["total"] == 2 - assert {log["request_id"] for log in data["data"]} == {"req1", "req2"} - assert all(log["session_id"] == "session-abc" for log in data["data"]) + assert data["total"] == len(expected_request_ids) + assert {log["request_id"] for log in data["data"]} == expected_request_ids + assert all(session_id_query in log["session_id"] for log in data["data"]) # Mock spend logs with distinct values for sorting tests. diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 8e26729aa18..4ca2f85be2b 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -48633,7 +48633,7 @@ export interface operations { user_id?: string | null; /** @description request_id to get spend logs for specific request_id */ request_id?: string | null; - /** @description Filter spend logs by session_id */ + /** @description Filter spend logs by session_id (partial string match) */ session_id?: string | null; /** @description Filter spend logs by team_id */ team_id?: string | null; @@ -48741,7 +48741,7 @@ export interface operations { user_id?: string | null; /** @description request_id to get spend logs for specific request_id */ request_id?: string | null; - /** @description Filter spend logs by session_id */ + /** @description Filter spend logs by session_id (partial string match) */ session_id?: string | null; /** @description Filter spend logs by team_id */ team_id?: string | null; From 7801324ab0c06b1997010c14aed18b6a2d8fdce4 Mon Sep 17 00:00:00 2001 From: Thibault Serot Date: Thu, 9 Jul 2026 15:33:11 +1000 Subject: [PATCH 024/365] fix(spend): guard session_id filter against non-str query default --- litellm/proxy/spend_tracking/spend_management_endpoints.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 0bf35352d5f..bce2e3581b5 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -1910,7 +1910,7 @@ async def ui_view_spend_logs( p += 2 sql_conditions.append(or_clause) - if session_id is not None: + if session_id is not None and isinstance(session_id, str): like_escaped_session_id = session_id.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") sql_conditions.append(f"session_id LIKE ${p}") sql_params.append(f"%{like_escaped_session_id}%") From 8a44fdd66321f26d87223112487e72e0a8c24e27 Mon Sep 17 00:00:00 2001 From: Thibault Serot Date: Thu, 9 Jul 2026 15:53:13 +1000 Subject: [PATCH 025/365] chore(ui): refresh eslint metrics for rebased base --- ui/litellm-dashboard/eslint-metrics.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index ded6ab97e1e..37bad071081 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { "@typescript-eslint/no-explicit-any": 1982, "complexity": 128, - "local/no-large-inline-object-arg": 512, + "local/no-large-inline-object-arg": 519, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 From e84a19acd566f6ac95ec6346ba603104adea728f Mon Sep 17 00:00:00 2001 From: yucheng-berri Date: Wed, 8 Jul 2026 23:24:11 -0700 Subject: [PATCH 026/365] fix(guardrails): walk Responses-API text taxonomy in shared content helpers (#32542) * fix(guardrails): walk Responses-API text taxonomy in shared content helpers Every guardrail sharing litellm/proxy/guardrails/_content_utils.py silently drops all text on the /v1/responses path. AIM turns it into a loud 422 ( {"error":"No messages in the request"}); every other guardrail (Lakera v2, Cato, Lasso, Repello, IBM, Azure Content Safety, enterprise secret detection) scans an empty payload and lets the request through unscanned. Three defects, all in _content_utils.py: 1. _iter_text_parts_in_content recognised only part.type == "text", but the Responses API uses input_text (request) and output_text (assistant). 2. _coerce_input_to_messages gated on "every item has a role key"; any Responses input list containing a function_call or function_call_output item failed the check and was wrapped as one opaque blob. 3. build_inspection_messages forwarded any role through, including a bare tool role missing tool_call_id, which validators like AIM's /fw/v1/analyze reject with a schema error. Fix walks the actual Responses item taxonomy (message, function_call, function_call_output, bare content parts and strings), recognises {text, input_text, output_text} everywhere, and coerces any role outside {system, user, assistant} to user in the outbound inspection payload. * style: ruff-format changed guardrail files * test(guardrails): cover function_call_output string form; drop em-dash in new docstring * fix(guardrails): map function_call_output straight to user role Avoids ever materialising a schema-invalid bare tool message. The downstream role-safety coercion in build_inspection_messages still guards genuinely caller-supplied non-standard roles (developer, function, custom values); add a regression test covering that path so the coercion has real coverage after this simplification. * test(guardrails): pin chat-completions tool-role coercion in build_inspection_messages * docs(test): soften AIM-specific claims in LIT-4294 test docstrings Ryan's review flagged that several test docstrings assert AIM's /fw/v1/analyze validates + rejects specific schema violations. That behavior is customer-reported in the LIT-4294 writeup, not directly verified by us. Rephrase to attribute the AIM 422 to the customer's writeup and describe the underlying constraint as the OpenAI chat schema; any downstream API that validates against that schema rejects the same shape. * refactor(guardrails): move unsupported-role coercion into AIM only The generic coercion in build_inspection_messages collapsed any role outside {system, user, assistant} to user for every caller of the helper. Combined with the pre-existing apply_redacted_messages_back write-back behavior in Lakera/AIM/Cato, that turned a loud OpenAI 400 on chat-completions tool-message masking into a silent semantic corruption of the outbound request (role tool with tool_call_id got rewritten to bare role user, dropping the assistant + tool_calls sibling). AIM specifically requires the coercion because its /fw/v1/analyze validates the payload against the OpenAI chat schema; other guardrails either do not validate roles or do their own reconstruction. Move the coercion to AimGuardrail._build_aim_inspection_messages so the shared helper keeps caller roles intact and no new cross-guardrail role corruption is introduced. The pre-existing apply_redacted_messages_back structural flatten remains as separate follow-up work. function_call_output items still synthesise role user in the shared helper because they have no natural role field, which is a different concern from coercing a caller-supplied role. * refactor(guardrails): preserve role fidelity in shared _content_utils Shared inspection helpers should extract text and preserve semantic role signals; role coercion for third-party schema safety stays inside the guardrail that needs it (AIM). Three shared-helper changes: - Bare content-part dicts (input_text/output_text) with an explicit role keep it; only role-less parts default to user. - Responses message items already had their role preserved; the behavior is now covered by an explicit test. - function_call_output items default to role tool (semantic equivalent of the chat-completions tool message shape) instead of role user, so Responses and chat completions produce symmetric inspection payloads. A caller-supplied role on the item is still preserved. AIM's schema-safe coercion in _build_aim_inspection_messages already handles the resulting role tool: it collapses to user before the POST to /fw/v1/analyze so AIM's OpenAI-schema validator does not reject the bare tool message (no tool_call_id can survive the flatten). Added a regression test in test_aim.py covering that path. --- litellm/proxy/guardrails/_content_utils.py | 60 ++--- .../guardrails/guardrail_hooks/aim/aim.py | 17 +- .../guardrails/guardrail_hooks/test_aim.py | 88 +++++++ .../proxy/guardrails/test_content_utils.py | 237 +++++++++++++++++- 4 files changed, 364 insertions(+), 38 deletions(-) create mode 100644 tests/test_litellm/proxy/guardrails/guardrail_hooks/test_aim.py diff --git a/litellm/proxy/guardrails/_content_utils.py b/litellm/proxy/guardrails/_content_utils.py index 1d31e33c77c..6cdf49818e6 100644 --- a/litellm/proxy/guardrails/_content_utils.py +++ b/litellm/proxy/guardrails/_content_utils.py @@ -32,6 +32,9 @@ def is_text_content_call_type(call_type: str) -> bool: return call_type in TEXT_CONTENT_CALL_TYPES +TEXT_PART_TYPES: FrozenSet[str] = frozenset({"text", "input_text", "output_text"}) + + def _iter_text_parts_in_content(content: Any) -> Iterator[str]: """Yield text fragments from a ``message.content`` value (string or multimodal list). Non-text parts (images, audio, …) are skipped.""" @@ -48,7 +51,7 @@ def _iter_text_parts_in_content(content: Any) -> Iterator[str]: continue if not isinstance(part, dict): continue - if part.get("type") == "text": + if part.get("type") in TEXT_PART_TYPES: text = part.get("text") if isinstance(text, str) and text: yield text @@ -58,14 +61,20 @@ def _coerce_input_to_messages(input_value: Any) -> List[Dict[str, Any]]: """Coerce a Responses-API ``data["input"]`` value into chat-style messages.""" if isinstance(input_value, str): return [{"role": "user", "content": input_value}] - if isinstance(input_value, list): - if input_value and all(isinstance(item, dict) and "role" in item for item in input_value): - return list(input_value) - # Mixed lists (content-part dicts + bare strings) and pure - # string/dict lists all become a single user message; the content - # iterator below handles each element type uniformly. - return [{"role": "user", "content": input_value}] - return [] + if not isinstance(input_value, list): + return [] + messages: List[Dict[str, Any]] = [] + for item in input_value: + if isinstance(item, str): + messages.append({"role": "user", "content": item}) + elif isinstance(item, dict): + if item.get("type") in TEXT_PART_TYPES: + messages.append({"role": item.get("role") or "user", "content": [item]}) + elif "content" in item: + messages.append({"role": item.get("role") or "user", "content": item["content"]}) + elif item.get("type") == "function_call_output" and "output" in item: + messages.append({"role": item.get("role") or "tool", "content": item["output"]}) + return messages def _iter_inspection_messages(data: Dict[str, Any]) -> Iterator[Dict[str, Any]]: @@ -112,7 +121,7 @@ def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int: new_parts.append(visit(part)) elif ( isinstance(part, dict) - and part.get("type") == "text" + and part.get("type") in TEXT_PART_TYPES and isinstance(part.get("text"), str) and part["text"] ): @@ -136,25 +145,20 @@ def walk_user_text(data: Dict[str, Any], visit: Callable[[str], str]) -> int: data["input"] = visit(input_value) return visited if isinstance(input_value, list): - # List of full messages: rewrite each message's content. - if input_value and all(isinstance(item, dict) and "role" in item for item in input_value): - for item in input_value: - if "content" in item: - item["content"] = _rewrite_content(item["content"]) - return visited - # List of content parts and/or bare strings: rewrite in place. for idx, item in enumerate(input_value): - if isinstance(item, str) and item: - visited += 1 - input_value[idx] = visit(item) - elif ( - isinstance(item, dict) - and item.get("type") == "text" - and isinstance(item.get("text"), str) - and item["text"] - ): - visited += 1 - input_value[idx] = {**item, "text": visit(item["text"])} + if isinstance(item, str): + if item: + visited += 1 + input_value[idx] = visit(item) + elif isinstance(item, dict): + if item.get("type") in TEXT_PART_TYPES: + if isinstance(item.get("text"), str) and item["text"]: + visited += 1 + input_value[idx] = {**item, "text": visit(item["text"])} + elif "content" in item: + item["content"] = _rewrite_content(item["content"]) + elif item.get("type") == "function_call_output" and "output" in item: + item["output"] = _rewrite_content(item["output"]) return visited return visited diff --git a/litellm/proxy/guardrails/guardrail_hooks/aim/aim.py b/litellm/proxy/guardrails/guardrail_hooks/aim/aim.py index e7d9406ae3b..d22243cbe88 100644 --- a/litellm/proxy/guardrails/guardrail_hooks/aim/aim.py +++ b/litellm/proxy/guardrails/guardrail_hooks/aim/aim.py @@ -93,11 +93,10 @@ class AimGuardrail(CustomGuardrail): user_email=user_email, litellm_call_id=call_id, ) - # Covers multimodal list content + Responses-API input. response = await self.async_handler.post( f"{self.api_base}/fw/v1/analyze", headers=headers, - json={"messages": build_inspection_messages(data)}, + json={"messages": self._build_aim_inspection_messages(data)}, ) response.raise_for_status() res = response.json() @@ -116,6 +115,15 @@ class AimGuardrail(CustomGuardrail): verbose_proxy_logger.error(f"Aim: {action_type} action") return data + @staticmethod + def _build_aim_inspection_messages(data: dict) -> list[dict[str, str]]: + """AIM validates against the OpenAI chat schema. Bare ``role: "tool"`` + without ``tool_call_id`` and bare ``role: "function"`` without ``name`` + are rejected; the flatten drops those fields, so any role outside + ``{system, user, assistant}`` collapses to ``user`` for the AIM POST.""" + safe_roles = {"system", "user", "assistant"} + return [{**m, "role": "user"} if m["role"] not in safe_roles else m for m in build_inspection_messages(data)] + @staticmethod def _rejection(message: str, *, openai_code: str | None = None) -> ProxyException: return ProxyException( @@ -177,7 +185,10 @@ class AimGuardrail(CustomGuardrail): user_email=user_email, litellm_call_id=call_id, ), - json={"messages": build_inspection_messages(request_data) + [{"role": "assistant", "content": output}]}, + json={ + "messages": self._build_aim_inspection_messages(request_data) + + [{"role": "assistant", "content": output}] + }, ) response.raise_for_status() res = response.json() diff --git a/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_aim.py b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_aim.py new file mode 100644 index 00000000000..2e83422074e --- /dev/null +++ b/tests/test_litellm/proxy/guardrails/guardrail_hooks/test_aim.py @@ -0,0 +1,88 @@ +"""Tests for the AIM guardrail's inspection-payload construction.""" + +from litellm.proxy.guardrails.guardrail_hooks.aim.aim import AimGuardrail + + +def test_aim_inspection_messages_coerces_chat_completions_tool_role_to_user(): + """LIT-4294: A valid chat-completions ``role: "tool"`` message carries a + ``tool_call_id``, but the inspection flatten drops every field except + ``role`` and ``content``. A bare ``tool`` message without ``tool_call_id`` + is schema-invalid per the OpenAI chat schema, and the customer's writeup + reproduced AIM's ``/fw/v1/analyze`` returning 422 on exactly that shape. + The AIM POST collapses the role to ``user``; the outbound request to the + LLM is untouched.""" + data = { + "messages": [ + {"role": "user", "content": "weather in SF"}, + { + "role": "assistant", + "content": "", + "tool_calls": [ + { + "id": "c1", + "type": "function", + "function": {"name": "get_weather", "arguments": "{}"}, + } + ], + }, + {"role": "tool", "tool_call_id": "c1", "content": "sunny"}, + ] + } + assert AimGuardrail._build_aim_inspection_messages(data) == [ + {"role": "user", "content": "weather in SF"}, + {"role": "user", "content": "sunny"}, + ] + + +def test_aim_inspection_messages_coerces_non_standard_caller_role_to_user(): + """LIT-4294: A caller-supplied role outside {system, user, assistant} + (e.g. ``developer``, ``function``) is coerced to ``user`` for the AIM + POST, since AIM validates the payload against the OpenAI chat schema + and rejects unknown roles the same way it rejects bare ``tool``.""" + data = { + "messages": [ + {"role": "developer", "content": "system-ish instruction"}, + {"role": "user", "content": "normal user text"}, + ] + } + assert AimGuardrail._build_aim_inspection_messages(data) == [ + {"role": "user", "content": "system-ish instruction"}, + {"role": "user", "content": "normal user text"}, + ] + + +def test_aim_inspection_messages_coerces_responses_function_call_output_role(): + """LIT-4294: the shared helper synthesises ``role: "tool"`` for a + Responses ``function_call_output`` item (semantic equivalent of + chat-completions tool messages). AIM's schema-validating POST cannot + carry ``tool_call_id`` in the flat inspection payload, so AIM collapses + that ``tool`` role to ``user`` locally before POSTing.""" + data = { + "input": [ + { + "type": "function_call_output", + "call_id": "c1", + "output": [{"type": "input_text", "text": "sunny"}], + }, + ] + } + assert AimGuardrail._build_aim_inspection_messages(data) == [ + {"role": "user", "content": "sunny"}, + ] + + +def test_aim_inspection_messages_preserves_safe_roles(): + """Safe roles pass through untouched — the coercion only fires for + roles the OpenAI chat schema flatten cannot represent standalone.""" + data = { + "messages": [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] + } + assert AimGuardrail._build_aim_inspection_messages(data) == [ + {"role": "system", "content": "be helpful"}, + {"role": "user", "content": "hi"}, + {"role": "assistant", "content": "hello"}, + ] diff --git a/tests/test_litellm/proxy/guardrails/test_content_utils.py b/tests/test_litellm/proxy/guardrails/test_content_utils.py index 099fca78a62..34d92505359 100644 --- a/tests/test_litellm/proxy/guardrails/test_content_utils.py +++ b/tests/test_litellm/proxy/guardrails/test_content_utils.py @@ -8,7 +8,6 @@ from litellm.proxy.guardrails._content_utils import ( walk_user_text, ) - # ── iter_message_text ──────────────────────────────────────────────────────────── @@ -101,6 +100,55 @@ def test_iter_message_text_empty_data(): assert list(iter_message_text({"input": ""})) == [] +def test_iter_message_text_responses_api_input_text_and_output_text_parts(): + """LIT-4294: Responses-API content parts use ``input_text`` (request) and + ``output_text`` (assistant); reading only ``type == "text"`` skipped every + ``/v1/responses`` body and every text guardrail was a no-op on that path.""" + data = { + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "user text"}], + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "assistant text"}], + }, + ] + } + assert list(iter_message_text(data)) == ["user text", "assistant text"] + + +def test_iter_message_text_responses_api_tool_call_taxonomy(): + """LIT-4294: a Responses ``input`` list freely mixes message items, + ``function_call`` (no ``role``), and ``function_call_output`` items. The + old ``all(item has 'role')`` gate wrapped the whole list as one blob and + yielded nothing; every text fragment must be visited independently.""" + data = { + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + }, + { + "type": "function_call", + "call_id": "c1", + "name": "get_weather", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": "c1", + "output": [{"type": "input_text", "text": "sunny"}], + }, + ] + } + assert list(iter_message_text(data)) == ["hello", "sunny"] + + # ── walk_user_text ──────────────────────────────────────────────────────────── @@ -160,6 +208,89 @@ def test_walk_user_text_redacts_responses_api_list_input(): assert data["input"][1] == {"type": "image_url", "image_url": {"url": "..."}} +def test_walk_user_text_redacts_responses_input_text_and_output_text_parts(): + """LIT-4294: ``walk_user_text`` must recognise the Responses text-part + variants so masking guardrails (secret detection, PII) actually redact + ``/v1/responses`` bodies instead of no-op'ing on them.""" + data = { + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "AKIAEXAMPLE"}], + }, + { + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "AKIAEXAMPLE too"}], + }, + ] + } + visited = walk_user_text(data, lambda s: s.replace("AKIAEXAMPLE", "[REDACTED]")) + assert visited == 2 + assert data["input"][0]["content"][0] == { + "type": "input_text", + "text": "[REDACTED]", + } + assert data["input"][1]["content"][0] == { + "type": "output_text", + "text": "[REDACTED] too", + } + + +def test_walk_user_text_redacts_function_call_output_text(): + """LIT-4294: tool-call round-trips carry secrets in + ``function_call_output.output``; the redact walker must descend into it + while leaving ``function_call`` items (call_id, arguments) untouched.""" + data = { + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "AKIAEXAMPLE user"}], + }, + { + "type": "function_call", + "call_id": "c1", + "name": "get_weather", + "arguments": '{"AKIAEXAMPLE": 1}', + }, + { + "type": "function_call_output", + "call_id": "c1", + "output": [{"type": "input_text", "text": "AKIAEXAMPLE tool"}], + }, + ] + } + visited = walk_user_text(data, lambda s: s.replace("AKIAEXAMPLE", "[REDACTED]")) + assert visited == 2 + assert data["input"][0]["content"][0]["text"] == "[REDACTED] user" + assert data["input"][1] == { + "type": "function_call", + "call_id": "c1", + "name": "get_weather", + "arguments": '{"AKIAEXAMPLE": 1}', + } + assert data["input"][2]["output"][0]["text"] == "[REDACTED] tool" + + +def test_walk_user_text_redacts_function_call_output_string_output(): + """LIT-4294: ``function_call_output.output`` is also a plain string in + OpenAI's Responses spec; the redact walker must handle both forms.""" + data = { + "input": [ + { + "type": "function_call_output", + "call_id": "c1", + "output": "AKIAEXAMPLE tool", + }, + ] + } + visited = walk_user_text(data, lambda s: s.replace("AKIAEXAMPLE", "[REDACTED]")) + assert visited == 1 + assert data["input"][0]["output"] == "[REDACTED] tool" + + def test_walk_user_text_redacts_mixed_list_input(): """Read and write helpers must agree on coverage — bare strings inside a mixed ``input`` list are inspected by both.""" @@ -206,17 +337,13 @@ def test_build_inspection_messages_joins_multimodal_text_parts(): } ] } - assert build_inspection_messages(data) == [ - {"role": "user", "content": "first part\nsecond part"} - ] + assert build_inspection_messages(data) == [{"role": "user", "content": "first part\nsecond part"}] def test_build_inspection_messages_lifts_responses_api_input(): """fniVO9-F: ``input`` must be visible to hooks that POST messages to a remote API.""" data = {"input": "responses-api content"} - assert build_inspection_messages(data) == [ - {"role": "user", "content": "responses-api content"} - ] + assert build_inspection_messages(data) == [{"role": "user", "content": "responses-api content"}] def test_build_inspection_messages_drops_messages_with_no_text(): @@ -233,6 +360,102 @@ def test_build_inspection_messages_drops_messages_with_no_text(): assert build_inspection_messages(data) == [{"role": "user", "content": "kept"}] +def test_build_inspection_messages_responses_api_tool_call_taxonomy(): + """LIT-4294: mixed Responses ``input`` (message + function_call + + function_call_output) must produce a non-empty inspection list. The + customer's writeup reproduced a 422 from AIM's ``/fw/v1/analyze`` + (``No messages in the request``) when this synthesised list came back + empty; every other guardrail silently scanned nothing on the same + input.""" + data = { + "input": [ + { + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "hello"}], + }, + { + "type": "function_call", + "call_id": "c1", + "name": "get_weather", + "arguments": "{}", + }, + { + "type": "function_call_output", + "call_id": "c1", + "output": [{"type": "input_text", "text": "sunny"}], + }, + ] + } + assert build_inspection_messages(data) == [ + {"role": "user", "content": "hello"}, + {"role": "tool", "content": "sunny"}, + ] + + +def test_build_inspection_messages_function_call_output_defaults_to_tool(): + """LIT-4294: a Responses ``function_call_output`` item is the semantic + equivalent of a chat-completions ``role: "tool"`` message, so the shared + helper synthesises ``role: "tool"`` when the item has no explicit role. + AIM's schema-safe coercion happens at the AIM call site, not here.""" + data = { + "input": [ + { + "type": "function_call_output", + "call_id": "c1", + "output": [{"type": "input_text", "text": "tool text"}], + }, + ] + } + assert build_inspection_messages(data) == [{"role": "tool", "content": "tool text"}] + + +def test_build_inspection_messages_function_call_output_preserves_explicit_role(): + """When ``function_call_output`` carries a caller-supplied ``role`` the + shared helper preserves it rather than synthesising ``tool``.""" + data = { + "input": [ + { + "type": "function_call_output", + "role": "assistant", + "call_id": "c1", + "output": [{"type": "input_text", "text": "tool text"}], + }, + ] + } + assert build_inspection_messages(data) == [{"role": "assistant", "content": "tool text"}] + + +def test_build_inspection_messages_bare_content_part_preserves_explicit_role(): + """A bare content-part dict with an explicit ``role`` keeps it. Only + absent roles get defaulted to ``user``.""" + data = { + "input": [ + {"type": "input_text", "text": "no role"}, + {"type": "output_text", "role": "assistant", "text": "with role"}, + ] + } + assert build_inspection_messages(data) == [ + {"role": "user", "content": "no role"}, + {"role": "assistant", "content": "with role"}, + ] + + +def test_build_inspection_messages_message_item_preserves_role(): + """Responses message items carry a role explicitly; the shared helper + passes it through untouched.""" + data = { + "input": [ + {"type": "message", "role": "system", "content": [{"type": "input_text", "text": "sys"}]}, + {"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "asst"}]}, + ] + } + assert build_inspection_messages(data) == [ + {"role": "system", "content": "sys"}, + {"role": "assistant", "content": "asst"}, + ] + + def test_build_inspection_messages_empty_data(): assert build_inspection_messages({}) == [] assert build_inspection_messages({"messages": []}) == [] From 1d870842125f52d901573cd0c2d6ba9c9399d39f Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 10:51:37 +0300 Subject: [PATCH 027/365] refactor(otel): move litellm error detail keys under the litellm.* namespace (#32591) The v2 OTel integration stamped litellm-specific error details as error.code, error.stack_trace, and error.llm_provider, squatting on the semconv-owned error.* namespace. They now live at litellm.provider.error.code, litellm.provider.error.stack_trace, and litellm.provider.error.llm_provider alongside the other vendor-extension keys. error.type and error.message stay on the semconv keys. --- litellm/integrations/otel/model/semconv.py | 19 ++++++------- .../otel/test_otel_v2_components.py | 28 +++++++++---------- .../otel/test_otel_v2_sources_of_truth.py | 2 -- 3 files changed, 21 insertions(+), 28 deletions(-) diff --git a/litellm/integrations/otel/model/semconv.py b/litellm/integrations/otel/model/semconv.py index 69d1e454655..aab80c7e5c4 100644 --- a/litellm/integrations/otel/model/semconv.py +++ b/litellm/integrations/otel/model/semconv.py @@ -147,24 +147,21 @@ class Error: """OTel-defined error attribute keys, from the semconv ``error.*`` registry. ``MESSAGE`` is marked *Deprecated* upstream in favor of domain-specific error message keys plus ``exception.message`` on the exception event, but - is still defined and stamped by litellm's v1 integration; keeping it here - for byte-for-byte parity.""" + litellm still stamps it.""" TYPE: Final = "error.type" MESSAGE: Final = "error.message" class LiteLLMError: - """LiteLLM-specific error attribute keys. Emitted under the ``error.*`` - namespace (not ``litellm.*``) for byte-for-byte compat with the v1 - integration in ``opentelemetry.py``; consumers reading these keys on v1 - spans read the same keys on v2 spans. OTel semconv does not define any of - these three, and per its extension rules a namespace may carry additional - vendor keys as long as they don't collide with defined names.""" + """Detail keys for the mapped provider exception of a failed LLM call. + OTel semconv does not define these, so they live under the ``litellm.*`` + vendor namespace rather than squatting on the semconv-owned ``error.*`` + namespace.""" - CODE: Final = "error.code" - STACK_TRACE: Final = "error.stack_trace" - LLM_PROVIDER: Final = "error.llm_provider" + CODE: Final = "litellm.provider.error.code" + STACK_TRACE: Final = "litellm.provider.error.stack_trace" + LLM_PROVIDER: Final = "litellm.provider.error.llm_provider" class ExceptionEvent: diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_components.py b/tests/test_litellm/integrations/otel/test_otel_v2_components.py index 298047ec18b..eb795a64b79 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_components.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_components.py @@ -604,8 +604,7 @@ def test_error_details_stamped_as_span_attributes_for_labels_ingest(): """OTel-defined keys and litellm-specific detail keys both ride span attributes so backends that flatten attrs into label indexes (Elastic APM ``labels.*``, Datadog span tags) render them. The exception event with the - full untruncated message stays alongside — both places, matching v1's - shape.""" + full untruncated message stays alongside.""" from litellm.integrations.otel.model.semconv import Error, ExceptionEvent, LiteLLMError from litellm.integrations.otel.emitter import SpanEmitter @@ -638,8 +637,8 @@ def test_error_details_stamped_as_span_attributes_for_labels_ingest(): # OTel-defined keys (from the ``error.*`` semconv registry). assert span.attributes[Error.TYPE] == "litellm.BadRequestError" assert span.attributes[Error.MESSAGE] == "400: violated moderation policy" - # LiteLLM-specific detail keys — vendor-namespaced under ``error.*`` - # for v1-parity, not defined by OTel semconv. + # LiteLLM-specific detail keys, under the ``litellm.provider.error.*`` + # vendor namespace, not defined by OTel semconv. assert span.attributes[LiteLLMError.CODE] == "400" assert span.attributes[LiteLLMError.STACK_TRACE] == "File proxy_server.py line 8570 ..." assert span.attributes[LiteLLMError.LLM_PROVIDER] == "openai" @@ -666,19 +665,18 @@ def test_error_details_omitted_when_span_error_carries_only_message(): assert LiteLLMError.LLM_PROVIDER not in span.attributes -def test_v2_error_attribute_keys_match_v1_error_attributes_byte_for_byte(): - """v1 (``opentelemetry.py``) and v2 (``otel/`` package) stamp identical - span-attribute keys so consumers reading ``labels.error_message`` don't - care which integration produced the span. Renaming either side is a - breaking change for downstream dashboards; this test locks the vocabulary.""" - from litellm.integrations._types.open_inference import ErrorAttributes +def test_error_attribute_keys_are_pinned(): + """``error.type`` and ``error.message`` come from the semconv ``error.*`` + registry; the litellm-specific detail keys are vendor keys under + ``litellm.provider.error.*``. Pins the exact strings so the emitted + vocabulary can't drift silently.""" from litellm.integrations.otel.model.semconv import Error, LiteLLMError - assert Error.TYPE == ErrorAttributes.ERROR_TYPE - assert Error.MESSAGE == ErrorAttributes.ERROR_MESSAGE - assert LiteLLMError.CODE == ErrorAttributes.ERROR_CODE - assert LiteLLMError.STACK_TRACE == ErrorAttributes.ERROR_STACK_TRACE - assert LiteLLMError.LLM_PROVIDER == ErrorAttributes.ERROR_LLM_PROVIDER + assert Error.TYPE == "error.type" + assert Error.MESSAGE == "error.message" + assert LiteLLMError.CODE == "litellm.provider.error.code" + assert LiteLLMError.STACK_TRACE == "litellm.provider.error.stack_trace" + assert LiteLLMError.LLM_PROVIDER == "litellm.provider.error.llm_provider" def test_error_message_falls_back_to_error_type_when_message_absent(): diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py index 89aa73a6066..71be28ea485 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py @@ -147,8 +147,6 @@ def test_attribute_keys_are_unique_across_namespaces(): from litellm.integrations.otel import MCP, Client, JsonRpc, LiteLLMError, Network # prefixes are allowed to be substrings; exact keys must not collide. - # ``LiteLLMError`` shares the ``error.*`` prefix with ``Error`` by design - # (v1-parity); the assert below is the guarantee they never overlap. exact = set() for cls in (GenAI, Error, LiteLLMError, Server, HTTP, DB, MCP, JsonRpc, Network, Client): for key in _all_constants(cls): From cda99a08c8eda814e573404d09e899a1f81b3646 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 11:13:13 +0300 Subject: [PATCH 028/365] fix(proxy): surface OAuth error params in SSO callback (#32433) When an IdP denies SSO access it redirects back to /sso/callback with error and error_description query params and no code param. The callback previously fell through to the provider token exchange, which failed with a generic "'code' parameter was not found in callback request" 400 that hides the real denial reason. Raise a 401 that surfaces the IdP's error and description instead. Ported from #26640 with conflicts resolved against current staging --- litellm/proxy/management_endpoints/ui_sso.py | 12 ++++ .../proxy/management_endpoints/test_ui_sso.py | 60 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/litellm/proxy/management_endpoints/ui_sso.py b/litellm/proxy/management_endpoints/ui_sso.py index 43fdd3ed05a..065464aa565 100644 --- a/litellm/proxy/management_endpoints/ui_sso.py +++ b/litellm/proxy/management_endpoints/ui_sso.py @@ -1746,6 +1746,18 @@ async def auth_callback(request: Request, state: Optional[str] = None): """Verify login""" verbose_proxy_logger.info(f"Starting SSO callback with state: {state}") + oauth_error = request.query_params.get("error") + if oauth_error: + oauth_error_description = request.query_params.get("error_description") + verbose_proxy_logger.warning( + f"SSO callback received OAuth error: {oauth_error}, description: {oauth_error_description}" + ) + raise HTTPException( + status_code=401, + detail=f"OAuth error: {oauth_error}" + + (f", error_description: {oauth_error_description}" if oauth_error_description else ""), + ) + # Check if this is a CLI login (state starts with our CLI prefix) from litellm.constants import LITELLM_CLI_SESSION_TOKEN_PREFIX from litellm.proxy._types import LiteLLM_JWTAuth diff --git a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py index 642f20906a0..045e15f8b8b 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py +++ b/tests/test_litellm/proxy/management_endpoints/test_ui_sso.py @@ -2783,6 +2783,7 @@ class TestCLIKeyRegenerationFlow: # Mock request mock_request = MagicMock(spec=Request) + mock_request.query_params = {"code": "some-auth-code"} cli_state = f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-new-session-key-456" @@ -2824,6 +2825,7 @@ class TestCLIKeyRegenerationFlow: from litellm.proxy.management_endpoints.ui_sso import auth_callback mock_request = MagicMock(spec=Request) + mock_request.query_params = {"code": "some-auth-code"} cli_state = ( f"{LITELLM_CLI_SESSION_TOKEN_PREFIX}:cli-new-session-key-456:WXYZ-2345" ) @@ -7254,3 +7256,61 @@ async def test_cli_poll_key_tolerates_missing_user_row(): assert result["status"] == "ready" assert result["key"] == mock_jwt_token assert result["user_id"] == "just-created-user" + + +def _make_sso_callback_request(query_params: dict) -> MagicMock: + mock_request = MagicMock(spec=Request) + mock_request.query_params = query_params + return mock_request + + +@pytest.mark.asyncio +async def test_auth_callback_surfaces_oauth_error_with_description(): + """ + Regression: when the IdP denies access it redirects back with + ?error=...&error_description=... and no `code`. The callback must surface + that reason as a 401 instead of failing later on the missing `code` param. + """ + from litellm.proxy.management_endpoints.ui_sso import auth_callback + + mock_request = _make_sso_callback_request( + {"error": "access_denied", "error_description": "User is not assigned to the client application"} + ) + + with pytest.raises(HTTPException) as exc_info: + await auth_callback(request=mock_request, state=None) + + assert exc_info.value.status_code == 401 + assert "access_denied" in str(exc_info.value.detail) + assert "User is not assigned to the client application" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_auth_callback_surfaces_oauth_error_without_description(): + """error_description is optional in the OAuth error response; the 401 detail must not render 'None'.""" + from litellm.proxy.management_endpoints.ui_sso import auth_callback + + mock_request = _make_sso_callback_request({"error": "access_denied"}) + + with pytest.raises(HTTPException) as exc_info: + await auth_callback(request=mock_request, state=None) + + assert exc_info.value.status_code == 401 + assert "access_denied" in str(exc_info.value.detail) + assert "None" not in str(exc_info.value.detail) + assert "error_description" not in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_auth_callback_without_oauth_error_proceeds_to_normal_flow(): + """Without an `error` query param the guard must not fire; the callback proceeds into the normal flow.""" + from litellm.proxy.management_endpoints.ui_sso import auth_callback + + mock_request = _make_sso_callback_request({"code": "some-auth-code"}) + + with patch("litellm.proxy.proxy_server.prisma_client", None): + with pytest.raises(HTTPException) as exc_info: + await auth_callback(request=mock_request, state=None) + + assert exc_info.value.status_code == 500 + assert "DB not connected" in str(exc_info.value.detail) From 60729f733ec7dd1d2a37826c3bb776e27daa6d11 Mon Sep 17 00:00:00 2001 From: Yassin Kortam Date: Thu, 9 Jul 2026 11:14:22 +0300 Subject: [PATCH 029/365] test(benchmarks): run shared logging executor inline to make CodSpeed measurements deterministic (#32435) --- tests/benchmarks/conftest.py | 36 +++++++++++++++++++++++++++++ tests/benchmarks/test_benchmarks.py | 21 +++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 tests/benchmarks/conftest.py diff --git a/tests/benchmarks/conftest.py b/tests/benchmarks/conftest.py new file mode 100644 index 00000000000..c9b31cfb7d7 --- /dev/null +++ b/tests/benchmarks/conftest.py @@ -0,0 +1,36 @@ +"""Shared setup keeping CodSpeed measurements hermetic. + +CodSpeed's callgrind instrumentation counts instructions from every thread while +a measurement window is open, and valgrind serializes all threads onto one +virtual CPU. Work deferred to litellm's shared logging executor would therefore +be attributed to whichever benchmark the valgrind scheduler resumes it under, +flipping results between runs. Running the executor inline keeps each +benchmark's cost self-contained and deterministic. +""" + +from collections.abc import Callable, Iterator +from concurrent.futures import Future +from typing import ParamSpec, TypeVar + +import pytest + +from litellm.litellm_core_utils.thread_pool_executor import executor + +P = ParamSpec("P") +R = TypeVar("R") + + +def _submit_inline(fn: Callable[P, R], /, *args: P.args, **kwargs: P.kwargs) -> Future[R]: + future: Future[R] = Future() + try: + future.set_result(fn(*args, **kwargs)) + except BaseException as exc: + future.set_exception(exc) + return future + + +@pytest.fixture(autouse=True, scope="session") +def inline_logging_executor() -> Iterator[None]: + executor.submit = _submit_inline + yield + del executor.submit diff --git a/tests/benchmarks/test_benchmarks.py b/tests/benchmarks/test_benchmarks.py index 123dad93e11..59b3e0b6d5c 100644 --- a/tests/benchmarks/test_benchmarks.py +++ b/tests/benchmarks/test_benchmarks.py @@ -6,10 +6,13 @@ in the litellm hot path: token counting, model info lookup, provider resolution, and cost calculation. """ +import threading + import pytest import litellm from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider +from litellm.litellm_core_utils.thread_pool_executor import executor from litellm.litellm_core_utils.token_counter import token_counter @@ -205,3 +208,21 @@ def test_get_model_cost_key_exact_match(): def test_get_model_cost_key_case_insensitive(): """Benchmark model cost key lookup with case-insensitive fallback.""" litellm.utils._get_model_cost_key("GPT-4o") + + +# --------------------------------------------------------------------------- +# Measurement hermeticity guard +# --------------------------------------------------------------------------- + + +@pytest.mark.benchmark +def test_logging_executor_runs_inline(): + """Guard that the shared logging executor runs submissions inline. + + Deferred submissions execute on worker threads, and callgrind attributes + their instructions to whichever benchmark's measurement window is open when + the valgrind scheduler resumes them, making results nondeterministic. + """ + future = executor.submit(threading.get_ident) + assert future.done() + assert future.result() == threading.get_ident() From c36525f9bef7549aa1810b50834ab131a7bcd531 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 9 Jul 2026 09:10:32 -0700 Subject: [PATCH 030/365] bump: litellm-enterprise 0.1.48 -> 0.1.49 --- enterprise/pyproject.toml | 4 ++-- pyproject.toml | 2 +- uv.lock | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/enterprise/pyproject.toml b/enterprise/pyproject.toml index b3864ce7878..85ccbef752f 100644 --- a/enterprise/pyproject.toml +++ b/enterprise/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "litellm-enterprise" -version = "0.1.48" +version = "0.1.49" description = "Package for LiteLLM Enterprise features" readme = "README.md" requires-python = ">=3.9" @@ -26,7 +26,7 @@ required-version = ">=0.10.9" module-root = "" [tool.commitizen] -version = "0.1.48" +version = "0.1.49" version_files = [ "pyproject.toml:^version", "../pyproject.toml:litellm-enterprise==", diff --git a/pyproject.toml b/pyproject.toml index 3f5458c5494..99425ef3aef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,7 +63,7 @@ proxy = [ "azure-storage-blob>=12.28.0,<13.0", "mcp>=1.26.0,<2.0", "litellm-proxy-extras==0.4.75", - "litellm-enterprise==0.1.48", + "litellm-enterprise==0.1.49", "RestrictedPython>=8.1,<9.0", "rich>=13.9.4,<14.0", "polars>=1.38.1,<2.0", diff --git a/uv.lock b/uv.lock index e36da722261..a226ef172c7 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-04T17:13:14.93495Z" +exclude-newer = "2026-07-06T16:10:51.214184Z" exclude-newer-span = "P3D" [manifest] @@ -3639,7 +3639,7 @@ proxy-dev = [ [[package]] name = "litellm-enterprise" -version = "0.1.48" +version = "0.1.49" source = { editable = "enterprise" } [[package]] From b3a44bd1b2d46f2fd5f53a49107a6c1105901169 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:03:28 +0000 Subject: [PATCH 031/365] fix(deps): constrain soupsieve>=2.8.4 to patch two high-severity CVEs --- pyproject.toml | 1 + uv.lock | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3f5458c5494..d0ea2b9da4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -259,6 +259,7 @@ constraint-dependencies = [ "tornado>=6.5.6", "aiohttp>=3.14.1,<4.0", "packaging>=24.0", + "soupsieve>=2.8.4", ] override-dependencies = [ # a2a-sdk 1.x requires packaging>=24.0; lunary 1.4.x still caps at <24.0. diff --git a/uv.lock b/uv.lock index e36da722261..313fd682ccf 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ ] [options] -exclude-newer = "2026-07-04T17:13:14.93495Z" +exclude-newer = "2026-07-06T17:03:18.138046444Z" exclude-newer-span = "P3D" [manifest] @@ -21,6 +21,7 @@ members = [ constraints = [ { name = "aiohttp", specifier = ">=3.14.1,<4.0" }, { name = "packaging", specifier = ">=24.0" }, + { name = "soupsieve", specifier = ">=2.8.4" }, { name = "tornado", specifier = ">=6.5.6" }, ] overrides = [{ name = "packaging", specifier = ">=24.0" }] @@ -7076,11 +7077,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] From ceeb90abdba02d502ee6391014a84954b406b852 Mon Sep 17 00:00:00 2001 From: Tin Date: Tue, 7 Jul 2026 20:17:35 -0700 Subject: [PATCH 032/365] feat(ui): expose true_passthrough and oauth_delegate auth types with a no-auth warning Adds the two client-forwarded token modes to the MCP server create and edit form auth dropdowns, and shows a warning when true_passthrough is selected: the gateway performs no admission auth for that server, so callers reach the upstream without a LiteLLM key and per-key/per-team rate limits and spend tracking do not apply. The warning is a shared component so the two forms cannot drift on the copy. --- .../mcp_tools/TruePassthroughWarning.tsx | 21 ++++++++++ .../mcp_tools/create_mcp_server.test.tsx | 25 ++++++++++++ .../mcp_tools/create_mcp_server.tsx | 7 ++++ .../mcp_tools/mcp_server_edit.test.tsx | 39 +++++++++++++++++++ .../components/mcp_tools/mcp_server_edit.tsx | 32 +++++++++------ .../src/components/mcp_tools/types.tsx | 2 + 6 files changed, 114 insertions(+), 12 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/TruePassthroughWarning.tsx diff --git a/ui/litellm-dashboard/src/components/mcp_tools/TruePassthroughWarning.tsx b/ui/litellm-dashboard/src/components/mcp_tools/TruePassthroughWarning.tsx new file mode 100644 index 00000000000..b52d3f4c672 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/TruePassthroughWarning.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import { Alert } from "antd"; +import { AUTH_TYPE } from "./types"; + +/** + * Warning shown in the create/edit MCP server forms when auth_type + * true_passthrough is selected: the gateway performs no admission auth for + * that server, so callers reach the upstream without a LiteLLM identity. + */ +export default function TruePassthroughWarning({ authType }: { authType?: string | null }) { + if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH) return null; + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index 36af2f8d9fc..27e6d6a8e3d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -164,6 +164,31 @@ describe("CreateMCPServer", () => { }); }); + it("should warn that LiteLLM auth is disabled when True Passthrough is selected", async () => { + await selectHttpTransport(); + + await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)"); + + await waitFor(() => { + expect( + screen.getByText("True Passthrough disables LiteLLM authentication for this server"), + ).toBeInTheDocument(); + }); + }); + + it("should not show the True Passthrough warning when OAuth Delegate is selected", async () => { + await selectHttpTransport(); + + await selectAntOption("Authentication", "OAuth Delegate (client-supplied upstream token)"); + + await waitFor(() => { + expect(screen.getAllByText("OAuth Delegate (client-supplied upstream token)").length).toBeGreaterThan(0); + }); + expect( + screen.queryByText("True Passthrough disables LiteLLM authentication for this server"), + ).not.toBeInTheDocument(); + }); + it("should not require auth value when creating a server with API Key auth type", async () => { await selectHttpTransport(); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 10668468c15..1362835f475 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -16,6 +16,7 @@ import { MCP_OAUTH2_FLOW_INTERACTIVE, } from "./types"; import OAuthFormFields from "./OAuthFormFields"; +import TruePassthroughWarning from "./TruePassthroughWarning"; import TokenExchangeFormFields from "./TokenExchangeFormFields"; import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPConnectionStatus from "./mcp_connection_status"; @@ -970,9 +971,15 @@ const CreateMCPServer: React.FC = ({ OAuth OAuth Token Exchange (OBO) AWS SigV4 (Bedrock AgentCore MCPs) + True Passthrough (no LiteLLM auth) + + OAuth Delegate (client-supplied upstream token) + + + {shouldShowAuthValueField && ( { }); }); +describe("MCPServerEdit (true passthrough warning)", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const renderWithAuthType = (authType: string) => + render( + , + ); + + it("warns that LiteLLM auth is disabled for a true_passthrough server", async () => { + renderWithAuthType("true_passthrough"); + + await waitFor(() => { + expect(screen.getByText("True Passthrough disables LiteLLM authentication for this server")).toBeInTheDocument(); + }); + }); + + it("does not warn for an oauth_delegate server", async () => { + renderWithAuthType("oauth_delegate"); + + await waitFor(() => { + expect(screen.getAllByRole("button", { name: "Save Changes" }).length).toBeGreaterThan(0); + }); + expect( + screen.queryByText("True Passthrough disables LiteLLM authentication for this server"), + ).not.toBeInTheDocument(); + }); +}); + describe("MCPServerEdit (auth type switch)", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 70632b459fc..2c4f674c14b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -18,6 +18,7 @@ import { getToken, isTokenValid, setToken } from "@/utils/mcpTokenStore"; import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils"; import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPPermissionManagement from "./MCPPermissionManagement"; +import TruePassthroughWarning from "./TruePassthroughWarning"; import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; import TokenExchangeFormFields from "./TokenExchangeFormFields"; @@ -874,18 +875,25 @@ const MCPServerEdit: React.FC = ({ {/* Authentication - for HTTP, SSE, and OpenAPI */} {!isStdioTransport && ( - - - + <> + + + + + )} {isStdioTransport && ( diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 9469d7bd89e..70cc8129bf1 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -41,6 +41,8 @@ export const AUTH_TYPE = { OAUTH2: "oauth2", OAUTH2_TOKEN_EXCHANGE: "oauth2_token_exchange", AWS_SIGV4: "aws_sigv4", + TRUE_PASSTHROUGH: "true_passthrough", + OAUTH_DELEGATE: "oauth_delegate", }; export const OAUTH_FLOW = { From 22ab518071716688f9b0f70003fb30780ca6ff42 Mon Sep 17 00:00:00 2001 From: Tin Date: Tue, 7 Jul 2026 21:29:43 -0700 Subject: [PATCH 033/365] feat(ui): browser-only Authorize & Fetch for the client-forwarded token modes true_passthrough and oauth_delegate persist no upstream credentials, so the create/edit forms had no way to preview tools or configure the tool allowlist: tools/list went upstream unauthenticated and came back 401. This reuses the existing OAuth authorize machinery in browser-only mode for those two auth types: the admin authorizes against the upstream (DCR/PKCE, with optional client credentials for IdPs without dynamic registration), the token lands in sessionStorage exactly like the legacy PKCE-passthrough path, and the tools preview forwards it via the per-server x-mcp-{alias}-authorization header, which the passthrough resolver arm already accepts. Nothing is written to the server row or the per-user credential store; the create payload keeps excluding credentials for these auth types via AUTH_TYPES_REQUIRING_CREDENTIALS. The tools preview endpoint now also extracts the Authorization header for the two new auth types so the browser-held token reaches the passthrough arm during create-time previews. --- .../mcp_server/rest_endpoints.py | 6 +- .../mcp_server/test_rest_endpoints.py | 53 +++++++++++++ .../mcp_tools/PassthroughAuthorizeSection.tsx | 74 +++++++++++++++++++ .../mcp_tools/create_mcp_server.test.tsx | 26 +++++++ .../mcp_tools/create_mcp_server.tsx | 11 +++ .../mcp_tools/mcp_server_edit.test.tsx | 49 ++++++++++++ .../components/mcp_tools/mcp_server_edit.tsx | 66 +++++++++++++---- .../src/hooks/useTestMCPConnection.tsx | 4 +- 8 files changed, 272 insertions(+), 17 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index b917530dd52..21682b4dd3e 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -1322,7 +1322,11 @@ if MCP_AVAILABLE: mcp_auth_header = credentials.get("auth_value") oauth2_headers: Optional[Dict[str, str]] = None - if new_mcp_server_request.auth_type == MCPAuth.oauth2: + if new_mcp_server_request.auth_type in { + MCPAuth.oauth2, + MCPAuth.true_passthrough, + MCPAuth.oauth_delegate, + }: oauth2_headers = MCPRequestHandler._get_oauth2_headers_from_headers(headers) async def _list_tools_operation(client): diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py index 3d9afd8f250..090b4711dc9 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_rest_endpoints.py @@ -593,6 +593,59 @@ class TestTestToolsList: assert captured["oauth2_headers"] == oauth_headers assert oauth_call_counter["count"] == 1 + @pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) + async def test_extracts_oauth2_headers_for_client_forwarded_modes(self, monkeypatch, auth_type): + """The browser-only authorize flow sends the upstream token as Authorization; the preview + must thread it through for the client-forwarded token modes so the passthrough arm can + forward it, instead of probing the upstream unauthenticated.""" + + captured: dict = {} + + async def fake_execute( + request, + operation, + mcp_auth_header=None, + oauth2_headers=None, + raw_headers=None, + ): + captured["mcp_auth_header"] = mcp_auth_header + captured["oauth2_headers"] = oauth2_headers + return { + "tools": [], + "error": None, + "message": "Successfully retrieved tools", + } + + monkeypatch.setattr(rest_endpoints, "_execute_with_mcp_client", fake_execute, raising=False) + + oauth_headers = {"Authorization": "Bearer upstream-token"} + + monkeypatch.setattr( + auth_mcp.MCPRequestHandler, + "_get_oauth2_headers_from_headers", + staticmethod(lambda headers: oauth_headers), + raising=False, + ) + + request = _build_request({"authorization": "Bearer upstream-token"}) + payload = NewMCPServerRequest( + server_name="example", + url="https://example.com", + auth_type=auth_type, + ) + + from litellm.proxy._types import LitellmUserRoles + + result = await rest_endpoints.test_tools_list( + request, + payload, + user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN), + ) + + assert result["message"] == "Successfully retrieved tools" + assert captured["mcp_auth_header"] is None + assert captured["oauth2_headers"] == oauth_headers + class TestListToolsRestAPI: pytestmark = pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx new file mode 100644 index 00000000000..453e3024000 --- /dev/null +++ b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { Button, Form, Input } from "antd"; +import { AUTH_TYPE } from "./types"; + +interface PassthroughOAuthFlow { + startOAuthFlow: () => void | Promise; + status: string; + error: string | null; + tokenResponse: { access_token?: string; expires_in?: number } | null; +} + +/** + * Browser-only Authorize & Fetch for the client-forwarded token modes + * (true_passthrough / oauth_delegate). LiteLLM never stores upstream + * credentials for these modes, so the token obtained here lives in this + * browser session only: it is forwarded per-server for the tools preview and + * allowlist configuration, and is never written to the server row or the + * per-user credential store. The optional client credentials cover IdPs + * without dynamic client registration (e.g. a pre-registered Slack app) and + * ride the temporary authorize session only. + */ +export default function PassthroughAuthorizeSection({ + authType, + oauthFlow, +}: { + authType?: string | null; + oauthFlow: PassthroughOAuthFlow; +}) { + if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH && authType !== AUTH_TYPE.OAUTH_DELEGATE) return null; + return ( +
+

+ Callers bring their own upstream token for this auth type, so LiteLLM stores no upstream credentials. To preview + tools and configure the tool allowlist, authorize against the upstream here: the token stays in this browser + session only and is never saved to LiteLLM. +

+ OAuth Client ID (optional, not saved)} + name={["credentials", "client_id"]} + extra="Only needed when the upstream does not support dynamic client registration (e.g. a pre-registered Slack app). Used for this browser authorization only." + > + + + OAuth Client Secret (optional, not saved)} + name={["credentials", "client_secret"]} + > + + + + {oauthFlow.error &&

{oauthFlow.error}

} + {oauthFlow.status === "success" && oauthFlow.tokenResponse?.access_token && ( +

+ Token held for this browser session. Tools can now be previewed and configured; nothing was saved to LiteLLM. +

+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index 27e6d6a8e3d..ca94aae20e9 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -189,6 +189,32 @@ describe("CreateMCPServer", () => { ).not.toBeInTheDocument(); }); + it.each([["True Passthrough (no LiteLLM auth)"], ["OAuth Delegate (client-supplied upstream token)"]])( + "should show the browser-only authorize section when %s is selected", + async (optionLabel) => { + await selectHttpTransport(); + + await selectAntOption("Authentication", optionLabel); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Authorize & Fetch Tools (browser-only)" })).toBeInTheDocument(); + }); + expect(screen.getByText("OAuth Client ID (optional, not saved)")).toBeInTheDocument(); + expect(screen.getByText("OAuth Client Secret (optional, not saved)")).toBeInTheDocument(); + }, + ); + + it("should not show the browser-only authorize section for API Key auth", async () => { + await selectHttpTransport(); + + await selectAntOption("Authentication", "API Key"); + + await waitFor(() => { + expect(screen.getByText("Authentication Value")).toBeInTheDocument(); + }); + expect(screen.queryByRole("button", { name: "Authorize & Fetch Tools (browser-only)" })).not.toBeInTheDocument(); + }); + it("should not require auth value when creating a server with API Key auth type", async () => { await selectHttpTransport(); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 1362835f475..b123e3397a1 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -17,6 +17,7 @@ import { } from "./types"; import OAuthFormFields from "./OAuthFormFields"; import TruePassthroughWarning from "./TruePassthroughWarning"; +import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection"; import TokenExchangeFormFields from "./TokenExchangeFormFields"; import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPConnectionStatus from "./mcp_connection_status"; @@ -980,6 +981,16 @@ const CreateMCPServer: React.FC = ({ + + {shouldShowAuthValueField && ( { expect(mockGetToken).toHaveBeenCalledWith("oauth_server_1", "user-1"); }); + it("forwards the sessionStorage token as the x-mcp header for an oauth_delegate server", async () => { + mockIsTokenValid.mockReturnValue(true); + mockGetToken.mockReturnValue({ access_token: "browser-token" }); + + render( + , + ); + + await waitFor(() => { + expect(networking.listMCPTools).toHaveBeenCalledWith( + "access-token", + "oauth_server_1", + { "x-mcp-oauth_server-authorization": "Bearer browser-token" }, + true, + ); + }); + expect(mockGetToken).toHaveBeenCalledWith("oauth_server_1", "user-1"); + }); + + it("prompts for the browser-only authorize when a true_passthrough server has no token", async () => { + mockIsTokenValid.mockReturnValue(false); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId("mcp-tool-config").getAttribute("data-external-error")).toContain( + "Authorize with the upstream (browser-only", + ); + }); + expect(networking.listMCPTools).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Authorize & Fetch Tools (browser-only)" })).toBeInTheDocument(); + }); + it("uses the staged OAuth token to load passthrough tools after authorize", async () => { const passthroughServer = { ...interactiveOAuthServer, delegate_auth_to_upstream: true }; mockIsTokenValid.mockReturnValue(false); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 2c4f674c14b..ee67ead8121 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -19,6 +19,7 @@ import { buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils"; import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPPermissionManagement from "./MCPPermissionManagement"; import TruePassthroughWarning from "./TruePassthroughWarning"; +import PassthroughAuthorizeSection from "./PassthroughAuthorizeSection"; import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; import TokenExchangeFormFields from "./TokenExchangeFormFields"; @@ -172,20 +173,40 @@ const MCPServerEdit: React.FC = ({ }; }, onTokenReceived: (token) => { - if (token?.access_token) { - const credentials = { - access_token: token.access_token, - ...(token.refresh_token && { refresh_token: token.refresh_token }), - ...(token.expires_in && { expires_in: token.expires_in }), - ...(token.scope && { scope: token.scope }), - }; - - form.setFieldsValue({ credentials }); - - NotificationsManager.success( - "OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.", - ); + if (!token?.access_token) { + return; } + + const effectiveAuthType = form.getFieldValue("auth_type") ?? mcpServer.auth_type; + if (effectiveAuthType === AUTH_TYPE.TRUE_PASSTHROUGH || effectiveAuthType === AUTH_TYPE.OAUTH_DELEGATE) { + setToken( + mcpServer.server_id, + { + access_token: token.access_token, + expires_in: token.expires_in, + refresh_token: token.refresh_token, + token_type: token.token_type, + }, + userID, + ); + NotificationsManager.success( + "Token held for this browser session. Tools can now be loaded and configured; nothing was saved to LiteLLM.", + ); + return; + } + + const credentials = { + access_token: token.access_token, + ...(token.refresh_token && { refresh_token: token.refresh_token }), + ...(token.expires_in && { expires_in: token.expires_in }), + ...(token.scope && { scope: token.scope }), + }; + + form.setFieldsValue({ credentials }); + + NotificationsManager.success( + "OAuth authorization successful! Please click 'Update MCP Server' to save the credentials.", + ); }, onBeforeRedirect: persistEditUiState, flowSource: "edit", @@ -369,7 +390,9 @@ const MCPServerEdit: React.FC = ({ oauth2_flow: mcpServer.oauth2_flow, delegate_auth_to_upstream: mcpServer.delegate_auth_to_upstream, }) === "passthrough"; - if (isPassthrough) { + const isBrowserHeldTokenMode = + mcpServer.auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || mcpServer.auth_type === AUTH_TYPE.OAUTH_DELEGATE; + if (isPassthrough || isBrowserHeldTokenMode) { const token = oauthTokenResponse?.access_token ?? (isTokenValid(mcpServer.server_id, userID) @@ -377,7 +400,11 @@ const MCPServerEdit: React.FC = ({ : null); if (!token) { setTools([]); - setToolsError("Authenticate with this server in the Tools tab to load and configure its tools."); + setToolsError( + isBrowserHeldTokenMode + ? "Authorize with the upstream (browser-only, in the Authentication section) to load and configure this server's tools." + : "Authenticate with this server in the Tools tab to load and configure its tools.", + ); return; } customHeaders = buildMcpPassthroughAuthHeader(mcpServer.alias, token); @@ -893,6 +920,15 @@ const MCPServerEdit: React.FC = ({ + )} diff --git a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx index 055e15350ca..3208b6b02b2 100644 --- a/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx +++ b/ui/litellm-dashboard/src/hooks/useTestMCPConnection.tsx @@ -56,7 +56,9 @@ export const useTestMCPConnection = ({ // Check if we have the minimum required fields to fetch tools const isM2MOAuth = formValues.auth_type === AUTH_TYPE.OAUTH2 && formValues.oauth_flow_type === OAUTH_FLOW.M2M; - const requiresOAuthToken = formValues.auth_type === AUTH_TYPE.OAUTH2 && !isM2MOAuth; + const isBrowserHeldTokenMode = + formValues.auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || formValues.auth_type === AUTH_TYPE.OAUTH_DELEGATE; + const requiresOAuthToken = (formValues.auth_type === AUTH_TYPE.OAUTH2 && !isM2MOAuth) || isBrowserHeldTokenMode; const isOpenAPITransport = formValues.transport === TRANSPORT.OPENAPI; const hasEndpoint = isOpenAPITransport ? !!formValues.spec_path : !!formValues.url; From 367aa904de68c6945c6006261b6a1ff6017b750f Mon Sep 17 00:00:00 2001 From: Tin Date: Tue, 7 Jul 2026 21:52:56 -0700 Subject: [PATCH 034/365] fix(ui): wire the tool playground and gateway authorize flow for the client-forwarded token modes The server detail page's Tool Testing Playground gated its browser-held token handling on the legacy PKCE-passthrough shape, so a true_passthrough or oauth_delegate server listed tools unauthenticated and surfaced 'Failed to fetch MCP tools' with no way to authorize. The playground now treats both modes as browser-held-token servers: it reads the sessionStorage token established by the create/edit browser-only Authorize, forwards it via the x-mcp-{alias}-authorization header, evicts it on a 401, and shows its own Authorize gate when the token is absent. That gate's flow uses the gateway's relayed authorize/register/token endpoints with the real server id, which previously 400ed for anything but oauth2. Those endpoints now also accept the client-forwarded token modes (the minted token is upstream-audienced and browser-held; DCR persistence stays off on this path), and registry builds run the same RFC 9728/8414 endpoint discovery for these modes that oauth2 rows get, since their rows never store an authorization_url. --- .../mcp_server/discoverable_endpoints.py | 14 +++-- .../mcp_server/mcp_server_manager.py | 5 +- .../mcp_server/test_discoverable_endpoints.py | 55 +++++++++++++++++++ .../mcp_server/test_mcp_server_manager.py | 33 +++++++++++ .../components/mcp_tools/mcp_tools.test.tsx | 31 +++++++++++ .../src/components/mcp_tools/mcp_tools.tsx | 23 +++++--- 6 files changed, 147 insertions(+), 14 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py index c87e900aa2a..7606deac241 100644 --- a/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/discoverable_endpoints.py @@ -465,8 +465,15 @@ async def _store_per_user_token_server_side( def _raise_if_not_oauth2(mcp_server: MCPServer) -> None: - """Reject a non-oauth2 server from the gateway's OAuth authorize/token/register flow.""" - if mcp_server.auth_type == MCPAuth.oauth2: + """Reject a server without upstream OAuth from the gateway's authorize/token/register flow. + + The client-forwarded token modes (``true_passthrough`` / ``oauth_delegate``) are allowed + through: the caller owns the upstream token, and this relayed flow is how a browser obtains + one against the upstream IdP (the admin UI's browser-only Authorize uses it). The minted + token is upstream-audienced and held by the caller; the gateway persists nothing for these + modes (DCR persistence is opt-in and never enabled on this path). + """ + if mcp_server.auth_type in (MCPAuth.oauth2, MCPAuth.true_passthrough, MCPAuth.oauth_delegate): return raise HTTPException( status_code=400, @@ -515,8 +522,7 @@ async def authorize_with_server( response_type: Optional[str] = None, scope: Optional[str] = None, ): - if mcp_server.auth_type != "oauth2": - raise HTTPException(status_code=400, detail="MCP server is not OAuth2") + _raise_if_not_oauth2(mcp_server) if mcp_server.authorization_url is None: raise HTTPException(status_code=400, detail="MCP server authorization url is not set") diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index c4ad673b88f..c8681b94f5e 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -1384,8 +1384,9 @@ class MCPServerManager: auth_type = cast(MCPAuthType, mcp_server.auth_type) server_url = mcp_server.url + upstream_oauth_auth_types = (MCPAuth.oauth2, MCPAuth.true_passthrough, MCPAuth.oauth_delegate) needs_discovery = bool(server_url) and ( - (auth_type == MCPAuth.oauth2 and not mcp_server.authorization_url) + (auth_type in upstream_oauth_auth_types and not mcp_server.authorization_url) or self._obo_needs_endpoint_discovery( auth_type, mcp_server.token_exchange_endpoint @@ -1396,7 +1397,7 @@ class MCPServerManager: mcp_oauth_metadata = ( await self._descovery_metadata( server_url=server_url, # type: ignore[arg-type] - allow_origin_fallback=auth_type == MCPAuth.oauth2, + allow_origin_fallback=auth_type in upstream_oauth_auth_types, ) if needs_discovery else None diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 19d030f17c4..926c3d5a1f4 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -119,6 +119,61 @@ async def test_authorize_endpoint_includes_response_type(): assert "scope=read+write" in response.headers["location"] +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type_value", ["true_passthrough", "oauth_delegate"]) +async def test_authorize_endpoint_allows_client_forwarded_modes(auth_type_value): + """The browser-only Authorize relays the gateway authorize flow for the client-forwarded + token modes; the oauth2-only gate must let them through and redirect to the upstream IdP.""" + try: + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + authorize, + ) + from litellm.proxy._experimental.mcp_server.mcp_server_manager import ( + global_mcp_server_manager, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp import MCPAuth + from litellm.types.mcp_server.mcp_server_manager import MCPServer + except ImportError: + pytest.skip("MCP discoverable endpoints not available") + + global_mcp_server_manager.registry.clear() + + server = MCPServer( + server_id="test_cf_server", + name="test_cf", + server_name="test_cf", + alias="test_cf", + transport=MCPTransport.http, + auth_type=MCPAuth(auth_type_value), + # Discovery stamps these onto the in-memory registry entry at build time. + authorization_url="https://provider.com/oauth/authorize", + token_url="https://provider.com/oauth/token", + ) + global_mcp_server_manager.registry[server.server_id] = server + + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + with patch("litellm.proxy._experimental.mcp_server.discoverable_endpoints.encrypt_value_helper") as mock_encrypt: + mock_encrypt.return_value = "mocked_encrypted_state" + + response = await authorize( + request=mock_request, + client_id="dcr_client_id", + mcp_server_name="test_cf", + redirect_uri="http://127.0.0.1:60108/callback", + state="test_state", + ) + + assert response.status_code == 307 + assert "https://provider.com/oauth/authorize" in response.headers["location"] + assert "client_id=dcr_client_id" in response.headers["location"] + + @pytest.mark.asyncio async def test_authorize_endpoint_preserves_existing_query_params(): """Test that authorize endpoint merges OAuth params with existing query params in authorization_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 12e22de195b..e8fca9ac6ab 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 @@ -833,6 +833,39 @@ class TestMCPServerManager: assert spec is not None and isinstance(spec.config, TokenExchangeConfig) assert spec.config.profile == "entra_obo" + @pytest.mark.asyncio + @pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) + async def test_build_from_table_discovers_upstream_oauth_for_client_forwarded_modes(self, auth_type): + """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( + server_id="cf-db-1", + alias="cf_db", + description="client-forwarded from db", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=auth_type, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + metadata = SimpleNamespace( + authorization_url="https://idp.example.com/authorize", + token_url="https://idp.example.com/token", + registration_url="https://idp.example.com/register", + scopes=None, + ) + 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/authorize" + assert built.token_url == "https://idp.example.com/token" + 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``.""" diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.test.tsx index 3346bc342f3..2e8fa901f6d 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.test.tsx @@ -91,6 +91,37 @@ describe("MCPToolsViewer auth gate routing", () => { expect(screen.queryByText(GATE_TEXT)).not.toBeInTheDocument(); }); + it.each([["true_passthrough"], ["oauth_delegate"]])( + "shows the Authorize gate for a %s server without a browser token and does not list tools", + async (authType) => { + renderViewer({ auth_type: authType, oauth2_flow: null, delegate_auth_to_upstream: false }); + + expect(await screen.findByText(GATE_TEXT)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Authorize" })).toBeInTheDocument(); + expect(vi.mocked(listMCPTools)).not.toHaveBeenCalled(); + expect(vi.mocked(getMCPOAuthUserCredentialStatus)).not.toHaveBeenCalled(); + }, + ); + + it.each([["true_passthrough"], ["oauth_delegate"]])( + "forwards the session token via the x-mcp header for a %s server that has one", + async (authType) => { + vi.mocked(isTokenValid).mockReturnValue(true); + vi.mocked(getToken).mockReturnValue({ access_token: "upstream-tok" } as ReturnType); + + renderViewer({ auth_type: authType, oauth2_flow: null, delegate_auth_to_upstream: false }); + + await waitFor(() => + expect(vi.mocked(listMCPTools)).toHaveBeenCalledWith( + "litellm-key", + "srv-1", + expect.objectContaining({ "x-mcp-slack-authorization": "Bearer upstream-tok" }), + ), + ); + expect(screen.queryByText(GATE_TEXT)).not.toBeInTheDocument(); + }, + ); + it("lists tools for an OBO server when the user has a DB credential, with no x-mcp header", async () => { renderViewer({ oauth2_flow: null, delegate_auth_to_upstream: false }); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx index e436732ba3b..6c99a53afaa 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { ToolTestPanel } from "./ToolTestPanel"; import { resolveLogoSrc } from "@/lib/assetPaths"; -import { MCPTool, MCPToolsViewerProps, MCPContent, CallMCPToolResponse, getMcpOAuthMode } from "./types"; +import { AUTH_TYPE, MCPTool, MCPToolsViewerProps, MCPContent, CallMCPToolResponse, getMcpOAuthMode } from "./types"; import { listMCPTools, callMCPTool, getMCPOAuthUserCredentialStatus } from "../networking"; import { isTokenValid, getToken, removeToken } from "@/utils/mcpTokenStore"; import { sanitizeMcpAliasForHeader, buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils"; @@ -42,19 +42,24 @@ const MCPToolsViewer = ({ // service token and needs no gate. const oauthMode = getMcpOAuthMode({ auth_type, oauth2_flow, delegate_auth_to_upstream }); const isPassthrough = oauthMode === "passthrough"; + // The client-forwarded token modes gate the same way as PKCE passthrough: the + // browser session token (established via the browser-only Authorize in the + // create/edit forms, or right here) is the upstream credential. + const usesBrowserHeldToken = + isPassthrough || auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || auth_type === AUTH_TYPE.OAUTH_DELEGATE; const isAuthorizationCode = oauthMode === "authorization_code"; const [oauthToken, setOauthToken] = useState(() => - isPassthrough && isTokenValid(serverId, userID) ? getToken(serverId, userID)?.access_token ?? null : null, + usesBrowserHeldToken && isTokenValid(serverId, userID) ? getToken(serverId, userID)?.access_token ?? null : null, ); // Re-sync token when serverId/userID changes (useState initializer only runs on mount). useEffect(() => { - if (!isPassthrough) { + if (!usesBrowserHeldToken) { setOauthToken(null); return; } setOauthToken(isTokenValid(serverId, userID) ? getToken(serverId, userID)?.access_token ?? null : null); - }, [serverId, userID, isPassthrough]); + }, [serverId, userID, usesBrowserHeldToken]); const { startOAuthFlow, @@ -109,7 +114,7 @@ const MCPToolsViewer = ({ // x-mcp-{alias}-{header} pattern and forwards it to the upstream MCP server. // When no alias is available, fall back to x-mcp-auth (legacy but still supported). // Passthrough only: authorization_code/token_exchange/M2M tokens are attached server-side, not from the browser. - if (isPassthrough && oauthToken) { + if (usesBrowserHeldToken && oauthToken) { Object.assign(customHeaders, buildMcpPassthroughAuthHeader(serverAlias, oauthToken)); } @@ -164,7 +169,8 @@ const MCPToolsViewer = ({ // Passthrough blocks until a browser session token exists; authorization_code blocks until // the user has a valid DB credential (else the backend returns no tools). enabled: - !!accessToken && (isPassthrough ? oauthToken !== null : isAuthorizationCode ? hasAuthorizationCodeCred : true), + !!accessToken && + (usesBrowserHeldToken ? oauthToken !== null : isAuthorizationCode ? hasAuthorizationCodeCred : true), staleTime: 30000, // Consider data fresh for 30 seconds retry: (failureCount, error: any) => { // Don't retry on 401 — token is invalid, user must re-authenticate @@ -253,7 +259,8 @@ const MCPToolsViewer = ({ // passthrough needs a browser token; authorization_code needs a stored DB credential or a // still-valid one — a 401 from the list call means the backend has none even // after attempting a refresh, so re-authorization is required. - const authGateActive = (isPassthrough && !oauthToken) || authorizationCodeNeedsAuth || authorizationCodeTokenRejected; + const authGateActive = + (usesBrowserHeldToken && !oauthToken) || authorizationCodeNeedsAuth || authorizationCodeTokenRejected; // Treat authorization_code credential-status loading as "tools loading" so the empty state // doesn't flash before we know whether the user needs to authorize. const toolsAreaLoading = isLoadingTools || authorizationCodeStatusLoading; @@ -359,7 +366,7 @@ const MCPToolsViewer = ({ {/* Passthrough auth gate — browser session token absent */} - {isPassthrough && !oauthToken && ( + {usesBrowserHeldToken && !oauthToken && (

Authentication required

From 74a15c21ae2324af75f786b748aff9313e609f8e Mon Sep 17 00:00:00 2001 From: Tin Date: Tue, 7 Jul 2026 22:45:48 -0700 Subject: [PATCH 035/365] fix(mcp): run upstream OAuth endpoint discovery for config-defined client-forwarded servers The DB build already discovers authorization/token endpoints for true_passthrough and oauth_delegate rows; the config.yaml load path kept the oauth2-only gate, so a YAML-defined server in either mode could not use the relayed authorize flow unless the YAML declared authorization_url. Both paths now share the same auth type set. --- litellm/proxy/_experimental/mcp_server/mcp_server_manager.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index c8681b94f5e..dd7d3e96262 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -983,8 +983,9 @@ class MCPServerManager: ) auth_type = server_config.get("auth_type", None) + config_upstream_oauth_auth_types = (MCPAuth.oauth2, MCPAuth.true_passthrough, MCPAuth.oauth_delegate) if server_url and ( - auth_type == MCPAuth.oauth2 + auth_type in config_upstream_oauth_auth_types or self._obo_needs_endpoint_discovery( auth_type, server_config.get("token_exchange_endpoint"), @@ -993,7 +994,7 @@ class MCPServerManager: ): mcp_oauth_metadata = await self._descovery_metadata( server_url=server_url, - allow_origin_fallback=auth_type == MCPAuth.oauth2, + allow_origin_fallback=auth_type in config_upstream_oauth_auth_types, ) else: mcp_oauth_metadata = None From 98818df418f4e613e510500c91ad1a86a12c1d6c Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 14:28:19 -0700 Subject: [PATCH 036/365] fix(mcp): recognize per-server auth header at connect and stop persisting browser-authorize tokens Two correctness fixes for the client-forwarded token modes. The preemptive-401 connect gate for true_passthrough and oauth_delegate only inspected the request-wide Authorization, so a caller who bound the upstream token via the per-server x-mcp-{alias}-authorization header (the mandatory shape in a multi-server aggregate, where the request-wide Authorization is withheld) was spuriously 401'd at connect even though egress already honors that header. The gate now recognizes the per-server header for both modes via a shared helper, mode-correctly: true_passthrough treats any Authorization or the per-server header as the upstream token, oauth_delegate keeps requiring a distinct x-litellm-api-key so a lone Authorization consumed for admission is never mistaken for an upstream token. The preemptive raise is also gated to single-server scopes so a multi-server aggregate degrades gracefully (the listing absorbs a per-server failure) instead of one missing token 401-ing the whole connect. The browser-only Authorize flow was writing the upstream access and refresh token to LiteLLM_MCPUserCredentials, contradicting the modes' persist-nothing contract: the temp OAuth-relay server was cached with a hardcoded oauth2 auth_type, so needs_user_oauth_token was true and the token exchange stored it. The create and edit forms now send the real auth_type for these modes, so the temp server is not oauth2, needs_user_oauth_token is false, and the exchange skips storage while still returning the token to the browser session. --- .../mcp_server/test_discoverable_endpoints.py | 79 +++++++++++++++++++ .../mcp_tools/create_mcp_server.tsx | 5 +- .../components/mcp_tools/mcp_server_edit.tsx | 5 +- 3 files changed, 87 insertions(+), 2 deletions(-) diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py index 926c3d5a1f4..9de342eabd7 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_discoverable_endpoints.py @@ -6,6 +6,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import HTTPException +from litellm.types.mcp import MCPAuth + # Fixture to mock IP address check for all MCP tests # This prevents tests from failing due to IP-based access control @@ -3403,6 +3405,83 @@ async def test_token_exchange_passes_through_upstream_expires_in(): assert body["expires_in"] == 43200 +async def _exchange_persistence_attempted_for_auth_type(auth_type) -> bool: + """Run exchange_token_with_server for a server of ``auth_type`` and report whether it attempted + to persist the exchanged token server-side. The client-forwarded token modes must not persist: + their contract is that the upstream token stays browser-held, minted/stored/refreshed nowhere.""" + from fastapi import Request + + from litellm.proxy._experimental.mcp_server.discoverable_endpoints import ( + exchange_token_with_server, + ) + from litellm.proxy._types import MCPTransport + from litellm.types.mcp_server.mcp_server_manager import MCPServer + + server = MCPServer( + server_id="t", + name="t", + server_name="t", + alias="t", + transport=MCPTransport.http, + auth_type=auth_type, + client_id="cid", + client_secret="cs", + authorization_url="https://provider.com/oauth/authorize", + token_url="https://provider.com/oauth/token", + ) + mock_request = MagicMock(spec=Request) + mock_request.base_url = "https://litellm.example.com/" + mock_request.headers = {} + + fake_http_response = MagicMock() + fake_http_response.json.return_value = {"access_token": "tok", "refresh_token": "r", "token_type": "Bearer"} + fake_http_response.raise_for_status = MagicMock() + fake_http_client = MagicMock() + fake_http_client.post = AsyncMock(return_value=fake_http_response) + + with ( + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints.get_async_httpx_client", + return_value=fake_http_client, + ), + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints._extract_user_id_from_request", + new_callable=AsyncMock, + return_value="admin-user", + ), + patch( + "litellm.proxy._experimental.mcp_server.discoverable_endpoints._store_per_user_token_server_side", + new_callable=AsyncMock, + ) as mock_store, + ): + await exchange_token_with_server( + request=mock_request, + mcp_server=server, + grant_type="authorization_code", + code="c", + redirect_uri="http://127.0.0.1:3000/cb", + client_id="cid", + client_secret=None, + code_verifier=None, + ) + return mock_store.await_count > 0 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("auth_type", [MCPAuth.true_passthrough, MCPAuth.oauth_delegate]) +async def test_token_exchange_does_not_persist_for_client_forwarded_modes(auth_type): + """The browser-only Authorize for true_passthrough / oauth_delegate must not write the upstream + token to the DB: these modes forward a browser-held token and persist nothing server-side.""" + assert await _exchange_persistence_attempted_for_auth_type(auth_type) is False + + +@pytest.mark.asyncio +async def test_token_exchange_persists_for_oauth2(): + """Guard the test's own discriminator: a genuine oauth2 (authorization_code) server DOES persist, + so the passthrough no-persist assertion above is meaningful and not vacuously true.""" + assert await _exchange_persistence_attempted_for_auth_type(MCPAuth.oauth2) is True + + # ------------------------------------------------------------------- # OBO (token_exchange) Protected Resource Metadata: discovery must name the # JWT-auth issuer the client SSOs with, not the gateway. diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index b123e3397a1..ad7d4530c92 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -184,7 +184,10 @@ const CreateMCPServer: React.FC = ({ description: values.description, url, transport: transport === TRANSPORT.OPENAPI ? "http" : transport, - auth_type: AUTH_TYPE.OAUTH2, + auth_type: + values.auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || values.auth_type === AUTH_TYPE.OAUTH_DELEGATE + ? values.auth_type + : AUTH_TYPE.OAUTH2, credentials: values.credentials, authorization_url: values.authorization_url, token_url: values.token_url, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index ee67ead8121..7d9316e1baa 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -163,7 +163,10 @@ const MCPServerEdit: React.FC = ({ description: values.description || mcpServer.description, url, transport, - auth_type: AUTH_TYPE.OAUTH2, + auth_type: + mcpServer.auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || mcpServer.auth_type === AUTH_TYPE.OAUTH_DELEGATE + ? mcpServer.auth_type + : AUTH_TYPE.OAUTH2, credentials: values.credentials, mcp_access_groups: values.mcp_access_groups || mcpServer.mcp_access_groups, static_headers: staticHeaders, From 1693761a51ef0e6481a784088d44f901fbc1cc2c Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 14:34:20 -0700 Subject: [PATCH 037/365] feat(mcp): record auth_mode and upstream resource on MCP tool-call logs Adds mcp_auth_mode and mcp_server_resource to StandardLoggingMCPToolCall so a relayed passthrough/delegate request can be attributed in an audit to its mode and its upstream target without logging any credential. Both are non-sensitive metadata derived from the resolved server; the admission and upstream tokens stay SecretStr and are never logged. --- litellm/proxy/_experimental/mcp_server/server.py | 2 ++ litellm/types/utils.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 55a0fa083c0..5c814774fda 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -3064,6 +3064,8 @@ if MCP_AVAILABLE: mcp_server_logo_url=mcp_info.get("logo_url"), namespaced_tool_name=namespaced_tool_name, mcp_session_id=session_id, + mcp_auth_mode=mcp_server.auth_type, + mcp_server_resource=mcp_server.url, ) else: return StandardLoggingMCPToolCall( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index 908f5b76424..e71c42084d1 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2523,6 +2523,20 @@ class StandardLoggingMCPToolCall(TypedDict, total=False): the client is driving a stateful session. Absent for stateless calls. """ + mcp_auth_mode: Optional[str] + """ + The server's auth_type for this call (e.g. `true_passthrough`, `oauth_delegate`, + `oauth2`). For the client-forwarded token modes this records that the caller's own + upstream token was relayed, so an audit can attribute a relayed request to its mode + without logging any credential. + """ + + mcp_server_resource: Optional[str] + """ + The upstream MCP server URL (the RFC 8707 resource) the tool call was forwarded to. + Records which upstream received a relayed request; never a credential. + """ + class StandardLoggingVectorStoreRequest(TypedDict, total=False): """ From 7c52cde5057131469fccd1d8f6625c81c9b5d7d9 Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 15:20:07 -0700 Subject: [PATCH 038/365] fix(mcp): redact upstream URL in tool-call logs and plug fan-out Authorization bypass Two review findings on the passthrough modes. The tool-call log records the upstream MCP server URL as mcp_server_resource, which is persisted in spend-log metadata and sent to logging callbacks. A URL carrying embedded userinfo or a secret query parameter would leak into logs, so the value is now redacted to its bare resource identifier (scheme + host + path); userinfo, query string, and fragment are stripped before it is logged. The listing fan-out withholds the request-wide Authorization from a true_passthrough / oauth_delegate server when another server in scope also consumes it, so one bearer is not replayed across upstreams. The later server.extra_headers copy loop did not honor that decision: a server listing Authorization in extra_headers would re-copy the withheld bearer from raw_headers. The withhold decision is now computed once and applied to both the forwarding branch and the extra_headers loop. --- .../proxy/_experimental/mcp_server/server.py | 23 ++++++++++++++++++- litellm/types/utils.py | 4 +++- .../mcp_server/test_mcp_server.py | 22 ++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index 5c814774fda..938cc2bc43b 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -27,6 +27,7 @@ from typing import ( Union, cast, ) +from urllib.parse import urlsplit, urlunsplit import httpx from fastapi import FastAPI, HTTPException @@ -105,6 +106,26 @@ _MAX_STATEFUL_SESSIONS_PER_OWNER = 100 _MCP_ROUTING_PEEK_MAX_BYTES = 4096 +def _redact_mcp_resource_url(url: Optional[str]) -> Optional[str]: + """Reduce an MCP server URL to its bare resource identifier for logging. + + Keeps scheme, host, and path; drops userinfo (``user:pass@``), the query string, + and the fragment, so an upstream URL carrying an embedded token, userinfo, or a + secret query parameter never reaches spend-log metadata or logging callbacks. + Returns None when the URL has no host to identify (nothing safe to log). + """ + if not isinstance(url, str) or not url: + return None + try: + parts = urlsplit(url) + except ValueError: + return None + if not parts.hostname: + return None + netloc = f"{parts.hostname}:{parts.port}" if parts.port else parts.hostname + return urlunsplit((parts.scheme, netloc, parts.path, "", "")) or None + + def _invalidate_byok_cred_cache(user_id: str, server_id: str) -> None: """Remove a (user_id, server_id) entry from the BYOK credential cache. @@ -3065,7 +3086,7 @@ if MCP_AVAILABLE: namespaced_tool_name=namespaced_tool_name, mcp_session_id=session_id, mcp_auth_mode=mcp_server.auth_type, - mcp_server_resource=mcp_server.url, + mcp_server_resource=_redact_mcp_resource_url(mcp_server.url), ) else: return StandardLoggingMCPToolCall( diff --git a/litellm/types/utils.py b/litellm/types/utils.py index e71c42084d1..6b99cfa3314 100644 --- a/litellm/types/utils.py +++ b/litellm/types/utils.py @@ -2533,7 +2533,9 @@ class StandardLoggingMCPToolCall(TypedDict, total=False): mcp_server_resource: Optional[str] """ - The upstream MCP server URL (the RFC 8707 resource) the tool call was forwarded to. + The upstream MCP server resource identifier (scheme + host + path) the tool call was + forwarded to. Redacted for logging: userinfo, query string, and fragment are stripped so an + upstream URL carrying an embedded token or secret query parameter never reaches log metadata. Records which upstream received a relayed request; never a credential. """ diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py index e9dccdcd4ad..0db36e75e48 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_mcp_server.py @@ -6854,3 +6854,25 @@ async def test_call_tool_with_legacy_db_m2m_server_resolves_oauth2_flow(): resolved = captured_servers["allowed"] assert resolved and resolved[0].oauth2_flow == "client_credentials" assert resolved[0].has_client_credentials is True + + +@pytest.mark.parametrize( + "url, expected", + [ + # userinfo + secret query param must both be stripped from the logged resource + ("https://user:s3cr3t@mcp.example.com/mcp?token=abcd1234&x=1", "https://mcp.example.com/mcp"), + ("https://mcp.example.com/mcp#frag", "https://mcp.example.com/mcp"), + ("https://host:8443/a/b?q=1", "https://host:8443/a/b"), + ("https://mcp.notion.com/mcp", "https://mcp.notion.com/mcp"), + (None, None), + ("", None), + ("not a url", None), + ], +) +def test_redact_mcp_resource_url_strips_credentials(url, expected): + """The MCP tool-call log records the upstream resource, so the URL must be redacted to + scheme+host+path: userinfo, query string, and fragment (which can carry embedded tokens or + secret parameters) must never reach spend-log metadata or logging callbacks.""" + from litellm.proxy._experimental.mcp_server.server import _redact_mcp_resource_url + + assert _redact_mcp_resource_url(url) == expected From b62b30bac0d575425da7e484cbd4f93360847b6a Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 16:10:30 -0700 Subject: [PATCH 039/365] fix(ui): edit-form browser-authorize payload uses the selected auth_type The edit form's getTemporaryPayload read the server's stored auth_type instead of the value the admin selected in the dropdown, so an admin who switched an existing oauth2 server to true_passthrough (or oauth_delegate) and ran the browser authorize flow built the temporary OAuth-relay server as oauth2. That made needs_user_oauth_token true and persisted the token to the DB, contrary to the mode's browser-held contract, and left it inconsistent with onTokenReceived and the submit payload, both of which already read the form value. It now reads values.auth_type, matching the create form. --- .../mcp_tools/mcp_server_edit.test.tsx | 44 ++++++++++++++++--- .../components/mcp_tools/mcp_server_edit.tsx | 4 +- 2 files changed, 39 insertions(+), 9 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx index f071e247466..0579a3208d1 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.test.tsx @@ -19,14 +19,20 @@ vi.mock("../molecules/notifications_manager", () => ({ }, })); -const mockOauth: { tokenResponse: any } = { tokenResponse: null }; +const mockOauth: { + tokenResponse: any; + getTemporaryPayload: (() => Record | null) | null; +} = { tokenResponse: null, getTemporaryPayload: null }; vi.mock("@/hooks/useMcpOAuthFlow", () => ({ - useMcpOAuthFlow: () => ({ - startOAuthFlow: vi.fn(), - status: "idle", - error: null, - tokenResponse: mockOauth.tokenResponse, - }), + useMcpOAuthFlow: (opts: { getTemporaryPayload?: () => Record | null }) => { + mockOauth.getTemporaryPayload = opts?.getTemporaryPayload ?? null; + return { + startOAuthFlow: vi.fn(), + status: "idle", + error: null, + tokenResponse: mockOauth.tokenResponse, + }; + }, })); vi.mock("./mcp_server_cost_config", () => ({ @@ -344,6 +350,30 @@ describe("MCPServerEdit (true passthrough warning)", () => { screen.queryByText("True Passthrough disables LiteLLM authentication for this server"), ).not.toBeInTheDocument(); }); + + it("browser-authorize temp payload uses the selected auth_type, not the stored one", async () => { + // Stored server is oauth2; the admin switches the dropdown to true_passthrough before saving. + // The temp OAuth-relay payload must reflect the selection so the exchange is treated as + // browser-held (no DB persistence), matching onTokenReceived and the create form. + render( + , + ); + + await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)"); + + await waitFor(() => { + expect(mockOauth.getTemporaryPayload).toBeTruthy(); + }); + const payload = mockOauth.getTemporaryPayload!(); + expect(payload).toBeTruthy(); + expect(payload?.auth_type).toBe("true_passthrough"); + }); }); describe("MCPServerEdit (auth type switch)", () => { diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 7d9316e1baa..0a00f05ab53 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -164,8 +164,8 @@ const MCPServerEdit: React.FC = ({ url, transport, auth_type: - mcpServer.auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || mcpServer.auth_type === AUTH_TYPE.OAUTH_DELEGATE - ? mcpServer.auth_type + values.auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || values.auth_type === AUTH_TYPE.OAUTH_DELEGATE + ? values.auth_type : AUTH_TYPE.OAUTH2, credentials: values.credentials, mcp_access_groups: values.mcp_access_groups || mcpServer.mcp_access_groups, From ee5a0651161b8c71a5852e2590e6c9f9285f937b Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 17:39:01 -0700 Subject: [PATCH 040/365] refactor(ui): extract isClientForwardedTokenMode helper for the pass-through modes The 'auth_type is true_passthrough or oauth_delegate' check was duplicated inline across both server forms' browser-authorize temp payloads, the edit form's onTokenReceived and tool-preview gate, PassthroughAuthorizeSection, and mcp_tools' usesBrowserHeldToken. Extracted a single isClientForwardedTokenMode helper in types.tsx and routed every site through it so the set of client-forwarded modes lives in one place and cannot drift. Also replaced a pre-existing nested ternary in the authorize button label surfaced by touching the file. --- ui/litellm-dashboard/eslint-metrics.json | 2 +- .../mcp_tools/PassthroughAuthorizeSection.tsx | 15 ++++++++------- .../components/mcp_tools/create_mcp_server.tsx | 6 ++---- .../src/components/mcp_tools/mcp_server_edit.tsx | 11 ++++------- .../src/components/mcp_tools/mcp_tools.tsx | 12 +++++++++--- .../src/components/mcp_tools/types.tsx | 7 +++++++ 6 files changed, 31 insertions(+), 22 deletions(-) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index 37bad071081..e4647b33d61 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { "@typescript-eslint/no-explicit-any": 1982, "complexity": 128, - "local/no-large-inline-object-arg": 519, + "local/no-large-inline-object-arg": 513, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 diff --git a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx index 453e3024000..af81f2713ae 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/PassthroughAuthorizeSection.tsx @@ -1,6 +1,6 @@ import React from "react"; import { Button, Form, Input } from "antd"; -import { AUTH_TYPE } from "./types"; +import { isClientForwardedTokenMode } from "./types"; interface PassthroughOAuthFlow { startOAuthFlow: () => void | Promise; @@ -26,7 +26,12 @@ export default function PassthroughAuthorizeSection({ authType?: string | null; oauthFlow: PassthroughOAuthFlow; }) { - if (authType !== AUTH_TYPE.TRUE_PASSTHROUGH && authType !== AUTH_TYPE.OAUTH_DELEGATE) return null; + if (!isClientForwardedTokenMode(authType)) return null; + const authorizeButtonLabels: Record = { + authorizing: "Waiting for authorization...", + exchanging: "Exchanging authorization code...", + }; + const authorizeButtonLabel = authorizeButtonLabels[oauthFlow.status] ?? "Authorize & Fetch Tools (browser-only)"; return (

@@ -57,11 +62,7 @@ export default function PassthroughAuthorizeSection({ onClick={oauthFlow.startOAuthFlow} disabled={oauthFlow.status === "authorizing" || oauthFlow.status === "exchanging"} > - {oauthFlow.status === "authorizing" - ? "Waiting for authorization..." - : oauthFlow.status === "exchanging" - ? "Exchanging authorization code..." - : "Authorize & Fetch Tools (browser-only)"} + {authorizeButtonLabel} {oauthFlow.error &&

{oauthFlow.error}

} {oauthFlow.status === "success" && oauthFlow.tokenResponse?.access_token && ( diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index ad7d4530c92..7d160407d02 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -14,6 +14,7 @@ import { getMcpOAuthMode, MCP_OAUTH2_FLOW_M2M, MCP_OAUTH2_FLOW_INTERACTIVE, + isClientForwardedTokenMode, } from "./types"; import OAuthFormFields from "./OAuthFormFields"; import TruePassthroughWarning from "./TruePassthroughWarning"; @@ -184,10 +185,7 @@ const CreateMCPServer: React.FC = ({ description: values.description, url, transport: transport === TRANSPORT.OPENAPI ? "http" : transport, - auth_type: - values.auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || values.auth_type === AUTH_TYPE.OAUTH_DELEGATE - ? values.auth_type - : AUTH_TYPE.OAUTH2, + auth_type: isClientForwardedTokenMode(values.auth_type) ? values.auth_type : AUTH_TYPE.OAUTH2, credentials: values.credentials, authorization_url: values.authorization_url, token_url: values.token_url, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 0a00f05ab53..2c43b9cb056 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -4,6 +4,7 @@ import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { AUTH_TYPE, + isClientForwardedTokenMode, OAUTH_FLOW, MCP_OAUTH2_FLOW_M2M, MCP_OAUTH2_FLOW_INTERACTIVE, @@ -163,10 +164,7 @@ const MCPServerEdit: React.FC = ({ description: values.description || mcpServer.description, url, transport, - auth_type: - values.auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || values.auth_type === AUTH_TYPE.OAUTH_DELEGATE - ? values.auth_type - : AUTH_TYPE.OAUTH2, + auth_type: isClientForwardedTokenMode(values.auth_type) ? values.auth_type : AUTH_TYPE.OAUTH2, credentials: values.credentials, mcp_access_groups: values.mcp_access_groups || mcpServer.mcp_access_groups, static_headers: staticHeaders, @@ -181,7 +179,7 @@ const MCPServerEdit: React.FC = ({ } const effectiveAuthType = form.getFieldValue("auth_type") ?? mcpServer.auth_type; - if (effectiveAuthType === AUTH_TYPE.TRUE_PASSTHROUGH || effectiveAuthType === AUTH_TYPE.OAUTH_DELEGATE) { + if (isClientForwardedTokenMode(effectiveAuthType)) { setToken( mcpServer.server_id, { @@ -393,8 +391,7 @@ const MCPServerEdit: React.FC = ({ oauth2_flow: mcpServer.oauth2_flow, delegate_auth_to_upstream: mcpServer.delegate_auth_to_upstream, }) === "passthrough"; - const isBrowserHeldTokenMode = - mcpServer.auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || mcpServer.auth_type === AUTH_TYPE.OAUTH_DELEGATE; + const isBrowserHeldTokenMode = isClientForwardedTokenMode(mcpServer.auth_type); if (isPassthrough || isBrowserHeldTokenMode) { const token = oauthTokenResponse?.access_token ?? diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx index 6c99a53afaa..928a2e3c6bb 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_tools.tsx @@ -2,7 +2,14 @@ import React, { useCallback, useEffect, useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { ToolTestPanel } from "./ToolTestPanel"; import { resolveLogoSrc } from "@/lib/assetPaths"; -import { AUTH_TYPE, MCPTool, MCPToolsViewerProps, MCPContent, CallMCPToolResponse, getMcpOAuthMode } from "./types"; +import { + isClientForwardedTokenMode, + MCPTool, + MCPToolsViewerProps, + MCPContent, + CallMCPToolResponse, + getMcpOAuthMode, +} from "./types"; import { listMCPTools, callMCPTool, getMCPOAuthUserCredentialStatus } from "../networking"; import { isTokenValid, getToken, removeToken } from "@/utils/mcpTokenStore"; import { sanitizeMcpAliasForHeader, buildMcpPassthroughAuthHeader } from "@/utils/mcpHeaderUtils"; @@ -45,8 +52,7 @@ const MCPToolsViewer = ({ // The client-forwarded token modes gate the same way as PKCE passthrough: the // browser session token (established via the browser-only Authorize in the // create/edit forms, or right here) is the upstream credential. - const usesBrowserHeldToken = - isPassthrough || auth_type === AUTH_TYPE.TRUE_PASSTHROUGH || auth_type === AUTH_TYPE.OAUTH_DELEGATE; + const usesBrowserHeldToken = isPassthrough || isClientForwardedTokenMode(auth_type); const isAuthorizationCode = oauthMode === "authorization_code"; const [oauthToken, setOauthToken] = useState(() => usesBrowserHeldToken && isTokenValid(serverId, userID) ? getToken(serverId, userID)?.access_token ?? null : null, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 70cc8129bf1..dca9e574e22 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -45,6 +45,13 @@ export const AUTH_TYPE = { OAUTH_DELEGATE: "oauth_delegate", }; +// The two client-forwarded token modes: the caller supplies the upstream Authorization (forwarded +// verbatim for true_passthrough, alongside LiteLLM admission for oauth_delegate). The dashboard holds +// their token in sessionStorage instead of persisting it, and the browser-authorize temp payload keeps +// their real auth_type so the backend does not treat them as needing a stored per-user token. +export const isClientForwardedTokenMode = (authType?: string | null): boolean => + authType === AUTH_TYPE.TRUE_PASSTHROUGH || authType === AUTH_TYPE.OAUTH_DELEGATE; + export const OAUTH_FLOW = { INTERACTIVE: "interactive", M2M: "m2m", From a199bf975d588754a33331173dc4a406428a5e51 Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 17:46:28 -0700 Subject: [PATCH 041/365] refactor(mcp): share one constant for the upstream-OAuth discovery auth types The config-YAML loader and the DB loader each defined their own local tuple (oauth2, true_passthrough, oauth_delegate) to decide which auth types trigger upstream OAuth endpoint discovery, under two different names. Hoisted them to a single module constant _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES so the two load paths cannot drift on which modes get discovery. --- .../mcp_server/mcp_server_manager.py | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index dd7d3e96262..356ed7a2729 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -171,6 +171,16 @@ _user_env_vars_cache: dict[tuple[str, str], tuple[dict[str, str], float]] = {} _USER_ENV_VARS_CACHE_TTL = 60 # seconds _USER_ENV_VARS_CACHE_MAX_SIZE = 4096 # cap to prevent unbounded growth +# Auth types whose upstream OAuth endpoints (protected-resource + authorization-server metadata) the +# gateway discovers from the upstream itself: interactive oauth2 and the two client-forwarded modes. +# OBO/M2M endpoint discovery is decided separately via _obo_needs_endpoint_discovery. Shared by the +# config-YAML and DB server loaders so the two paths cannot drift on which modes trigger discovery. +_UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES: tuple[MCPAuth, ...] = ( + MCPAuth.oauth2, + MCPAuth.true_passthrough, + MCPAuth.oauth_delegate, +) + 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 @@ -983,9 +993,8 @@ class MCPServerManager: ) auth_type = server_config.get("auth_type", None) - config_upstream_oauth_auth_types = (MCPAuth.oauth2, MCPAuth.true_passthrough, MCPAuth.oauth_delegate) if server_url and ( - auth_type in config_upstream_oauth_auth_types + auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES or self._obo_needs_endpoint_discovery( auth_type, server_config.get("token_exchange_endpoint"), @@ -994,7 +1003,7 @@ class MCPServerManager: ): mcp_oauth_metadata = await self._descovery_metadata( server_url=server_url, - allow_origin_fallback=auth_type in config_upstream_oauth_auth_types, + allow_origin_fallback=auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, ) else: mcp_oauth_metadata = None @@ -1385,9 +1394,8 @@ class MCPServerManager: auth_type = cast(MCPAuthType, mcp_server.auth_type) server_url = mcp_server.url - upstream_oauth_auth_types = (MCPAuth.oauth2, MCPAuth.true_passthrough, MCPAuth.oauth_delegate) needs_discovery = bool(server_url) and ( - (auth_type in upstream_oauth_auth_types and not mcp_server.authorization_url) + (auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES and not mcp_server.authorization_url) or self._obo_needs_endpoint_discovery( auth_type, mcp_server.token_exchange_endpoint @@ -1398,7 +1406,7 @@ class MCPServerManager: mcp_oauth_metadata = ( await self._descovery_metadata( server_url=server_url, # type: ignore[arg-type] - allow_origin_fallback=auth_type in upstream_oauth_auth_types, + allow_origin_fallback=auth_type in _UPSTREAM_OAUTH_DISCOVERY_AUTH_TYPES, ) if needs_discovery else None From e29e24e628661e1faf75955e66d185386ddf5a4d Mon Sep 17 00:00:00 2001 From: Tin Date: Wed, 8 Jul 2026 23:09:19 -0700 Subject: [PATCH 042/365] fix(ui): keep the browser-authorized token out of form.credentials for the pass-through modes The create form wrote the upstream token obtained by Authorize & Fetch into form.credentials for every mode, so for true_passthrough / oauth_delegate the browser-held token leaked into the OAuth flow's getCredentials (preview requests) and the redirect-persist cache, and was a step away from server-level credential persistence. onTokenReceived now early-returns for the client-forwarded modes, holding the token only in local state for preview (mirroring the edit form), instead of writing it into form.credentials. --- .../mcp_tools/create_mcp_server.test.tsx | 25 +++++++++++ .../mcp_tools/create_mcp_server.tsx | 45 ++++++++++++------- 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx index ca94aae20e9..eecbc253b6b 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.test.tsx @@ -30,6 +30,7 @@ const oauthHook = vi.hoisted(() => ({ onTokenReceived: null as | ((token: Record | null, registeredClient?: { clientId?: string; clientSecret?: string }) => void) | null, + getCredentials: null as (() => Record | undefined) | null, })); vi.mock("@/hooks/useMcpOAuthFlow", () => ({ useMcpOAuthFlow: (opts: { @@ -37,8 +38,10 @@ vi.mock("@/hooks/useMcpOAuthFlow", () => ({ token: Record | null, registeredClient?: { clientId?: string; clientSecret?: string }, ) => void; + getCredentials?: () => Record | undefined; }) => { oauthHook.onTokenReceived = opts.onTokenReceived; + oauthHook.getCredentials = opts.getCredentials ?? null; return { startOAuthFlow: vi.fn(), status: "idle", @@ -349,6 +352,28 @@ describe("CreateMCPServer", () => { expect(payload.credentials).toEqual({ auth_value: "my-secret-key" }); }); + it("does not write the browser-authorized token into form.credentials for true_passthrough", async () => { + await selectHttpTransport(); + + const user = userEvent.setup({ delay: null }); + await user.type(getServerNameInput(), "PT_Server"); + await user.type(screen.getByPlaceholderText("https://your-mcp-server.com"), "https://example.com/mcp"); + + await selectAntOption("Authentication", "True Passthrough (no LiteLLM auth)"); + + // Simulate the browser Authorize & Fetch flow handing back an upstream token. + await waitFor(() => expect(oauthHook.onTokenReceived).toBeTruthy()); + await act(async () => { + oauthHook.onTokenReceived!({ access_token: "upstream-tok", token_type: "Bearer" }, undefined); + }); + + // For a browser-only mode the token must never land in form.credentials, which the OAuth flow's + // getCredentials reads for preview requests and the redirect-persist cache serializes. Without + // the guard, onTokenReceived writes it here and this returns { access_token: "upstream-tok" }. + const credentials = oauthHook.getCredentials?.() ?? {}; + expect(credentials.access_token).toBeUndefined(); + }); + it("should not show auth value field when None auth type is selected", async () => { await selectHttpTransport(); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 7d160407d02..00eda92ba25 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -200,23 +200,36 @@ const CreateMCPServer: React.FC = ({ onTokenReceived: (token, registeredClient) => { setOauthAccessToken(token?.access_token ?? null); - if (token?.access_token) { - const credentials = { - access_token: token.access_token, - ...(token.refresh_token && { refresh_token: token.refresh_token }), - ...(token.expires_in && { expires_in: token.expires_in }), - ...(token.scope && { scope: token.scope }), - ...(registeredClient?.clientId && { client_id: registeredClient.clientId }), - ...(registeredClient?.clientSecret && { client_secret: registeredClient.clientSecret }), - }; - - form.setFieldsValue({ credentials }); - setAuthorizedUrl(getOAuthAuthorizationTarget(form.getFieldsValue(true))); - - NotificationsManager.success( - "OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.", - ); + if (!token?.access_token) { + return; } + + if (isClientForwardedTokenMode(form.getFieldValue("auth_type"))) { + // Browser-only modes: the token is held in local state (oauthAccessToken) for tool preview + // and committed to sessionStorage on submit; it must never be written into form.credentials, + // which would persist it as server-level credentials on the created server row. Mirrors the + // edit form's onTokenReceived early return. + NotificationsManager.success( + "Token held for this browser session. Tools can now be previewed and configured; nothing will be saved to LiteLLM.", + ); + return; + } + + const credentials = { + access_token: token.access_token, + ...(token.refresh_token && { refresh_token: token.refresh_token }), + ...(token.expires_in && { expires_in: token.expires_in }), + ...(token.scope && { scope: token.scope }), + ...(registeredClient?.clientId && { client_id: registeredClient.clientId }), + ...(registeredClient?.clientSecret && { client_secret: registeredClient.clientSecret }), + }; + + form.setFieldsValue({ credentials }); + setAuthorizedUrl(getOAuthAuthorizationTarget(form.getFieldsValue(true))); + + NotificationsManager.success( + "OAuth authorization successful! Please click 'Create MCP Server' to save the configuration.", + ); }, onBeforeRedirect: persistCreateUiState, flowSource: "create", From a3f1873a8791db24e85b5db1266b0e06f8f2f6f3 Mon Sep 17 00:00:00 2001 From: Tin Date: Thu, 9 Jul 2026 11:04:15 -0700 Subject: [PATCH 043/365] fix(ui): extract inline object args in the MCP forms The create/edit forms passed several large object literals inline as arguments (persist-state JSON.stringify, storeMCPOAuthUserCredential, setToken, transport-clear setFieldsValue), tripping local/no-large-inline-object-arg. Assigned each to a named variable at the call site - a pure, behavior-preserving refactor verified by the create/edit suites - which lowers the whole-tree count so the eslint baseline is 512 rather than being raised to accommodate them. --- ui/litellm-dashboard/eslint-metrics.json | 2 +- .../mcp_tools/create_mcp_server.tsx | 48 +++++++++--------- .../components/mcp_tools/mcp_server_edit.tsx | 49 +++++++++---------- 3 files changed, 46 insertions(+), 53 deletions(-) diff --git a/ui/litellm-dashboard/eslint-metrics.json b/ui/litellm-dashboard/eslint-metrics.json index e4647b33d61..ded6ab97e1e 100644 --- a/ui/litellm-dashboard/eslint-metrics.json +++ b/ui/litellm-dashboard/eslint-metrics.json @@ -1,7 +1,7 @@ { "@typescript-eslint/no-explicit-any": 1982, "complexity": 128, - "local/no-large-inline-object-arg": 513, + "local/no-large-inline-object-arg": 512, "local/no-long-condition-chain": 233, "max-depth": 59, "no-console": 15 diff --git a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx index 00eda92ba25..0b39add234c 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/create_mcp_server.tsx @@ -137,20 +137,18 @@ const CreateMCPServer: React.FC = ({ } try { const values = form.getFieldsValue(true); - setSecureItem( - CREATE_OAUTH_UI_STATE_KEY, - JSON.stringify({ - modalVisible: isModalVisible, - formValues: values, - transportType, - costConfig, - allowedTools, - hasToolAllowlistInteraction, - searchValue, - aliasManuallyEdited, - logoUrl, - }), - ); + const uiState = { + modalVisible: isModalVisible, + formValues: values, + transportType, + costConfig, + allowedTools, + hasToolAllowlistInteraction, + searchValue, + aliasManuallyEdited, + logoUrl, + }; + setSecureItem(CREATE_OAUTH_UI_STATE_KEY, JSON.stringify(uiState)); } catch (err) { console.warn("Failed to persist MCP create state", err); } @@ -510,23 +508,21 @@ const CreateMCPServer: React.FC = ({ }); if (oauthMode === "authorization_code") { const scope = oauthTokenResponse.scope; - await storeMCPOAuthUserCredential(accessToken, response.server_id, { + const oauthCredentialPayload = { access_token: oauthTokenResponse.access_token, refresh_token: oauthTokenResponse.refresh_token, expires_in: oauthTokenResponse.expires_in, scopes: typeof scope === "string" && scope ? scope.split(" ") : undefined, - }); + }; + await storeMCPOAuthUserCredential(accessToken, response.server_id, oauthCredentialPayload); } else { - setToken( - response.server_id, - { - access_token: oauthTokenResponse.access_token, - expires_in: oauthTokenResponse.expires_in, - refresh_token: oauthTokenResponse.refresh_token, - token_type: oauthTokenResponse.token_type, - }, - userID, - ); + const browserHeldToken = { + access_token: oauthTokenResponse.access_token, + expires_in: oauthTokenResponse.expires_in, + refresh_token: oauthTokenResponse.refresh_token, + token_type: oauthTokenResponse.token_type, + }; + setToken(response.server_id, browserHeldToken, userID); } } diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx index 2c43b9cb056..59d7b28aacb 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_edit.tsx @@ -180,16 +180,13 @@ const MCPServerEdit: React.FC = ({ const effectiveAuthType = form.getFieldValue("auth_type") ?? mcpServer.auth_type; if (isClientForwardedTokenMode(effectiveAuthType)) { - setToken( - mcpServer.server_id, - { - access_token: token.access_token, - expires_in: token.expires_in, - refresh_token: token.refresh_token, - token_type: token.token_type, - }, - userID, - ); + const browserHeldToken = { + access_token: token.access_token, + expires_in: token.expires_in, + refresh_token: token.refresh_token, + token_type: token.token_type, + }; + setToken(mcpServer.server_id, browserHeldToken, userID); NotificationsManager.success( "Token held for this browser session. Tools can now be loaded and configured; nothing was saved to LiteLLM.", ); @@ -467,7 +464,7 @@ const MCPServerEdit: React.FC = ({ const handleTransportChange = (value: string) => { // Clear fields that are not relevant for the selected transport. if (value === "stdio") { - form.setFieldsValue({ + const clearedForStdio = { url: undefined, spec_path: undefined, auth_type: undefined, @@ -475,15 +472,17 @@ const MCPServerEdit: React.FC = ({ authorization_url: undefined, token_url: undefined, registration_url: undefined, - }); + }; + form.setFieldsValue(clearedForStdio); } else if (value === TRANSPORT.OPENAPI) { - form.setFieldsValue({ + const clearedForOpenapi = { url: undefined, command: undefined, args: undefined, env_json: undefined, stdio_config: undefined, - }); + }; + form.setFieldsValue(clearedForOpenapi); } else { form.setFieldsValue({ spec_path: undefined, @@ -761,23 +760,21 @@ const MCPServerEdit: React.FC = ({ try { if (oauthMode === "authorization_code") { const scope = oauthTokenResponse.scope; - await storeMCPOAuthUserCredential(accessToken, mcpServer.server_id, { + const oauthCredentialPayload = { access_token: oauthTokenResponse.access_token, refresh_token: oauthTokenResponse.refresh_token, expires_in: oauthTokenResponse.expires_in, scopes: typeof scope === "string" && scope ? scope.split(" ") : undefined, - }); + }; + await storeMCPOAuthUserCredential(accessToken, mcpServer.server_id, oauthCredentialPayload); } else if (oauthMode === "passthrough") { - setToken( - mcpServer.server_id, - { - access_token: oauthTokenResponse.access_token, - expires_in: oauthTokenResponse.expires_in, - refresh_token: oauthTokenResponse.refresh_token, - token_type: oauthTokenResponse.token_type, - }, - userID, - ); + const browserHeldToken = { + access_token: oauthTokenResponse.access_token, + expires_in: oauthTokenResponse.expires_in, + refresh_token: oauthTokenResponse.refresh_token, + token_type: oauthTokenResponse.token_type, + }; + setToken(mcpServer.server_id, browserHeldToken, userID); } } catch (error: unknown) { const message = error instanceof Error ? error.message : ""; From 0a40bd7ae5e8578eba0c60fe4efb60510eb032f9 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:47:51 -0700 Subject: [PATCH 044/365] fix(ui): prevent reasoning block from expanding chat playground layout (#32485) The expanded reasoning block did not constrain its width or break long unbreakable tokens, so its inline-block bubble grew past its max width and pushed the whole page wider (#32481). Mirror the message body handling by capping the container width and breaking long words/code. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../chat_ui/ReasoningContent.test.tsx | 32 +++++++++++++++++++ .../components/chat_ui/ReasoningContent.tsx | 14 ++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.test.tsx diff --git a/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.test.tsx b/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.test.tsx new file mode 100644 index 00000000000..35540d3ebde --- /dev/null +++ b/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.test.tsx @@ -0,0 +1,32 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { describe, it, expect } from "vitest"; +import ReasoningContent from "./ReasoningContent"; + +describe("ReasoningContent", () => { + it("should render nothing when reasoningContent is empty", () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("should show reasoning content expanded by default and toggle on click", () => { + render(); + + expect(screen.getByText("thinking hard")).toBeInTheDocument(); + expect(screen.getByText("Hide reasoning")).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button")); + + expect(screen.queryByText("thinking hard")).not.toBeInTheDocument(); + expect(screen.getByText("Show reasoning")).toBeInTheDocument(); + }); + + it("should constrain width and break long words so it cannot expand the layout (regression #32481)", () => { + const longToken = "a".repeat(500); + render(); + + const contentBox = screen.getByText(longToken).closest("div.mt-2"); + expect(contentBox).not.toBeNull(); + expect(contentBox).toHaveClass("max-w-full"); + expect(contentBox).toHaveStyle({ wordBreak: "break-word", overflowWrap: "break-word" }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx b/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx index e537c6de79f..30ba2d3fd95 100644 --- a/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx +++ b/ui/litellm-dashboard/src/components/chat_ui/ReasoningContent.tsx @@ -27,7 +27,10 @@ const ReasoningContent: React.FC = ({ reasoningContent }) {isExpanded && ( -
+
= ({ reasoningContent }) language={match[1]} PreTag="div" className="rounded-md my-2" + wrapLines={true} + wrapLongLines={true} {...props} > {String(children).replace(/\n$/, "")} ) : ( - + {children} ); }, + pre: ({ node, ...props }) =>
,
             }}
           >
             {reasoningContent}

From 4e63c0c9e66618d5a6e2385e966a05d4da5a51ec Mon Sep 17 00:00:00 2001
From: T K Chandra Hasan 
Date: Fri, 10 Jul 2026 00:18:11 +0530
Subject: [PATCH 045/365] Fix enterprise doc link (#31815)

Signed-off-by: T K Chandra Hasan 
---
 enterprise/README.md | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/enterprise/README.md b/enterprise/README.md
index f5eb5078e81..c708dad5a06 100644
--- a/enterprise/README.md
+++ b/enterprise/README.md
@@ -6,4 +6,4 @@ Code in this folder is licensed under a commercial license. Please review the [L
 
 👉 **Using in an Enterprise / Need specific features ?** Meet with us [here](https://enterprise.litellm.ai/demo?month=2024-02)
 
-See all Enterprise Features here 👉 [Docs](https://docs.litellm.ai/docs/proxy/enterprise)
+See all Enterprise Features here 👉 [Docs](https://docs.litellm.ai/docs/enterprise)

From a874de6ac60a4c4cc940576adaf181bc4ae8494a Mon Sep 17 00:00:00 2001
From: "devin-ai-integration[bot]"
 <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Date: Thu, 9 Jul 2026 11:51:12 -0700
Subject: [PATCH 046/365] feat(models): add GPT-5.6 (sol/terra/luna) pricing
 and metadata (#32659)

* feat(models): add GPT-5.6 (sol/terra/luna) pricing and metadata

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test: allow gpt-5.6 service-tier cache-write keys in model prices schema

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: floating point entry errors

---------

Co-authored-by: mateo 
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
---
 ...odel_prices_and_context_window_backup.json | 212 ++++++++++++++++++
 model_prices_and_context_window.json          | 212 ++++++++++++++++++
 .../llm_cost_calc/test_llm_cost_calc_utils.py |  55 +++++
 .../llms/openai/test_is_model_gpt_5_model.py  |  43 ++++
 .../test_gpt_5_6_model_metadata.py            |  79 +++++++
 tests/test_litellm/test_utils.py              |   3 +
 6 files changed, 604 insertions(+)
 create mode 100644 tests/test_litellm/test_gpt_5_6_model_metadata.py

diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json
index db534b52df9..a111f301d11 100644
--- a/litellm/model_prices_and_context_window_backup.json
+++ b/litellm/model_prices_and_context_window_backup.json
@@ -22273,6 +22273,218 @@
         "supports_xhigh_reasoning_effort": true,
         "supports_minimal_reasoning_effort": true
     },
+    "gpt-5.6": {
+        "cache_creation_input_token_cost": 6.25e-06,
+        "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
+        "cache_creation_input_token_cost_flex": 3.125e-06,
+        "cache_creation_input_token_cost_priority": 1.25e-05,
+        "cache_read_input_token_cost": 5e-07,
+        "cache_read_input_token_cost_above_272k_tokens": 1e-06,
+        "cache_read_input_token_cost_flex": 2.5e-07,
+        "cache_read_input_token_cost_priority": 1e-06,
+        "input_cost_per_token": 5e-06,
+        "input_cost_per_token_above_272k_tokens": 1e-05,
+        "input_cost_per_token_batches": 2.5e-06,
+        "input_cost_per_token_flex": 2.5e-06,
+        "input_cost_per_token_priority": 1e-05,
+        "litellm_provider": "openai",
+        "max_input_tokens": 1050000,
+        "max_output_tokens": 128000,
+        "max_tokens": 128000,
+        "mode": "chat",
+        "output_cost_per_token": 3e-05,
+        "output_cost_per_token_above_272k_tokens": 4.5e-05,
+        "output_cost_per_token_batches": 1.5e-05,
+        "output_cost_per_token_flex": 1.5e-05,
+        "output_cost_per_token_priority": 6e-05,
+        "regional_processing_uplift_multiplier_eu": 1.1,
+        "regional_processing_uplift_multiplier_us": 1.1,
+        "supported_endpoints": [
+            "/v1/chat/completions",
+            "/v1/batch",
+            "/v1/responses"
+        ],
+        "supported_modalities": [
+            "text",
+            "image"
+        ],
+        "supported_output_modalities": [
+            "text"
+        ],
+        "supports_function_calling": true,
+        "supports_minimal_reasoning_effort": false,
+        "supports_native_streaming": true,
+        "supports_none_reasoning_effort": true,
+        "supports_parallel_function_calling": true,
+        "supports_pdf_input": true,
+        "supports_prompt_caching": true,
+        "supports_reasoning": true,
+        "supports_response_schema": true,
+        "supports_system_messages": true,
+        "supports_tool_choice": true,
+        "supports_vision": true,
+        "supports_web_search": true,
+        "supports_xhigh_reasoning_effort": true
+    },
+    "gpt-5.6-sol": {
+        "cache_creation_input_token_cost": 6.25e-06,
+        "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
+        "cache_creation_input_token_cost_flex": 3.125e-06,
+        "cache_creation_input_token_cost_priority": 1.25e-05,
+        "cache_read_input_token_cost": 5e-07,
+        "cache_read_input_token_cost_above_272k_tokens": 1e-06,
+        "cache_read_input_token_cost_flex": 2.5e-07,
+        "cache_read_input_token_cost_priority": 1e-06,
+        "input_cost_per_token": 5e-06,
+        "input_cost_per_token_above_272k_tokens": 1e-05,
+        "input_cost_per_token_batches": 2.5e-06,
+        "input_cost_per_token_flex": 2.5e-06,
+        "input_cost_per_token_priority": 1e-05,
+        "litellm_provider": "openai",
+        "max_input_tokens": 1050000,
+        "max_output_tokens": 128000,
+        "max_tokens": 128000,
+        "mode": "chat",
+        "output_cost_per_token": 3e-05,
+        "output_cost_per_token_above_272k_tokens": 4.5e-05,
+        "output_cost_per_token_batches": 1.5e-05,
+        "output_cost_per_token_flex": 1.5e-05,
+        "output_cost_per_token_priority": 6e-05,
+        "regional_processing_uplift_multiplier_eu": 1.1,
+        "regional_processing_uplift_multiplier_us": 1.1,
+        "supported_endpoints": [
+            "/v1/chat/completions",
+            "/v1/batch",
+            "/v1/responses"
+        ],
+        "supported_modalities": [
+            "text",
+            "image"
+        ],
+        "supported_output_modalities": [
+            "text"
+        ],
+        "supports_function_calling": true,
+        "supports_minimal_reasoning_effort": false,
+        "supports_native_streaming": true,
+        "supports_none_reasoning_effort": true,
+        "supports_parallel_function_calling": true,
+        "supports_pdf_input": true,
+        "supports_prompt_caching": true,
+        "supports_reasoning": true,
+        "supports_response_schema": true,
+        "supports_system_messages": true,
+        "supports_tool_choice": true,
+        "supports_vision": true,
+        "supports_web_search": true,
+        "supports_xhigh_reasoning_effort": true
+    },
+    "gpt-5.6-terra": {
+        "cache_creation_input_token_cost": 3.125e-06,
+        "cache_creation_input_token_cost_above_272k_tokens": 6.25e-06,
+        "cache_creation_input_token_cost_flex": 1.5625e-06,
+        "cache_creation_input_token_cost_priority": 6.25e-06,
+        "cache_read_input_token_cost": 2.5e-07,
+        "cache_read_input_token_cost_above_272k_tokens": 5e-07,
+        "cache_read_input_token_cost_flex": 1.25e-07,
+        "cache_read_input_token_cost_priority": 5e-07,
+        "input_cost_per_token": 2.5e-06,
+        "input_cost_per_token_above_272k_tokens": 5e-06,
+        "input_cost_per_token_batches": 1.25e-06,
+        "input_cost_per_token_flex": 1.25e-06,
+        "input_cost_per_token_priority": 5e-06,
+        "litellm_provider": "openai",
+        "max_input_tokens": 1050000,
+        "max_output_tokens": 128000,
+        "max_tokens": 128000,
+        "mode": "chat",
+        "output_cost_per_token": 1.5e-05,
+        "output_cost_per_token_above_272k_tokens": 2.25e-05,
+        "output_cost_per_token_batches": 7.5e-06,
+        "output_cost_per_token_flex": 7.5e-06,
+        "output_cost_per_token_priority": 3e-05,
+        "regional_processing_uplift_multiplier_eu": 1.1,
+        "regional_processing_uplift_multiplier_us": 1.1,
+        "supported_endpoints": [
+            "/v1/chat/completions",
+            "/v1/batch",
+            "/v1/responses"
+        ],
+        "supported_modalities": [
+            "text",
+            "image"
+        ],
+        "supported_output_modalities": [
+            "text"
+        ],
+        "supports_function_calling": true,
+        "supports_minimal_reasoning_effort": false,
+        "supports_native_streaming": true,
+        "supports_none_reasoning_effort": true,
+        "supports_parallel_function_calling": true,
+        "supports_pdf_input": true,
+        "supports_prompt_caching": true,
+        "supports_reasoning": true,
+        "supports_response_schema": true,
+        "supports_system_messages": true,
+        "supports_tool_choice": true,
+        "supports_vision": true,
+        "supports_web_search": true,
+        "supports_xhigh_reasoning_effort": true
+    },
+    "gpt-5.6-luna": {
+        "cache_creation_input_token_cost": 1.25e-06,
+        "cache_creation_input_token_cost_above_272k_tokens": 2.5e-06,
+        "cache_creation_input_token_cost_flex": 6.25e-07,
+        "cache_creation_input_token_cost_priority": 2.5e-06,
+        "cache_read_input_token_cost": 1e-07,
+        "cache_read_input_token_cost_above_272k_tokens": 2e-07,
+        "cache_read_input_token_cost_flex": 5e-08,
+        "cache_read_input_token_cost_priority": 2e-07,
+        "input_cost_per_token": 1e-06,
+        "input_cost_per_token_above_272k_tokens": 2e-06,
+        "input_cost_per_token_batches": 5e-07,
+        "input_cost_per_token_flex": 5e-07,
+        "input_cost_per_token_priority": 2e-06,
+        "litellm_provider": "openai",
+        "max_input_tokens": 1050000,
+        "max_output_tokens": 128000,
+        "max_tokens": 128000,
+        "mode": "chat",
+        "output_cost_per_token": 6e-06,
+        "output_cost_per_token_above_272k_tokens": 9e-06,
+        "output_cost_per_token_batches": 3e-06,
+        "output_cost_per_token_flex": 3e-06,
+        "output_cost_per_token_priority": 1.2e-05,
+        "regional_processing_uplift_multiplier_eu": 1.1,
+        "regional_processing_uplift_multiplier_us": 1.1,
+        "supported_endpoints": [
+            "/v1/chat/completions",
+            "/v1/batch",
+            "/v1/responses"
+        ],
+        "supported_modalities": [
+            "text",
+            "image"
+        ],
+        "supported_output_modalities": [
+            "text"
+        ],
+        "supports_function_calling": true,
+        "supports_minimal_reasoning_effort": false,
+        "supports_native_streaming": true,
+        "supports_none_reasoning_effort": true,
+        "supports_parallel_function_calling": true,
+        "supports_pdf_input": true,
+        "supports_prompt_caching": true,
+        "supports_reasoning": true,
+        "supports_response_schema": true,
+        "supports_system_messages": true,
+        "supports_tool_choice": true,
+        "supports_vision": true,
+        "supports_web_search": true,
+        "supports_xhigh_reasoning_effort": true
+    },
     "gpt-5.5": {
         "cache_read_input_token_cost": 5e-07,
         "cache_read_input_token_cost_above_272k_tokens": 1e-06,
diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json
index 363ba9842b0..d6e4a265da0 100644
--- a/model_prices_and_context_window.json
+++ b/model_prices_and_context_window.json
@@ -22431,6 +22431,218 @@
         "supports_xhigh_reasoning_effort": true,
         "supports_minimal_reasoning_effort": true
     },
+    "gpt-5.6": {
+        "cache_creation_input_token_cost": 6.25e-06,
+        "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
+        "cache_creation_input_token_cost_flex": 3.125e-06,
+        "cache_creation_input_token_cost_priority": 1.25e-05,
+        "cache_read_input_token_cost": 5e-07,
+        "cache_read_input_token_cost_above_272k_tokens": 1e-06,
+        "cache_read_input_token_cost_flex": 2.5e-07,
+        "cache_read_input_token_cost_priority": 1e-06,
+        "input_cost_per_token": 5e-06,
+        "input_cost_per_token_above_272k_tokens": 1e-05,
+        "input_cost_per_token_batches": 2.5e-06,
+        "input_cost_per_token_flex": 2.5e-06,
+        "input_cost_per_token_priority": 1e-05,
+        "litellm_provider": "openai",
+        "max_input_tokens": 1050000,
+        "max_output_tokens": 128000,
+        "max_tokens": 128000,
+        "mode": "chat",
+        "output_cost_per_token": 3e-05,
+        "output_cost_per_token_above_272k_tokens": 4.5e-05,
+        "output_cost_per_token_batches": 1.5e-05,
+        "output_cost_per_token_flex": 1.5e-05,
+        "output_cost_per_token_priority": 6e-05,
+        "regional_processing_uplift_multiplier_eu": 1.1,
+        "regional_processing_uplift_multiplier_us": 1.1,
+        "supported_endpoints": [
+            "/v1/chat/completions",
+            "/v1/batch",
+            "/v1/responses"
+        ],
+        "supported_modalities": [
+            "text",
+            "image"
+        ],
+        "supported_output_modalities": [
+            "text"
+        ],
+        "supports_function_calling": true,
+        "supports_minimal_reasoning_effort": false,
+        "supports_native_streaming": true,
+        "supports_none_reasoning_effort": true,
+        "supports_parallel_function_calling": true,
+        "supports_pdf_input": true,
+        "supports_prompt_caching": true,
+        "supports_reasoning": true,
+        "supports_response_schema": true,
+        "supports_system_messages": true,
+        "supports_tool_choice": true,
+        "supports_vision": true,
+        "supports_web_search": true,
+        "supports_xhigh_reasoning_effort": true
+    },
+    "gpt-5.6-sol": {
+        "cache_creation_input_token_cost": 6.25e-06,
+        "cache_creation_input_token_cost_above_272k_tokens": 1.25e-05,
+        "cache_creation_input_token_cost_flex": 3.125e-06,
+        "cache_creation_input_token_cost_priority": 1.25e-05,
+        "cache_read_input_token_cost": 5e-07,
+        "cache_read_input_token_cost_above_272k_tokens": 1e-06,
+        "cache_read_input_token_cost_flex": 2.5e-07,
+        "cache_read_input_token_cost_priority": 1e-06,
+        "input_cost_per_token": 5e-06,
+        "input_cost_per_token_above_272k_tokens": 1e-05,
+        "input_cost_per_token_batches": 2.5e-06,
+        "input_cost_per_token_flex": 2.5e-06,
+        "input_cost_per_token_priority": 1e-05,
+        "litellm_provider": "openai",
+        "max_input_tokens": 1050000,
+        "max_output_tokens": 128000,
+        "max_tokens": 128000,
+        "mode": "chat",
+        "output_cost_per_token": 3e-05,
+        "output_cost_per_token_above_272k_tokens": 4.5e-05,
+        "output_cost_per_token_batches": 1.5e-05,
+        "output_cost_per_token_flex": 1.5e-05,
+        "output_cost_per_token_priority": 6e-05,
+        "regional_processing_uplift_multiplier_eu": 1.1,
+        "regional_processing_uplift_multiplier_us": 1.1,
+        "supported_endpoints": [
+            "/v1/chat/completions",
+            "/v1/batch",
+            "/v1/responses"
+        ],
+        "supported_modalities": [
+            "text",
+            "image"
+        ],
+        "supported_output_modalities": [
+            "text"
+        ],
+        "supports_function_calling": true,
+        "supports_minimal_reasoning_effort": false,
+        "supports_native_streaming": true,
+        "supports_none_reasoning_effort": true,
+        "supports_parallel_function_calling": true,
+        "supports_pdf_input": true,
+        "supports_prompt_caching": true,
+        "supports_reasoning": true,
+        "supports_response_schema": true,
+        "supports_system_messages": true,
+        "supports_tool_choice": true,
+        "supports_vision": true,
+        "supports_web_search": true,
+        "supports_xhigh_reasoning_effort": true
+    },
+    "gpt-5.6-terra": {
+        "cache_creation_input_token_cost": 3.125e-06,
+        "cache_creation_input_token_cost_above_272k_tokens": 6.25e-06,
+        "cache_creation_input_token_cost_flex": 1.5625e-06,
+        "cache_creation_input_token_cost_priority": 6.25e-06,
+        "cache_read_input_token_cost": 2.5e-07,
+        "cache_read_input_token_cost_above_272k_tokens": 5e-07,
+        "cache_read_input_token_cost_flex": 1.25e-07,
+        "cache_read_input_token_cost_priority": 5e-07,
+        "input_cost_per_token": 2.5e-06,
+        "input_cost_per_token_above_272k_tokens": 5e-06,
+        "input_cost_per_token_batches": 1.25e-06,
+        "input_cost_per_token_flex": 1.25e-06,
+        "input_cost_per_token_priority": 5e-06,
+        "litellm_provider": "openai",
+        "max_input_tokens": 1050000,
+        "max_output_tokens": 128000,
+        "max_tokens": 128000,
+        "mode": "chat",
+        "output_cost_per_token": 1.5e-05,
+        "output_cost_per_token_above_272k_tokens": 2.25e-05,
+        "output_cost_per_token_batches": 7.5e-06,
+        "output_cost_per_token_flex": 7.5e-06,
+        "output_cost_per_token_priority": 3e-05,
+        "regional_processing_uplift_multiplier_eu": 1.1,
+        "regional_processing_uplift_multiplier_us": 1.1,
+        "supported_endpoints": [
+            "/v1/chat/completions",
+            "/v1/batch",
+            "/v1/responses"
+        ],
+        "supported_modalities": [
+            "text",
+            "image"
+        ],
+        "supported_output_modalities": [
+            "text"
+        ],
+        "supports_function_calling": true,
+        "supports_minimal_reasoning_effort": false,
+        "supports_native_streaming": true,
+        "supports_none_reasoning_effort": true,
+        "supports_parallel_function_calling": true,
+        "supports_pdf_input": true,
+        "supports_prompt_caching": true,
+        "supports_reasoning": true,
+        "supports_response_schema": true,
+        "supports_system_messages": true,
+        "supports_tool_choice": true,
+        "supports_vision": true,
+        "supports_web_search": true,
+        "supports_xhigh_reasoning_effort": true
+    },
+    "gpt-5.6-luna": {
+        "cache_creation_input_token_cost": 1.25e-06,
+        "cache_creation_input_token_cost_above_272k_tokens": 2.5e-06,
+        "cache_creation_input_token_cost_flex": 6.25e-07,
+        "cache_creation_input_token_cost_priority": 2.5e-06,
+        "cache_read_input_token_cost": 1e-07,
+        "cache_read_input_token_cost_above_272k_tokens": 2e-07,
+        "cache_read_input_token_cost_flex": 5e-08,
+        "cache_read_input_token_cost_priority": 2e-07,
+        "input_cost_per_token": 1e-06,
+        "input_cost_per_token_above_272k_tokens": 2e-06,
+        "input_cost_per_token_batches": 5e-07,
+        "input_cost_per_token_flex": 5e-07,
+        "input_cost_per_token_priority": 2e-06,
+        "litellm_provider": "openai",
+        "max_input_tokens": 1050000,
+        "max_output_tokens": 128000,
+        "max_tokens": 128000,
+        "mode": "chat",
+        "output_cost_per_token": 6e-06,
+        "output_cost_per_token_above_272k_tokens": 9e-06,
+        "output_cost_per_token_batches": 3e-06,
+        "output_cost_per_token_flex": 3e-06,
+        "output_cost_per_token_priority": 1.2e-05,
+        "regional_processing_uplift_multiplier_eu": 1.1,
+        "regional_processing_uplift_multiplier_us": 1.1,
+        "supported_endpoints": [
+            "/v1/chat/completions",
+            "/v1/batch",
+            "/v1/responses"
+        ],
+        "supported_modalities": [
+            "text",
+            "image"
+        ],
+        "supported_output_modalities": [
+            "text"
+        ],
+        "supports_function_calling": true,
+        "supports_minimal_reasoning_effort": false,
+        "supports_native_streaming": true,
+        "supports_none_reasoning_effort": true,
+        "supports_parallel_function_calling": true,
+        "supports_pdf_input": true,
+        "supports_prompt_caching": true,
+        "supports_reasoning": true,
+        "supports_response_schema": true,
+        "supports_system_messages": true,
+        "supports_tool_choice": true,
+        "supports_vision": true,
+        "supports_web_search": true,
+        "supports_xhigh_reasoning_effort": true
+    },
     "gpt-5.5": {
         "cache_read_input_token_cost": 5e-07,
         "cache_read_input_token_cost_above_272k_tokens": 1e-06,
diff --git a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py
index 0e6c6061b46..dcc359ceff4 100644
--- a/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py
+++ b/tests/test_litellm/litellm_core_utils/llm_cost_calc/test_llm_cost_calc_utils.py
@@ -507,6 +507,61 @@ def test_generic_cost_per_token_gpt55_pro():
     )
 
 
+@pytest.mark.parametrize(
+    "model,input_cost,output_cost,cache_read_cost,cache_write_cost",
+    [
+        ("gpt-5.6", 5e-6, 3e-5, 5e-7, 6.25e-6),
+        ("gpt-5.6-sol", 5e-6, 3e-5, 5e-7, 6.25e-6),
+        ("gpt-5.6-terra", 2.5e-6, 1.5e-5, 2.5e-7, 3.125e-6),
+        ("gpt-5.6-luna", 1e-6, 6e-6, 1e-7, 1.25e-6),
+    ],
+)
+def test_generic_cost_per_token_gpt56(
+    model, input_cost, output_cost, cache_read_cost, cache_write_cost
+):
+    """gpt-5.6 (sol/terra/luna): base pricing + new cache-write cost.
+
+    Cache writes are billed at 1.25x the uncached input rate for this family.
+    """
+    custom_llm_provider = "openai"
+    os.environ["LITELLM_LOCAL_MODEL_COST_MAP"] = "True"
+    litellm.model_cost = litellm.get_model_cost_map(url="")
+
+    model_cost_map = litellm.model_cost[model]
+
+    assert model_cost_map["input_cost_per_token"] == input_cost
+    assert model_cost_map["output_cost_per_token"] == output_cost
+    assert model_cost_map["cache_read_input_token_cost"] == cache_read_cost
+    assert model_cost_map["cache_creation_input_token_cost"] == cache_write_cost
+    assert model_cost_map["litellm_provider"] == "openai"
+    assert model_cost_map["mode"] == "chat"
+    assert model_cost_map["cache_creation_input_token_cost"] == pytest.approx(
+        input_cost * 1.25
+    )
+    assert model_cost_map["max_input_tokens"] == 1050000
+    assert model_cost_map["input_cost_per_token_above_272k_tokens"] == pytest.approx(
+        input_cost * 2
+    )
+    assert model_cost_map["output_cost_per_token_above_272k_tokens"] == pytest.approx(
+        output_cost * 1.5
+    )
+
+    prompt_tokens = 1000
+    completion_tokens = 500
+    usage = Usage(
+        prompt_tokens=prompt_tokens,
+        completion_tokens=completion_tokens,
+        total_tokens=prompt_tokens + completion_tokens,
+    )
+    prompt_cost, completion_cost = generic_cost_per_token(
+        model=model,
+        usage=usage,
+        custom_llm_provider=custom_llm_provider,
+    )
+    assert round(prompt_cost, 10) == round(input_cost * prompt_tokens, 10)
+    assert round(completion_cost, 10) == round(output_cost * completion_tokens, 10)
+
+
 @pytest.mark.parametrize(
     "model,expected_none,expected_xhigh,expected_minimal",
     [
diff --git a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py
index 02dd9dade0a..1095819c98c 100644
--- a/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py
+++ b/tests/test_litellm/llms/openai/test_is_model_gpt_5_model.py
@@ -50,6 +50,10 @@ GPT5_MODELS = [
     "gpt-5.5-pro",
     "gpt-5.5-2026-04-23",  # dated variant
     "gpt-5.5-pro-2026-04-23",  # dated variant
+    "gpt-5.6",
+    "gpt-5.6-sol",
+    "gpt-5.6-terra",
+    "gpt-5.6-luna",
     "gpt-5.1-chat",  # versioned chat — THE KEY REGRESSION CASE
     "gpt-5.2-chat",  # versioned chat — also a regression case
     "gpt-5.3-chat",  # versioned chat — THE KEY REGRESSION CASE
@@ -112,6 +116,45 @@ class TestOpenAIGPT5ConfigIsModelGpt5Model:
             ), f"Expected '{model}' (gpt-5-chat family) NOT to be on the GPT-5 path"
 
 
+# Models that are gpt-5.4 or newer. main.py gates the automatic switch to the
+# /v1/responses bridge (when reasoning_effort is set and tools are passed) on
+# is_model_gpt_5_4_plus_model, so the gpt-5.6 family must land on the True side.
+GPT5_4_PLUS_MODELS = [
+    "gpt-5.4",
+    "gpt-5.5",
+    "gpt-5.5-pro",
+    "gpt-5.6",
+    "gpt-5.6-sol",
+    "gpt-5.6-terra",
+    "gpt-5.6-luna",
+    "openai/gpt-5.6-sol",
+]
+
+GPT5_PRE_5_4_MODELS = [
+    "gpt-5",
+    "gpt-5.1",
+    "gpt-5.2",
+    "gpt-5.3",
+    "gpt-5.3-chat",
+    "gpt-4o",
+]
+
+
+class TestOpenAIGPT5ConfigIsModelGpt54PlusModel:
+
+    @pytest.mark.parametrize("model", GPT5_4_PLUS_MODELS)
+    def test_gpt5_4_plus_models_are_classified_as_5_4_plus(self, model: str):
+        assert OpenAIGPT5Config.is_model_gpt_5_4_plus_model(
+            model
+        ), f"Expected '{model}' to be classified as gpt-5.4-or-newer"
+
+    @pytest.mark.parametrize("model", GPT5_PRE_5_4_MODELS)
+    def test_pre_5_4_models_are_not_classified_as_5_4_plus(self, model: str):
+        assert not OpenAIGPT5Config.is_model_gpt_5_4_plus_model(
+            model
+        ), f"Expected '{model}' NOT to be classified as gpt-5.4-or-newer"
+
+
 # ---------------------------------------------------------------------------
 # AzureOpenAIGPT5Config
 # ---------------------------------------------------------------------------
diff --git a/tests/test_litellm/test_gpt_5_6_model_metadata.py b/tests/test_litellm/test_gpt_5_6_model_metadata.py
new file mode 100644
index 00000000000..30a0777f477
--- /dev/null
+++ b/tests/test_litellm/test_gpt_5_6_model_metadata.py
@@ -0,0 +1,79 @@
+import json
+from pathlib import Path
+
+import pytest
+
+from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider
+
+GPT_5_6_MODELS = ("gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna")
+
+STANDARD_PRICING = {
+    "gpt-5.6": (5e-06, 3e-05, 5e-07, 6.25e-06),
+    "gpt-5.6-sol": (5e-06, 3e-05, 5e-07, 6.25e-06),
+    "gpt-5.6-terra": (2.5e-06, 1.5e-05, 2.5e-07, 3.125e-06),
+    "gpt-5.6-luna": (1e-06, 6e-06, 1e-07, 1.25e-06),
+}
+
+
+@pytest.mark.parametrize("model", GPT_5_6_MODELS)
+def test_openai_gpt_5_6_model_info(model):
+    json_path = Path(__file__).parents[2] / "model_prices_and_context_window.json"
+    with open(json_path) as f:
+        model_cost = json.load(f)
+
+    info = model_cost.get(model)
+    assert info is not None, f"{model} not found in model_prices_and_context_window.json"
+
+    assert info["litellm_provider"] == "openai"
+    assert info["mode"] == "chat"
+
+    input_cost, output_cost, cache_read_cost, cache_write_cost = STANDARD_PRICING[model]
+    assert info["input_cost_per_token"] == input_cost
+    assert info["output_cost_per_token"] == output_cost
+    assert info["cache_read_input_token_cost"] == cache_read_cost
+    assert info["cache_creation_input_token_cost"] == cache_write_cost
+    assert info["cache_creation_input_token_cost"] == pytest.approx(input_cost * 1.25)
+
+    assert info["input_cost_per_token_above_272k_tokens"] == pytest.approx(input_cost * 2)
+    assert info["output_cost_per_token_above_272k_tokens"] == pytest.approx(output_cost * 1.5)
+    assert info["cache_read_input_token_cost_above_272k_tokens"] == pytest.approx(cache_read_cost * 2)
+
+    assert info["max_input_tokens"] == 1050000
+    assert info["max_output_tokens"] == 128000
+    assert info["max_tokens"] == 128000
+
+    assert info["supports_function_calling"] is True
+    assert info["supports_prompt_caching"] is True
+    assert info["supports_reasoning"] is True
+    assert info["supports_response_schema"] is True
+    assert info["supports_tool_choice"] is True
+    assert info["supports_vision"] is True
+    assert info["supports_web_search"] is True
+    assert info["supports_none_reasoning_effort"] is True
+    assert info["supports_xhigh_reasoning_effort"] is True
+    assert info["supports_minimal_reasoning_effort"] is False
+
+    assert info["supported_endpoints"] == ["/v1/chat/completions", "/v1/batch", "/v1/responses"]
+    assert info["supported_modalities"] == ["text", "image"]
+    assert info["supported_output_modalities"] == ["text"]
+
+    routed_model, provider, _, _ = get_llm_provider(model=f"openai/{model}")
+    assert routed_model == model
+    assert provider == "openai"
+
+
+def test_gpt_5_6_backup_matches_main():
+    """Ensure the bundled model cost map stays in sync with the canonical file."""
+    repo_root = Path(__file__).parents[2]
+    main_path = repo_root / "model_prices_and_context_window.json"
+    backup_path = repo_root / "litellm" / "model_prices_and_context_window_backup.json"
+
+    with open(main_path) as f:
+        main_cost = json.load(f)
+    with open(backup_path) as f:
+        backup_cost = json.load(f)
+
+    for model in GPT_5_6_MODELS:
+        assert backup_cost.get(model) == main_cost.get(model), (
+            f"{model} differs between main and backup model cost maps"
+        )
diff --git a/tests/test_litellm/test_utils.py b/tests/test_litellm/test_utils.py
index 053fe970d3e..47be8cb2eec 100644
--- a/tests/test_litellm/test_utils.py
+++ b/tests/test_litellm/test_utils.py
@@ -710,6 +710,9 @@ def test_aaamodel_prices_and_context_window_json_is_valid():
                 "cache_creation_input_token_cost": {"type": "number"},
                 "cache_creation_input_token_cost_above_1hr": {"type": "number"},
                 "cache_creation_input_token_cost_above_200k_tokens": {"type": "number"},
+                "cache_creation_input_token_cost_above_272k_tokens": {"type": "number"},
+                "cache_creation_input_token_cost_flex": {"type": "number"},
+                "cache_creation_input_token_cost_priority": {"type": "number"},
                 "cache_read_input_token_cost": {"type": "number"},
                 "cache_read_input_token_cost_above_200k_tokens": {"type": "number"},
                 "cache_read_input_token_cost_above_272k_tokens": {"type": "number"},

From 7d63b86e00cf0b4acabf5f582eef6a9e904c57e6 Mon Sep 17 00:00:00 2001
From: ryan-crabbe-berri 
Date: Thu, 9 Jul 2026 11:59:16 -0700
Subject: [PATCH 047/365] fix(ui): forward refs through ui primitives and fail
 tests on swallowed refs (#32401)

* fix(ui): forward refs through ui primitives and fail tests on swallowed refs

Under React 18 a ref passed to a plain function component is dropped
with only a dev console warning, so Base UI render-prop triggers
composed over our shadcn-style primitives silently stop working (the
tooltip just never opens; ui/badge.tsx hit exactly this on the shared
DataTable branch). Label, Separator, Skeleton, UiLoadingSpinner and the
Table family now use React.forwardRef like Button and Input already
did, a contract test pins ref delivery for each, and setupTests turns
React's ref warning into a test failure so the next primitive that
swallows a ref fails CI instead of shipping a dead tooltip

* fix(ui): include captured ref warnings in the tripwire error

The afterEach tripwire threw a fixed message and discarded the collected
React warnings, so a failure never said which component swallowed the ref.
Append the captured warnings (component name + stack) to the thrown error.
---
 .../src/components/ui/label.tsx               |  10 +-
 .../src/components/ui/ref-forwarding.test.tsx | 103 ++++++++++++++++++
 .../src/components/ui/separator.tsx           |  11 +-
 .../src/components/ui/skeleton.tsx            |  11 +-
 .../src/components/ui/table.tsx               |  85 +++++++++------
 .../src/components/ui/ui-loading-spinner.tsx  |  68 ++++++------
 ui/litellm-dashboard/tests/setupTests.ts      |  22 ++++
 7 files changed, 235 insertions(+), 75 deletions(-)
 create mode 100644 ui/litellm-dashboard/src/components/ui/ref-forwarding.test.tsx

diff --git a/ui/litellm-dashboard/src/components/ui/label.tsx b/ui/litellm-dashboard/src/components/ui/label.tsx
index ded2dfc1a7b..1ac4eed0d4e 100644
--- a/ui/litellm-dashboard/src/components/ui/label.tsx
+++ b/ui/litellm-dashboard/src/components/ui/label.tsx
@@ -4,9 +4,10 @@ import * as React from "react";
 
 import { cn } from "@/lib/cva.config";
 
-function Label({ className, ...props }: React.ComponentProps<"label">) {
-  return (
+const Label = React.forwardRef>(
+  ({ className, ...props }, ref) => (