From ae2a5e1472644e568252b2c1b1e0b926112383c0 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Fri, 14 Aug 2026 13:39:37 -0700 Subject: [PATCH 1/3] fix(ui): distinguish hosted and local vLLM in the provider dropdown --- .../provider_create_fields.json | 4 +-- .../public_endpoints/test_public_endpoints.py | 27 +++++++++++++++++++ .../components/provider_info_helpers.test.tsx | 10 +++++++ .../src/components/provider_info_helpers.tsx | 4 +-- 4 files changed, 41 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/public_endpoints/provider_create_fields.json b/litellm/proxy/public_endpoints/provider_create_fields.json index e24e5b21583..ab13773614a 100644 --- a/litellm/proxy/public_endpoints/provider_create_fields.json +++ b/litellm/proxy/public_endpoints/provider_create_fields.json @@ -2980,7 +2980,7 @@ }, { "provider": "Hosted_Vllm", - "provider_display_name": "vllm", + "provider_display_name": "Hosted vLLM", "litellm_provider": "hosted_vllm", "credential_fields": [ { @@ -3008,7 +3008,7 @@ }, { "provider": "VLLM", - "provider_display_name": "Vllm", + "provider_display_name": "Local vLLM", "litellm_provider": "vllm", "credential_fields": [ { diff --git a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py index e99bdfb5c35..03d228cc732 100644 --- a/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py +++ b/tests/test_litellm/proxy/public_endpoints/test_public_endpoints.py @@ -243,6 +243,33 @@ def test_bedrock_mantle_provider_fields(): assert fields_by_key["api_base"]["field_type"] == "text" +def test_vllm_provider_display_names_are_distinct(): + """Hosted and local vLLM must not share a dropdown label. + + The Add Model provider dropdown is driven by /public/providers/fields. + Both entries previously rendered as near-identical "vllm"/"Vllm" rows + with the same logo, so admins could not tell them apart. + """ + app_instance = FastAPI() + app_instance.include_router(router) + test_client = TestClient(app_instance) + + response = test_client.get("/public/providers/fields") + assert response.status_code == 200 + providers = response.json() + + hosted = next((p for p in providers if p["provider"] == "Hosted_Vllm"), None) + local = next((p for p in providers if p["provider"] == "VLLM"), None) + assert hosted is not None, "Hosted vLLM provider entry not found" + assert local is not None, "Local vLLM provider entry not found" + + assert hosted["provider_display_name"] == "Hosted vLLM" + assert local["provider_display_name"] == "Local vLLM" + assert hosted["provider_display_name"].casefold() != local["provider_display_name"].casefold() + assert hosted["litellm_provider"] == "hosted_vllm" + assert local["litellm_provider"] == "vllm" + + def test_nvidia_riva_provider_fields(): app_instance = FastAPI() app_instance.include_router(router) diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx index b163a6b341c..e9bb63dd964 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx @@ -94,6 +94,16 @@ describe("provider_info_helpers", () => { expect(result.displayName).toBe(Providers.ZAI); }); + it("should give hosted_vllm and vllm distinct display names", () => { + const hosted = getProviderLogoAndName("hosted_vllm"); + const local = getProviderLogoAndName("vllm"); + expect(hosted.displayName).toBe("Hosted vLLM"); + expect(local.displayName).toBe("Local vLLM"); + expect(hosted.displayName.toLowerCase()).not.toBe(local.displayName.toLowerCase()); + expect(hosted.logo).toBe(providerLogoMap[Providers.Hosted_Vllm]); + expect(local.logo).toBe(providerLogoMap[Providers.VLLM]); + }); + it("should resolve the nvidia_riva provider value to the Nvidia Riva display name and logo", () => { const result = getProviderLogoAndName("nvidia_riva"); expect(result.displayName).toBe(Providers.NVIDIA_RIVA); diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx index ae311070c07..ad6d044eb7b 100644 --- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx +++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx @@ -110,7 +110,7 @@ export enum Providers { GradientAI = "GradientAI", Groq = "Groq", HEROKU = "Heroku", - Hosted_Vllm = "vllm", + Hosted_Vllm = "Hosted vLLM", HUGGINGFACE = "Huggingface", HYPERBOLIC = "Hyperbolic", Infinity = "Infinity", @@ -162,7 +162,7 @@ export enum Providers { VERCEL_AI_GATEWAY = "Vercel Ai Gateway", Vertex_AI = "Vertex AI (Anthropic, Gemini, etc.)", VERTEX_AI_BETA = "Vertex Ai Beta", - VLLM = "Vllm", + VLLM = "Local vLLM", VolcEngine = "VolcEngine", Voyage = "Voyage AI", WANDB = "Wandb", From 3783bbf2bfad572616711e327953c0fd421c59eb Mon Sep 17 00:00:00 2001 From: tin-berri Date: Fri, 14 Aug 2026 16:25:58 -0700 Subject: [PATCH 2/3] fix(ui): show zeroed auto-router usage stats when a window has no sessions (#36868) * fix(ui): show zeroed auto-router usage stats when a window has no sessions * test(ui): assert the muted track on the empty share-of-turns bar --- .../AutoRouterBenchmarksTab.test.tsx | 53 +++++++++++++++++-- .../_components/AutoRouterBenchmarksTab.tsx | 5 +- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx index 482901dfd14..8d628a264f2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.test.tsx @@ -66,6 +66,36 @@ const totals = (overrides: Partial = {}): Totals => ({ ...overrides, }); +const zeroBucket = { turns: 0, hits: 0, hit_rate_pct: 0 }; + +const zeroCache: AutoRouterCacheStats = { + coverage_pct: 0, + hit_rate_pct: 0, + same_model: zeroBucket, + first_visit: zeroBucket, + return_to_tier: zeroBucket, + unordered_turns: 0, + return_misses_expired: 0, + return_misses_within_ttl: 0, + return_misses_unknown: 0, + ttl_5m_turns: 0, + ttl_1h_turns: 0, +}; + +const zeroTotals: Totals = { + sessions: 0, + turns: 0, + avg_turns_per_session: 0, + avg_session_seconds: 0, + avg_tokens_per_session: 0, + spend: 0, + saved_spend: 0, + baseline_spend: 0, + saved_pct: 0, + saved_per_session: 0, + cache: zeroCache, +}; + const group = (overrides: Partial = {}): AutoRouterBenchmarkGroup => ({ router_name: "claude-auto", router_type: "complexity", @@ -162,6 +192,7 @@ describe("AutoRouterBenchmarksTab", () => { expect(screen.getByText("97.7%")).toBeInTheDocument(); expect(screen.getByText("24.3%")).toBeInTheDocument(); expect(screen.getByText("81.6%")).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Share of turns by bucket" })).not.toHaveClass("bg-muted"); }); it("summarizes the cache column from the bucketed turns, not the session turns", () => { @@ -252,11 +283,25 @@ describe("AutoRouterBenchmarksTab", () => { expect(screen.getByText("Auto-router usage is unavailable right now")).toBeInTheDocument(); }); - it("says so when there are no auto-router sessions at all", () => { - mockHook({ data: response([]) }); + it("renders the full dashboard with zeroed stats when the window has no sessions", () => { + mockHook({ data: response([], zeroTotals) }); renderTab(); - expect(screen.getByText("No auto-router sessions in this window yet")).toBeInTheDocument(); + expect(screen.getByText("Total estimated savings")).toBeInTheDocument(); + expect(screen.getAllByText("$0.00")).toHaveLength(4); + expect(screen.getByText("across 0 sessions")).toBeInTheDocument(); + expect(screen.getByText("0s")).toBeInTheDocument(); + expect(screen.getByText(/turns measured/)).toBeInTheDocument(); + expect(screen.getAllByText("0.0%").length).toBeGreaterThan(0); + expect(screen.getByRole("img", { name: "Share of turns by bucket" })).toHaveClass("bg-muted"); + }); + + it("shows the savings delta as an unsigned zero when nothing was saved", () => { + mockHook({ data: response([], zeroTotals) }); + renderTab(); + + expect(screen.getByText("0%")).toBeInTheDocument(); + expect(screen.queryByText("-0%")).not.toBeInTheDocument(); }); it("requests the default thirty day window and widens or narrows it from the picker", () => { @@ -303,7 +348,7 @@ describe("AutoRouterBenchmarksTab", () => { }); it("keeps the window picker reachable while a window has no sessions", () => { - mockHook({ data: response([]) }); + mockHook({ data: response([], zeroTotals) }); renderTab(); expect(screen.getByRole("tab", { name: "30d" })).toBeInTheDocument(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx index 80ddef29c9d..f0ca536da39 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/AutoRouterBenchmarksTab.tsx @@ -64,7 +64,7 @@ const HeroCard: React.FC<{ view: BenchmarkView }> = ({ view }) => { variant="secondary" className={cheaper ? "bg-emerald-50 text-emerald-700" : "bg-red-50 text-destructive"} > - {cheaper ? "-" : "+"} + {stats.saved_spend !== 0 && (cheaper ? "-" : "+")} {Math.abs(stats.saved_pct).toFixed(0)}% @@ -95,7 +95,7 @@ const StackedTurnBar: React.FC<{ buckets: BucketRow[] }> = ({ buckets }) => { return (
@@ -230,7 +230,6 @@ const BenchmarksBody: React.FC = ({ isPending, error, data, return Auto-router usage is visible to proxy admin roles only; } if (error || !data) return Auto-router usage is unavailable right now; - if (data.groups.length === 0) return No auto-router sessions in this window yet; const view = viewFor(data, selectedKey); const stats = view.stats; From 40d999b693afd01d425292ef6d0e5d3f4e3d3fc5 Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:27:37 -0700 Subject: [PATCH 3/3] fix(mcp): keep admin-entered oauth endpoints in management reads (#36888) * fix(mcp): keep admin-entered oauth endpoints in management reads Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(mcp): cover configured oauth endpoints on the config load path Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../mcp_server/mcp_server_manager.py | 24 +++++-- .../types/mcp_server/mcp_server_manager.py | 6 ++ .../mcp_server/test_mcp_server_manager.py | 66 +++++++++++++++++++ 3 files changed, 90 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 a1adda2bc95..4f94f94acb5 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -1599,6 +1599,9 @@ class MCPServerManager: manual_token_url, ) use_issuer_anchor = _uses_issuer_anchor(manual_issuer, is_discovery_auth_type or obo_needs_discovery) + configured_authorization_url = manual_authorization_url + configured_token_url = manual_token_url + configured_registration_url = manual_registration_url manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer( manual_issuer, is_discovery_auth_type, @@ -1725,6 +1728,9 @@ class MCPServerManager: authorization_url=resolved_authorization_url, token_url=resolved_token_url, registration_url=resolved_registration_url, + configured_authorization_url=configured_authorization_url, + configured_token_url=configured_token_url, + configured_registration_url=configured_registration_url, token_endpoint_auth_method=server_config.get("token_endpoint_auth_method", None), # TODO: utility fn the default values transport=server_config.get("transport", MCPTransport.http), @@ -2170,6 +2176,9 @@ class MCPServerManager: is_discovery_auth_type or self._obo_needs_endpoint_discovery(auth_type, token_exchange_endpoint, manual_token_url), ) + configured_authorization_url: Final = manual_authorization_url + configured_token_url: Final = manual_token_url + configured_registration_url: Final = manual_registration_url manual_authorization_url, manual_token_url, manual_registration_url = _endpoints_yield_to_issuer( manual_issuer, is_discovery_auth_type, @@ -2222,6 +2231,9 @@ class MCPServerManager: authorization_url=manual_authorization_url or getattr(gated_oauth_metadata, "authorization_url", None), token_url=manual_token_url or getattr(gated_oauth_metadata, "token_url", None), registration_url=manual_registration_url or getattr(gated_oauth_metadata, "registration_url", None), + configured_authorization_url=configured_authorization_url, + configured_token_url=configured_token_url, + configured_registration_url=configured_registration_url, token_endpoint_auth_method=( credentials_dict.get("token_endpoint_auth_method") if credentials_dict else None ), @@ -5858,9 +5870,9 @@ class MCPServerManager: args=getattr(server, "args", None) or [], env=getattr(server, "env", None) or {}, issuer=server.issuer, - authorization_url=server.authorization_url, - token_url=server.token_url, - registration_url=server.registration_url, + authorization_url=server.configured_authorization_url or server.authorization_url, + token_url=server.configured_token_url or server.token_url, + registration_url=server.configured_registration_url or server.registration_url, oauth2_flow=server.oauth2_flow, dcr_bridge=server.dcr_bridge, token_exchange_endpoint=server.token_exchange_endpoint, @@ -5968,9 +5980,9 @@ class MCPServerManager: args=getattr(server, "args", None) or [], env=getattr(server, "env", None) or {}, issuer=server.issuer, - authorization_url=server.authorization_url, - token_url=server.token_url, - registration_url=server.registration_url, + authorization_url=server.configured_authorization_url or server.authorization_url, + token_url=server.configured_token_url or server.token_url, + registration_url=server.configured_registration_url or server.registration_url, oauth2_flow=server.oauth2_flow, token_exchange_endpoint=server.token_exchange_endpoint, audience=server.audience, diff --git a/litellm/types/mcp_server/mcp_server_manager.py b/litellm/types/mcp_server/mcp_server_manager.py index 7ec117208a0..aeeeca21d3b 100644 --- a/litellm/types/mcp_server/mcp_server_manager.py +++ b/litellm/types/mcp_server/mcp_server_manager.py @@ -71,6 +71,12 @@ class MCPServer(BaseModel): authorization_url: str | None = None token_url: str | None = None registration_url: str | None = None + # Endpoints exactly as an admin stored them, unlike the resolved fields above which an anchored + # issuer empties (RFC 8414 section 3.3). Management reads serve these so the edit form does not + # load blanks and then save those blanks over the stored config. + configured_authorization_url: str | None = None + configured_token_url: str | None = None + configured_registration_url: str | None = None # How the gateway authenticates to the upstream token endpoint. When # "client_secret_basic" the credentials go in an HTTP Basic Authorization # header (omitted from the body); None defaults to "client_secret_post". 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 54fd5242d5f..55cc8565a71 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 @@ -479,6 +479,35 @@ class TestMCPServerManager: assert server.oauth2_flow == "authorization_code" assert server.needs_user_oauth_token is True + @pytest.mark.asyncio + async def test_load_servers_from_config_keeps_configured_endpoints_for_management_view(self): + """A yaml server with a pinned issuer still reports its configured endpoints to the management + view, even though the runtime fields are empty because the anchored issuer is the sole endpoint + source. The dashboard edits that view, so emptied values there load as blank fields and the next + save writes the blanks over the config.""" + manager = MCPServerManager() + + config = self._oauth2_config( + oauth2_flow="authorization_code", + issuer="https://idp.example.com", + authorization_url="https://example.com/oauth/authorize", + token_url="https://example.com/oauth/token", + registration_url="https://example.com/oauth/register", + ) + with patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=None)): + await manager.load_servers_from_config(config) + + server = next(iter(manager.config_mcp_servers.values())) + assert server.authorization_url is None + assert server.token_url is None + assert server.registration_url is None + + view = manager._build_mcp_server_table(server) + + assert view.authorization_url == "https://example.com/oauth/authorize" + assert view.token_url == "https://example.com/oauth/token" + assert view.registration_url == "https://example.com/oauth/register" + @pytest.mark.asyncio async def test_load_servers_from_config_rejects_uncorroborated_endpoints_but_keeps_resource_scopes(self): """A yaml server with a manual authorization_url has the same config-time mix-up exposure as a @@ -1611,6 +1640,43 @@ class TestMCPServerManager: assert built.token_url == "https://idp.example.com/token" assert built.token_url != "https://attacker.example.com/steal" + @pytest.mark.asyncio + async def test_management_view_keeps_stored_endpoints_when_issuer_is_pinned(self): + """A pinned issuer empties the endpoints the runtime uses, but the management view must still + report what the admin stored. Serving the emptied values made the dashboard edit form load the + three endpoint fields blank, so saving with no edits sent them back as explicit nulls and wiped + the row, and re-entering them looked like it never saved.""" + manager = MCPServerManager() + row = LiteLLM_MCPServerTable( + server_id="issuer-anchored-management-view", + alias="issuer_anchored_management_view", + description="issuer pinned with admin-entered endpoints", + url="https://up.example.com/mcp", + transport=MCPTransport.http, + auth_type=MCPAuth.oauth2, + oauth2_flow="authorization_code", + issuer="https://idp.example.com", + authorization_url="https://up.example.com/oauth/authorize", + token_url="https://up.example.com/oauth/token", + registration_url="https://up.example.com/oauth/register", + created_at=datetime.now(), + updated_at=datetime.now(), + ) + + with patch.object(manager, "_fetch_issuer_anchored_oauth_metadata", new=AsyncMock(return_value=None)): + built = await manager.build_mcp_server_from_table(row, credentials_are_encrypted=False) + + assert built.authorization_url is None + assert built.token_url is None + assert built.registration_url is None + + view = manager._build_mcp_server_table(built) + + assert view.issuer == "https://idp.example.com" + assert view.authorization_url == "https://up.example.com/oauth/authorize" + assert view.token_url == "https://up.example.com/oauth/token" + assert view.registration_url == "https://up.example.com/oauth/register" + @pytest.mark.asyncio @pytest.mark.parametrize( "advertised_authorization_url",