From fffc069eb959078fd38176e1b2241cf7577f8b17 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 7 Mar 2026 16:11:56 -0800 Subject: [PATCH 01/12] [Fix] UI - MCP Servers: stop health checks triggering on deletion Removing an MCP server caused useMCPServerHealth to receive a new serverIds array (one fewer item), which changed the React Query key and triggered a fresh health check for every remaining server. Fix: remove serverIds from the hook's signature and query key entirely. The hook now uses a stable, constant key and always fetches health for all servers. The 30-second polling interval is unaffected, and the serversWithHealth merge already ignores health data for deleted servers. Co-Authored-By: Claude Sonnet 4.6 --- .../mcpServers/useMCPServerHealth.test.ts | 58 ++++--------- .../hooks/mcpServers/useMCPServerHealth.ts | 6 +- .../components/mcp_tools/mcp_servers.test.tsx | 87 ++++++++++++++++++- .../src/components/mcp_tools/mcp_servers.tsx | 3 +- 4 files changed, 107 insertions(+), 47 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.test.ts index be910acf7e4..567e1d23013 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.test.ts @@ -38,7 +38,7 @@ describe("useMCPServerHealth", () => { vi.clearAllMocks(); }); - it("should fetch health status for given server IDs", async () => { + it("should fetch health status for all servers", async () => { const mockHealthStatuses = [ { server_id: "server-1", status: "healthy" }, { server_id: "server-2", status: "unhealthy" }, @@ -46,27 +46,6 @@ describe("useMCPServerHealth", () => { vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue(mockHealthStatuses); - const { result } = renderHook(() => useMCPServerHealth(["server-1", "server-2"]), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("test-token-123", ["server-1", "server-2"]); - expect(result.current.data).toEqual(mockHealthStatuses); - }); - - it("should fetch health status for all servers when no server IDs provided", async () => { - const mockHealthStatuses = [ - { server_id: "server-1", status: "healthy" }, - { server_id: "server-2", status: "healthy" }, - { server_id: "server-3", status: "unhealthy" }, - ]; - - vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue(mockHealthStatuses); - const { result } = renderHook(() => useMCPServerHealth(), { wrapper, }); @@ -75,30 +54,15 @@ describe("useMCPServerHealth", () => { expect(result.current.isSuccess).toBe(true); }); - expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("test-token-123", undefined); + expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("test-token-123"); expect(result.current.data).toEqual(mockHealthStatuses); }); - it("should handle empty server list", async () => { - vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue([]); - - const { result } = renderHook(() => useMCPServerHealth([]), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.isSuccess).toBe(true); - }); - - expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("test-token-123", []); - expect(result.current.data).toEqual([]); - }); - it("should handle errors when fetching health status", async () => { const mockError = new Error("Failed to fetch health status"); vi.mocked(networking.fetchMCPServerHealth).mockRejectedValue(mockError); - const { result } = renderHook(() => useMCPServerHealth(["server-1"]), { + const { result } = renderHook(() => useMCPServerHealth(), { wrapper, }); @@ -116,7 +80,7 @@ describe("useMCPServerHealth", () => { accessToken: null, } as any); - const { result } = renderHook(() => useMCPServerHealth(["server-1"]), { + const { result } = renderHook(() => useMCPServerHealth(), { wrapper, }); @@ -124,4 +88,18 @@ describe("useMCPServerHealth", () => { expect(result.current.status).toBe("pending"); expect(networking.fetchMCPServerHealth).not.toHaveBeenCalled(); }); + + it("should use a stable query key that does not include server IDs", () => { + // Regression test: deleting a server used to pass a changing serverIds array into the + // hook, which was embedded in the query key. React Query would see a new key and fire + // a health check for every remaining server. + // + // The fix: the hook takes no serverIds parameter and uses a constant query key, so + // deleting (or adding) a server never causes an extra health check request. + // + // We verify the contract here by confirming the hook accepts no arguments. + // The stable-key behaviour is further exercised by mcp_servers.test.tsx. + const hookLength = useMCPServerHealth.length; + expect(hookLength).toBe(0); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts index 95d7f3bcee0..f81ade047e2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/mcpServers/useMCPServerHealth.ts @@ -10,11 +10,11 @@ interface MCPServerHealth { status: string; } -export const useMCPServerHealth = (serverIds?: string[]) => { +export const useMCPServerHealth = () => { const { accessToken } = useAuthorized(); return useQuery({ - queryKey: [...mcpServerHealthKeys.lists(), { serverIds }], - queryFn: async () => await fetchMCPServerHealth(accessToken!, serverIds), + queryKey: mcpServerHealthKeys.lists(), + queryFn: async () => await fetchMCPServerHealth(accessToken!), enabled: !!accessToken, // Refetch health status every 30 seconds to keep it up to date refetchInterval: 30000, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx index 8385fc7ac70..0ca3d698956 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.test.tsx @@ -185,9 +185,10 @@ describe("MCPServers", () => { expect(getByText("MCP Servers")).toBeInTheDocument(); }); - // Verify the health check API was called with server IDs + // Verify the health check API was called (without a server ID filter — the hook always + // fetches health for all servers so the query key stays stable) await waitFor(() => { - expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("123", ["server-1", "server-2"]); + expect(networking.fetchMCPServerHealth).toHaveBeenCalledWith("123"); }); }); @@ -348,4 +349,86 @@ describe("MCPServers", () => { // Team B server should not be visible expect(screen.queryByText("Team B Server")).not.toBeInTheDocument(); }); + + it("should not trigger an extra health check when the server list changes after deletion", async () => { + // Regression test: previously useMCPServerHealth received serverIds derived from the + // server list. Deleting a server changed serverIds, which changed the React Query key, + // which caused a new health check request for every remaining server. + // + // Fix: useMCPServerHealth uses a stable, argument-free query key. The component + // re-rendering with a shorter server list must NOT produce a second health fetch. + const twoServers = [ + { + server_id: "server-1", + server_name: "Test Server 1", + alias: "test-server-1", + url: "https://example.com/mcp", + transport: "http", + auth_type: "none", + created_at: "2024-01-01T00:00:00Z", + created_by: "user-1", + updated_at: "2024-01-01T00:00:00Z", + updated_by: "user-1", + teams: [], + mcp_access_groups: [], + }, + { + server_id: "server-2", + server_name: "Test Server 2", + alias: "test-server-2", + url: "https://example2.com/mcp", + transport: "sse", + auth_type: "api_key", + created_at: "2024-01-02T00:00:00Z", + created_by: "user-2", + updated_at: "2024-01-02T00:00:00Z", + updated_by: "user-2", + teams: [], + mcp_access_groups: [], + }, + ]; + const oneServer = twoServers.slice(0, 1); + + // First call returns two servers; second (after deletion) returns one + vi.mocked(networking.fetchMCPServers) + .mockResolvedValueOnce(twoServers) + .mockResolvedValueOnce(oneServer); + vi.mocked(networking.fetchMCPServerHealth).mockResolvedValue([ + { server_id: "server-1", status: "healthy" }, + { server_id: "server-2", status: "healthy" }, + ]); + + // Use a shared queryClient with a non-zero gcTime so cached health data survives + // the re-render triggered by the server list refresh + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 60_000 } }, + }); + + const { rerender } = render( + + + , + ); + + // Wait for the initial health fetch to complete + await waitFor(() => { + expect(networking.fetchMCPServerHealth).toHaveBeenCalledTimes(1); + }); + + // Simulate what happens after a server is deleted: the server list query is + // refetched (returns oneServer), causing the component to re-render with the + // shorter list. + await act(async () => { + await queryClient.invalidateQueries({ queryKey: ["mcpServers"] }); + }); + + rerender( + + + , + ); + + // The server list refresh must NOT trigger a second health check + expect(networking.fetchMCPServerHealth).toHaveBeenCalledTimes(1); + }); }); diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx index 75e5bc8d453..a2f63ef73ad 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_servers.tsx @@ -27,8 +27,7 @@ const MCPServers: React.FC = ({ accessToken, userRole, userID }) const { data: mcpServers, isLoading: isLoadingServers, refetch } = useMCPServers(); // Fetch health status for all servers - const serverIds = useMemo(() => mcpServers?.map((server) => server.server_id), [mcpServers]); - const { data: healthStatuses, isLoading: isLoadingHealth } = useMCPServerHealth(serverIds); + const { data: healthStatuses, isLoading: isLoadingHealth } = useMCPServerHealth(); // Merge health status data into servers const serversWithHealth = useMemo(() => { From 9c07325396d4058974b0663968eb1e189a1622da Mon Sep 17 00:00:00 2001 From: Yong woo Song Date: Mon, 9 Mar 2026 08:58:10 +0000 Subject: [PATCH 02/12] feat: add qwen3.5 series for openrouter --- model_prices_and_context_window.json | 129 +++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index ae256ed0784..ac0bb53a86a 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -27444,6 +27444,135 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/qwen/qwen3.5-35b-a3b": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 655536, + "max_tokens": 655536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/qwen/qwen3.5-27b": { + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 655536, + "max_tokens": 655536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-27b", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/qwen/qwen3.5-122b-a10b": { + "input_cost_per_token": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 655536, + "max_tokens": 655536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + + "openrouter/qwen/qwen3.5-flash-02-23": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 655536, + "max_tokens": 655536, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/qwen/qwen3.5-plus-02-15": { + "input_cost_per_token": 4e-07, + "input_cost_per_token_above_256k_tokens": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 655536, + "max_tokens": 655536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "output_cost_per_token_above_256k_tokens": 3e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, + "openrouter/qwen/qwen3.5-397b-a17b": { + "input_cost_per_token": 6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 655536, + "max_tokens": 655536, + "mode": "chat", + "output_cost_per_token": 3.6e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", + "supported_modalities": [ + "text", + "image", + "video" + ], + "supported_output_modalities": [ + "text" + ], + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true + }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-07, "litellm_provider": "openrouter", From 2683fa714c14154d3d8d449da44ea766afe42fb6 Mon Sep 17 00:00:00 2001 From: Yong woo Song Date: Mon, 9 Mar 2026 09:11:31 +0000 Subject: [PATCH 03/12] fix: typo on max_output_tokens and max_tokens from qwen3.5 series --- model_prices_and_context_window.json | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index ac0bb53a86a..e5900d7d9c8 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -27448,8 +27448,8 @@ "input_cost_per_token": 2.5e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 655536, - "max_tokens": 655536, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", @@ -27469,8 +27469,8 @@ "input_cost_per_token": 3e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 655536, - "max_tokens": 655536, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 2.4e-06, "source": "https://openrouter.ai/qwen/qwen3.5-27b", @@ -27490,8 +27490,8 @@ "input_cost_per_token": 4e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 655536, - "max_tokens": 655536, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", @@ -27512,8 +27512,8 @@ "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", "max_input_tokens": 1000000, - "max_output_tokens": 655536, - "max_tokens": 655536, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 4e-07, "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", @@ -27534,8 +27534,8 @@ "input_cost_per_token_above_256k_tokens": 5e-07, "litellm_provider": "openrouter", "max_input_tokens": 1000000, - "max_output_tokens": 655536, - "max_tokens": 655536, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 2.4e-06, "output_cost_per_token_above_256k_tokens": 3e-06, @@ -27556,8 +27556,8 @@ "input_cost_per_token": 6e-07, "litellm_provider": "openrouter", "max_input_tokens": 262144, - "max_output_tokens": 655536, - "max_tokens": 655536, + "max_output_tokens": 65536, + "max_tokens": 65536, "mode": "chat", "output_cost_per_token": 3.6e-06, "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", From 0d8880ab9f09a84e71181855b76cbfa79c26a0f8 Mon Sep 17 00:00:00 2001 From: Yong woo Song Date: Mon, 9 Mar 2026 11:03:39 +0000 Subject: [PATCH 04/12] chore: fix --- model_prices_and_context_window.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index e5900d7d9c8..fb5c6e036a4 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -27507,7 +27507,6 @@ "supports_reasoning": true, "supports_tool_choice": true }, - "openrouter/qwen/qwen3.5-flash-02-23": { "input_cost_per_token": 1e-07, "litellm_provider": "openrouter", @@ -27528,7 +27527,7 @@ "supports_function_calling": true, "supports_reasoning": true, "supports_tool_choice": true - }, + }, "openrouter/qwen/qwen3.5-plus-02-15": { "input_cost_per_token": 4e-07, "input_cost_per_token_above_256k_tokens": 5e-07, From 6bb9f9ceb6d8e4d8b7f149deda1f2aed9a89d6be Mon Sep 17 00:00:00 2001 From: Yong woo Song Date: Tue, 10 Mar 2026 04:05:42 +0000 Subject: [PATCH 05/12] chore: fix --- model_prices_and_context_window.json | 66 +++++----------------------- 1 file changed, 12 insertions(+), 54 deletions(-) diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index fb5c6e036a4..1dd94bdcdd4 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -27453,17 +27453,10 @@ "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", - "supported_modalities": [ - "text", - "image", - "video" - ], - "supported_output_modalities": [ - "text" - ], "supports_function_calling": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "openrouter/qwen/qwen3.5-27b": { "input_cost_per_token": 3e-07, @@ -27474,17 +27467,10 @@ "mode": "chat", "output_cost_per_token": 2.4e-06, "source": "https://openrouter.ai/qwen/qwen3.5-27b", - "supported_modalities": [ - "text", - "image", - "video" - ], - "supported_output_modalities": [ - "text" - ], "supports_function_calling": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "openrouter/qwen/qwen3.5-122b-a10b": { "input_cost_per_token": 4e-07, @@ -27495,17 +27481,10 @@ "mode": "chat", "output_cost_per_token": 2e-06, "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", - "supported_modalities": [ - "text", - "image", - "video" - ], - "supported_output_modalities": [ - "text" - ], "supports_function_calling": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "openrouter/qwen/qwen3.5-flash-02-23": { "input_cost_per_token": 1e-07, @@ -27516,17 +27495,10 @@ "mode": "chat", "output_cost_per_token": 4e-07, "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", - "supported_modalities": [ - "text", - "image", - "video" - ], - "supported_output_modalities": [ - "text" - ], "supports_function_calling": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "openrouter/qwen/qwen3.5-plus-02-15": { "input_cost_per_token": 4e-07, @@ -27539,17 +27511,10 @@ "output_cost_per_token": 2.4e-06, "output_cost_per_token_above_256k_tokens": 3e-06, "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", - "supported_modalities": [ - "text", - "image", - "video" - ], - "supported_output_modalities": [ - "text" - ], "supports_function_calling": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "openrouter/qwen/qwen3.5-397b-a17b": { "input_cost_per_token": 6e-07, @@ -27560,17 +27525,10 @@ "mode": "chat", "output_cost_per_token": 3.6e-06, "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", - "supported_modalities": [ - "text", - "image", - "video" - ], - "supported_output_modalities": [ - "text" - ], "supports_function_calling": true, "supports_reasoning": true, - "supports_tool_choice": true + "supports_tool_choice": true, + "supports_vision": true }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-07, From a71ba39b78eeecb0d04d0baa1e4a88af817a757c Mon Sep 17 00:00:00 2001 From: Shivam Rawat <161387515+shivamrawat1@users.noreply.github.com> Date: Tue, 10 Mar 2026 15:38:59 -0700 Subject: [PATCH 06/12] Revert "policy builder" --- .../guardrail_pipeline_flow_builder.md | 270 ------------------ .../proxy/guardrails/guardrail_policies.md | 15 - docs/my-website/sidebars.js | 1 - 3 files changed, 286 deletions(-) delete mode 100644 docs/my-website/docs/proxy/guardrails/guardrail_pipeline_flow_builder.md diff --git a/docs/my-website/docs/proxy/guardrails/guardrail_pipeline_flow_builder.md b/docs/my-website/docs/proxy/guardrails/guardrail_pipeline_flow_builder.md deleted file mode 100644 index 0ea56eef081..00000000000 --- a/docs/my-website/docs/proxy/guardrails/guardrail_pipeline_flow_builder.md +++ /dev/null @@ -1,270 +0,0 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# Guardrail Pipeline Flow Builder - -The **Flow Builder** lets you design guardrail policies with **conditional, sequential execution**. Instead of running guardrails independently, you chain them into a pipeline where each step has configurable **ON PASS** and **ON FAIL** actions. This enables multi-tier fallbacks, retries, and escalation paths. - -## When to use the Flow Builder - -| Use Case | Simple Policy | Pipeline (Flow Builder) | -|----------|---------------|-------------------------| -| Run multiple guardrails together | ✅ | ✅ | -| All guardrails run independently | ✅ | ❌ | -| Conditional execution (if A fails → try B) | ❌ | ✅ | -| Fallback to different guardrail on failure | ❌ | ✅ | -| Retry same guardrail before blocking | ❌ | ✅ | -| Pass modified data (e.g., PII-masked) to next step | ❌ | ✅ | - -**Use the Flow Builder when** you need: -- **Fallbacks** — Try a fast/simple guardrail first; if it fails, escalate to a stricter one -- **Retries** — Run the same guardrail multiple times before blocking (e.g., for flaky APIs) -- **Escalation** — Route to different guardrails based on pass/fail outcomes - -## Quick Start - - - - -1. Go to **Policies** → **+ Create New Policy** -2. Choose **Flow Builder** (instead of Simple) -3. Click **Continue to Builder** to open the full-screen Flow Builder -4. Add steps, select guardrails, and configure ON PASS / ON FAIL actions -5. Use **Test** to run a sample message through the pipeline before saving -6. Save the policy - - - - -```yaml showLineNumbers title="config.yaml" -guardrails: - - guardrail_name: strict-filter - litellm_params: - guardrail: lakera - mode: pre_call - api_key: os.environ/LAKERA_API_KEY - - guardrail_name: permissive-filter - litellm_params: - guardrail: presidio - mode: pre_call - -policies: - content-safety: - guardrails: - add: [strict-filter, permissive-filter] - pipeline: - mode: pre_call - steps: - - guardrail: strict-filter - on_fail: next - on_pass: allow - - guardrail: permissive-filter - on_fail: block - on_pass: allow - -policy_attachments: - - policy: content-safety - scope: "*" -``` - - - - -## Step Actions - -Each pipeline step has two actions: - -| Action | When | Description | -|--------|------|-------------| -| **Next Step** | ON PASS or ON FAIL | Continue to the next step in the pipeline | -| **Allow** | ON PASS or ON FAIL | Stop the pipeline and allow the request | -| **Block** | ON PASS or ON FAIL | Stop the pipeline and block the request | -| **Custom Response** | ON PASS or ON FAIL | Stop and return a custom message instead of the default block/allow | - -### Common patterns - -**Fallback chain** — Try strict first, escalate to permissive on failure: - -```yaml -steps: - - guardrail: strict-filter - on_fail: next # strict failed → try next - on_pass: allow - - guardrail: permissive-filter - on_fail: block # permissive failed → block - on_pass: allow -``` - -**Retry same guardrail** — Run the same guardrail twice before blocking: - -```yaml -steps: - - guardrail: lakera-pii - on_fail: next - on_pass: allow - - guardrail: lakera-pii - on_fail: block - on_pass: allow -``` - -**Pass modified data** — Forward PII-masked content to the next step: - -```yaml -steps: - - guardrail: presidio-pii - on_fail: block - on_pass: next - pass_data: true # PII-masked request/response sent to next step - - guardrail: prompt-injection - on_fail: block - on_pass: allow -``` - -## Pipeline Fields - -### `pipeline` - -| Field | Type | Required | Description | -|-------|------|----------|-------------| -| `mode` | `pre_call` \| `post_call` | Yes | When the pipeline runs (before or after the LLM call) | -| `steps` | `list[PipelineStep]` | Yes | Ordered list of steps (at least 1) | - -### `PipelineStep` - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `guardrail` | `string` | — | Name of the guardrail to run | -| `on_fail` | `next` \| `block` \| `allow` \| `modify_response` | `block` | Action when guardrail rejects | -| `on_pass` | `next` \| `block` \| `allow` \| `modify_response` | `allow` | Action when guardrail passes | -| `pass_data` | `bool` | `false` | Forward modified request/response to next step | -| `modify_response_message` | `string` | Optional | Custom message for `modify_response` action | - -## Example: Multi-tier content safety - -```yaml showLineNumbers title="config.yaml" -guardrails: - - guardrail_name: "pii-masking" - litellm_params: - guardrail: presidio - mode: pre_call - - guardrail_name: "prompt-injection" - litellm_params: - guardrail: lakera - mode: pre_call - api_key: os.environ/LAKERA_API_KEY - -policies: - content-safety-pipeline: - description: "PII mask → prompt injection check → allow or block" - guardrails: - add: [pii-masking, prompt-injection] - pipeline: - mode: pre_call - steps: - - guardrail: pii-masking - on_fail: block - on_pass: next - pass_data: true - - guardrail: prompt-injection - on_fail: block - on_pass: allow - -policy_attachments: - - policy: content-safety-pipeline - scope: "*" -``` - -**Flow:** 1) Mask PII → 2) Check masked content for prompt injection → 3) Allow or block. - -## Example: Retry with same guardrail - -Useful when a guardrail API is flaky or rate-limited: - -```yaml showLineNumbers title="config.yaml" -policies: - retry-on-failure: - guardrails: - add: [pii_masking] - pipeline: - mode: pre_call - steps: - - guardrail: pii_masking - on_fail: next - on_pass: allow - - guardrail: pii_masking - on_fail: block - on_pass: allow -``` - -**Flow:** Run `pii_masking` twice. Block only if it fails both times. - -## Example: Custom response on failure - -Return a branded message instead of the default block: - -```yaml -steps: - - guardrail: strict-filter - on_fail: modify_response - modify_response_message: "Your request was blocked. Please remove sensitive content and try again." - on_pass: allow -``` - -## Pipeline vs Simple Policy - -**Simple policy** — All guardrails run independently. If any fail, the request is blocked (or handled per guardrail config). - -**Pipeline policy** — Guardrails run in order. Each step has conditional actions. You control the flow (fallback, retry, escalate). - -```mermaid -flowchart TD - subgraph Simple["Simple Policy"] - S1[Guardrail A] --> R[Result: block if any fail] - S2[Guardrail B] --> R - end - - subgraph Pipeline["Pipeline Policy"] - P1[Step 1: Guardrail A] -->|on_fail: next| P2[Step 2: Guardrail B] - P1 -->|on_pass: allow| Allow - P2 -->|on_fail: block| Block - P2 -->|on_pass: allow| Allow - end - -## Testing the pipeline - -### In the UI - -The Flow Builder includes a **Test** panel. Enter a sample message and click **Run** to see which steps pass or fail and what action is taken. - -### Via API - -Use the [Test Playground](/docs/proxy/guardrails/test_playground) or send a request with the policy attached: - -```bash -curl -X POST http://localhost:4000/v1/chat/completions \ - -H "Authorization: Bearer sk-..." \ - -H "Content-Type: application/json" \ - -d '{ - "model": "gpt-4", - "messages": [{"role": "user", "content": "Test message"}], - "guardrails": ["content-safety-pipeline"] - }' -``` - -## Response headers - -When a pipeline runs, response headers include: - -| Header | Description | -|--------|-------------| -| `x-litellm-applied-policies` | Policies that matched | -| `x-litellm-applied-guardrails` | Guardrails that ran | -| `x-litellm-policy-sources` | Why each policy matched | - -## Related - -- [Guardrail Policies](/docs/proxy/guardrails/guardrail_policies) — Policies overview, attachments, inheritance -- [Policy Templates](/docs/proxy/guardrails/policy_templates) — Pre-configured policy templates -- [Guardrails Quick Start](/docs/proxy/guardrails/quick_start) — Defining guardrails diff --git a/docs/my-website/docs/proxy/guardrails/guardrail_policies.md b/docs/my-website/docs/proxy/guardrails/guardrail_policies.md index 72cb15afe0a..e2cb839203e 100644 --- a/docs/my-website/docs/proxy/guardrails/guardrail_policies.md +++ b/docs/my-website/docs/proxy/guardrails/guardrail_policies.md @@ -12,17 +12,6 @@ Use policies to group guardrails and control which ones run for specific teams, - Group guardrails into a single policy - Inherit from existing policies and override what you need -## Policy types - -LiteLLM supports two ways to run guardrails in a policy: - -| Type | Description | Use when | -|------|--------------|----------| -| **Simple** | All guardrails run independently. If any fail, the request is blocked. | You want a flat list of guardrails with no conditional logic. | -| **Pipeline (Flow Builder)** | Guardrails run sequentially with configurable ON PASS / ON FAIL actions per step. Supports fallbacks, retries, and escalation. | You need conditional execution (e.g., try strict filter first, fallback to permissive on failure). | - -For pipelines, see [Guardrail Pipeline Flow Builder](/docs/proxy/guardrails/guardrail_pipeline_flow_builder) for detailed documentation. - ## Quick Start @@ -332,9 +321,6 @@ policies: guardrails: add: [...] remove: [...] - pipeline: # Optional. Enables sequential, conditional execution. - mode: pre_call # or post_call - steps: [...] condition: model: ... ``` @@ -345,7 +331,6 @@ policies: | `inherit` | `string` | Optional. Parent policy to inherit guardrails from. | | `guardrails.add` | `list[string]` | Guardrails to enable. | | `guardrails.remove` | `list[string]` | Guardrails to disable (useful with inheritance). | -| `pipeline` | `object` | Optional. Enables [Flow Builder](/docs/proxy/guardrails/guardrail_pipeline_flow_builder) — sequential execution with ON PASS/ON FAIL actions. | | `condition.model` | `string` or `list[string]` | Optional. Only apply when model matches. Supports regex. | ### `policy_attachments` diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index c36c0cd167e..758fdc82a98 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -100,7 +100,6 @@ const sidebars = { label: "Policies", items: [ "proxy/guardrails/guardrail_policies", - "proxy/guardrails/guardrail_pipeline_flow_builder", "proxy/guardrails/policy_templates", "proxy/guardrails/policy_tags", ], From 1c0c504877d9662231a9cce4b32a7ce35679f594 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 10 Mar 2026 16:48:53 -0700 Subject: [PATCH 07/12] =?UTF-8?q?fix(mcp):=20fix=20OpenAPI=20OAuth=20flow?= =?UTF-8?q?=20=E2=80=94=20transport=20mapping,=20error=20messages,=20disco?= =?UTF-8?q?very=20bypass=20(#23300)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): fix OpenAPI OAuth flow — transport mapping, error messages, and discovery bypass Three bugs fixed to make the end-to-end OAuth flow work for OpenAPI MCP servers: 1. **Transport mapping in getTemporaryPayload**: `TRANSPORT.OPENAPI` is a UI-only concept; the backend only accepts `"http"`, `"sse"`, or `"stdio"`. The pre-OAuth temp-session call was sending `transport: "openapi"` and getting a 422. Fixed by mapping to `"http"`. 2. **deriveErrorMessage handles FastAPI 422 arrays**: FastAPI validation errors return `detail` as an array of `{loc, msg, type}` objects. The shared error extractor was returning the array directly, causing `Error: [object Object]`. Fixed to map each item to its `.msg` field. 3. **Skip OAuth discovery when authorization_url already provided**: `build_mcp_server_from_table` was unconditionally calling `_descovery_metadata(server_url)` for OAuth servers. For OpenAPI servers the url is the spec JSON file, not the API base — this caused a timeout fetching e.g. the GitHub spec (2 MB). Fixed by skipping discovery when `authorization_url` is already set. Also: collapsible auth section in MCP server form, "Create OAuth App →" link next to Client ID when a docs URL is available (e.g. GitHub OAuth App creation page), and `extractErrorMessage` helper in `useMcpOAuthFlow` for cleaner error display. * refactor(mcp): extract needs_discovery flag and reduceStaticHeaders helper --- .../mcp_server/mcp_server_manager.py | 17 +- .../components/mcp_tools/OAuthFormFields.tsx | 20 ++- .../mcp_tools/OpenAPIFormSection.tsx | 11 +- .../mcp_tools/create_mcp_server.tsx | 161 ++++++++++-------- .../src/components/networking.tsx | 103 ++++++++++- .../src/hooks/useMcpOAuthFlow.tsx | 15 +- 6 files changed, 239 insertions(+), 88 deletions(-) diff --git a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py index 48ea3d384a9..8769d3952a4 100644 --- a/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py +++ b/litellm/proxy/_experimental/mcp_server/mcp_server_manager.py @@ -618,12 +618,17 @@ class MCPServerManager: mcp_info["description"] = mcp_server.description auth_type = cast(MCPAuthType, mcp_server.auth_type) - if mcp_server.url and auth_type == MCPAuth.oauth2: - mcp_oauth_metadata = await self._descovery_metadata( - server_url=mcp_server.url, - ) - else: - mcp_oauth_metadata = None + server_url = mcp_server.url + needs_discovery = ( + bool(server_url) + and auth_type == MCPAuth.oauth2 + and not mcp_server.authorization_url + ) + mcp_oauth_metadata = ( + await self._descovery_metadata(server_url=server_url) # type: ignore[arg-type] + if needs_discovery + else None + ) resolved_scopes = scopes or ( mcp_oauth_metadata.scopes if mcp_oauth_metadata else None diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx index acad72cb21c..85487a8a479 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OAuthFormFields.tsx @@ -16,6 +16,8 @@ interface OAuthFormFieldsProps { isEditing?: boolean; oauthFlow?: OAuthFlowStatus; initialFlowType?: string; + /** Link to provider docs for creating an OAuth app (e.g. GitHub). */ + docsUrl?: string | null; } const fieldClassName = "rounded-lg border-gray-300 focus:border-blue-500 focus:ring-blue-500"; @@ -34,6 +36,7 @@ const OAuthFormFields: React.FC = ({ isEditing = false, oauthFlow, initialFlowType, + docsUrl, }) => { const placeholderSuffix = isEditing ? " (leave blank to keep existing)" : ""; @@ -98,7 +101,22 @@ const OAuthFormFields: React.FC = ({ ) : ( <> } + label={ + + + {docsUrl && ( + e.stopPropagation()} + > + Create OAuth App → + + )} + + } name={["credentials", "client_id"]} > diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx index 68412ea21bc..23aae6cb14f 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx @@ -2,7 +2,7 @@ import React, { useState } from "react"; import { Form, Input, Tooltip } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { FormInstance } from "antd/es/form"; -import { AUTH_TYPE } from "./types"; +import { AUTH_TYPE, OAUTH_FLOW } from "./types"; import OpenAPIQuickPicker, { OpenAPIRegistryEntry, OpenAPIKeyTool } from "./OpenAPIQuickPicker"; interface OpenAPIFormSectionProps { @@ -12,6 +12,8 @@ interface OpenAPIFormSectionProps { onValuesChange: (updates: Record) => void; /** Called when key tools change (from registry preset selection). */ onKeyToolsChange?: (tools: OpenAPIKeyTool[]) => void; + /** Called when the OAuth docs URL changes (e.g. link to create a GitHub OAuth App). */ + onOAuthDocsUrlChange?: (url: string | null) => void; } /** @@ -24,6 +26,7 @@ const OpenAPIFormSection: React.FC = ({ accessToken, onValuesChange, onKeyToolsChange, + onOAuthDocsUrlChange, }) => { const [selectedPreset, setSelectedPreset] = useState(null); @@ -35,14 +38,19 @@ const OpenAPIFormSection: React.FC = ({ }; if (entry.oauth) { updates.auth_type = AUTH_TYPE.OAUTH2; + // OAuth2 registry entries always use the interactive (PKCE) flow — users + // authorize via their browser, not machine-to-machine client credentials. + updates.oauth_flow_type = OAUTH_FLOW.INTERACTIVE; updates.authorization_url = entry.oauth.authorization_url; updates.token_url = entry.oauth.token_url; form.setFieldsValue(updates); + onOAuthDocsUrlChange?.(entry.oauth.docs_url ?? null); } else { // resetFields is required to visually clear Ant Design form fields — // setFieldsValue with undefined silently skips undefined keys. form.resetFields(["auth_type", "authorization_url", "token_url"]); form.setFieldsValue(updates); + onOAuthDocsUrlChange?.(null); } onValuesChange(updates); }; @@ -75,6 +83,7 @@ const OpenAPIFormSection: React.FC = ({ // so stale suggested tools from a previous preset don't persist. setSelectedPreset(null); onKeyToolsChange?.([]); + onOAuthDocsUrlChange?.(null); }} /> 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 de60ff2b782..90ecd4731cf 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 @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Modal, Tooltip, Form, Select, Input, Switch } from "antd"; +import { Modal, Tooltip, Form, Select, Input, Switch, Collapse } from "antd"; import { InfoCircleOutlined } from "@ant-design/icons"; import { Button, TextInput } from "@tremor/react"; import { createMCPServer, registerMCPServer } from "../networking"; @@ -35,6 +35,15 @@ const AUTH_TYPES_REQUIRING_AUTH_VALUE = [AUTH_TYPE.API_KEY, AUTH_TYPE.BEARER_TOK const AUTH_TYPES_REQUIRING_CREDENTIALS = [...AUTH_TYPES_REQUIRING_AUTH_VALUE, AUTH_TYPE.OAUTH2]; const CREATE_OAUTH_UI_STATE_KEY = "litellm-mcp-oauth-create-state"; +const reduceStaticHeaders = (list: unknown): Record => { + if (!Array.isArray(list)) return {}; + return list.reduce((acc: Record, entry: Record) => { + const header = entry?.header?.trim(); + if (header) acc[header] = entry?.value ?? ""; + return acc; + }, {}); +}; + const CreateMCPServer: React.FC = ({ userRole, accessToken, @@ -61,6 +70,7 @@ const CreateMCPServer: React.FC = ({ const [keyTools, setKeyTools] = useState([]); const [searchValue, setSearchValue] = useState(""); const [oauthAccessToken, setOauthAccessToken] = useState(null); + const [oauthDocsUrl, setOauthDocsUrl] = useState(null); // Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests. const { tools, isLoadingTools, toolsError, toolsErrorStackTrace, canFetchTools, fetchTools, clearTools } = useTestMCPConnection({ @@ -108,21 +118,16 @@ const CreateMCPServer: React.FC = ({ getCredentials: () => form.getFieldValue("credentials"), getTemporaryPayload: () => { const values = form.getFieldsValue(true); - const url = values.url; const transport = values.transport || transportType; + // For OpenAPI transport the form has spec_path instead of url. + // We pass the spec_path as url so the temp-session endpoint has something + // to store; the backend uses authorization_url / token_url for the actual + // OAuth redirect, so the spec_path value is never used for OAuth itself. + const url = values.url || (transport === TRANSPORT.OPENAPI ? values.spec_path : undefined); if (!url || !transport) { return null; } - const staticHeaders = Array.isArray(values.static_headers) - ? values.static_headers.reduce((acc: Record, entry: Record) => { - const header = entry?.header?.trim(); - if (!header) { - return acc; - } - acc[header] = entry?.value ?? ""; - return acc; - }, {}) - : ({} as Record); + const staticHeaders = reduceStaticHeaders(values.static_headers); return { server_id: undefined, @@ -130,7 +135,7 @@ const CreateMCPServer: React.FC = ({ alias: values.alias, description: values.description, url, - transport, + transport: transport === TRANSPORT.OPENAPI ? "http" : transport, auth_type: AUTH_TYPE.OAUTH2, credentials: values.credentials, authorization_url: values.authorization_url, @@ -277,16 +282,7 @@ const CreateMCPServer: React.FC = ({ // Transform access groups into objects with name property const accessGroups = restValues.mcp_access_groups; - const staticHeaders = Array.isArray(staticHeadersList) - ? staticHeadersList.reduce((acc: Record, entry: Record) => { - const header = entry?.header?.trim(); - if (!header) { - return acc; - } - acc[header] = entry?.value ?? ""; - return acc; - }, {}) - : ({} as Record); + const staticHeaders = reduceStaticHeaders(staticHeadersList); const credentialsPayload = credentialValues && typeof credentialValues === "object" @@ -415,7 +411,7 @@ const CreateMCPServer: React.FC = ({ const handleCancel = () => { form.resetFields(); setCostConfig({}); - setTools([]); + clearTools(); setAllowedTools([]); setAliasManuallyEdited(false); setModalVisible(false); @@ -649,6 +645,7 @@ const CreateMCPServer: React.FC = ({ setFormValues((prev) => ({ ...prev, ...updates })) } onKeyToolsChange={setKeyTools} + onOAuthDocsUrlChange={setOauthDocsUrl} /> )} @@ -738,60 +735,74 @@ const CreateMCPServer: React.FC = ({ {/* Authentication - show for HTTP, SSE, and OpenAPI */} {transportType !== "stdio" && transportType !== "" && ( - Authentication} - name="auth_type" - rules={[{ required: true, message: "Please select an auth type" }]} - > - - - )} - - {transportType !== "stdio" && transportType !== "" && shouldShowAuthValueField && ( - - Authentication Value - - - - - } - name={["credentials", "auth_value"]} - rules={[ + - value && typeof value === "string" && value.trim() === "" - ? Promise.reject(new Error("Authentication value cannot be empty whitespace")) - : Promise.resolve(), + key: "auth", + label: Authentication, + children: ( + <> + + + + + {shouldShowAuthValueField && ( + + Authentication Value + + + + + } + name={["credentials", "auth_value"]} + rules={[ + { + validator: (_, value) => + value && typeof value === "string" && value.trim() === "" + ? Promise.reject(new Error("Authentication value cannot be empty whitespace")) + : Promise.resolve(), + }, + ]} + > + + + )} + + {isOAuthAuthType && ( + + )} + + ), }, ]} - > - - - )} - - {transportType !== "stdio" && transportType !== "" && isOAuthAuthType && ( - )} diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index fbde083c2da..2176ef584a4 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -8946,11 +8946,16 @@ export const perUserAnalyticsCall = async ( }; export const deriveErrorMessage = (errorData: any): string => { + const detail = errorData?.detail; + const detailStr = Array.isArray(detail) + ? detail.map((d: any) => d?.msg || JSON.stringify(d)).join("; ") + : typeof detail === "string" + ? detail + : undefined; return ( - (errorData?.error && (errorData.error.message || errorData.error)) || + (errorData?.error && (errorData.error.message || (typeof errorData.error === "string" ? errorData.error : undefined))) || errorData?.message || - errorData?.detail || - errorData?.error || + detailStr || JSON.stringify(errorData) ); }; @@ -9556,3 +9561,95 @@ export const deleteToolPolicyOverride = async ( } return response.json(); }; + +// ── MCP OAuth user-credential helpers ──────────────────────────────────────── + +export interface MCPOAuthUserCredentialStatus { + server_id: string; + has_credential: boolean; + expires_at?: string | null; + is_expired: boolean; + connected_at?: string | null; +} + +export interface MCPUserCredentialListItem { + server_id: string; + server_name?: string | null; + alias?: string | null; + credential_type: string; + has_credential: boolean; + expires_at?: string | null; + connected_at?: string | null; +} + +export const storeMCPOAuthUserCredential = async ( + accessToken: string, + serverId: string, + tokenResponse: { access_token: string; refresh_token?: string; expires_in?: number; scopes?: string[] }, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth-user-credential` + : `/v1/mcp/server/${serverId}/oauth-user-credential`; + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(tokenResponse), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error((err as { detail?: { error?: string } })?.detail?.error || "Failed to store OAuth credential"); + } + return response.json(); +}; + +export const deleteMCPOAuthUserCredential = async ( + accessToken: string, + serverId: string, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth-user-credential` + : `/v1/mcp/server/${serverId}/oauth-user-credential`; + const response = await fetch(url, { + method: "DELETE", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error((err as { detail?: { error?: string } })?.detail?.error || "Failed to revoke OAuth credential"); + } + return response.json(); +}; + +export const getMCPOAuthUserCredentialStatus = async ( + accessToken: string, + serverId: string, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/server/${serverId}/oauth-user-credential/status` + : `/v1/mcp/server/${serverId}/oauth-user-credential/status`; + const response = await fetch(url, { + method: "GET", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + return { server_id: serverId, has_credential: false, is_expired: false }; + } + return response.json(); +}; + +export const listMCPUserCredentials = async ( + accessToken: string, +): Promise => { + const url = proxyBaseUrl + ? `${proxyBaseUrl}/v1/mcp/user-credentials` + : `/v1/mcp/user-credentials`; + const response = await fetch(url, { + method: "GET", + headers: { [globalLitellmHeaderName]: `Bearer ${accessToken}` }, + }); + if (!response.ok) return []; + return response.json(); +}; diff --git a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx index f914e42f043..97d48510c2e 100644 --- a/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx +++ b/ui/litellm-dashboard/src/hooks/useMcpOAuthFlow.tsx @@ -13,6 +13,17 @@ import { export type McpOAuthStatus = "idle" | "authorizing" | "exchanging" | "success" | "error"; +function extractErrorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (err && typeof err === "object") { + const e = err as Record; + if (typeof e.detail === "string") return e.detail; + if (typeof e.message === "string") return e.message; + return JSON.stringify(err); + } + return String(err); +} + interface UseMcpOAuthFlowOptions { accessToken: string | null; getCredentials: () => { @@ -223,7 +234,7 @@ export const useMcpOAuthFlow = ({ } catch (err) { console.error("Failed to start OAuth flow", err); setStatus("error"); - const message = err instanceof Error ? err.message : String(err); + const message = extractErrorMessage(err); setError(message); NotificationsManager.error(message); } @@ -310,7 +321,7 @@ export const useMcpOAuthFlow = ({ setError(null); NotificationsManager.success("OAuth token retrieved successfully"); } catch (err) { - const message = err instanceof Error ? err.message : String(err); + const message = extractErrorMessage(err); setError(message); setStatus("error"); NotificationsManager.error(message); From 9d83a6f814454a6b2f8374c16c571c31d58252c7 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 10 Mar 2026 16:55:48 -0700 Subject: [PATCH 08/12] [Feature] UI - Keys: Improve VirtualKeysTable column display and usability Consolidate User Email and User ID columns into a single "User" column with fallback display (Alias > Email > ID) and hover popover with copyable values. Resolve Team and Organization columns to show aliases instead of raw UUIDs. Co-Authored-By: Claude Opus 4.6 --- .../VirtualKeysPage/VirtualKeysTable.test.tsx | 17 +- .../VirtualKeysPage/VirtualKeysTable.tsx | 197 ++++++++++++------ .../components/key_team_helpers/key_list.tsx | 1 + 3 files changed, 147 insertions(+), 68 deletions(-) diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx index 35f87e8770e..418bca64ea9 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.test.tsx @@ -57,6 +57,18 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({ default: vi.fn(), })); +// Mock useOrganizations hook +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: vi.fn().mockReturnValue({ + data: [ + { + organization_id: "org-1", + organization_alias: "Test Organization", + }, + ], + }), +})); + // Mock fetchTeams to prevent network calls vi.mock("@/app/(dashboard)/networking", async (importOriginal) => { const actual = await importOriginal(); @@ -125,6 +137,7 @@ const mockKey: KeyResponse = { user: { user_email: "user@example.com", user_id: "user-1", + user_alias: null, }, }; @@ -380,7 +393,7 @@ it("should render table headers correctly", () => { // Check that main headers are rendered (testing the header.isPlaceholder condition path) expect(screen.getByText("Key ID")).toBeInTheDocument(); expect(screen.getByText("Key Alias")).toBeInTheDocument(); - expect(screen.getByText("Team Alias")).toBeInTheDocument(); + expect(screen.getByText("Team")).toBeInTheDocument(); expect(screen.getByText("Models")).toBeInTheDocument(); expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); }); @@ -463,6 +476,8 @@ it("should display 'Default Proxy Admin' for user_id when value is 'default_user const keyWithDefaultUserId = { ...mockKey, user_id: "default_user_id", + user_email: "", + user: { user_id: "default_user_id", user_email: "", user_alias: null }, }; mockUseFilterLogic.mockReturnValue({ diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index d9c590938fb..20cc1b8153c 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -1,5 +1,6 @@ "use client"; import { useKeys } from "@/app/(dashboard)/hooks/keys/useKeys"; +import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { formatNumberWithCommas } from "@/utils/dataUtils"; import { ChevronDownIcon, ChevronRightIcon, ChevronUpIcon, SwitchVerticalIcon } from "@heroicons/react/outline"; import { @@ -25,13 +26,14 @@ import { Text, } from "@tremor/react"; import { InfoCircleOutlined, SyncOutlined } from "@ant-design/icons"; -import { Button as AntButton, Popover, Skeleton, Tooltip } from "antd"; +import { Button as AntButton, Popover, Skeleton, Tooltip, Typography } from "antd"; import React, { useEffect, useDeferredValue, useMemo, useState } from "react"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; import { useFilterLogic } from "../key_team_helpers/filter_logic"; import { PaginatedKeyAliasSelect } from "../KeyAliasSelect/PaginatedKeyAliasSelect/PaginatedKeyAliasSelect"; import { KeyResponse, Team } from "../key_team_helpers/key_list"; import FilterComponent, { FilterOption } from "../molecules/filter"; +import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag"; import { Organization } from "../networking"; import KeyInfoView from "../templates/key_info_view"; @@ -51,6 +53,8 @@ interface VirtualKeysTableProps { */ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSort }: VirtualKeysTableProps) { + const { data: fetchedOrganizations } = useOrganizations(); + const resolvedOrganizations = fetchedOrganizations ?? organizations ?? []; const [selectedKey, setSelectedKey] = useState(null); const [sorting, setSorting] = React.useState(() => { if (currentSort) { @@ -86,6 +90,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo } = useKeys(tablePagination.pageIndex + 1, tablePagination.pageSize, { sortBy: sortBy || undefined, sortOrder: sortOrder || undefined, + expand: "user", }); const [expandedAccordions, setExpandedAccordions] = useState>({}); @@ -172,11 +177,9 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo const value = info.getValue() as string; const width = info.cell.column.getSize(); return ( - - - {value ?? "-"} - - + + {value ?? "-"} + ); }, }, @@ -191,76 +194,110 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo { id: "team_alias", accessorKey: "team_id", - header: "Team Alias", + header: "Team", size: 120, enableSorting: false, - cell: ({ row, getValue }) => { - const teamId = getValue() as string; - const team = teams?.find((t) => t.team_id === teamId); - return team?.team_alias || "Unknown"; - }, - }, - { - id: "team_id", - accessorKey: "team_id", - header: "Team ID", - size: 80, - enableSorting: false, cell: (info) => { - const value = info.getValue() as string | null; + const teamId = info.getValue() as string | null; + if (!teamId) return "-"; + const team = teams?.find((t) => t.team_id === teamId); + const displayValue = team?.team_alias || teamId; const width = info.cell.column.getSize(); return ( - - - {value ?? "-"} - - + + {displayValue} + ); }, }, { - id: "organization_id", + id: "organization_alias", accessorKey: "org_id", - header: "Organization ID", + header: "Organization", size: 140, enableSorting: false, - cell: (info) => (info.getValue() ? info.renderValue() : "-"), - }, - { - id: "user_email", - accessorKey: "user", - header: "User Email", - size: 160, - enableSorting: false, cell: (info) => { - const user = info.getValue() as any; - const value = user?.user_email; + const orgId = info.getValue() as string | null; + if (!orgId) return "-"; + const org = resolvedOrganizations.find((o) => o.organization_id === orgId); + const displayValue = org?.organization_alias || orgId; const width = info.cell.column.getSize(); return ( - - - {value ?? "-"} - - + + {displayValue} + ); }, }, { - id: "user_id", - accessorKey: "user_id", - header: "User ID", - size: 70, + id: "user", + accessorKey: "user", + header: () => ( + + User + + + + + ), + size: 160, enableSorting: false, - cell: (info) => { - const userId = info.getValue() as string | null; - const displayValue = userId === "default_user_id" ? "Default Proxy Admin" : userId; - const width = info.cell.column.getSize(); + cell: ({ row }) => { + const key = row.original; + const userAlias = key.user?.user_alias ?? null; + const userEmail = key.user?.user_email ?? key.user_email ?? null; + const userId = key.user_id ?? null; + const isDefaultAdmin = userId === "default_user_id"; + const displayValue = userAlias || userEmail || userId; + const width = 160; + + const popoverContent = ( +
+ {[ + { label: "User Alias", value: userAlias }, + { label: "User Email", value: userEmail }, + { label: "User ID", value: userId }, + ].map(({ label, value }) => ( +
+ {label} + {value ? ( + + {value} + + ) : ( + - + )} +
+ ))} +
+ ); + + if (isDefaultAdmin && !userAlias && !userEmail) { + return ( + + + + + + ); + } + return ( - - - {displayValue ?? "-"} + + + {displayValue || "-"} - + ); }, }, @@ -279,18 +316,48 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo id: "created_by", accessorKey: "created_by", header: "Created By", - size: 70, + size: 160, enableSorting: false, cell: (info) => { - const value = info.getValue() as string | null; - const displayValue = value === "default_user_id" ? "Default Proxy Admin" : value; - const width = info.cell.column.getSize(); + const userId = info.getValue() as string | null; + if (!userId) return "-"; + const isDefaultAdmin = userId === "default_user_id"; + const width = 160; + + const popoverContent = ( +
+
+ User ID + + {userId} + +
+
+ ); + + if (isDefaultAdmin) { + return ( + + + + + + ); + } + return ( - - - {displayValue ?? "-"} + + + {userId} - + ); }, }, @@ -477,7 +544,7 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo ); }, }, - ], []); + ], [teams, resolvedOrganizations]); const filterOptions: FilterOption[] = [ { @@ -535,8 +602,6 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo }, ]; - console.log(`keys: ${JSON.stringify(keys)}`); - const table = useReactTable({ data: filteredKeys, columns: columns.filter((col) => col.id !== "expander"), @@ -548,13 +613,11 @@ export function VirtualKeysTable({ teams, organizations, onSortChange, currentSo }, onSortingChange: (updaterOrValue) => { const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; - console.log(`newSorting: ${JSON.stringify(newSorting)}`); setSorting(newSorting); if (newSorting && newSorting.length > 0) { const sortState = newSorting[0]; const sortBy = sortState.id; const sortOrder = sortState.desc ? "desc" : "asc"; - console.log(`sortBy: ${sortBy}, sortOrder: ${sortOrder}`); // Update filters state without triggering debouncedSearch // The useKeys hook will automatically refetch with the new sort parameters handleFilterChange( diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index 4cc3367f71d..a31162cb2f2 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -99,6 +99,7 @@ export interface KeyResponse { user?: { user_id: string; user_email: string; + user_alias: string | null; }; } From 8bcc8fe1e2c4ae3c4fec67c5b55f65beeb3aeb07 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Tue, 10 Mar 2026 17:49:09 -0700 Subject: [PATCH 09/12] Rename 'Team-Based Guardrails' to 'Team Bring-Your-Own Guardrails' (#23307) Co-authored-by: Cursor Agent --- docs/my-website/docs/proxy/guardrails/team_based_guardrails.md | 2 +- docs/my-website/release_notes/v1.81.9.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md b/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md index 2d55294a711..0e610b6e445 100644 --- a/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md +++ b/docs/my-website/docs/proxy/guardrails/team_based_guardrails.md @@ -1,6 +1,6 @@ import Image from '@theme/IdealImage'; -# Team-Based Guardrails +# Team Bring-Your-Own Guardrails Team-based guardrails let **developers** register a guardrail for their team via the API; an **admin** then reviews and approves or rejects it in the LiteLLM UI. Only [Generic Guardrail API](/docs/adding_provider/generic_guardrail_api) guardrails can be registered this way. diff --git a/docs/my-website/release_notes/v1.81.9.md b/docs/my-website/release_notes/v1.81.9.md index c7659442c4c..80be4179b46 100644 --- a/docs/my-website/release_notes/v1.81.9.md +++ b/docs/my-website/release_notes/v1.81.9.md @@ -279,7 +279,7 @@ Let's dive in. - Add HTTP support to custom code guardrails + Unified guardrails for MCP + Agent guardrail support - [PR #20619](https://github.com/BerriAI/litellm/pull/20619) - Custom Code Guardrails UI Playground - [PR #20377](https://github.com/BerriAI/litellm/pull/20377) -- **Team-Based Guardrails** +- **Team Bring-Your-Own Guardrails** - Implement team-based isolation guardrails management - [PR #20318](https://github.com/BerriAI/litellm/pull/20318) - **[OpenAI Moderations](../../docs/apply_guardrail)** From 82a9b0ea03e88b8086c29eaf5b711c989b5c0987 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 10 Mar 2026 18:53:54 -0700 Subject: [PATCH 10/12] feat(chat-ui): responses API + MCP tool execution in /chat (#23297) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ui): add Chat UI v0 — standalone LiteLLM-branded chat window Adds a full chat UI accessible from the sidebar Chat link (opens in new tab). - Standalone route at /chat (outside dashboard layout — no Navbar/Sidebar chrome) - Claude.ai-style layout: model selector top-left, LiteLLM logo center, settings top-right - Greeting with time-of-day, centered input card, suggestion chips (Write/Learn/Code/Brainstorm) - Sliding conversation history sidebar with Cmd+K search, rename, delete, date grouping - localStorage-backed conversation persistence (litellm_chat_history_v1) - Streaming completions via makeOpenAIChatCompletionRequest with AbortController stop support - MCP server picker (toggle servers on/off per conversation) - LiteLLM aesthetic: white/light-gray background, Ant Design blue (#1677ff) primary, system font - Sidebar2: Chat menu item opens in new tab via window.open * feat(chat-ui): responses API + MCP tool execution display - Switch /chat from chat completions to responses API (previous_response_id session chaining) - Add MCP server picker with search filter in chat input bar - Show MCP tool call events (list_tools + call_tool) inline in chat via MCPEventsDisplay - Add tool chip strip showing available tools when MCP servers are selected - Non-blocking MCP toggle: server added immediately, verification in background (works for no-auth MCPs like deepwiki) - Add truncateAfterMessage to useChatHistory for edit/retry - Sync activeConversationId on URL change (fixes stale conversation on new chat) - Add "Open Chat" shortcut button to sidebar * fix(chat-ui): switch to responses API, remove dead code, add tests - Switch handleSend from makeOpenAIChatCompletionRequest to makeOpenAIResponsesRequest with previous_response_id session chaining - Add responsesSessionId state; reset to null when starting a new conversation - Remove unused ChatInputBar.tsx and ModelSelector.tsx (dead code) - Add tests/test_litellm/test_chat_ui_responses_session.py covering previous_response_id forwarding and signature validation * fix(chat-ui): address greptile review issues - Reset responsesSessionId when activeConversationId changes (not just on new conversation) - Wire onMCPEvent callback into makeOpenAIResponsesRequest; render MCPEventsDisplay below messages - Clear mcpEvents on each new send - Explicitly filter history to user/assistant roles only (no tool-role casting) - Remove duplicate "Chat" menu item from sidebar (pinned button serves same purpose) - Make Sider a flex column so "Open Chat" button actually pins to bottom - Fix tests to intercept real HTTP requests and assert previous_response_id in body * fix(chat-ui): address greptile review feedback (greploop iteration 1) - Fix duplicate context: when responsesSessionId is set, only send the new user message as input (prior context is already server-side via session chaining). Full history is still sent on the first turn. - Fix ephemeral MCP events: store events per-message in ChatMessage.mcpEvents instead of ephemeral component state. Events now survive across turns and render inline below each assistant response via MCPEventsDisplay. - Remove stale mcpEvents useState and ephemeral panel at bottom of chat. * fix(chat-ui): address greptile review feedback (greploop iteration 2) - Fix stale session on edit/retry: derive previousResponseId as null when historyOverride is set so edit/retry always starts a fresh Responses API session rather than chaining off a now-invalid prior session - Fix unsafe MCPEvent cast: import MCPEvent directly from MCPEventsDisplay into types.ts and type ChatMessage.mcpEvents as MCPEvent[], eliminating the bare 'as MCPEvent[]' cast in ChatMessages.tsx * fix(chat-ui): fix MCPEvent layering, batch localStorage writes, module-level test imports - Move MCPEvent interface definition into chat/types.ts (single source of truth) - MCPEventsDisplay.tsx now imports MCPEvent from types.ts instead of defining it locally - Batch MCP event localStorage writes: accumulate during stream, persist once in finally - Move test imports to module level per PEP 8 convention * fix(chat-ui): fix MCPEvent import path and rename truncateFromMessage - responses_api.tsx now imports MCPEvent directly from chat/types (not via MCPEventsDisplay re-export) - Remove the now-unnecessary MCPEvent re-export from MCPEventsDisplay.tsx - Rename truncateAfterMessage → truncateFromMessage: the function removes the target message and all subsequent ones (not just what comes after), so the new name accurately describes the behavior * fix(responses-api): fix whitespace token filter and MCP server URL construction - Drop the delta.trim() whitespace filter that was silently swallowing spaces and newlines during streaming, causing words to concatenate and paragraphs to collapse. Only skip truly empty strings (delta.length > 0). - Use proxyBaseUrl for MCP server_url construction instead of the hardcoded relative path "litellm_proxy/mcp", so non-root deployments route correctly. * fix(responses-api): use unique server_label per MCP server to prevent tool routing collisions * fix(chat-ui): move MCPEvent to shared mcp_tools/types, skip partial events on abort - Move MCPEvent interface to mcp_tools/types.tsx (shared with MCPServer/MCPTool), eliminating the playground→chat cross-module dependency. chat/types.ts and both playground components now import from mcp_tools/types. - Only persist accumulated MCP events when the stream completes cleanly; aborted or errored turns drop partial events to avoid showing incomplete tool calls. * fix(responses-api): use server_name for MCP URL routing, fix test path - Use server_name (not alias) as the URL path segment for MCP server_url; alias is a display name that may differ from the registered proxy route. URL-encode the path to handle names with spaces/special characters. - Fix sys.path.insert in tests to use __file__-relative path so tests pass regardless of which directory pytest is invoked from. * fix(chat-ui): fix stale session after failed edit, clean MCP event persistence, unique server_label - Eagerly call setResponsesSessionId(null) when historyOverride is set so a failed/aborted edit does not leave a stale session contaminating the next turn - Replace abort-signal check with streamCompletedCleanly flag to correctly skip MCP event persistence on both abort and non-abort errors (network/API failures) - Use server_name (unique) as server_label instead of alias to prevent silent tool-routing failures when two MCP servers share the same display name --- .../test_chat_ui_responses_session.py | 127 ++++++++++++++++++ .../app/(dashboard)/components/Sidebar2.tsx | 68 +++++++++- .../src/components/chat/ChatMessages.tsx | 10 ++ .../src/components/chat/ChatPage.tsx | 82 ++++++++--- .../src/components/chat/types.ts | 4 + .../src/components/chat/useChatHistory.ts | 11 +- .../src/components/mcp_tools/types.tsx | 27 ++++ .../playground/chat_ui/MCPEventsDisplay.tsx | 27 +--- .../playground/llm_calls/responses_api.tsx | 15 ++- 9 files changed, 310 insertions(+), 61 deletions(-) create mode 100644 tests/test_litellm/test_chat_ui_responses_session.py diff --git a/tests/test_litellm/test_chat_ui_responses_session.py b/tests/test_litellm/test_chat_ui_responses_session.py new file mode 100644 index 00000000000..09ef003ebdb --- /dev/null +++ b/tests/test_litellm/test_chat_ui_responses_session.py @@ -0,0 +1,127 @@ +""" +Tests for responses API session chaining used by the chat UI. + +Verifies that: +1. previous_response_id is correctly forwarded when provided +2. Absence of previous_response_id does not break the call +3. The aresponses function signature exposes the expected parameters +""" +import inspect +import json +import os +import sys +import unittest.mock as mock + +# Use __file__ so the import path is correct regardless of the pytest working directory. +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + +import httpx +import pytest + +import litellm + + +class TestResponsesSessionChaining: + """Test previous_response_id session chaining for the chat UI.""" + + def test_responses_api_signature_accepts_previous_response_id(self): + """aresponses must accept previous_response_id and onResponseId-like params.""" + sig = inspect.signature(litellm.aresponses) + assert "previous_response_id" in sig.parameters, ( + "aresponses must accept previous_response_id for multi-turn session chaining" + ) + assert "input" in sig.parameters, "aresponses must accept input" + assert "model" in sig.parameters, "aresponses must accept model" + + @pytest.mark.asyncio + async def test_previous_response_id_included_in_request_body(self): + """previous_response_id must appear in the outgoing HTTP request body.""" + captured_body: dict = {} + + async def mock_send(self_transport, request: httpx.Request, **kwargs): + try: + captured_body.update(json.loads(request.content)) + except Exception: + pass + # Return a minimal valid responses API response + response_json = { + "id": "resp_test123", + "object": "response", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi", "annotations": []}], + "status": "completed", + } + ], + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + "status": "completed", + "created_at": 1700000000, + } + return httpx.Response( + 200, + json=response_json, + request=request, + ) + + with mock.patch("httpx.AsyncClient.send", mock_send): + try: + await litellm.aresponses( + input="hello", + model="gpt-4o-mini", + previous_response_id="resp_prev_abc", + api_key="sk-test-fake", + ) + except Exception: + pass # response parsing may fail; we only care about the outgoing body + + assert captured_body.get("previous_response_id") == "resp_prev_abc", ( + f"Expected previous_response_id in request body, got: {captured_body}" + ) + + @pytest.mark.asyncio + async def test_no_previous_response_id_omitted_from_request(self): + """When previous_response_id is None, it must not appear in the request body.""" + captured_body: dict = {} + + async def mock_send(self_transport, request: httpx.Request, **kwargs): + try: + captured_body.update(json.loads(request.content)) + except Exception: + pass + response_json = { + "id": "resp_new001", + "object": "response", + "model": "gpt-4o-mini", + "output": [ + { + "type": "message", + "id": "msg_001", + "role": "assistant", + "content": [{"type": "output_text", "text": "hi", "annotations": []}], + "status": "completed", + } + ], + "usage": {"input_tokens": 5, "output_tokens": 3, "total_tokens": 8}, + "status": "completed", + "created_at": 1700000000, + } + return httpx.Response(200, json=response_json, request=request) + + with mock.patch("httpx.AsyncClient.send", mock_send): + try: + await litellm.aresponses( + input="hello", + model="gpt-4o-mini", + previous_response_id=None, + api_key="sk-test-fake", + ) + except Exception: + pass + + assert "previous_response_id" not in captured_body, ( + "previous_response_id must be omitted from the request body when None" + ) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx index dbc1c4d10e2..ceb14864ad9 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/components/Sidebar2.tsx @@ -20,6 +20,7 @@ import { ToolOutlined, TagsOutlined, AuditOutlined, + MessageOutlined, } from "@ant-design/icons"; // import { // all_admin_roles, @@ -47,6 +48,7 @@ interface SidebarProps { interface MenuItemCfg { key: string; + newTab?: boolean; page: string; // legacy id; we map this to a path below label: string; roles?: string[]; @@ -105,6 +107,8 @@ const routeFor = (slug: string): string => { return "guardrails"; case "policies": return "policies"; + case "chat": + return "chat"; // tools case "mcp-servers": @@ -371,19 +375,29 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect }, [pathname, filteredMenuItems, defaultSelectedKey]); // ----- Navigation ----- - const goTo = (slug: string) => { + const goTo = (slug: string, newTab?: boolean) => { const href = toHref(slug); - router.push(href); + if (newTab) { + window.open(href, "_blank"); + } else { + router.push(href); + } }; // Wrap label in so every nav item supports right-click → "Open in new tab" // and Ctrl/Cmd+click to open in a new tab, while preserving SPA navigation for normal clicks. - const renderNavLink = (label: string, page: string): React.ReactNode => { + const renderNavLink = (label: string, page: string, newTab?: boolean): React.ReactNode => { const href = toHref(page); return ( { + if (newTab) { + e.stopPropagation(); + return; + } if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { e.stopPropagation(); return; @@ -409,6 +423,8 @@ const Sidebar2: React.FC = ({ accessToken, userRole, defaultSelect style={{ transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)", position: "relative", + display: "flex", + flexDirection: "column", }} > = ({ accessToken, userRole, defaultSelect borderRight: 0, backgroundColor: "transparent", fontSize: "14px", + flex: 1, + overflowY: "auto", }} items={filteredMenuItems.map((item) => ({ key: item.key, icon: item.icon, - label: renderNavLink(item.label, item.page), + label: renderNavLink(item.label, item.page, item.newTab), children: item.children?.map((child) => ({ key: child.key, icon: child.icon, - label: renderNavLink(child.label, child.page), - onClick: () => goTo(child.page), + label: renderNavLink(child.label, child.page, child.newTab), + onClick: () => goTo(child.page, child.newTab), })), - onClick: !item.children ? () => goTo(item.page) : undefined, + onClick: !item.children ? () => goTo(item.page, item.newTab) : undefined, }))} /> {isAdminRole(userRole) && !collapsed && } + + {/* Pinned "Open Chat" button at bottom */} + ); diff --git a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx index 10bf6b61927..2ff07c7065b 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatMessages.tsx @@ -8,6 +8,7 @@ import remarkGfm from "remark-gfm"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { coy } from "react-syntax-highlighter/dist/esm/styles/prism"; import ReasoningContent from "../playground/chat_ui/ReasoningContent"; +import MCPEventsDisplay from "../playground/chat_ui/MCPEventsDisplay"; import { ChatMessage } from "./types"; const { Panel } = Collapse; @@ -237,6 +238,8 @@ interface AssistantBubbleProps { isLastMessage: boolean; isStreaming: boolean; isTypingIndicator: boolean; + /** MCP events stored on the message — rendered inline below the response. */ + mcpEvents?: ChatMessage["mcpEvents"]; } function AssistantBubble({ @@ -244,6 +247,7 @@ function AssistantBubble({ isLastMessage, isStreaming, isTypingIndicator, + mcpEvents, }: AssistantBubbleProps) { // Ref to control ReasoningContent collapse on streaming end. // ReasoningContent manages its own expanded state; we use a key to @@ -321,6 +325,11 @@ function AssistantBubble({ + {mcpEvents && mcpEvents.length > 0 && ( +
+ +
+ )} ); } @@ -566,6 +575,7 @@ const ChatMessages: React.FC = ({ messages, isStreaming, onEditMessage }) isLastMessage={isLastMessage} isStreaming={isStreaming} isTypingIndicator={isLastMessage && isTypingIndicator} + mcpEvents={msg.mcpEvents} /> ); })} diff --git a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx index 34473165bb1..6a67814e2c6 100644 --- a/ui/litellm-dashboard/src/components/chat/ChatPage.tsx +++ b/ui/litellm-dashboard/src/components/chat/ChatPage.tsx @@ -26,6 +26,8 @@ import MCPConnectPicker from "./MCPConnectPicker"; import MCPAppsPanel from "./MCPAppsPanel"; import { fetchAvailableModels } from "../playground/llm_calls/fetch_models"; import { makeOpenAIChatCompletionRequest } from "../playground/llm_calls/chat_completion"; +import { makeOpenAIResponsesRequest } from "../playground/llm_calls/responses_api"; +import type { MCPEvent } from "./types"; import { getProxyBaseUrl } from "@/components/networking"; import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig"; import { getProviderLogoAndName } from "@/components/provider_info_helpers"; @@ -135,6 +137,7 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user const [modelSearchText, setModelSearchText] = useState(""); const [selectedMCPServers, setSelectedMCPServers] = useState([]); + const [responsesSessionId, setResponsesSessionId] = useState(null); const [isStreaming, setIsStreaming] = useState(false); const [inputText, setInputText] = useState(""); const [mcpPopoverOpen, setMcpPopoverOpen] = useState(false); @@ -162,7 +165,7 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user createConversation, appendMessage, updateLastAssistantMessage, - truncateAfterMessage, + truncateFromMessage, deleteConversation, renameConversation, } = useChatHistory(activeConversationId); @@ -203,6 +206,12 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user if (staleId) router.replace(getChatUrl(uiRoot)); }, [staleId, router]); + // Reset the responses session when switching between conversations so that + // previous_response_id from conversation A is never sent for conversation B. + useEffect(() => { + setResponsesSessionId(null); + }, [activeConversationId]); + const toggleModel = useCallback((model: string) => { setSelectedModels((prev) => { let next: string[]; @@ -231,6 +240,7 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user let convId = activeConversationId; if (!convId) { convId = createConversation(model); + setResponsesSessionId(null); // new conversation starts a fresh session router.push(getChatUrl(uiRoot, convId)); } @@ -240,29 +250,56 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user setIsStreaming(true); abortControllerRef.current = new AbortController(); - const history = [ - ...(historyOverride ?? (activeConversation?.messages ?? []) - .filter((m) => m.role === "user" || m.role === "assistant") - .map((m) => ({ - role: m.role as "user" | "assistant", - content: m.content, - }))), - { role: "user" as const, content: trimmed }, - ]; + // When historyOverride is set (edit / retry), the existing server-side + // session chain covers messages that were just truncated and is no longer + // valid for the rewritten history. Eagerly clear the session so that a + // failed/aborted edit does not leave a stale session ID that contaminates + // the next regular send. + if (historyOverride) { + setResponsesSessionId(null); + } + + // On a normal continuation turn with an active session, the Responses API + // already holds the prior context server-side, so we only pass the new + // user message (sending the full history would double-count it). + // + // On the very first turn (no session yet), we send the full history. + const previousResponseId = historyOverride ? null : responsesSessionId; + + const history: Array<{ role: "user" | "assistant"; content: string }> = + historyOverride + ? [...historyOverride, { role: "user" as const, content: trimmed }] + : previousResponseId + ? [{ role: "user" as const, content: trimmed }] + : [ + // Explicitly filter to only user/assistant roles — tool messages + // lack a required tool_call_id and would cause API errors. + ...(activeConversation?.messages ?? []) + .filter((m): m is typeof m & { role: "user" | "assistant" } => + m.role === "user" || m.role === "assistant" + ) + .map((m) => ({ role: m.role, content: m.content })), + { role: "user" as const, content: trimmed }, + ]; let accumulatedContent = ""; let accumulatedReasoning = ""; + // MCP events accumulated locally so we can persist them to the message + // without relying on component state (which would cause stale closures). + const accumulatedMCPEvents: MCPEvent[] = []; + // Track clean completion so partial events are not shown on error/abort. + let streamCompletedCleanly = false; try { - await makeOpenAIChatCompletionRequest( + await makeOpenAIResponsesRequest( history, - (chunk: string) => { + (_role: string, chunk: string) => { accumulatedContent += chunk; updateLastAssistantMessage(convId!, { content: accumulatedContent }); }, model, accessToken, - undefined, + undefined, // tags abortControllerRef.current.signal, (rc: string) => { accumulatedReasoning += rc; @@ -270,7 +307,15 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user }, undefined, undefined, undefined, undefined, undefined, undefined, selectedMCPServers.length > 0 ? selectedMCPServers : undefined, + previousResponseId, + (id: string) => setResponsesSessionId(id), + (event: MCPEvent) => { + // Accumulate locally only — persisted once in finally to avoid + // one full localStorage write per MCP event during streaming. + accumulatedMCPEvents.push(event); + }, ); + streamCompletedCleanly = true; } catch (err: unknown) { if (err instanceof Error && err.name === "AbortError") { updateLastAssistantMessage(convId!, { @@ -282,12 +327,17 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user }); } } finally { + // Only persist MCP events on clean completion — partial events from an + // aborted or errored turn would show incomplete tool calls to the user. + if (accumulatedMCPEvents.length > 0 && streamCompletedCleanly) { + updateLastAssistantMessage(convId!, { mcpEvents: accumulatedMCPEvents }); + } setIsStreaming(false); abortControllerRef.current = null; } }, [activeConversationId, activeConversation, selectedModels, selectedMCPServers, accessToken, - createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming], + createConversation, appendMessage, updateLastAssistantMessage, router, isStreaming, responsesSessionId], ); const handleSendComparison = useCallback( @@ -355,10 +405,10 @@ const ChatPage: React.FC = ({ accessToken, userRole, userId, user const priorMessages = (idx === -1 ? msgs : msgs.slice(0, idx)) .filter((m) => m.role === "user" || m.role === "assistant") .map((m) => ({ role: m.role as "user" | "assistant", content: m.content })); - truncateAfterMessage(activeConversationId, messageId); + truncateFromMessage(activeConversationId, messageId); handleSend(newContent, priorMessages); }, - [activeConversationId, isStreaming, activeConversation, truncateAfterMessage, handleSend], + [activeConversationId, isStreaming, activeConversation, truncateFromMessage, handleSend], ); const handleSubmit = useCallback( diff --git a/ui/litellm-dashboard/src/components/chat/types.ts b/ui/litellm-dashboard/src/components/chat/types.ts index c886bbe03b4..4c0da7a095e 100644 --- a/ui/litellm-dashboard/src/components/chat/types.ts +++ b/ui/litellm-dashboard/src/components/chat/types.ts @@ -1,8 +1,12 @@ +export type { MCPEvent } from "../mcp_tools/types"; + export interface ChatMessage { id: string; role: "user" | "assistant" | "tool"; content: string; reasoningContent?: string; + /** MCP tool events that occurred during this assistant turn, in order. */ + mcpEvents?: MCPEvent[]; toolName?: string; toolArgs?: Record; toolResult?: string; diff --git a/ui/litellm-dashboard/src/components/chat/useChatHistory.ts b/ui/litellm-dashboard/src/components/chat/useChatHistory.ts index 1f27a15b39f..b0a42d82dea 100644 --- a/ui/litellm-dashboard/src/components/chat/useChatHistory.ts +++ b/ui/litellm-dashboard/src/components/chat/useChatHistory.ts @@ -51,8 +51,9 @@ export function useChatHistory(activeConversationId: string | null): { staleId: boolean; createConversation: (model: string) => string; appendMessage: (conversationId: string, message: Omit) => void; - updateLastAssistantMessage: (conversationId: string, updates: Partial>) => void; - truncateAfterMessage: (conversationId: string, messageId: string) => void; + updateLastAssistantMessage: (conversationId: string, updates: Partial>) => void; + /** Remove the message with `messageId` and all subsequent messages from the conversation. */ + truncateFromMessage: (conversationId: string, messageId: string) => void; deleteConversation: (id: string) => void; renameConversation: (id: string, newTitle: string) => void; setActiveConversationId: (id: string | null) => void; @@ -148,7 +149,7 @@ export function useChatHistory(activeConversationId: string | null): { const updateLastAssistantMessage = useCallback( ( conversationId: string, - updates: Partial>, + updates: Partial>, ) => { setConversations((prev) => { const updated = prev.map((conv) => { @@ -168,7 +169,7 @@ export function useChatHistory(activeConversationId: string | null): { [], ); - const truncateAfterMessage = useCallback( + const truncateFromMessage = useCallback( (conversationId: string, messageId: string) => { setConversations((prev) => { const updated = prev.map((conv) => { @@ -222,7 +223,7 @@ export function useChatHistory(activeConversationId: string | null): { createConversation, appendMessage, updateLastAssistantMessage, - truncateAfterMessage, + truncateFromMessage, deleteConversation, renameConversation, setActiveConversationId, diff --git a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx index 3f8bc41e206..38868b50a5e 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/types.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/types.tsx @@ -1,3 +1,30 @@ +/** A single MCP tool event emitted by the LiteLLM proxy during a Responses API turn. */ +export interface MCPEvent { + type: string; + sequence_number?: number; + output_index?: number; + item_id?: string; + item?: { + id?: string; + type?: string; + server_label?: string; + tools?: Array<{ + name: string; + description: string; + annotations?: { + read_only?: boolean; + }; + input_schema?: unknown; + }>; + name?: string; + arguments?: string; + output?: string; + }; + delta?: string; + arguments?: string; + timestamp?: number; +} + export interface Team { team_id: string; team_alias?: string; diff --git a/ui/litellm-dashboard/src/components/playground/chat_ui/MCPEventsDisplay.tsx b/ui/litellm-dashboard/src/components/playground/chat_ui/MCPEventsDisplay.tsx index b3ec75f01dc..cd7ecbf266f 100644 --- a/ui/litellm-dashboard/src/components/playground/chat_ui/MCPEventsDisplay.tsx +++ b/ui/litellm-dashboard/src/components/playground/chat_ui/MCPEventsDisplay.tsx @@ -1,35 +1,10 @@ import React from "react"; import { Typography, Collapse } from "antd"; +import type { MCPEvent } from "../../mcp_tools/types"; const { Text } = Typography; const { Panel } = Collapse; -export interface MCPEvent { - type: string; - sequence_number?: number; - output_index?: number; - item_id?: string; - item?: { - id?: string; - type?: string; - server_label?: string; - tools?: Array<{ - name: string; - description: string; - annotations?: { - read_only?: boolean; - }; - input_schema?: any; - }>; - name?: string; - arguments?: string; - output?: string; - }; - delta?: string; - arguments?: string; - timestamp?: number; -} - interface MCPEventsDisplayProps { events: MCPEvent[]; className?: string; diff --git a/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx b/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx index c69f82a37bf..48d0efca6ee 100644 --- a/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx +++ b/ui/litellm-dashboard/src/components/playground/llm_calls/responses_api.tsx @@ -3,7 +3,7 @@ import { MessageType } from "../chat_ui/types"; import { TokenUsage } from "../chat_ui/ResponseMetrics"; import { getProxyBaseUrl } from "@/components/networking"; import NotificationManager from "@/components/molecules/notifications_manager"; -import { MCPEvent } from "../chat_ui/MCPEventsDisplay"; +import type { MCPEvent } from "../../mcp_tools/types"; import { MCPServer } from "../../mcp_tools/types"; import { CodeInterpreterResult, @@ -98,20 +98,22 @@ export async function makeOpenAIResponsesRequest( tools.push({ type: "mcp", server_label: "litellm", - server_url: "litellm_proxy/mcp", + server_url: `${proxyBaseUrl}/mcp`, require_approval: "never", }); } else { // Individual servers selected - create one entry per server selectedMCPServers.forEach((serverId) => { const server = mcpServers?.find((s) => s.server_id === serverId); - const serverName = server?.alias || server?.server_name || serverId; + // Use server_name for both routing and labelling. server_name is the + // unique registered identifier; aliases can collide across servers. + const routeName = server?.server_name || serverId; const allowedTools = mcpServerToolRestrictions?.[serverId] || []; tools.push({ type: "mcp", - server_label: "litellm", - server_url: `litellm_proxy/mcp/${serverName}`, + server_label: routeName, // unique per request — collisions cause silent tool-routing failures + server_url: `${proxyBaseUrl}/mcp/${encodeURIComponent(routeName)}`, require_approval: "never", ...(allowedTools.length > 0 ? { allowed_tools: allowedTools } : {}), }); @@ -197,8 +199,7 @@ export async function makeOpenAIResponsesRequest( if (event.type === "response.output_text.delta" && typeof event.delta === "string") { const delta = event.delta; console.log("Text delta", delta); - // skip pure whitespace/newlines - if (delta.trim().length > 0) { + if (delta.length > 0) { updateTextUI("assistant", delta, selectedModel); // Calculate time to first token From 65842eb7b531533ace4f3cce703a62ede2ba164c Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 10 Mar 2026 20:27:13 -0700 Subject: [PATCH 11/12] [Feat] UI - Show logos on MCP Apps page (#23320) * feat(ui): add MCP server logo support across admin and chat UIs - New MCPLogoSelector component with grid of well-known logos (GitHub, Slack, Notion, Linear, Jira, etc.) and custom URL input - Create MCP Server form: logo picker with preview, OpenAPI presets auto-fill logo from registry icon_url - Edit MCP Server form: logo picker pre-populated from mcp_info.logo_url - Admin table: logos rendered next to server name in Name column - Chat MCPAppsPanel: logos on server cards (list + detail view) with graceful fallback to letter avatars - Chat MCPConnectPicker: logos next to server names in toggle list - Fix pre-existing bug: setTools -> clearTools in create form cancel - All 321 vitest files / 3211 tests pass Co-authored-by: Ishaan Jaff * feat(ui): use local SVG logos for MCP services, fix Chat UI rendering - Add 15 new MCP service logo SVGs (Slack, Notion, Linear, Jira, Figma, Gmail, Stripe, Salesforce, Shopify, HubSpot, Twilio, Sentry, Zapier, GitLab, Google Drive) to both source and pre-built directories - Switch MCPLogoSelector from CDN URLs (cdn.simpleicons.org) to local asset paths (/ui/assets/logos/) for reliable rendering - Logos now served by the proxy itself, working from any page path including /ui/chat/ (absolute paths resolve correctly everywhere) Co-authored-by: Ishaan Jaff --------- Co-authored-by: Cursor Agent Co-authored-by: Ishaan Jaff --- ...odel_prices_and_context_window_backup.json | 86 ++++++++++++ .../_experimental/out/assets/logos/figma.svg | 7 + .../_experimental/out/assets/logos/gitlab.svg | 8 ++ .../_experimental/out/assets/logos/gmail.svg | 3 + .../out/assets/logos/google_drive.svg | 6 + .../out/assets/logos/hubspot.svg | 3 + .../_experimental/out/assets/logos/jira.svg | 15 +++ .../_experimental/out/assets/logos/linear.svg | 3 + .../_experimental/out/assets/logos/notion.svg | 3 + .../out/assets/logos/salesforce.svg | 3 + .../_experimental/out/assets/logos/sentry.svg | 3 + .../out/assets/logos/shopify.svg | 4 + .../_experimental/out/assets/logos/slack.svg | 6 + .../_experimental/out/assets/logos/stripe.svg | 3 + .../_experimental/out/assets/logos/twilio.svg | 3 + .../_experimental/out/assets/logos/zapier.svg | 3 + .../public/assets/logos/figma.svg | 7 + .../public/assets/logos/gitlab.svg | 8 ++ .../public/assets/logos/gmail.svg | 3 + .../public/assets/logos/google_drive.svg | 6 + .../public/assets/logos/hubspot.svg | 3 + .../public/assets/logos/jira.svg | 15 +++ .../public/assets/logos/linear.svg | 3 + .../public/assets/logos/notion.svg | 3 + .../public/assets/logos/salesforce.svg | 3 + .../public/assets/logos/sentry.svg | 3 + .../public/assets/logos/shopify.svg | 4 + .../public/assets/logos/slack.svg | 6 + .../public/assets/logos/stripe.svg | 3 + .../public/assets/logos/twilio.svg | 3 + .../public/assets/logos/zapier.svg | 3 + .../src/components/chat/MCPAppsPanel.tsx | 37 +++++- .../src/components/chat/MCPConnectPicker.tsx | 12 ++ .../components/mcp_tools/MCPLogoSelector.tsx | 123 ++++++++++++++++++ .../mcp_tools/OpenAPIFormSection.tsx | 4 + .../mcp_tools/create_mcp_server.tsx | 12 ++ .../mcp_tools/mcp_server_columns.tsx | 17 +++ .../components/mcp_tools/mcp_server_edit.tsx | 4 + 38 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 litellm/proxy/_experimental/out/assets/logos/figma.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/gitlab.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/gmail.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/google_drive.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/hubspot.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/jira.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/linear.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/notion.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/salesforce.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/sentry.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/shopify.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/slack.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/stripe.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/twilio.svg create mode 100644 litellm/proxy/_experimental/out/assets/logos/zapier.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/figma.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/gitlab.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/gmail.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/google_drive.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/hubspot.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/jira.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/linear.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/notion.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/salesforce.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/sentry.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/shopify.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/slack.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/stripe.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/twilio.svg create mode 100644 ui/litellm-dashboard/public/assets/logos/zapier.svg create mode 100644 ui/litellm-dashboard/src/components/mcp_tools/MCPLogoSelector.tsx diff --git a/litellm/model_prices_and_context_window_backup.json b/litellm/model_prices_and_context_window_backup.json index bb4a678b541..d3dd6b3d992 100644 --- a/litellm/model_prices_and_context_window_backup.json +++ b/litellm/model_prices_and_context_window_backup.json @@ -27665,6 +27665,92 @@ "supports_reasoning": true, "supports_tool_choice": true }, + "openrouter/qwen/qwen3.5-35b-a3b": { + "input_cost_per_token": 2.5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-35b-a3b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-27b": { + "input_cost_per_token": 3e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-27b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-122b-a10b": { + "input_cost_per_token": 4e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-122b-a10b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-flash-02-23": { + "input_cost_per_token": 1e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 4e-07, + "source": "https://openrouter.ai/qwen/qwen3.5-flash-02-23", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-plus-02-15": { + "input_cost_per_token": 4e-07, + "input_cost_per_token_above_256k_tokens": 5e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 1000000, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 2.4e-06, + "output_cost_per_token_above_256k_tokens": 3e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-plus-02-15", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, + "openrouter/qwen/qwen3.5-397b-a17b": { + "input_cost_per_token": 6e-07, + "litellm_provider": "openrouter", + "max_input_tokens": 262144, + "max_output_tokens": 65536, + "max_tokens": 65536, + "mode": "chat", + "output_cost_per_token": 3.6e-06, + "source": "https://openrouter.ai/qwen/qwen3.5-397b-a17b", + "supports_function_calling": true, + "supports_reasoning": true, + "supports_tool_choice": true, + "supports_vision": true + }, "openrouter/switchpoint/router": { "input_cost_per_token": 8.5e-07, "litellm_provider": "openrouter", diff --git a/litellm/proxy/_experimental/out/assets/logos/figma.svg b/litellm/proxy/_experimental/out/assets/logos/figma.svg new file mode 100644 index 00000000000..2d8b70457d9 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/figma.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/gitlab.svg b/litellm/proxy/_experimental/out/assets/logos/gitlab.svg new file mode 100644 index 00000000000..18a89fa328d --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/gitlab.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/gmail.svg b/litellm/proxy/_experimental/out/assets/logos/gmail.svg new file mode 100644 index 00000000000..d702890620d --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/gmail.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/google_drive.svg b/litellm/proxy/_experimental/out/assets/logos/google_drive.svg new file mode 100644 index 00000000000..7048af9915e --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/google_drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/hubspot.svg b/litellm/proxy/_experimental/out/assets/logos/hubspot.svg new file mode 100644 index 00000000000..b993945ac6b --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/hubspot.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/jira.svg b/litellm/proxy/_experimental/out/assets/logos/jira.svg new file mode 100644 index 00000000000..fb10ca75173 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/jira.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/linear.svg b/litellm/proxy/_experimental/out/assets/logos/linear.svg new file mode 100644 index 00000000000..83662a1f9ff --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/linear.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/notion.svg b/litellm/proxy/_experimental/out/assets/logos/notion.svg new file mode 100644 index 00000000000..170b9bb4140 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/notion.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/salesforce.svg b/litellm/proxy/_experimental/out/assets/logos/salesforce.svg new file mode 100644 index 00000000000..1a541a004f1 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/salesforce.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/sentry.svg b/litellm/proxy/_experimental/out/assets/logos/sentry.svg new file mode 100644 index 00000000000..9c3733dc43e --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/sentry.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/shopify.svg b/litellm/proxy/_experimental/out/assets/logos/shopify.svg new file mode 100644 index 00000000000..fcc7547269d --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/shopify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/slack.svg b/litellm/proxy/_experimental/out/assets/logos/slack.svg new file mode 100644 index 00000000000..801de4f70c8 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/stripe.svg b/litellm/proxy/_experimental/out/assets/logos/stripe.svg new file mode 100644 index 00000000000..ac16a6fb170 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/stripe.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/twilio.svg b/litellm/proxy/_experimental/out/assets/logos/twilio.svg new file mode 100644 index 00000000000..3517a2824d9 --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/twilio.svg @@ -0,0 +1,3 @@ + + + diff --git a/litellm/proxy/_experimental/out/assets/logos/zapier.svg b/litellm/proxy/_experimental/out/assets/logos/zapier.svg new file mode 100644 index 00000000000..8428ba82a5b --- /dev/null +++ b/litellm/proxy/_experimental/out/assets/logos/zapier.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/figma.svg b/ui/litellm-dashboard/public/assets/logos/figma.svg new file mode 100644 index 00000000000..2d8b70457d9 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/figma.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/gitlab.svg b/ui/litellm-dashboard/public/assets/logos/gitlab.svg new file mode 100644 index 00000000000..18a89fa328d --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/gitlab.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/gmail.svg b/ui/litellm-dashboard/public/assets/logos/gmail.svg new file mode 100644 index 00000000000..d702890620d --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/gmail.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/google_drive.svg b/ui/litellm-dashboard/public/assets/logos/google_drive.svg new file mode 100644 index 00000000000..7048af9915e --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/google_drive.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/hubspot.svg b/ui/litellm-dashboard/public/assets/logos/hubspot.svg new file mode 100644 index 00000000000..b993945ac6b --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/hubspot.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/jira.svg b/ui/litellm-dashboard/public/assets/logos/jira.svg new file mode 100644 index 00000000000..fb10ca75173 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/jira.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/linear.svg b/ui/litellm-dashboard/public/assets/logos/linear.svg new file mode 100644 index 00000000000..83662a1f9ff --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/linear.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/notion.svg b/ui/litellm-dashboard/public/assets/logos/notion.svg new file mode 100644 index 00000000000..170b9bb4140 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/notion.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/salesforce.svg b/ui/litellm-dashboard/public/assets/logos/salesforce.svg new file mode 100644 index 00000000000..1a541a004f1 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/salesforce.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/sentry.svg b/ui/litellm-dashboard/public/assets/logos/sentry.svg new file mode 100644 index 00000000000..9c3733dc43e --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/sentry.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/shopify.svg b/ui/litellm-dashboard/public/assets/logos/shopify.svg new file mode 100644 index 00000000000..fcc7547269d --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/shopify.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/slack.svg b/ui/litellm-dashboard/public/assets/logos/slack.svg new file mode 100644 index 00000000000..801de4f70c8 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/slack.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/ui/litellm-dashboard/public/assets/logos/stripe.svg b/ui/litellm-dashboard/public/assets/logos/stripe.svg new file mode 100644 index 00000000000..ac16a6fb170 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/stripe.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/twilio.svg b/ui/litellm-dashboard/public/assets/logos/twilio.svg new file mode 100644 index 00000000000..3517a2824d9 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/twilio.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/public/assets/logos/zapier.svg b/ui/litellm-dashboard/public/assets/logos/zapier.svg new file mode 100644 index 00000000000..8428ba82a5b --- /dev/null +++ b/ui/litellm-dashboard/public/assets/logos/zapier.svg @@ -0,0 +1,3 @@ + + + diff --git a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx index f6d728f7882..625478e98a4 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPAppsPanel.tsx @@ -170,9 +170,25 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange {/* Avatar + name + connect */}
+ {detailServer.mcp_info?.logo_url ? ( + {`${name} { + const el = e.target as HTMLImageElement; + el.style.display = "none"; + if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex"; + }} + /> + ) : null}
@@ -351,9 +367,26 @@ const MCPAppsPanel: React.FC = ({ accessToken, selectedServers, onChange onMouseEnter={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fafafa"; }} onMouseLeave={(e) => { (e.currentTarget as HTMLDivElement).style.background = "#fff"; }} > + {server.mcp_info?.logo_url ? ( + {`${name} { + const el = e.target as HTMLImageElement; + el.style.display = "none"; + if (el.nextElementSibling) (el.nextElementSibling as HTMLElement).style.display = "flex"; + }} + /> + ) : null}
{name.charAt(0).toUpperCase()} diff --git a/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx b/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx index 234aa8a5281..a52ce181568 100644 --- a/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx +++ b/ui/litellm-dashboard/src/components/chat/MCPConnectPicker.tsx @@ -112,6 +112,18 @@ const MCPConnectPicker: React.FC = ({ accessToken, selectedServers, onCha gap: 12, }} > + {server.mcp_info?.logo_url && ( + {`${name} { (e.target as HTMLImageElement).style.display = "none"; }} + /> + )}
void; +} + +const MCPLogoSelector: React.FC = ({ value, onChange }) => { + const [imgErrors, setImgErrors] = useState>(new Set()); + + const handleSelect = (url: string) => { + onChange?.(value === url ? undefined : url); + }; + + const handleImgError = (url: string) => { + setImgErrors((prev) => new Set(prev).add(url)); + }; + + return ( +
+
+ Logo + + + +
+ + {/* Preview */} + {value && ( +
+ Selected logo { (e.target as HTMLImageElement).style.display = "none"; }} + /> +
+
{value}
+
+ +
+ )} + + {/* Well-known logo grid */} +
+ {WELL_KNOWN_LOGOS.map((logo) => { + const isSelected = value === logo.url; + const hasFailed = imgErrors.has(logo.url); + if (hasFailed) return null; + return ( + + + + ); + })} +
+ + {/* Custom URL input */} + } + placeholder="Or paste a custom logo URL..." + value={value && !WELL_KNOWN_LOGOS.some((l) => l.url === value) ? value : ""} + onChange={(e) => { + const v = e.target.value.trim(); + onChange?.(v || undefined); + }} + className="rounded-lg" + size="small" + /> +
+ ); +}; + +export default MCPLogoSelector; diff --git a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx index 23aae6cb14f..b25e1dcadd4 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/OpenAPIFormSection.tsx @@ -12,6 +12,8 @@ interface OpenAPIFormSectionProps { onValuesChange: (updates: Record) => void; /** Called when key tools change (from registry preset selection). */ onKeyToolsChange?: (tools: OpenAPIKeyTool[]) => void; + /** Called when a preset is selected so the parent can set the logo URL from icon_url. */ + onLogoUrlChange?: (url: string | undefined) => void; /** Called when the OAuth docs URL changes (e.g. link to create a GitHub OAuth App). */ onOAuthDocsUrlChange?: (url: string | null) => void; } @@ -26,6 +28,7 @@ const OpenAPIFormSection: React.FC = ({ accessToken, onValuesChange, onKeyToolsChange, + onLogoUrlChange, onOAuthDocsUrlChange, }) => { const [selectedPreset, setSelectedPreset] = useState(null); @@ -33,6 +36,7 @@ const OpenAPIFormSection: React.FC = ({ const handlePresetSelect = (entry: OpenAPIRegistryEntry) => { setSelectedPreset(entry.name); onKeyToolsChange?.(entry.key_tools ?? []); + onLogoUrlChange?.(entry.icon_url || undefined); const updates: Record = { spec_path: entry.spec_url, }; 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 90ecd4731cf..74945731a24 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 @@ -11,6 +11,7 @@ import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; import MCPPermissionManagement from "./MCPPermissionManagement"; import OpenAPIFormSection, { OpenAPIKeyTool } from "./OpenAPIFormSection"; +import MCPLogoSelector from "./MCPLogoSelector"; import { isAdminRole } from "@/utils/roles"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; @@ -70,6 +71,7 @@ const CreateMCPServer: React.FC = ({ const [keyTools, setKeyTools] = useState([]); const [searchValue, setSearchValue] = useState(""); const [oauthAccessToken, setOauthAccessToken] = useState(null); + const [logoUrl, setLogoUrl] = useState(undefined); const [oauthDocsUrl, setOauthDocsUrl] = useState(null); // Single hook call shared by MCPConnectionStatus and MCPToolConfiguration to avoid duplicate requests. @@ -101,6 +103,7 @@ const CreateMCPServer: React.FC = ({ allowedTools, searchValue, aliasManuallyEdited, + logoUrl, }), ); } catch (err) { @@ -202,6 +205,9 @@ const CreateMCPServer: React.FC = ({ if (typeof parsed.aliasManuallyEdited === "boolean") { setAliasManuallyEdited(parsed.aliasManuallyEdited); } + if (parsed.logoUrl) { + setLogoUrl(parsed.logoUrl); + } } catch (err) { console.error("Failed to restore MCP create state", err); } finally { @@ -357,6 +363,7 @@ const CreateMCPServer: React.FC = ({ mcp_info: { server_name: restValues.server_name || restValues.url, description: restValues.description, + logo_url: logoUrl || undefined, mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null, }, mcp_access_groups: accessGroups, @@ -394,6 +401,7 @@ const CreateMCPServer: React.FC = ({ clearTools(); setAllowedTools([]); setAliasManuallyEdited(false); + setLogoUrl(undefined); setModalVisible(false); onCreateSuccess(response); } @@ -414,6 +422,7 @@ const CreateMCPServer: React.FC = ({ clearTools(); setAllowedTools([]); setAliasManuallyEdited(false); + setLogoUrl(undefined); setModalVisible(false); }; @@ -590,6 +599,8 @@ const CreateMCPServer: React.FC = ({ /> + + GitHub / Source URL} name="source_url" @@ -645,6 +656,7 @@ const CreateMCPServer: React.FC = ({ setFormValues((prev) => ({ ...prev, ...updates })) } onKeyToolsChange={setKeyTools} + onLogoUrlChange={setLogoUrl} onOAuthDocsUrlChange={setOauthDocsUrl} /> )} diff --git a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx index 8130f96856f..551045765cc 100644 --- a/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx +++ b/ui/litellm-dashboard/src/components/mcp_tools/mcp_server_columns.tsx @@ -31,6 +31,23 @@ export const mcpServerColumns = ( accessorKey: "server_name", header: "Name", enableSorting: true, + cell: ({ row }) => { + const logoUrl = row.original.mcp_info?.logo_url; + const name = row.original.server_name; + return ( +
+ {logoUrl ? ( + {`${name { (e.target as HTMLImageElement).style.display = "none"; }} + /> + ) : null} + {name} +
+ ); + }, }, { accessorKey: "alias", 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 fc55542a0c9..eadf93d8a96 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 @@ -8,6 +8,7 @@ import MCPServerCostConfig from "./mcp_server_cost_config"; import MCPPermissionManagement from "./MCPPermissionManagement"; import MCPToolConfiguration from "./mcp_tool_configuration"; import StdioConfiguration from "./StdioConfiguration"; +import MCPLogoSelector from "./MCPLogoSelector"; import { validateMCPServerUrl, validateMCPServerName } from "./utils"; import NotificationsManager from "../molecules/notifications_manager"; import { useMcpOAuthFlow } from "@/hooks/useMcpOAuthFlow"; @@ -41,6 +42,7 @@ const MCPServerEdit: React.FC = ({ const [toolNameToDisplayName, setToolNameToDisplayName] = useState>({}); const [toolNameToDescription, setToolNameToDescription] = useState>({}); const [pendingRestoredValues, setPendingRestoredValues] = useState | null>(null); + const [logoUrl, setLogoUrl] = useState(mcpServer.mcp_info?.logo_url || undefined); const authType = Form.useWatch("auth_type", form) as string | undefined; const transportType = Form.useWatch("transport", form) as string | undefined; const isStdioTransport = transportType === "stdio"; @@ -538,6 +540,7 @@ const MCPServerEdit: React.FC = ({ mcp_info: { server_name: mcpInfoServerName, description: restValues.description, + logo_url: logoUrl || undefined, mcp_server_cost_info: Object.keys(costConfig).length > 0 ? costConfig : null, }, mcp_access_groups: accessGroups, @@ -604,6 +607,7 @@ const MCPServerEdit: React.FC = ({ +